diff --git a/packages/api/.gitignore b/packages/api/.gitignore index 244b4f6..9610a1e 100644 --- a/packages/api/.gitignore +++ b/packages/api/.gitignore @@ -10,3 +10,5 @@ src/cert.pem src/key.pem storage + +src/services/push/vapid.json diff --git a/packages/api/dev/postgres/initdb/webapi.sql b/packages/api/dev/postgres/initdb/webapi.sql index a4efc14..7d6c65c 100644 --- a/packages/api/dev/postgres/initdb/webapi.sql +++ b/packages/api/dev/postgres/initdb/webapi.sql @@ -13,4 +13,4 @@ GRANT DELETE, INSERT, SELECT, UPDATE ON TABLES TO webapi; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT USAGE ON TYPES TO webapi; - \ No newline at end of file + --GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO webapi; diff --git a/packages/api/package.json b/packages/api/package.json index eba14a2..0f3621f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,7 +8,7 @@ "postinstall": "playwright install" }, "type": "module", - "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", "private": true, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", @@ -17,6 +17,7 @@ "@types/express-session": "^1.18.2", "@types/node": "^25.3.5", "@types/pg": "^8.18.0", + "@types/web-push": "^3.6.4", "prisma": "^7.4.2", "tsx": "^4.21.0", "typescript": "~5.9.3" @@ -37,6 +38,7 @@ "pg": "^8.20.0", "playwright": "^1.58.2", "rxjs": "~7.8.2", - "tsoa": "^6.6.0" + "tsoa": "^6.6.0", + "web-push": "^3.6.7" } } diff --git a/packages/api/prisma/migrations/20260309234839_push_subscriptions/migration.sql b/packages/api/prisma/migrations/20260309234839_push_subscriptions/migration.sql new file mode 100644 index 0000000..9770ce0 --- /dev/null +++ b/packages/api/prisma/migrations/20260309234839_push_subscriptions/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "PushSubscription" ( + "id" UUID NOT NULL DEFAULT uuidv7(), + "endpoint" VARCHAR NOT NULL, + "expirationTime" DOUBLE PRECISION, + "userSubscriptionId" UUID, + "p256dh" VARCHAR NOT NULL, + "auth" VARCHAR NOT NULL, + "userId" VARCHAR NOT NULL, + + CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PushSubscription_userSubscriptionId_key" ON "PushSubscription"("userSubscriptionId"); diff --git a/packages/api/prisma/migrations/20260312000535_subscriptions2/migration.sql b/packages/api/prisma/migrations/20260312000535_subscriptions2/migration.sql new file mode 100644 index 0000000..9ba7aaf --- /dev/null +++ b/packages/api/prisma/migrations/20260312000535_subscriptions2/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `userSubscriptionId` on the `PushSubscription` table. All the data in the column will be lost. + - Added the required column `clientId` to the `PushSubscription` table without a default value. This is not possible if the table is not empty. + - Added the required column `topic` to the `PushSubscription` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "PushSubscription_userSubscriptionId_key"; + +-- AlterTable +ALTER TABLE "PushSubscription" DROP COLUMN "userSubscriptionId", +ADD COLUMN "clientId" UUID NOT NULL, +ADD COLUMN "topic" "Topic" NOT NULL, +ADD COLUMN "topicConfiguration" JSONB; + +-- CreateTable +CREATE TABLE "User" ( + "id" VARCHAR NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 94d3abb..e47e9f4 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -21,7 +21,7 @@ model Member { kontakt String? @db.VarChar kuendigungzum DateTime? @db.Date marker Boolean? - creationDate DateTime? @db.Date @default(now()) + creationDate DateTime? @default(now()) @db.Date } model session { @@ -37,13 +37,13 @@ enum Topic { } model FoundEvents { - id String @id @default(dbgenerated("uuidv7()")) @db.Uuid - source String @db.VarChar - url String @db.VarChar - foundDate DateTime @db.Date @default(now()) - eventDate String? @db.VarChar - title String? @db.VarChar - description String? @db.VarChar + id String @id @default(dbgenerated("uuidv7()")) @db.Uuid + source String @db.VarChar + url String @db.VarChar + foundDate DateTime @default(now()) @db.Date + eventDate String? @db.VarChar + title String? @db.VarChar + description String? @db.VarChar attachedFiles FoundEventAttachments[] } @@ -53,3 +53,24 @@ model FoundEventAttachments { event FoundEvents @relation(fields: [eventId], references: [id]) eventId String @db.Uuid } + +model PushSubscription { + id String @id @default(dbgenerated("uuidv7()")) @db.Uuid + endpoint String @db.VarChar + expirationTime Float? + p256dh String @db.VarChar + auth String @db.VarChar + + userId String @db.VarChar + user User @relation(fields: [userId], references: [id]) + + topic Topic + topicConfiguration Json? @db.JsonB + clientId String @db.Uuid +} + +model User { + id String @db.VarChar @id + subscriptions PushSubscription[] +} + diff --git a/packages/api/src/controllers/pushController.ts b/packages/api/src/controllers/pushController.ts new file mode 100644 index 0000000..4042d6b --- /dev/null +++ b/packages/api/src/controllers/pushController.ts @@ -0,0 +1,50 @@ +import express from "express"; +import { Body, Controller, Post, Query, Request, Route, Security, SuccessResponse } from "tsoa"; +import type { PushSubscriptionCreateArgs as PushSubscriptionCreateArgsDto } from "../dtos/pushSubscription.js"; +import { inject } from "../infrastructure/di/injector.js"; +import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js"; +import { PushService } from "../services/push/pushService.js"; + +@Route("push") +export class PushContoller extends Controller { + private readonly pushService = inject(PushService); + + @Post("add") + @Security(KC_SECURITY_NAME) + @SuccessResponse("200", "OK") + public async addSubscription( + @Body() subscription: PushSubscriptionCreateArgsDto, + @Request() request: express.Request, + ): Promise { + return await this.pushService.storeSubscription(subscription, request, request.res); + } + + @Post('reset') + @Security(KC_SECURITY_NAME) + @SuccessResponse('200', 'OK') + public async resetSubscriptions( + @Request() request: express.Request + ): Promise{ + return await this.pushService.reset(request); + } + + @Post('clearSingle') + @Security(KC_SECURITY_NAME) + @SuccessResponse("200", 'OK') + public async clearSubscription( + @Request() request: express.Request, + @Query() clientId: string + ):Promise{ + await this.pushService.clearSubscription(clientId, request); + } + + @Post('test-publish') + @Security(KC_SECURITY_NAME) + @SuccessResponse("200", "OK") + public async publishTestMessage( + @Request() request: express.Request + ): Promise{ + this.pushService.sendtestNotification(request); + } + +} diff --git a/packages/api/src/controllers/usersController.ts b/packages/api/src/controllers/usersController.ts index 2c1ed6a..d778f56 100644 --- a/packages/api/src/controllers/usersController.ts +++ b/packages/api/src/controllers/usersController.ts @@ -13,11 +13,11 @@ import { Claim } from "../dtos/claim.js"; import { type User } from "../dtos/user.js"; import { inject } from "../infrastructure/di/index.js"; import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js"; -import { UserService } from "../services/userService.js"; +import { UserAdminService } from "../services/userAdminService.js"; @Route("users") export class UsersController extends Controller { - private readonly userService = inject(UserService); + private readonly userService = inject(UserAdminService); @Security(KC_SECURITY_NAME, [Claim.UserAdmin]) @SuccessResponse("200", "OK") diff --git a/packages/api/src/dtos/pushSubscription.ts b/packages/api/src/dtos/pushSubscription.ts new file mode 100644 index 0000000..70de8e3 --- /dev/null +++ b/packages/api/src/dtos/pushSubscription.ts @@ -0,0 +1,24 @@ +import type { JsonValue } from "@prisma/client/runtime/client"; + +export class PushSubscriptionDto{ + id!: string; + endpoint!: string; + expirationTime!: number | null; + keys!: { + p256dh: string; + auth: string; + }; + topic!: "TEST"; + topicConfiguration!: JsonValue | null; + clientId!: string; +} + +export class PushSubscriptionCreateArgs/* implements Omit*/{ + endpoint!: string; + expirationTime!: number | null; + keys!: { p256dh: string; auth: string; }; + topic!: "TEST"; + topicConfiguration!: string | null; + clientId!: string; + +} diff --git a/packages/api/src/services/db/prisma.ts b/packages/api/src/services/db/prisma.ts index f4e2525..c2df86e 100644 --- a/packages/api/src/services/db/prisma.ts +++ b/packages/api/src/services/db/prisma.ts @@ -1,6 +1,7 @@ import { PrismaPg } from "@prisma/adapter-pg"; import { type Request } from "express"; import { PrismaClient } from "../../generated/prisma/client.js"; +import type { UserModel } from "../../generated/prisma/models.js"; import { Injectable } from "../../infrastructure/di/injectable-decorator.js"; type ExtendedClient = ReturnType; @@ -23,7 +24,17 @@ export type SafeClient = Omit< >; function createExtendedClient(client: PrismaClient) { - return client; + return client.$extends({ + client: { + async ensureUserKnown(userId: string): Promise{ + let user = await client.user.findUnique({where: {id: userId}}); + if(user==null){ + user = await client.user.create({data:{id: userId}}); + } + return user + } + } + }); // .$extends({ // name: "testExtension", // model: { diff --git a/packages/api/src/services/keycloak/user/keycloak-user.ts b/packages/api/src/services/keycloak/user/keycloak-user.ts index 4edb9b8..b967006 100644 --- a/packages/api/src/services/keycloak/user/keycloak-user.ts +++ b/packages/api/src/services/keycloak/user/keycloak-user.ts @@ -31,6 +31,9 @@ export class KeycloakUser { this.keycloakConnect = new Promise(async resolve => { const config = await this.getConfig(); const client = new KeycloakConnect({ store: sessionStore }, config); + client.authenticated=(r)=>{ + console.dir(r); + }; resolve(client); }); } @@ -49,4 +52,10 @@ export class KeycloakUser { const client = await this.keycloakConnect; return client.middleware(); } + + public async getUid(request: Request, response?: Response): Promise{ + const client = await this.keycloakConnect; + const grant = await client.getGrant(request, response ?? ({} as any)); + return (grant.access_token as any)?.content?.sub; + } } diff --git a/packages/api/src/services/push/pushService.ts b/packages/api/src/services/push/pushService.ts new file mode 100644 index 0000000..6ad3f59 --- /dev/null +++ b/packages/api/src/services/push/pushService.ts @@ -0,0 +1,106 @@ +import { DbNull } from "@prisma/client/runtime/client"; +import type { Request, Response } from "express"; +import webpush from "web-push"; +import type { PushSubscriptionCreateArgs } from "../../dtos/pushSubscription.js"; +import { Topic } from "../../generated/prisma/enums.js"; +import { inject } from "../../infrastructure/di/index.js"; +import { Injectable } from "../../infrastructure/di/injectable-decorator.js"; +import { DatabaseService } from "../db/prisma.js"; +import { KeycloakUser } from "../keycloak/user/keycloak-user.js"; +import VAPID from './vapid.json' with { type: "json" }; + +@Injectable() +export class PushService{ + private readonly db = inject(DatabaseService); + private readonly userService = inject(KeycloakUser); + + public constructor(){ + webpush.setVapidDetails("mailto:toniwalter.blue@gmail.com", VAPID.publicKey, VAPID.privateKey); + } + + public async storeSubscription(subscription: PushSubscriptionCreateArgs, request: Request, response?: Response): Promise{ + const userId= await this.userService.getUid(request, response); + await this.db.doRequest(async prisma=>{ + await prisma.ensureUserKnown(userId); + return await prisma.pushSubscription.create({ + data: { + auth: subscription.keys.auth, + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + expirationTime: subscription.expirationTime ?? null, + userId: userId, + clientId: subscription.clientId, + topic: subscription.topic, + topicConfiguration: subscription.topicConfiguration ? JSON.parse(subscription.topicConfiguration) : DbNull, + } + }); + }, request); + } + + public async sendtestNotification(request: Request){ + const subscriptions = await this.db.doRequest(async prisma=>prisma.pushSubscription.findMany({where:{topic: "TEST"}}), request); + + const payload = { + notification: { + title: "ATitle", + body: "DatBod", + icon: "assets/some-icon.png", + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + }, + actions: [ + { + action: "explode", + title: "Make Boom" + } + ] + } + } + + console.log(`sending ${subscriptions.length} test messages`); + for(const s of subscriptions){ + const subscription: webpush.PushSubscription={ + endpoint: s.endpoint, + keys: { + auth: s.auth, + p256dh: s.p256dh + }, + expirationTime: s.expirationTime + }; + try{ + const result = await webpush.sendNotification(subscription, JSON.stringify(payload), { + topic: Topic.TEST, + }); + console.log("success"); + console.dir(result); + }catch(error){ + console.error("Cannot send notification: ", error); + throw error; + } + } + } + + public async reset(request: Request): Promise { + const userId=await this.userService.getUid(request, request.res); + const subscriptions = await this.db.doRequest(async prisma=>await prisma.pushSubscription.deleteMany({ + where:{userId: userId} + }), request); + + console.log(`deleted ${subscriptions.count} subs for ${userId}`); + } + + public async clearSubscription(clientId: string, request: Request): Promise { + const userId =await this.userService.getUid(request, request.res); + const batchResult =await this.db.doRequest(async prisma=>await prisma.pushSubscription.deleteMany({ + where: { + userId: userId, + AND: { + clientId: clientId + } + } + }), request); + return batchResult.count; + } +} diff --git a/packages/api/src/services/userService.ts b/packages/api/src/services/userAdminService.ts similarity index 96% rename from packages/api/src/services/userService.ts rename to packages/api/src/services/userAdminService.ts index dfa3e05..d68d5c0 100644 --- a/packages/api/src/services/userService.ts +++ b/packages/api/src/services/userAdminService.ts @@ -3,7 +3,7 @@ import { type User } from "../dtos/user.js"; import { inject } from "../infrastructure/di/index.js"; import { KeycloakAdmin } from "./keycloak/admin/index.js"; -export class UserService { +export class UserAdminService { private readonly keycloakAdmin = inject(KeycloakAdmin); public async setClaim( uid: string, diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 87f53a8..3f6fb7c 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -54,7 +54,9 @@ "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "preserveWatchOutput": true, + "esModuleInterop": true }, "files": ["./src/index.ts"], "include": ["src", "./package.json"] diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore index 0be734a..e348a9b 100644 --- a/packages/frontend/.gitignore +++ b/packages/frontend/.gitignore @@ -44,3 +44,5 @@ Thumbs.db ### src/app/generated-api/ + +src/app/services/push/vapid.json diff --git a/packages/frontend/angular.json b/packages/frontend/angular.json index 70a935a..a55001b 100644 --- a/packages/frontend/angular.json +++ b/packages/frontend/angular.json @@ -1,91 +1,92 @@ { - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "pnpm" - }, - "newProjectRoot": "projects", - "projects": { - "frontend": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "browser": "src/main.ts", - "tsConfig": "tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": ["src/styles.scss"] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm" + }, + "newProjectRoot": "projects", + "projects": { + "frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" } - ], - "outputHashing": "all", - "serviceWorker": "ngsw-config.json" }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.scss"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all", + "serviceWorker": "ngsw-config.json" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "serviceWorker": "ngsw-config.json" + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "frontend:build:production" + }, + "development": { + "buildTarget": "frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.scss"] + } + } } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "frontend:build:production" - }, - "development": { - "buildTarget": "frontend:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n" - }, - "test": { - "builder": "@angular/build:karma", - "options": { - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": ["src/styles.scss"] - } } - } } - } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e80c4b3..8a70459 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -42,7 +42,8 @@ "keycloak-angular": "^21.0.0", "keycloak-js": "^26.2.3", "rxjs": "^7.8.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "uuid": "^13.0.0" }, "devDependencies": { "@angular/build": "^21.2.1", diff --git a/packages/frontend/src/app/app.config.ts b/packages/frontend/src/app/app.config.ts index 5790f67..85cc9ef 100644 --- a/packages/frontend/src/app/app.config.ts +++ b/packages/frontend/src/app/app.config.ts @@ -86,7 +86,7 @@ export const appConfig: ApplicationConfig = { { provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }, { provide: MAT_DATE_LOCALE, useValue: de }, provideServiceWorker('ngsw-worker.js', { - enabled: !isDevMode(), + enabled: true,//!isDevMode(), registrationStrategy: 'registerWhenStable:30000', }), ], diff --git a/packages/frontend/src/app/components/test/test.html b/packages/frontend/src/app/components/test/test.html index ee8fb4f..13c0714 100644 --- a/packages/frontend/src/app/components/test/test.html +++ b/packages/frontend/src/app/components/test/test.html @@ -1,6 +1,9 @@

Testeite für interne Berechtigung

API: {{apiStatus()}}

Auth: {{authTest()}}

+ + + Claim:
    @let claims = this.auth.claims(); diff --git a/packages/frontend/src/app/components/test/test.ts b/packages/frontend/src/app/components/test/test.ts index f4205f7..c48e0d0 100644 --- a/packages/frontend/src/app/components/test/test.ts +++ b/packages/frontend/src/app/components/test/test.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import * as api from '../../generated-api/api'; import { Authentication } from '../../services/authentication'; +import { PushService } from '../../services/push/pushService'; @Component({ selector: 'app-test', @@ -17,6 +18,7 @@ import { Authentication } from '../../services/authentication'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class Test { + protected readonly pushService = inject(PushService); protected auth = inject(Authentication); protected apiStatus = signal('Waiting'); diff --git a/packages/frontend/src/app/services/push/pushService.ts b/packages/frontend/src/app/services/push/pushService.ts new file mode 100644 index 0000000..0ff333c --- /dev/null +++ b/packages/frontend/src/app/services/push/pushService.ts @@ -0,0 +1,70 @@ +import { inject, Injectable } from '@angular/core'; +import { SwPush } from '@angular/service-worker'; +import { v7 as uuidv7 } from 'uuid'; +import * as api from '../../generated-api/api'; +import VAPID from './vapid.json' with { type: "json" }; + +const SUBSCRIPTION_CLIENT_ID="subscriptionClientId"; + +@Injectable({providedIn: 'root'}) +export class PushService{ + private readonly swPush=inject(SwPush); + + private arrayBufferToString(buffer: ArrayBuffer|null){ + if(buffer==null){ + return ''; + } + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.length; + for(let i = 0; i< len; i++){ + binary+=String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + public async subscribeToEvents(){ + try{ + const subscription = await this.swPush.requestSubscription({ + serverPublicKey: VAPID.publicKey + }); + + const decoder = new TextDecoder(); + + const previousCliendId = localStorage.getItem(SUBSCRIPTION_CLIENT_ID); + if(previousCliendId){ + try{ + await api.clearSubscription(previousCliendId); + console.log("previous subscription cleared"); + }catch{} + } + + const newClientId= uuidv7(); + + await api.addSubscription({ + endpoint: subscription.endpoint, + keys: { + auth: this.arrayBufferToString(subscription.getKey('auth')), + p256dh: this.arrayBufferToString(subscription.getKey('p256dh')), + }, + expirationTime: subscription.expirationTime, + topic: api.Topic.Test, + topicConfiguration: null, + clientId: newClientId, + }); + + localStorage.setItem(SUBSCRIPTION_CLIENT_ID, newClientId); + }catch(error){ + console.error("Subscribing failed: ", error); + } + } + + public async resetSubscriptions(){ + await this.swPush.unsubscribe(); + await api.resetSubscriptions(); + } + + public async publishTestMessage(){ + await api.publishTestMessage(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaf671c..586e03c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: tsoa: specifier: ^6.6.0 version: 6.6.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 devDependencies: '@types/connect-pg-simple': specifier: ^7.0.3 @@ -84,6 +87,9 @@ importers: '@types/pg': specifier: ^8.18.0 version: 8.18.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 prisma: specifier: ^7.4.2 version: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) @@ -150,6 +156,9 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@angular/build': specifier: ^21.2.1 @@ -1264,49 +1273,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1414,42 +1416,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1570,28 +1566,24 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} @@ -1653,79 +1645,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1940,6 +1919,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2135,6 +2117,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2236,8 +2221,8 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - chromedriver@146.0.1: - resolution: {integrity: sha512-XT1FnStrsGAS6l3M0yLtLjqSZWcOK0cBoR/QKLzA0rvf4oGtxnxYtCv6G0j8/0xNZTY2Zv9GOARZ5uBNc3bjTw==} + chromedriver@146.0.2: + resolution: {integrity: sha512-/A6ht59pGGrV3bU6eC//yH6W+NRexVGXy/KEe+pNn1MP5Xb34krSA02bGlYuA5XCrfdXPsFI//slvsOBwH//4Q==} engines: {node: '>=20'} hasBin: true @@ -2548,6 +2533,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3048,6 +3036,10 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3295,9 +3287,15 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwk-to-pem@2.0.7: resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + karma-chrome-launcher@3.2.0: resolution: {integrity: sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==} @@ -4626,6 +4624,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + vali-date@1.0.0: resolution: {integrity: sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==} engines: {node: '>=0.10.0'} @@ -4708,6 +4710,11 @@ packages: weak-lru-cache@1.2.2: resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -6758,6 +6765,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 25.3.5 + '@types/yauzl@2.10.3': dependencies: '@types/node': 25.3.5 @@ -6976,6 +6987,8 @@ snapshots: buffer-crc32@0.2.13: optional: true + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -7115,7 +7128,7 @@ snapshots: chownr@3.0.0: {} - chromedriver@146.0.1: + chromedriver@146.0.2: dependencies: '@testim/chrome-version': 1.1.4 axios: 1.13.6 @@ -7418,6 +7431,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.18.4: @@ -8079,6 +8096,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -8345,12 +8364,23 @@ snapshots: jsonparse@1.3.1: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jwk-to-pem@2.0.7: dependencies: asn1.js: 5.4.1 elliptic: 6.6.1 safe-buffer: 5.2.1 + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + karma-chrome-launcher@3.2.0: dependencies: which: 1.3.1 @@ -8422,7 +8452,7 @@ snapshots: dependencies: jwk-to-pem: 2.0.7 optionalDependencies: - chromedriver: 146.0.1 + chromedriver: 146.0.2 transitivePeerDependencies: - debug - supports-color @@ -9849,6 +9879,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@13.0.0: {} + vali-date@1.0.0: {} valibot@1.2.0(typescript@5.9.3): @@ -9895,6 +9927,16 @@ snapshots: weak-lru-cache@1.2.2: optional: true + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: diff --git a/scripts/api-ts.ps1 b/scripts/api-ts.ps1 index 6e95e44..d4de263 100644 --- a/scripts/api-ts.ps1 +++ b/scripts/api-ts.ps1 @@ -3,4 +3,4 @@ $env:TKD_DB_HOST="localhost" $env:TKD_DB_USER="webapi" $env:TKD_DB_PASSWORD="devpassword" $env:TKD_COOKIE_SECRET="devsecret" -pnpx tsx --watch --tsconfig .\tsconfig.json --clear-screen=false .\src\index.ts +pnpx tsx --watch --tsconfig .\tsconfig.json .\src\index.ts