import admin from 'firebase-admin'; import * as path from 'path'; import { PushNotification, NotificationStatus, NotificationPriority, NotificationRecipient, NotificationChannel, } from '../types/notification.types'; import { FCMConfig, APNsConfig } from '../config/notification.config'; export class PushService { private fcm?: admin.app.App; private apnsConfig?: APNsConfig; constructor(fcmConfig?: FCMConfig, apnsConfig?: APNsConfig) { if (fcmConfig) { // Use named app instance for multi-tenant support const appName = fcmConfig.keyPath ? `fcm_${fcmConfig.projectId}` : 'fcm_default'; // Check if app with this name already exists const existingApp = admin.app(appName); if (!existingApp) { this.fcm = admin.initializeApp({ credential: admin.credential.cert({ projectId: fcmConfig.projectId, privateKey: fcmConfig.privateKey.replace(/\\n/g, '\n'), clientEmail: fcmConfig.clientEmail, }), ...(fcmConfig.keyPath && { storageBucket: `${fcmConfig.projectId}.appspot.com`, }), }, appName); } else { this.fcm = existingApp; } } this.apnsConfig = apnsConfig; } /** * Send push notification to FCM device */ async sendFCMPush( recipient: NotificationRecipient, title: string, body: string, data?: Record, badge?: number, sound?: string ): Promise { const notification: PushNotification = { id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, userId: recipient.userId, channel: NotificationChannel.PUSH, templateId: 'custom', priority: NotificationPriority.NORMAL, status: NotificationStatus.PENDING, title, body, data, badge, sound, fcmToken: recipient.fcmToken, createdAt: new Date(), }; if (!this.fcm || !recipient.fcmToken) { notification.status = NotificationStatus.FAILED; notification.failedAt = new Date(); notification.errorMessage = !this.fcm ? 'FCM not configured' : 'Missing FCM token'; return notification; } try { const message: admin.messaging.Message = { token: recipient.fcmToken, notification: { title, body, }, data: data ? Object.fromEntries( Object.entries(data).map(([key, value]) => [key, String(value)]) ) : undefined, apns: { payload: { aps: { badge, sound: sound || 'default', }, }, }, android: { priority: 'high', notification: { title, body, clickAction: 'FLUTTER_NOTIFICATION_CLICK', }, }, }; const response = await this.fcm.messaging().send(message); notification.status = NotificationStatus.SENT; notification.sentAt = new Date(); return notification; } catch (error) { notification.status = NotificationStatus.FAILED; notification.failedAt = new Date(); notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; return notification; } } /** * Send push notification using APNs */ async sendAPNSPush( recipient: NotificationRecipient, title: string, body: string, data?: Record, badge?: number ): Promise { const notification: PushNotification = { id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, userId: recipient.userId, channel: NotificationChannel.PUSH, templateId: 'custom', priority: NotificationPriority.NORMAL, status: NotificationStatus.PENDING, title, body, data, badge, apnsToken: recipient.apnsToken, createdAt: new Date(), }; if (!this.apnsConfig || !recipient.apnsToken) { notification.status = NotificationStatus.FAILED; notification.failedAt = new Date(); notification.errorMessage = !this.apnsConfig ? 'APNs not configured' : 'Missing APNs token'; return notification; } // FCM supports sending to APNs tokens (iOS devices) // This leverages FCM's unified push infrastructure for iOS // APNs token format: device-specific token from iOS if (this.fcm && recipient.apnsToken) { const message: admin.messaging.Message = { token: recipient.apnsToken, notification: { title, body, }, data: data ? Object.fromEntries( Object.entries(data).map(([key, value]) => [key, String(value)]) ) : undefined, apns: { payload: { aps: { badge, sound: 'default', contentAvailable: true, }, }, }, }; try { await this.fcm.messaging().send(message); notification.status = NotificationStatus.SENT; notification.sentAt = new Date(); } catch (error) { notification.status = NotificationStatus.FAILED; notification.failedAt = new Date(); notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; } } return notification; } /** * Send push notification (auto-detect platform) */ async sendPush( recipient: NotificationRecipient, title: string, body: string, data?: Record, badge?: number, sound?: string ): Promise { // Prefer APNs for iOS tokens, FCM for Android if (recipient.apnsToken) { return this.sendAPNSPush(recipient, title, body, data, badge); } else if (recipient.fcmToken) { return this.sendFCMPush(recipient, title, body, data, badge, sound); } else { return { id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, userId: recipient.userId, channel: NotificationChannel.PUSH, templateId: 'custom', priority: NotificationPriority.NORMAL, status: NotificationStatus.FAILED, title, body, data, badge, sound, createdAt: new Date(), failedAt: new Date(), errorMessage: 'No push token available', }; } } /** * Send broadcast push to multiple devices */ async sendBroadcastPush( recipients: Array, title: string, body: string, data?: Record ): Promise { const promises = recipients.map((recipient) => this.sendPush(recipient, title, body, data) ); return Promise.all(promises); } /** * Check if FCM is properly configured */ isFCMConfigured(): boolean { return !!this.fcm; } /** * Shutdown FCM app */ async shutdown(): Promise { if (this.fcm) { await this.fcm.delete(); } } }