- push-notifications.ts: FCM and APNS delivery services - Device token registration/management - sendPushNotification() for bulk delivery - Automatic token deactivation on APNS 410 responses
349 lines
9.4 KiB
TypeScript
349 lines
9.4 KiB
TypeScript
/**
|
|
* 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']
|
|
);
|
|
}
|