feat(platform-service): Phase 4.4 - Push notification wiring (FCM/APNS)

- push-notifications.ts: FCM and APNS delivery services
- Device token registration/management
- sendPushNotification() for bulk delivery
- Automatic token deactivation on APNS 410 responses
This commit is contained in:
saravanakumardb1 2026-03-03 08:05:21 -08:00
parent 6c29df4207
commit 4bf18f4d4f

View File

@ -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<string, string>;
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<void> {
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<void> {
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<DeviceToken>({ 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<DeviceToken[]> {
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<DeviceToken>({ 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<void> {
const container = getRegisteredContainer('devices');
const query = 'SELECT * FROM c WHERE c.token = @token';
const { resources } = await container.items
.query<DeviceToken>({ 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<CryptoKey> {
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']
);
}