diff --git a/packages/api/src/routes/device.routes.ts b/packages/api/src/routes/device.routes.ts new file mode 100644 index 0000000..ef59382 --- /dev/null +++ b/packages/api/src/routes/device.routes.ts @@ -0,0 +1,342 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { prisma } from '@shieldai/db'; + +// In-memory rate limiter for device registration +const registrationAttempts = new Map(); +const REGISTRATION_RATE_LIMIT = 10; // max registrations per window +const REGISTRATION_WINDOW_MS = 5 * 60 * 1000; // 5 minutes + +function checkRegistrationRateLimit(key: string): boolean { + const now = Date.now(); + const record = registrationAttempts.get(key); + + if (!record || now > record.resetAt) { + registrationAttempts.set(key, { count: 1, resetAt: now + REGISTRATION_WINDOW_MS }); + return true; + } + + if (record.count >= REGISTRATION_RATE_LIMIT) { + return false; + } + + record.count++; + return true; +} + +// Cleanup stale rate limit entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, record] of registrationAttempts.entries()) { + if (now > record.resetAt) { + registrationAttempts.delete(key); + } + } +}, REGISTRATION_WINDOW_MS); + +export async function deviceRoutes(fastify: FastifyInstance) { + // Register device for push notifications + fastify.post( + '/devices/register', + { + preHandler: async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.status(401).send({ error: 'Authentication required' }); + } + }, + }, + async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const { platform, fcmToken, apnsToken, appVersion, osVersion, deviceModel } = request.body as { + platform: 'ios' | 'android'; + fcmToken?: string; + apnsToken?: string; + appVersion: string; + osVersion: string; + deviceModel?: string; + }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!platform || !appVersion || !osVersion) { + return reply.status(400).send({ + error: 'Missing required fields', + required: ['platform', 'appVersion', 'osVersion'], + }); + } + + // Rate limit registration per user + if (!checkRegistrationRateLimit(authReq.user.id)) { + return reply.status(429).send({ + error: 'Too many registration attempts', + retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000), + }); + } + + if (platform === 'android' && !fcmToken) { + return reply.status(400).send({ + error: 'FCM token required for Android devices', + }); + } + + if (platform === 'ios' && !apnsToken) { + return reply.status(400).send({ + error: 'APNs token required for iOS devices', + }); + } + + try { + // Determine device type based on platform + const deviceType = platform === 'ios' || platform === 'android' ? 'mobile' : 'web'; + + // Determine the token to store + const deviceToken = platform === 'ios' ? apnsToken! : fcmToken!; + + // Upsert device registration to handle token rotation and re-registration + const deviceRegistration = await prisma.deviceToken.upsert({ + where: { token: deviceToken }, + create: { + userId: authReq.user.id, + deviceType, + token: deviceToken, + platform, + appVersion, + osVersion, + model: deviceModel, + isActive: true, + lastUsedAt: new Date(), + }, + update: { + userId: authReq.user.id, + deviceType, + platform, + appVersion, + osVersion, + model: deviceModel, + isActive: true, + lastUsedAt: new Date(), + }, + }); + + return { + success: true, + device: { + deviceId: deviceRegistration.id, + platform: deviceRegistration.platform, + registeredAt: deviceRegistration.createdAt.toISOString(), + }, + message: 'Device registered successfully', + }; + } catch (error) { + console.error('Failed to register device:', error); + reply.status(500).send({ + error: 'Failed to register device', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Update device push tokens + fastify.put( + '/devices/:deviceId/tokens', + { + preHandler: async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.status(401).send({ error: 'Authentication required' }); + } + }, + }, + async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const { deviceId } = request.params as { deviceId: string }; + const { fcmToken, apnsToken, deviceModel } = request.body as { + fcmToken?: string; + apnsToken?: string; + deviceModel?: string; + }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + if (!fcmToken && !apnsToken) { + return reply.status(400).send({ + error: 'At least one token is required', + }); + } + + try { + // Find the device first + const existingDevice = await prisma.deviceToken.findFirst({ + where: { + id: deviceId, + userId: authReq.user.id, + isActive: true, + }, + }); + + if (!existingDevice) { + return reply.status(404).send({ + error: 'Device not found', + }); + } + + // Determine which token to update based on platform + const updateData: { + token: string; + appVersion?: string; + osVersion?: string; + model?: string; + lastUsedAt: Date; + } = { + token: existingDevice.platform === 'ios' ? (apnsToken || existingDevice.token) : (fcmToken || existingDevice.token), + lastUsedAt: new Date(), + }; + + if (deviceModel) { + updateData.model = deviceModel; + } + + // Update device tokens in database + const device = await prisma.deviceToken.update({ + where: { + id: deviceId, + userId: authReq.user.id, + }, + data: updateData, + }); + + return { + success: true, + device: { + deviceId: device.id, + platform: device.platform, + lastActiveAt: device.lastUsedAt.toISOString(), + }, + message: 'Device tokens updated successfully', + }; + } catch (error) { + console.error('Failed to update device tokens:', error); + reply.status(500).send({ + error: 'Failed to update device tokens', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Get user's registered devices + fastify.get( + '/devices', + { + preHandler: async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.status(401).send({ error: 'Authentication required' }); + } + }, + }, + async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + try { + // Fetch devices from database + const devices = await prisma.deviceToken.findMany({ + where: { + userId: authReq.user.id, + isActive: true, + }, + select: { + id: true, + platform: true, + appVersion: true, + osVersion: true, + model: true, + lastUsedAt: true, + createdAt: true, + }, + orderBy: { + lastUsedAt: 'desc', + }, + }); + + return { + success: true, + devices: devices.map((d) => ({ + deviceId: d.id, + platform: d.platform, + appVersion: d.appVersion, + osVersion: d.osVersion, + model: d.model, + lastActiveAt: d.lastUsedAt.toISOString(), + registeredAt: d.createdAt.toISOString(), + })), + message: 'Device list retrieved', + }; + } catch (error) { + console.error('Failed to fetch devices:', error); + reply.status(500).send({ + error: 'Failed to fetch devices', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + // Deregister device + fastify.delete( + '/devices/:deviceId', + { + preHandler: async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + if (!userId) { + return reply.status(401).send({ error: 'Authentication required' }); + } + }, + }, + async (request, reply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const { deviceId } = request.params as { deviceId: string }; + + if (!authReq.user?.id) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + try { + // Soft delete by marking as inactive + await prisma.deviceToken.update({ + where: { + id: deviceId, + userId: authReq.user.id, + }, + data: { + isActive: false, + }, + }); + + return { + success: true, + message: 'Device deregistered successfully', + }; + } catch (error) { + console.error('Failed to deregister device:', error); + reply.status(500).send({ + error: 'Failed to deregister device', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); +} diff --git a/packages/shared-notifications/src/config/notification.config.ts b/packages/shared-notifications/src/config/notification.config.ts index 93f242f..686c1b7 100644 --- a/packages/shared-notifications/src/config/notification.config.ts +++ b/packages/shared-notifications/src/config/notification.config.ts @@ -38,24 +38,24 @@ export type NotificationConfig = z.infer; export const loadNotificationConfig = (): NotificationConfig => { const config = { resend: { - apiKey: process.env.RESEND_API_KEY!, + apiKey: process.env.RESEND_API_KEY ?? '', baseUrl: process.env.RESEND_BASE_URL || 'https://api.resend.com', }, fcm: { - privateKey: process.env.FCM_PRIVATE_KEY!, - projectId: process.env.FCM_PROJECT_ID!, - clientEmail: process.env.FCM_CLIENT_EMAIL!, + privateKey: process.env.FCM_PRIVATE_KEY ?? '', + projectId: process.env.FCM_PROJECT_ID ?? '', + clientEmail: process.env.FCM_CLIENT_EMAIL ?? '', }, apns: { - key: process.env.APNS_KEY!, - keyId: process.env.APNS_KEY_ID!, - teamId: process.env.APNS_TEAM_ID!, - bundleId: process.env.APNS_BUNDLE_ID!, + key: process.env.APNS_KEY ?? '', + keyId: process.env.APNS_KEY_ID ?? '', + teamId: process.env.APNS_TEAM_ID ?? '', + bundleId: process.env.APNS_BUNDLE_ID ?? '', }, twilio: { - accountSid: process.env.TWILIO_ACCOUNT_SID!, - authToken: process.env.TWILIO_AUTH_TOKEN!, - messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!, + accountSid: process.env.TWILIO_ACCOUNT_SID ?? '', + authToken: process.env.TWILIO_AUTH_TOKEN ?? '', + messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID ?? '', }, rateLimits: { emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10), @@ -69,5 +69,11 @@ export const loadNotificationConfig = (): NotificationConfig => { }, }; - return NotificationConfigSchema.parse(config); + const parsed = NotificationConfigSchema.safeParse(config); + if (!parsed.success) { + const errors = parsed.error.errors.map(e => ` - ${e.path.join('.')}: ${e.message}`).join('\n'); + throw new Error(`Invalid notification config:\n${errors}`); + } + + return parsed.data; }; diff --git a/packages/shared-notifications/src/services/apns.service.ts b/packages/shared-notifications/src/services/apns.service.ts new file mode 100644 index 0000000..80b6314 --- /dev/null +++ b/packages/shared-notifications/src/services/apns.service.ts @@ -0,0 +1,309 @@ +import jwt from 'jsonwebtoken'; +import https from 'https'; +import { loadNotificationConfig } from '../config/notification.config'; +import type { PushNotification, NotificationResult } from '../types/notification.types'; + +const config = loadNotificationConfig(); + +let apnsToken: string | null = null; +let tokenExpiration: number = 0; + +interface APNsPayload { + aps: { + alert: { + title: string; + body: string; + }; + badge?: number; + sound?: string | { + critical: number; + name: string; + volume: number; + }; + category?: string; + 'content-available'?: number; + 'mutable-content'?: number; + }; + [key: string]: any; +} + +function generateAPNSToken(): string { + const now = Math.floor(Date.now() / 1000); + + const token = jwt.sign( + {}, + config.apns.key, + { + algorithm: 'ES256' as any, + headers: { + alg: 'ES256', + kty: 'EC', + kid: config.apns.keyId, + }, + expiresIn: '1h', + issuer: config.apns.teamId, + } as any + ); + + tokenExpiration = now + 3500; // Refresh 1 minute before expiration + return token; +} + +function getAPNSToken(): string { + const now = Math.floor(Date.now() / 1000); + + if (!apnsToken || now > tokenExpiration) { + apnsToken = generateAPNSToken(); + } + + return apnsToken; +} + +export class APNSService { + private static instance: APNSService; + private host: string; + + private constructor() { + this.host = process.env.NODE_ENV === 'production' + ? 'api.push.apple.com' + : 'api.sandbox.push.apple.com'; + } + + static getInstance(): APNSService { + if (!APNSService.instance) { + APNSService.instance = new APNSService(); + } + return APNSService.instance; + } + + async send(notification: PushNotification): Promise { + try { + const token = getAPNSToken(); + const payload = this.buildPayload(notification); + const apnsToken = notification.data?.apnsToken as string || notification.userId; + + // Validate payload size (APNs limit: 4KB for alert, 256KB total) + const payloadStr = JSON.stringify(payload); + const payloadSize = Buffer.byteLength(payloadStr, 'utf8'); + if (payloadSize > 256 * 1024) { + return { + notificationId: `apns-${Date.now()}`, + channel: 'push', + status: 'failed', + error: `Payload size ${payloadSize} exceeds APNs limit of 256KB`, + }; + } + + const result = await this.sendToDevice(apnsToken, payload, token); + + return { + notificationId: `apns-${Date.now()}`, + channel: 'push', + status: result.success ? 'sent' : 'failed', + externalId: result.responseId, + error: result.error, + deliveredAt: result.success ? new Date() : undefined, + }; + } catch (error) { + return { + notificationId: `apns-${Date.now()}`, + channel: 'push', + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private buildPayload(notification: PushNotification): APNsPayload { + const payload: APNsPayload = { + aps: { + alert: { + title: notification.title, + body: notification.body, + }, + sound: notification.sound || 'default', + }, + }; + + if (notification.badge !== undefined) { + payload.aps.badge = notification.badge; + } + + if (notification.category) { + payload.aps.category = notification.category; + } + + if (notification.data) { + Object.entries(notification.data).forEach(([key, value]) => { + if (key !== 'apnsToken') { + payload[key] = value; + } + }); + } + + return payload; + } + + private sendToDevice( + deviceToken: string, + payload: APNsPayload, + authToken: string + ): Promise<{ success: boolean; responseId?: string; error?: string }> { + return new Promise((resolve) => { + const url = `https://${this.host}:443/3/device/${deviceToken}`; + + const options = { + hostname: this.host, + port: 443, + path: `/3/device/${deviceToken}`, + method: 'POST', + headers: { + 'Authorization': `bearer ${authToken}`, + 'Content-Type': 'application/json', + 'apns-push-type': 'alert', + 'apns-priority': '10', + 'apns-topic': config.apns.bundleId, + }, + }; + + const req = https.request(url, options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + resolve({ + success: true, + responseId: res.headers['apns-id'] as string, + }); + } else { + let error = `APNs error: ${res.statusCode}`; + try { + const errorBody = JSON.parse(responseData); + error = errorBody.reason || errorBody.message || error; + } catch { + // Keep default error message + } + resolve({ + success: false, + error, + }); + } + }); + }); + + req.on('error', (error) => { + resolve({ + success: false, + error: error.message, + }); + }); + + req.write(JSON.stringify(payload)); + req.end(); + }); + } + + async sendBatch(notifications: PushNotification[]): Promise { + const results = await Promise.all( + notifications.map(n => this.send(n)) + ); + return results; + } + + /** + * Test APNs connection by validating configuration and making a test request + */ + async testConnection(): Promise<{ success: boolean; message: string }> { + try { + // Validate required configuration + if (!config.apns.keyId || !config.apns.teamId || !config.apns.bundleId) { + return { + success: false, + message: 'Missing required APNs configuration: keyId, teamId, or bundleId', + }; + } + + if (!config.apns.key) { + return { + success: false, + message: 'Missing APNs private key', + }; + } + + // Generate a token to verify the key is valid + const token = getAPNSToken(); + if (!token) { + return { + success: false, + message: 'Failed to generate APNs authentication token', + }; + } + + // Make a minimal test request to APNs to verify connection + return new Promise((resolve) => { + const testDeviceToken = '0000000000000000000000000000000000000000000000000000000000000000'; + const url = `https://${this.host}:443/3/device/${testDeviceToken}`; + + const options = { + hostname: this.host, + port: 443, + path: `/3/device/${testDeviceToken}`, + method: 'POST', + headers: { + 'Authorization': `bearer ${token}`, + 'Content-Type': 'application/json', + 'apns-push-type': 'alert', + 'apns-priority': '10', + 'apns-topic': config.apns.bundleId, + }, + timeout: 5000, + }; + + const req = https.request(url, options, (res) => { + // Any response means we connected successfully + // Even 410 (device token not registered) means connection works + if (res.statusCode && res.statusCode < 500) { + resolve({ + success: true, + message: `APNs connection successful (status: ${res.statusCode})`, + }); + } else { + resolve({ + success: false, + message: `APNs connection failed with status: ${res.statusCode}`, + }); + } + }); + + req.on('error', (error) => { + // Connection errors are expected if no real APNs credentials + resolve({ + success: false, + message: `APNs connection error: ${error.message}`, + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ + success: false, + message: 'APNs connection timeout', + }); + }); + + // Send minimal payload + req.write(JSON.stringify({ aps: { sound: 'default' } })); + req.end(); + }); + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + }; + } + } +} diff --git a/packages/shared-notifications/src/services/push.service.ts b/packages/shared-notifications/src/services/push.service.ts index 2da52f5..3efc8da 100644 --- a/packages/shared-notifications/src/services/push.service.ts +++ b/packages/shared-notifications/src/services/push.service.ts @@ -1,30 +1,145 @@ import admin from 'firebase-admin'; import { loadNotificationConfig } from '../config/notification.config'; +import { APNSService } from './apns.service'; import type { PushNotification, NotificationResult } from '../types/notification.types'; const config = loadNotificationConfig(); let fcmApp: admin.app.App | null = null; +let fcmInitPromise: Promise | null = null; function getFCMApp(): admin.app.App { - if (!fcmApp) { - fcmApp = admin.initializeApp({ - credential: admin.credential.cert({ - projectId: config.fcm.projectId, - clientEmail: config.fcm.clientEmail, - privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'), - }), - }); + if (fcmApp) { + return fcmApp; } + + // If already initializing, throw to prevent duplicate initialization + // (caller should use getFCMAppAsync for async-safe access) + if (fcmInitPromise && !fcmApp) { + throw new Error('Firebase initialization in progress'); + } + + // Check if any Firebase apps exist + const existingApps = admin.apps; + if (existingApps && existingApps.length > 0) { + const existingApp = existingApps.find((app: admin.app.App) => app.name === '[DEFAULT]'); + if (existingApp) { + fcmApp = existingApp; + return fcmApp; + } + } + + // Initialize new app + fcmApp = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: config.fcm.projectId, + clientEmail: config.fcm.clientEmail, + privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'), + }), + }); + return fcmApp; } +/** + * Async-safe version that prevents race conditions during initialization. + * Multiple concurrent calls will share the same initialization promise. + */ +function getFCMAppAsync(): Promise { + if (fcmApp) { + return Promise.resolve(fcmApp); + } + + if (fcmInitPromise) { + return fcmInitPromise; + } + + fcmInitPromise = (async () => { + try { + // Check if any Firebase apps exist + const existingApps = admin.apps; + if (existingApps && existingApps.length > 0) { + const existingApp = existingApps.find((app: admin.app.App) => app.name === '[DEFAULT]'); + if (existingApp) { + fcmApp = existingApp; + return fcmApp; + } + } + + fcmApp = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: config.fcm.projectId, + clientEmail: config.fcm.clientEmail, + privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'), + }), + }); + return fcmApp; + } finally { + fcmInitPromise = null; + } + })(); + + return fcmInitPromise; +} + +// Retry configuration for transient failures +const RETRY_CONFIG = { + maxRetries: 3, + baseDelayMs: 1000, + maxDelayMs: 5000, + retryableErrors: [ + 'UNAVAILABLE', + 'DEADLINE_EXCEEDED', + 'RESOURCE_EXHAUSTED', + 'INTERNAL', + ], +}; + +function isRetryableError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toUpperCase(); + return RETRY_CONFIG.retryableErrors.some(re => message.includes(re)); + } + return false; +} + +async function withRetry( + fn: () => Promise, + maxRetries: number = RETRY_CONFIG.maxRetries +): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt < maxRetries && isRetryableError(error)) { + const delay = Math.min( + RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt), + RETRY_CONFIG.maxDelayMs + ); + console.warn(`Push notification attempt ${attempt + 1} failed, retrying in ${delay}ms:`, lastError.message); + await new Promise(resolve => setTimeout(resolve, delay)); + } else { + throw lastError; + } + } + } + + throw lastError!; +} + export class PushService { private static instance: PushService; private sentCount = new Map(); + private sentDedup = new Map(); + private apnsService: APNSService; private cleanupInterval: NodeJS.Timeout; private constructor() { + this.apnsService = APNSService.getInstance(); this.cleanupInterval = setInterval(() => { const now = Date.now(); for (const [key, timestamp] of this.sentCount.entries()) { @@ -32,6 +147,11 @@ export class PushService { this.sentCount.delete(key); } } + for (const [key, timestamp] of this.sentDedup.entries()) { + if (now - timestamp > config.redis.dedupWindowSeconds * 1000) { + this.sentDedup.delete(key); + } + } }, 60000); } @@ -42,6 +162,10 @@ export class PushService { return PushService.instance; } + /** + * Send push notification using the appropriate service + * Uses explicit platform from notification data instead of heuristic detection + */ async send(notification: PushNotification): Promise { const rateLimitKey = `push:${notification.userId}`; const currentCount = this.sentCount.get(rateLimitKey) || 0; @@ -50,43 +174,63 @@ export class PushService { throw new Error(`Push rate limit exceeded for user ${notification.userId}`); } - try { - const fcmApp = getFCMApp(); - const messaging = admin.messaging(fcmApp); - - const message: admin.messaging.Message = { - notification: { - title: notification.title, - body: notification.body, - }, - data: notification.data ? - Object.fromEntries( - Object.entries(notification.data).map(([k, v]) => [k, String(v)]) - ) : undefined, - token: notification.userId, - apns: { - payload: { - aps: { - badge: notification.badge, - sound: notification.sound || 'default', - category: notification.category, - }, - }, - }, - }; - - const response = await messaging.send(message); - - this.sentCount.set(rateLimitKey, currentCount + 1); - + // Deduplication: skip if identical notification was sent recently + const dedupKey = `${notification.userId}:${notification.title}:${notification.body}`; + const lastSent = this.sentDedup.get(dedupKey); + if (lastSent && Date.now() - lastSent < config.redis.dedupWindowSeconds * 1000) { return { - notificationId: `push-${response}`, + notificationId: `push-dedup-${Date.now()}`, channel: 'push', - status: 'sent', - externalId: response, - deliveredAt: new Date(), + status: 'skipped', + error: 'Duplicate notification within dedup window', }; + } + + try { + // Get the platform explicitly from notification data + const platform = notification.data?.platform as 'ios' | 'android' | undefined; + + if (!platform) { + return { + notificationId: `push-${Date.now()}`, + channel: 'push', + status: 'failed', + error: 'Platform not specified in notification data', + }; + } + + if (platform === 'ios') { + // Use APNs for iOS + const apnsToken = notification.data?.apnsToken as string; + if (!apnsToken) { + return { + notificationId: `push-${Date.now()}`, + channel: 'push', + status: 'failed', + error: 'APNs token not provided for iOS notification', + }; + } + + // Preserve userId and pass apnsToken separately to APNS service + const apnsResult = await this.apnsService.send(notification); + if (apnsResult.status === 'sent') { + const apnsDedupKey = `${notification.userId}:${notification.title}:${notification.body}`; + this.sentDedup.set(apnsDedupKey, Date.now()); + } + return apnsResult; + } else if (platform === 'android') { + // Use FCM for Android + return await this.sendViaFCM(notification); + } else { + return { + notificationId: `push-${Date.now()}`, + channel: 'push', + status: 'failed', + error: `Unsupported platform: ${platform}`, + }; + } } catch (error) { + console.error('Push notification failed:', error); return { notificationId: `push-${Date.now()}`, channel: 'push', @@ -96,6 +240,100 @@ export class PushService { } } + /** + * Send notification via Firebase Cloud Messaging (Android) + */ + private async sendViaFCM(notification: PushNotification): Promise { + try { + const fcmApp = await getFCMAppAsync(); + const messaging = admin.messaging(fcmApp); + + // Validate payload size (FCM limit is 4KB) + const payloadSize = JSON.stringify(notification).length; + if (payloadSize > 4096) { + return { + notificationId: `fcm-${Date.now()}`, + channel: 'push', + status: 'failed', + error: `Payload size ${payloadSize} exceeds FCM limit of 4KB`, + }; + } + + const message: admin.messaging.Message = { + notification: { + title: notification.title, + body: notification.body, + }, + data: notification.data ? + Object.fromEntries( + Object.entries(notification.data).map(([k, v]) => [k, String(v)]) + ) : undefined, + token: notification.data?.fcmToken as string, + }; + + // Use retry logic for transient failures + const response = await withRetry(() => messaging.send(message)); + + this.sentCount.set(`push:${notification.userId}`, + (this.sentCount.get(`push:${notification.userId}`) || 0) + 1); + + // Track for deduplication + const dedupKey = `${notification.userId}:${notification.title}:${notification.body}`; + this.sentDedup.set(dedupKey, Date.now()); + + return { + notificationId: `fcm-${response}`, + channel: 'push', + status: 'sent', + externalId: response, + deliveredAt: new Date(), + }; + } catch (error) { + console.error('FCM send failed:', error); + + // Handle specific FCM errors + if (error instanceof admin.messaging.MessagingError) { + const fcmCode = error.code; + + // Non-retryable errors - invalid token, unregistered device + if (fcmCode === 'messaging/invalid-registration-token' || + fcmCode === 'messaging/registration-token-not-registered' || + fcmCode === 'messaging/invalid-argument') { + return { + notificationId: `fcm-${Date.now()}`, + channel: 'push', + status: 'failed', + error: `Invalid FCM token: ${error.message}`, + }; + } + } + + return { + notificationId: `fcm-${Date.now()}`, + channel: 'push', + status: 'failed', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Send notification specifically via FCM (for Android devices) + */ + async sendViaFCMOnly(notification: PushNotification): Promise { + return this.sendViaFCM(notification); + } + + /** + * Send notification specifically via APNs (for iOS devices) + */ + async sendViaAPNS(notification: PushNotification): Promise { + return this.apnsService.send(notification); + } + + /** + * Send batch notifications with retry logic + */ async sendBatch(notifications: PushNotification[]): Promise { const results = await Promise.all( notifications.map(n => this.send(n)) @@ -103,10 +341,53 @@ export class PushService { return results; } + /** + * Get rate limit status + */ getRateLimitStatus(): { remaining: number; limit: number } { return { remaining: config.rateLimits.pushPerMinute - this.sentCount.size, limit: config.rateLimits.pushPerMinute, }; } + + /** + * Test connection to FCM by verifying app initialization + */ + async testFCMConnection(): Promise<{ success: boolean; message: string }> { + try { + const fcmApp = await getFCMAppAsync(); + + if (!fcmApp) { + return { + success: false, + message: 'Firebase app not initialized', + }; + } + + return { success: true, message: 'FCM connection successful' }; + } catch (error) { + console.error('FCM connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Test connection to APNs by making a real connection test + */ + async testAPNSConnection(): Promise<{ success: boolean; message: string }> { + try { + // Use the APNSService to test actual connection + return await this.apnsService.testConnection(); + } catch (error) { + console.error('APNs connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } + } }