263 lines
7.0 KiB
TypeScript
263 lines
7.0 KiB
TypeScript
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<string, unknown>,
|
|
badge?: number,
|
|
sound?: string
|
|
): Promise<PushNotification> {
|
|
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<string, unknown>,
|
|
badge?: number
|
|
): Promise<PushNotification> {
|
|
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<string, unknown>,
|
|
badge?: number,
|
|
sound?: string
|
|
): Promise<PushNotification> {
|
|
// 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<NotificationRecipient>,
|
|
title: string,
|
|
body: string,
|
|
data?: Record<string, unknown>
|
|
): Promise<PushNotification[]> {
|
|
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<void> {
|
|
if (this.fcm) {
|
|
await this.fcm.delete();
|
|
}
|
|
}
|
|
}
|