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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user