diff --git a/.env.example b/.env.example index 9fc0c44..8050262 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,14 @@ SMTP_SECURE="false" SMTP_USER="" SMTP_PASS="" +# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus +# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase +# service-account JSON with the "Firebase Cloud Messaging API" enabled) to +# send real notifications via FCM HTTP v1. +PUSH_PROVIDER="log" +FCM_PROJECT_ID="konfi-castle-app" +GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json" + # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to # use an S3-compatible bucket instead (see S3_* vars below). STORAGE_PROVIDER="webdav" diff --git a/prisma/migrations/20260910093835_device_tokens/migration.sql b/prisma/migrations/20260910093835_device_tokens/migration.sql new file mode 100644 index 0000000..ff7c9c9 --- /dev/null +++ b/prisma/migrations/20260910093835_device_tokens/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "DeviceToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "userId" TEXT, + "guestAccountId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token"); + +-- AddForeignKey +ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b89be4..782c69f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model User { memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] + deviceTokens DeviceToken[] } /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). @@ -107,10 +108,27 @@ model GuestAccount { lastName String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) - messages ChatMessage[] - teilnehmer Teilnehmer[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] + teilnehmer Teilnehmer[] + deviceTokens DeviceToken[] +} + +/// A push-notification target (FCM registration token) bound to whoever +/// registered it — a team `User` or a `GuestAccount`. Replicated so a +/// notification can be sent from either server. +model DeviceToken { + id String @id @default(cuid()) + token String @unique + platform String + userId String? + guestAccountId String? + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) } /// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer diff --git a/src/app.module.ts b/src/app.module.ts index ffaeec6..bbf2da8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { MailModule } from './mail/mail.module'; +import { PushModule } from './push/push.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; @@ -35,6 +36,7 @@ const webRoot = }), PrismaModule, MailModule, + PushModule, SyncModule, AuthModule, KcModule, diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index df9ef4d..5e89d4e 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module'; import { AuthenticatedUser } from '../auth/authenticated-request'; import { GuestJwtPayload } from '../auth/guest-auth.service'; import { SyncService } from '../sync/sync.service'; +import { PushService } from '../push/push.service'; export type ChatCaller = | { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }; +const CHANNEL_TITLES: Record = { + [ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe', + [ChatChannelType.DIREKT]: 'Direktnachricht', + [ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam', + [ChatChannelType.BROADCAST]: 'Ankündigung', +}; + @Injectable() export class ChatService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, + private readonly push: PushService, ) {} async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) { @@ -142,7 +151,7 @@ export class ChatService { } async sendMessage(channelId: string, caller: ChatCaller, body: string) { - await this.assertCanWrite(channelId, caller); + const channel = await this.assertCanWrite(channelId, caller); const message = await this.prisma.chatMessage.create({ data: { channelId, @@ -152,6 +161,20 @@ export class ChatService { }, }); await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message); + + void this.push.notifyChannel( + channelId, + { + title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE], + body: body.length > 140 ? `${body.slice(0, 137)}…` : body, + data: { channelId }, + }, + { + userId: caller.kind === 'user' ? caller.user.userId : null, + guestId: caller.kind === 'guest' ? caller.guest.guestId : null, + }, + ); + return message; } diff --git a/src/push/dto/register-device.dto.ts b/src/push/dto/register-device.dto.ts new file mode 100644 index 0000000..d8ea4fe --- /dev/null +++ b/src/push/dto/register-device.dto.ts @@ -0,0 +1,16 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + @IsNotEmpty() + token!: string; + + @IsIn(['web', 'android', 'ios']) + platform!: 'web' | 'android' | 'ios'; +} + +export class UnregisterDeviceDto { + @IsString() + @IsNotEmpty() + token!: string; +} diff --git a/src/push/fcm-push.provider.ts b/src/push/fcm-push.provider.ts new file mode 100644 index 0000000..63ff3fc --- /dev/null +++ b/src/push/fcm-push.provider.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'fs'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import { PushNotification, PushProvider, PushResult } from './push-provider'; + +interface ServiceAccount { + client_email: string; + private_key: string; + token_uri: string; +} + +/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged +/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken +/// is already here). Delivery failures are logged and swallowed. +export class FcmPushProvider implements PushProvider { + private readonly logger = new Logger('PushProvider'); + private readonly projectId: string; + private readonly sa: ServiceAccount; + private accessToken: { value: string; expiresAt: number } | null = null; + + constructor(config: ConfigService) { + this.projectId = config.getOrThrow('FCM_PROJECT_ID'); + const path = config.getOrThrow('GOOGLE_APPLICATION_CREDENTIALS'); + this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount; + } + + async sendToTokens(tokens: string[], n: PushNotification): Promise { + if (tokens.length === 0) return { sent: 0, invalidTokens: [] }; + let accessToken: string; + try { + accessToken = await this.getAccessToken(); + } catch (err) { + this.logger.error(`FCM auth failed: ${(err as Error).message}`); + return { sent: 0, invalidTokens: [] }; + } + + const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`; + const invalidTokens: string[] = []; + let sent = 0; + + await Promise.all( + tokens.map(async (token) => { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: { + token, + notification: { title: n.title, body: n.body }, + data: n.data, + webpush: { fcmOptions: {} }, + }, + }), + }); + if (res.ok) { + sent += 1; + } else if (res.status === 404 || res.status === 400) { + invalidTokens.push(token); + } else { + this.logger.warn(`FCM send ${res.status}: ${await res.text()}`); + } + } catch (err) { + this.logger.warn(`FCM send error: ${(err as Error).message}`); + } + }), + ); + + return { sent, invalidTokens }; + } + + private async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) { + return this.accessToken.value; + } + const now = Math.floor(Date.now() / 1000); + const assertion = jwt.sign( + { + iss: this.sa.client_email, + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: this.sa.token_uri, + iat: now, + exp: now + 3600, + }, + this.sa.private_key, + { algorithm: 'RS256' }, + ); + const res = await fetch(this.sa.token_uri, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }), + }); + if (!res.ok) { + throw new Error(`token exchange ${res.status}: ${await res.text()}`); + } + const json = (await res.json()) as { access_token: string; expires_in: number }; + this.accessToken = { + value: json.access_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; + return json.access_token; + } +} diff --git a/src/push/log-push.provider.ts b/src/push/log-push.provider.ts new file mode 100644 index 0000000..b08baee --- /dev/null +++ b/src/push/log-push.provider.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common'; +import { PushNotification, PushProvider, PushResult } from './push-provider'; + +/// Default provider: doesn't send, just logs. Keeps the app working before +/// FCM credentials are configured. +export class LogPushProvider implements PushProvider { + private readonly logger = new Logger('PushProvider'); + + async sendToTokens(tokens: string[], n: PushNotification): Promise { + this.logger.log( + `[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`, + ); + return { sent: 0, invalidTokens: [] }; + } +} diff --git a/src/push/push-provider.ts b/src/push/push-provider.ts new file mode 100644 index 0000000..0d4c41e --- /dev/null +++ b/src/push/push-provider.ts @@ -0,0 +1,20 @@ +/// Abstraction over the push backend. Default is a no-send provider that only +/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1). +export interface PushNotification { + title: string; + body: string; + data?: Record; +} + +export interface PushResult { + sent: number; + /// Tokens FCM reported as permanently invalid — the caller prunes them. + invalidTokens: string[]; +} + +export interface PushProvider { + /// Best-effort: never throws for delivery problems. + sendToTokens(tokens: string[], notification: PushNotification): Promise; +} + +export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER'); diff --git a/src/push/push.controller.ts b/src/push/push.controller.ts new file mode 100644 index 0000000..7b05715 --- /dev/null +++ b/src/push/push.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PushService } from './push.service'; +import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto'; +// Import the util directly (not via chat.service) to keep the module graph acyclic. +import { resolveChatCaller } from '../chat/caller.util'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; + +type PushRequest = AuthenticatedRequest & { + user?: AuthenticatedRequest['user'] | GuestJwtPayload; +}; + +@Controller('push') +export class PushController { + constructor(private readonly push: PushService) {} + + @Post('register') + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) + register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) { + return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!)); + } + + @Post('unregister') + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) + unregister(@Body() dto: UnregisterDeviceDto) { + return this.push.unregister(dto.token); + } +} diff --git a/src/push/push.module.ts b/src/push/push.module.ts new file mode 100644 index 0000000..fe3e15c --- /dev/null +++ b/src/push/push.module.ts @@ -0,0 +1,27 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PUSH_PROVIDER } from './push-provider'; +import { LogPushProvider } from './log-push.provider'; +import { FcmPushProvider } from './fcm-push.provider'; +import { PushService } from './push.service'; +import { PushController } from './push.controller'; + +/// Global so ChatService can inject PushService. Provider defaults to +/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging. +@Global() +@Module({ + controllers: [PushController], + providers: [ + PushService, + { + provide: PUSH_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('PUSH_PROVIDER') === 'fcm' + ? new FcmPushProvider(config) + : new LogPushProvider(), + }, + ], + exports: [PushService], +}) +export class PushModule {} diff --git a/src/push/push.service.ts b/src/push/push.service.ts new file mode 100644 index 0000000..d68a9ab --- /dev/null +++ b/src/push/push.service.ts @@ -0,0 +1,151 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ChatChannelType, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; +import type { ChatCaller } from '../chat/chat.service'; +import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider'; + +@Injectable() +export class PushService { + private readonly logger = new Logger(PushService.name); + + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + @Inject(PUSH_PROVIDER) private readonly provider: PushProvider, + ) {} + + /// Upsert a device token for the current caller (team user or guest). + async register(token: string, platform: string, caller: ChatCaller) { + const owner = + caller.kind === 'user' + ? { userId: caller.user.userId, guestAccountId: null } + : { userId: null, guestAccountId: caller.guest.guestId }; + const row = await this.prisma.deviceToken.upsert({ + where: { token }, + create: { token, platform, ...owner }, + update: { platform, lastSeenAt: new Date(), ...owner }, + }); + await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row); + return { ok: true }; + } + + async unregister(token: string) { + const existing = await this.prisma.deviceToken.findUnique({ where: { token } }); + if (!existing) return { ok: true }; + await this.prisma.deviceToken.delete({ where: { token } }); + await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, { + id: existing.id, + }); + return { ok: true }; + } + + /// Fan a chat message out as a push to everyone who can read the channel, + /// minus the sender. Best-effort — never throws into the caller. + async notifyChannel( + channelId: string, + notification: PushNotification, + exclude: { userId?: string | null; guestId?: string | null } = {}, + ): Promise { + try { + const channel = await this.prisma.chatChannel.findUnique({ + where: { id: channelId }, + include: { participants: { select: { userId: true } } }, + }); + if (!channel) return; + + const { userIds, guestIds } = await this.audience(channel); + const tokens = await this.prisma.deviceToken.findMany({ + where: { + OR: [ + userIds.length ? { userId: { in: userIds } } : undefined, + guestIds.length ? { guestAccountId: { in: guestIds } } : undefined, + ].filter(Boolean) as object[], + NOT: { + OR: [ + exclude.userId ? { userId: exclude.userId } : undefined, + exclude.guestId ? { guestAccountId: exclude.guestId } : undefined, + ].filter(Boolean) as object[], + }, + }, + select: { token: true }, + }); + if (tokens.length === 0) return; + + const { invalidTokens } = await this.provider.sendToTokens( + tokens.map((t) => t.token), + notification, + ); + if (invalidTokens.length) { + await this.prisma.deviceToken.deleteMany({ + where: { token: { in: invalidTokens } }, + }); + } + } catch (err) { + this.logger.warn(`notifyChannel failed: ${(err as Error).message}`); + } + } + + private async audience(channel: { + kcId: string; + type: ChatChannelType; + gemeindeId: string | null; + participants: { userId: string }[]; + }): Promise<{ userIds: string[]; guestIds: string[] }> { + if (channel.type === ChatChannelType.DIREKT) { + return { userIds: channel.participants.map((p) => p.userId), guestIds: [] }; + } + + const ltUsers = await this.prisma.user.findMany({ + where: { + OR: [ + { isLeitungsteam: true }, + { memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } }, + ], + }, + select: { id: true }, + }); + const ltIds = ltUsers.map((u) => u.id); + + if (channel.type === ChatChannelType.LT_UEBERGREIFEND) { + return { userIds: ltIds, guestIds: [] }; + } + + if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) { + const [members, guests] = await Promise.all([ + this.prisma.membership.findMany({ + where: { + kcId: channel.kcId, + gemeindeId: channel.gemeindeId, + status: 'ACTIVE', + }, + select: { userId: true }, + }), + this.prisma.guestAccount.findMany({ + where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId }, + select: { id: true }, + }), + ]); + return { + userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], + guestIds: guests.map((g) => g.id), + }; + } + + // BROADCAST: everyone in the KC. + const [members, guests] = await Promise.all([ + this.prisma.membership.findMany({ + where: { kcId: channel.kcId, status: 'ACTIVE' }, + select: { userId: true }, + }), + this.prisma.guestAccount.findMany({ + where: { kcId: channel.kcId }, + select: { id: true }, + }), + ]); + return { + userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], + guestIds: guests.map((g) => g.id), + }; + } +} diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 3d2746e..5e896a3 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -18,6 +18,7 @@ const SYNCED_MODELS = [ 'File', 'ChatChannel', 'ChatMessage', + 'DeviceToken', ] as const; export type SyncedModel = (typeof SYNCED_MODELS)[number];