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>
111 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|