feat(backend): push notifications module (FCM HTTP v1)

New global push/ module mirroring mail/ and files/storage/:
- PushProvider abstraction; default LogPushProvider (no delivery, logs),
  PUSH_PROVIDER=fcm switches to FcmPushProvider — Firebase Cloud Messaging
  HTTP v1, authenticated by a service-account JWT exchanged for an OAuth
  token (no extra dependency; jsonwebtoken does the signing). Prunes tokens
  FCM reports as invalid.
- DeviceToken model (token + platform, bound to a User or GuestAccount),
  migration + added to the sync log.
- POST /api/push/register + /unregister (any of the three token kinds).
- PushService.notifyChannel() resolves a channel's readable audience
  (DIREKT participants / LT / Gemeinde members + guests / whole KC for
  broadcast), looks up their device tokens (minus the sender), sends.
- ChatService.sendMessage() fires it best-effort after persisting.

New env: PUSH_PROVIDER, FCM_PROJECT_ID (default konfi-castle-app),
GOOGLE_APPLICATION_CREDENTIALS.

Verified against local Postgres: register a token, send a Gemeinde-group
chat message from another member -> log-push logs "would push ... to 1
device". Real FCM send needs the service-account JSON. npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:42:34 +02:00
co-authored by Claude Sonnet 5
parent d8ff49480d
commit 92e0029732
13 changed files with 446 additions and 5 deletions
+8
View File
@@ -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"
@@ -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;
+22 -4
View File
@@ -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
+2
View File
@@ -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,
+24 -1
View File
@@ -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, string> = {
[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;
}
+16
View File
@@ -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;
}
+110
View File
@@ -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<string>('FCM_PROJECT_ID');
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
}
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
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<string> {
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;
}
}
+15
View File
@@ -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<PushResult> {
this.logger.log(
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
);
return { sent: 0, invalidTokens: [] };
}
}
+20
View File
@@ -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<string, string>;
}
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<PushResult>;
}
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
+29
View File
@@ -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);
}
}
+27
View File
@@ -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<string>('PUSH_PROVIDER') === 'fcm'
? new FcmPushProvider(config)
: new LogPushProvider(),
},
],
exports: [PushService],
})
export class PushModule {}
+151
View File
@@ -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<void> {
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),
};
}
}
+1
View File
@@ -18,6 +18,7 @@ const SYNCED_MODELS = [
'File',
'ChatChannel',
'ChatMessage',
'DeviceToken',
] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number];