push service

This commit is contained in:
toni
2026-03-13 21:54:43 +01:00
parent 71ca4d81e1
commit 29c6828dd1
23 changed files with 526 additions and 137 deletions
+2
View File
@@ -10,3 +10,5 @@ src/cert.pem
src/key.pem
storage
src/services/push/vapid.json
+1 -1
View File
@@ -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;
--GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO webapi;
+4 -2
View File
@@ -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"
}
}
@@ -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");
@@ -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;
+29 -8
View File
@@ -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[]
}
@@ -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<void> {
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<void>{
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<void>{
await this.pushService.clearSubscription(clientId, request);
}
@Post('test-publish')
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async publishTestMessage(
@Request() request: express.Request
): Promise<void>{
this.pushService.sendtestNotification(request);
}
}
@@ -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")
+24
View File
@@ -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<PushSubscriptionDto, "id">*/{
endpoint!: string;
expirationTime!: number | null;
keys!: { p256dh: string; auth: string; };
topic!: "TEST";
topicConfiguration!: string | null;
clientId!: string;
}
+12 -1
View File
@@ -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<typeof createExtendedClient>;
@@ -23,7 +24,17 @@ export type SafeClient = Omit<
>;
function createExtendedClient(client: PrismaClient) {
return client;
return client.$extends({
client: {
async ensureUserKnown(userId: string): Promise<UserModel>{
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: {
@@ -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<string>{
const client = await this.keycloakConnect;
const grant = await client.getGrant(request, response ?? ({} as any));
return (grant.access_token as any)?.content?.sub;
}
}
@@ -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<void>{
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<void> {
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<number> {
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;
}
}
@@ -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,
+3 -1
View File
@@ -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"]