Files
KC-APP/backend/src/push/fcm-push.provider.ts
T
linusandClaude Sonnet 5 cc663c7e17 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>
2026-09-10 11:42:34 +02:00

111 lines
3.6 KiB
TypeScript

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;
}
}