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:
parent
6c29df4207
commit
4bf18f4d4f
348
services/platform-service/src/lib/push-notifications.ts
Normal file
348
services/platform-service/src/lib/push-notifications.ts
Normal 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']
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user