/** * Native FCM sender for staff chat (the Android app analog of pushNotifier.ts's web push). * * Lazy + fail-soft, exactly like fcmDb/chatDb: firebase-admin is initialized on first send from a * service-account credential, and if the credential is missing or invalid, every call no-ops with a * warning — native push degrades, the server never crashes at boot, and web push is untouched. * * Credential (a server secret — NEVER in the repo): provide ONE of * - FCM_SERVICE_ACCOUNT_JSON = the service-account JSON (raw or base64), or * - FCM_SERVICE_ACCOUNT_FILE = a path to the service-account JSON file. * * firebase-admin is stateless → does NOT affect the per-process presence (PM2 instances:1) constraint. */ import fs from 'fs' let app: any = null let initFailed = false const loadServiceAccount = (): any | null => { try { const raw = process.env.FCM_SERVICE_ACCOUNT_JSON if (raw && raw.trim()) { const s = raw.trim() const json = s.startsWith('{') ? s : Buffer.from(s, 'base64').toString('utf-8') return JSON.parse(json) } const file = process.env.FCM_SERVICE_ACCOUNT_FILE if (file && fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf-8')) } catch (e: any) { console.warn('[fcm] service-account parse failed:', e?.message) } return null } const getApp = async () => { if (app) return app if (initFailed) return null try { const { getApps, initializeApp, cert } = await import('firebase-admin/app') const existing = getApps() if (existing.length) { app = existing[0]; return app } const svc = loadServiceAccount() if (!svc) { initFailed = true; console.warn('[fcm] no service account → native push disabled'); return null } app = initializeApp({ credential: cert(svc) }) return app } catch (e: any) { initFailed = true console.warn('[fcm] init failed (native push disabled):', e?.message) return null } } /** * Send a chat notification to all of a user's registered devices. No tokens / no credential → no-op. * Dead tokens are pruned (FCM analog of web-push HTTP 410). */ export const sendFcmToUser = async ( userId: number, msg: { title: string; body: string; data?: Record }, ) => { const a = await getApp() if (!a) { console.warn('[fcm] skip: no credential loaded (set FCM_SERVICE_ACCOUNT_JSON/_FILE)'); return } const { getFcmTokensForUser, deleteFcmToken } = await import('./fcmDb') const tokens = getFcmTokensForUser(userId) if (!tokens.length) { console.warn(`[fcm] skip: user ${userId} has no registered device tokens`); return } const data: Record = {} for (const [k, v] of Object.entries(msg.data || {})) data[k] = String(v) try { const { getMessaging } = await import('firebase-admin/messaging') const res: any = await getMessaging(a).sendEachForMulticast({ tokens, // BOTH blocks so a Doze/killed device still surfaces a notification and the data deep-links on tap. notification: { title: msg.title, body: msg.body }, data, android: { priority: 'high', notification: { channelId: 'chat', tag: data.senderUserId ? `chat-${data.senderUserId}` : 'chat', }, }, }) console.log(`[fcm] user ${userId}: sent ${res.successCount}/${tokens.length} (failed ${res.failureCount})`) res.responses.forEach((r: any, i: number) => { const code = r?.error?.code if (!r.success) { console.warn(`[fcm] user ${userId} token#${i} failed:`, code, r?.error?.message) if (code === 'messaging/registration-token-not-registered' || code === 'messaging/invalid-argument' || code === 'messaging/invalid-registration-token') { deleteFcmToken(tokens[i]) } } }) } catch (e: any) { console.warn('[fcm] send failed:', e?.message) } }