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