/** * Push Notification Service * Handles FCM (Firebase Cloud Messaging) for Android and APNS for iOS */ import { getRegisteredContainer } from '@bytelyst/cosmos'; // Device token record interface DeviceToken { id: string; userId: string; productId: string; platform: 'ios' | 'android' | 'web'; token: string; provider: 'fcm' | 'apns'; createdAt: string; updatedAt: string; lastUsedAt?: string; isActive: boolean; } // Push payload interface PushPayload { title: string; body: string; data?: Record; imageUrl?: string; deepLink?: string; priority?: 'normal' | 'high'; sound?: string; badge?: number; } /** * Store or update device token for push notifications */ export async function registerDeviceToken( userId: string, productId: string, platform: 'ios' | 'android' | 'web', token: string ): Promise { const container = getRegisteredContainer('devices'); const id = `${userId}:${platform}:${token.substring(0, 16)}`; const now = new Date().toISOString(); const provider = platform === 'ios' ? 'apns' : 'fcm'; const deviceToken: DeviceToken = { id, userId, productId, platform, token, provider, createdAt: now, updatedAt: now, lastUsedAt: now, isActive: true, }; await container.items.upsert(deviceToken); } /** * Deactivate device token (e.g., on logout or uninstall) */ export async function unregisterDeviceToken( userId: string, token: string ): Promise { const container = getRegisteredContainer('devices'); // Find token by user and partial token match const query = 'SELECT * FROM c WHERE c.userId = @userId AND c.token = @token'; const { resources } = await container.items .query({ query, parameters: [ { name: '@userId', value: userId }, { name: '@token', value: token } ]}) .fetchAll(); for (const device of resources) { await container.items.upsert({ ...device, isActive: false, updatedAt: new Date().toISOString(), }); } } /** * Get active device tokens for users */ export async function getDeviceTokensForUsers( userIds: string[], platforms?: ('ios' | 'android' | 'web')[] ): Promise { const container = getRegisteredContainer('devices'); const userIdList = userIds.map((id, i) => ({ name: `@userId${i}`, value: id })); const userIdParams = userIdList.map((p) => p.name).join(', '); let query = `SELECT * FROM c WHERE c.userId IN (${userIdParams}) AND c.isActive = true`; const parameters = [...userIdList]; if (platforms && platforms.length > 0) { const platformList = platforms.map((p, i) => ({ name: `@platform${i}`, value: p })); const platformParams = platformList.map((p) => p.name).join(', '); query += ` AND c.platform IN (${platformParams})`; parameters.push(...platformList); } const { resources } = await container.items .query({ query, parameters }) .fetchAll(); return resources; } /** * Send push notification via FCM (Firebase Cloud Messaging) */ export async function sendFCM( tokens: string[], payload: PushPayload, productId: string ): Promise<{ success: string[]; failed: string[] }> { const results = { success: [] as string[], failed: [] as string[] }; // Get FCM server key from environment const fcmKey = process.env.FCM_SERVER_KEY; if (!fcmKey) { console.warn('[Push] FCM_SERVER_KEY not configured'); return results; } for (const token of tokens) { try { const response = await fetch('https://fcm.googleapis.com/fcm/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `key=${fcmKey}`, }, body: JSON.stringify({ to: token, notification: { title: payload.title, body: payload.body, image: payload.imageUrl, sound: payload.sound || 'default', badge: payload.badge, }, data: { ...payload.data, deepLink: payload.deepLink, productId, }, priority: payload.priority || 'normal', }), }); if (response.ok) { results.success.push(token); } else { const error = await response.text(); console.error(`[Push] FCM failed for token ${token.substring(0, 16)}...:`, error); results.failed.push(token); } } catch (err) { console.error(`[Push] FCM error for token ${token.substring(0, 16)}...:`, err); results.failed.push(token); } } return results; } /** * Send push notification via APNS (Apple Push Notification Service) */ export async function sendAPNS( tokens: string[], payload: PushPayload, productId: string ): Promise<{ success: string[]; failed: string[] }> { const results = { success: [] as string[], failed: [] as string[] }; // APNS requires JWT-based authentication with p8 key // This is a simplified implementation const apnsKeyId = process.env.APNS_KEY_ID; const apnsTeamId = process.env.APNS_TEAM_ID; const apnsBundleId = process.env.APNS_BUNDLE_ID; const apnsPrivateKey = process.env.APNS_PRIVATE_KEY; if (!apnsKeyId || !apnsTeamId || !apnsBundleId || !apnsPrivateKey) { console.warn('[Push] APNS credentials not fully configured'); return results; } // Import JWT library for APNS authentication const { SignJWT } = await import('jose'); // Generate JWT token for APNS const privateKey = await importPKCS8(apnsPrivateKey, 'ES256'); const jwt = await new SignJWT({}) .setProtectedHeader({ alg: 'ES256', kid: apnsKeyId }) .setIssuedAt() .setIssuer(apnsTeamId) .setExpirationTime('1h') .sign(privateKey); for (const token of tokens) { try { const response = await fetch(`https://api.push.apple.com/3/device/${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `bearer ${jwt}`, 'apns-topic': apnsBundleId, 'apns-priority': payload.priority === 'high' ? '10' : '5', 'apns-push-type': 'alert', }, body: JSON.stringify({ aps: { alert: { title: payload.title, body: payload.body, }, badge: payload.badge, sound: payload.sound || 'default', 'mutable-content': 1, }, ...payload.data, deepLink: payload.deepLink, productId, }), }); if (response.ok) { results.success.push(token); } else { const error = await response.text(); console.error(`[Push] APNS failed for token ${token.substring(0, 16)}...:`, error); results.failed.push(token); // Handle invalid token (410 Gone) if (response.status === 410) { await deactivateToken(token); } } } catch (err) { console.error(`[Push] APNS error for token ${token.substring(0, 16)}...:`, err); results.failed.push(token); } } return results; } /** * Send push notification to multiple users */ export async function sendPushNotification( userIds: string[], payload: PushPayload, productId: string, platforms?: ('ios' | 'android' | 'web')[] ): Promise<{ totalTokens: number; fcmSuccess: number; fcmFailed: number; apnsSuccess: number; apnsFailed: number; }> { const stats = { totalTokens: 0, fcmSuccess: 0, fcmFailed: 0, apnsSuccess: 0, apnsFailed: 0, }; // Get device tokens const devices = await getDeviceTokensForUsers(userIds, platforms); stats.totalTokens = devices.length; if (devices.length === 0) { return stats; } // Group by provider const fcmTokens = devices.filter((d) => d.provider === 'fcm').map((d) => d.token); const apnsTokens = devices.filter((d) => d.provider === 'apns').map((d) => d.token); // Send via FCM if (fcmTokens.length > 0) { const fcmResults = await sendFCM(fcmTokens, payload, productId); stats.fcmSuccess = fcmResults.success.length; stats.fcmFailed = fcmResults.failed.length; } // Send via APNS if (apnsTokens.length > 0) { const apnsResults = await sendAPNS(apnsTokens, payload, productId); stats.apnsSuccess = apnsResults.success.length; stats.apnsFailed = apnsResults.failed.length; } return stats; } /** * Deactivate a token that has been rejected by APNS */ async function deactivateToken(token: string): Promise { const container = getRegisteredContainer('devices'); const query = 'SELECT * FROM c WHERE c.token = @token'; const { resources } = await container.items .query({ query, parameters: [{ name: '@token', value: token }] }) .fetchAll(); for (const device of resources) { await container.items.upsert({ ...device, isActive: false, updatedAt: new Date().toISOString(), }); } } // Helper for importing PKCS8 key async function importPKCS8(pem: string, alg: string): Promise { const pemHeader = '-----BEGIN PRIVATE KEY-----'; const pemFooter = '-----END PRIVATE KEY-----'; const pemContents = pem.replace(pemHeader, '').replace(pemFooter, '').replace(/\s/g, ''); const binaryDer = Buffer.from(pemContents, 'base64'); return crypto.subtle.importKey( 'pkcs8', binaryDer, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] ); }