/** * Push Notification Service for InstaRecipe Queue System * * Handles web push notifications for background processing updates * when users are not actively viewing the application. */ // @ts-expect-error - web-push doesn't have TypeScript types, but we mock it anyway import webpush from 'web-push'; import { queueConfig } from '../queue/config'; interface PushSubscription { endpoint: string; keys: { p256dh: string; auth: string; }; } interface NotificationPayload { title?: string; body: string; type: 'success' | 'error' | 'progress'; itemId: string; recipeName?: string; tag?: string; requireInteraction?: boolean; analytics?: any; } class PushNotificationService { private subscriptions = new Map(); private vapidKeys: { publicKey: string; privateKey: string } | null = null; constructor() { this.loadVapidKeys(); // Configure web-push with VAPID details if (this.vapidKeys) { webpush.setVapidDetails( queueConfig.push.vapidEmail, this.vapidKeys.publicKey, this.vapidKeys.privateKey ); } } /** * Load VAPID keys for push notifications * In production, these should be stored securely and loaded from environment */ private loadVapidKeys() { // Load from config module which uses SvelteKit's $env/dynamic/private this.vapidKeys = { publicKey: queueConfig.push.vapidPublicKey, privateKey: queueConfig.push.vapidPrivateKey }; } /** * Get the public VAPID key for client-side subscription */ getPublicVapidKey(): string | null { return this.vapidKeys?.publicKey || null; } /** * Subscribe a client to push notifications */ async subscribe(clientId: string, subscription: PushSubscription): Promise { console.log(`[PushService] Subscribing client ${clientId}`); this.subscriptions.set(clientId, subscription); // In production, store subscriptions in database // For development, we'll keep them in memory } /** * Unsubscribe a client from push notifications */ async unsubscribe(clientId: string): Promise { console.log(`[PushService] Unsubscribing client ${clientId}`); this.subscriptions.delete(clientId); } /** * Send notification to all subscribed clients */ async sendNotification(payload: NotificationPayload): Promise { if (this.subscriptions.size === 0) { console.log('[PushService] No subscriptions, skipping notification'); return; } console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`); console.log(`[PushService] Notification payload:`, payload); // In a real implementation, this would use web-push library // For development/demo purposes, we'll simulate the notification const notificationData = { ...payload, timestamp: new Date().toISOString() }; for (const [clientId, subscription] of this.subscriptions) { try { await this.sendToSubscription(subscription, notificationData); console.log(`[PushService] ✓ Sent notification to client ${clientId}`); } catch (error) { console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error); // Remove invalid subscriptions this.subscriptions.delete(clientId); } } } /** * Send notification to specific subscription */ private async sendToSubscription(subscription: PushSubscription, data: any): Promise { try { const payload = JSON.stringify(data); await webpush.sendNotification( { endpoint: subscription.endpoint, keys: { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth } }, payload, { TTL: 60 * 60 * 24, // 24 hours } ); console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`); } catch (error) { // Check if subscription is expired/invalid if ((error as any).statusCode === 410) { console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`); throw new Error('Subscription expired'); } console.error('[PushService] Failed to send notification:', { endpoint: subscription.endpoint.substring(0, 50) + '...', error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Send success notification when recipe extraction completes */ async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise { const payload: NotificationPayload = { type: 'success', itemId, recipeName, body: recipeName ? `Recipe "${recipeName}" has been extracted and saved successfully!` : 'Your recipe extraction is complete and ready to view.', tag: `recipe-success-${itemId}`, requireInteraction: true, analytics: { event: 'recipe_extraction_complete', itemId, timestamp: Date.now() } }; if (tandoorUrl) { payload.body += ' View it in Tandoor.'; } await this.sendNotification(payload); } /** * Send error notification when recipe extraction fails */ async notifyError(itemId: string, error: string): Promise { const payload: NotificationPayload = { type: 'error', itemId, body: `Recipe extraction failed: ${error}. Tap to retry.`, tag: `recipe-error-${itemId}`, requireInteraction: true, analytics: { event: 'recipe_extraction_failed', itemId, error, timestamp: Date.now() } }; await this.sendNotification(payload); } /** * Send progress notification for long-running extractions */ async notifyProgress(itemId: string, phase: string): Promise { const payload: NotificationPayload = { type: 'progress', itemId, body: `Recipe extraction in progress: ${phase}`, tag: `recipe-progress-${itemId}`, requireInteraction: false, analytics: { event: 'recipe_extraction_progress', itemId, phase, timestamp: Date.now() } }; await this.sendNotification(payload); } /** * Get subscription count for monitoring */ getSubscriptionCount(): number { return this.subscriptions.size; } /** * Clear all subscriptions (for testing/cleanup) */ clearAllSubscriptions(): void { console.log('[PushService] Clearing all subscriptions'); this.subscriptions.clear(); } } // Singleton instance export const pushNotificationService = new PushNotificationService(); export type { PushSubscription, NotificationPayload };