import jwt from 'jsonwebtoken'; import https from 'https'; import { loadNotificationConfig } from '../config/notification.config'; import type { PushNotification, NotificationResult } from '../types/notification.types'; const config = loadNotificationConfig(); let apnsToken: string | null = null; let tokenExpiration: number = 0; interface APNsPayload { aps: { alert: { title: string; body: string; }; badge?: number; sound?: string | { critical: number; name: string; volume: number; }; category?: string; 'content-available'?: number; 'mutable-content'?: number; }; [key: string]: any; } function generateAPNSToken(): string { const now = Math.floor(Date.now() / 1000); const token = jwt.sign( {}, config.apns.key, { algorithm: 'ES256' as any, headers: { alg: 'ES256', kty: 'EC', kid: config.apns.keyId, }, expiresIn: '1h', issuer: config.apns.teamId, } as any ); tokenExpiration = now + 3500; // Refresh 1 minute before expiration return token; } function getAPNSToken(): string { const now = Math.floor(Date.now() / 1000); if (!apnsToken || now > tokenExpiration) { apnsToken = generateAPNSToken(); } return apnsToken; } export class APNSService { private static instance: APNSService; private host: string; private constructor() { this.host = process.env.NODE_ENV === 'production' ? 'api.push.apple.com' : 'api.sandbox.push.apple.com'; } static getInstance(): APNSService { if (!APNSService.instance) { APNSService.instance = new APNSService(); } return APNSService.instance; } async send(notification: PushNotification): Promise { try { const token = getAPNSToken(); const payload = this.buildPayload(notification); const apnsToken = notification.data?.apnsToken as string || notification.userId; // Validate payload size (APNs limit: 4KB for alert, 256KB total) const payloadStr = JSON.stringify(payload); const payloadSize = Buffer.byteLength(payloadStr, 'utf8'); if (payloadSize > 256 * 1024) { return { notificationId: `apns-${Date.now()}`, channel: 'push', status: 'failed', error: `Payload size ${payloadSize} exceeds APNs limit of 256KB`, }; } const result = await this.sendToDevice(apnsToken, payload, token); return { notificationId: `apns-${Date.now()}`, channel: 'push', status: result.success ? 'sent' : 'failed', externalId: result.responseId, error: result.error, deliveredAt: result.success ? new Date() : undefined, }; } catch (error) { return { notificationId: `apns-${Date.now()}`, channel: 'push', status: 'failed', error: error instanceof Error ? error.message : 'Unknown error', }; } } private buildPayload(notification: PushNotification): APNsPayload { const payload: APNsPayload = { aps: { alert: { title: notification.title, body: notification.body, }, sound: notification.sound || 'default', }, }; if (notification.badge !== undefined) { payload.aps.badge = notification.badge; } if (notification.category) { payload.aps.category = notification.category; } if (notification.data) { Object.entries(notification.data).forEach(([key, value]) => { if (key !== 'apnsToken') { payload[key] = value; } }); } return payload; } private sendToDevice( deviceToken: string, payload: APNsPayload, authToken: string ): Promise<{ success: boolean; responseId?: string; error?: string }> { return new Promise((resolve) => { const url = `https://${this.host}:443/3/device/${deviceToken}`; const options = { hostname: this.host, port: 443, path: `/3/device/${deviceToken}`, method: 'POST', headers: { 'Authorization': `bearer ${authToken}`, 'Content-Type': 'application/json', 'apns-push-type': 'alert', 'apns-priority': '10', 'apns-topic': config.apns.bundleId, }, }; const req = https.request(url, options, (res) => { let responseData = ''; res.on('data', (chunk) => { responseData += chunk; }); res.on('end', () => { if (res.statusCode === 200) { resolve({ success: true, responseId: res.headers['apns-id'] as string, }); } else { let error = `APNs error: ${res.statusCode}`; try { const errorBody = JSON.parse(responseData); error = errorBody.reason || errorBody.message || error; } catch { // Keep default error message } resolve({ success: false, error, }); } }); }); req.on('error', (error) => { resolve({ success: false, error: error.message, }); }); req.write(JSON.stringify(payload)); req.end(); }); } async sendBatch(notifications: PushNotification[]): Promise { const results = await Promise.all( notifications.map(n => this.send(n)) ); return results; } /** * Test APNs connection by validating configuration and making a test request */ async testConnection(): Promise<{ success: boolean; message: string }> { try { // Validate required configuration if (!config.apns.keyId || !config.apns.teamId || !config.apns.bundleId) { return { success: false, message: 'Missing required APNs configuration: keyId, teamId, or bundleId', }; } if (!config.apns.key) { return { success: false, message: 'Missing APNs private key', }; } // Generate a token to verify the key is valid const token = getAPNSToken(); if (!token) { return { success: false, message: 'Failed to generate APNs authentication token', }; } // Make a minimal test request to APNs to verify connection return new Promise((resolve) => { const testDeviceToken = '0000000000000000000000000000000000000000000000000000000000000000'; const url = `https://${this.host}:443/3/device/${testDeviceToken}`; const options = { hostname: this.host, port: 443, path: `/3/device/${testDeviceToken}`, method: 'POST', headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/json', 'apns-push-type': 'alert', 'apns-priority': '10', 'apns-topic': config.apns.bundleId, }, timeout: 5000, }; const req = https.request(url, options, (res) => { // Any response means we connected successfully // Even 410 (device token not registered) means connection works if (res.statusCode && res.statusCode < 500) { resolve({ success: true, message: `APNs connection successful (status: ${res.statusCode})`, }); } else { resolve({ success: false, message: `APNs connection failed with status: ${res.statusCode}`, }); } }); req.on('error', (error) => { // Connection errors are expected if no real APNs credentials resolve({ success: false, message: `APNs connection error: ${error.message}`, }); }); req.on('timeout', () => { req.destroy(); resolve({ success: false, message: 'APNs connection timeout', }); }); // Send minimal payload req.write(JSON.stringify({ aps: { sound: 'default' } })); req.end(); }); } catch (error) { return { success: false, message: error instanceof Error ? error.message : 'Unknown error', }; } } }