diff --git a/services/platform-service/src/lib/push-notifications.ts b/services/platform-service/src/lib/push-notifications.ts new file mode 100644 index 00000000..bfbff43d --- /dev/null +++ b/services/platform-service/src/lib/push-notifications.ts @@ -0,0 +1,348 @@ +/** + * 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'] + ); +}