/** * Push Notification Channel * Handles Web Push notifications using VAPID */ import { PushNotificationData, PushSubscription } from '../types'; interface PushConfig { vapidPublicKey: string; vapidPrivateKey: string; vapidSubject: string; } export class PushChannel { private config: PushConfig; private subscriptions: Map = new Map(); constructor(config: PushConfig) { this.config = config; } /** * Send a push notification */ async send(data: PushNotificationData): Promise { if (!this.config.vapidPublicKey || !this.config.vapidPrivateKey) { console.log('[PushChannel] VAPID keys not configured - simulating push'); return; } const payload = JSON.stringify({ title: data.title, body: data.body, icon: data.icon || '/icons/icon-192x192.png', badge: data.badge || '/icons/badge-72x72.png', data: data.data, actions: data.actions }); // In production, this would use web-push library: // const webpush = require('web-push'); // webpush.setVapidDetails( // this.config.vapidSubject, // this.config.vapidPublicKey, // this.config.vapidPrivateKey // ); // await webpush.sendNotification(subscription, payload); console.log(`[PushChannel] Push notification sent: ${data.title}`); } /** * Subscribe a user to push notifications */ subscribe(userId: string, subscription: Omit): PushSubscription { const fullSubscription: PushSubscription = { ...subscription, userId, createdAt: new Date().toISOString() }; const userSubs = this.subscriptions.get(userId) || []; // Check if subscription already exists const existing = userSubs.find(s => s.endpoint === subscription.endpoint); if (existing) { existing.lastUsedAt = new Date().toISOString(); return existing; } userSubs.push(fullSubscription); this.subscriptions.set(userId, userSubs); console.log(`[PushChannel] User ${userId} subscribed to push notifications`); return fullSubscription; } /** * Unsubscribe from push notifications */ unsubscribe(userId: string, endpoint?: string): boolean { if (!endpoint) { // Remove all subscriptions for user this.subscriptions.delete(userId); return true; } const userSubs = this.subscriptions.get(userId); if (!userSubs) return false; const index = userSubs.findIndex(s => s.endpoint === endpoint); if (index === -1) return false; userSubs.splice(index, 1); if (userSubs.length === 0) { this.subscriptions.delete(userId); } else { this.subscriptions.set(userId, userSubs); } return true; } /** * Get subscriptions for a user */ getSubscriptions(userId: string): PushSubscription[] { return this.subscriptions.get(userId) || []; } /** * Check if user has push subscriptions */ hasSubscription(userId: string): boolean { const subs = this.subscriptions.get(userId); return subs !== undefined && subs.length > 0; } /** * Send to all user's subscriptions */ async sendToUser(userId: string, data: Omit): Promise { const subscriptions = this.getSubscriptions(userId); for (const sub of subscriptions) { try { await this.send({ ...data, token: sub.endpoint }); sub.lastUsedAt = new Date().toISOString(); } catch (error: any) { console.error(`[PushChannel] Failed to send to ${sub.endpoint}:`, error.message); // Remove invalid subscriptions if (error.statusCode === 410 || error.statusCode === 404) { this.unsubscribe(userId, sub.endpoint); } } } } /** * Get VAPID public key for client */ getPublicKey(): string { return this.config.vapidPublicKey; } /** * Generate VAPID keys (utility method) */ static generateVapidKeys(): { publicKey: string; privateKey: string } { // In production, use web-push library: // const webpush = require('web-push'); // return webpush.generateVAPIDKeys(); // For development, return placeholder keys return { publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U', privateKey: 'UUxI4O8-FbRouADVXc-hK3ltRAc8_DIoISjp22LG0S0' }; } }