import webpush from 'web-push'; import logger from '@utils/logger'; import { Subscription } from '@models/Subscription'; import { Notification } from '@models/Notification'; type PushSubscription = any; // Web Push protocol JSON interface NotificationPayload { title: string; body: string; requestId?: string; requestNumber?: string; url?: string; type?: string; priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; actionRequired?: boolean; metadata?: any; } class NotificationService { private userIdToSubscriptions: Map = new Map(); configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) { const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || ''; const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || ''; const contact = mailto || process.env.VAPID_CONTACT || 'mailto:admin@example.com'; if (!pub || !priv) { logger.warn('VAPID keys are not configured. Push notifications are disabled.'); return; } webpush.setVapidDetails(contact, pub, priv); logger.info('Web Push configured'); } async addSubscription(userId: string, subscription: PushSubscription, userAgent?: string) { // Persist to DB (upsert by endpoint) try { const endpoint: string = subscription?.endpoint || ''; const keys = subscription?.keys || {}; if (!endpoint || !keys?.p256dh || !keys?.auth) throw new Error('Invalid subscription payload'); await Subscription.upsert({ userId, endpoint, p256dh: keys.p256dh, auth: keys.auth, userAgent: userAgent || null, } as any); } catch (e) { logger.error('Failed to persist subscription', e); } const list = this.userIdToSubscriptions.get(userId) || []; const already = list.find((s) => JSON.stringify(s) === JSON.stringify(subscription)); if (!already) { list.push(subscription); this.userIdToSubscriptions.set(userId, list); } logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`); } /** * Send notification to users - saves to DB and sends via push/socket */ async sendToUsers(userIds: string[], payload: NotificationPayload) { const message = JSON.stringify(payload); const sentVia: string[] = ['IN_APP']; // Always save to DB for in-app display for (const userId of userIds) { try { // 1. Save notification to database for in-app display const notification = await Notification.create({ userId, requestId: payload.requestId, notificationType: payload.type || 'general', title: payload.title, message: payload.body, isRead: false, priority: payload.priority || 'MEDIUM', actionUrl: payload.url, actionRequired: payload.actionRequired || false, metadata: { requestNumber: payload.requestNumber, ...payload.metadata }, sentVia, emailSent: false, smsSent: false, pushSent: false } as any); logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`); // 2. Emit real-time socket event for immediate delivery try { const { emitToUser } = require('../realtime/socket'); if (emitToUser) { emitToUser(userId, 'notification:new', { notification: notification.toJSON(), ...payload }); logger.info(`[Notification] Emitted socket event to user ${userId}`); } } catch (socketError) { logger.warn(`[Notification] Socket emit failed (not critical):`, socketError); } // 3. Send push notification (if user has subscriptions) let subs = this.userIdToSubscriptions.get(userId) || []; // Load from DB if memory empty if (subs.length === 0) { try { const rows = await Subscription.findAll({ where: { userId } }); subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } })); } catch {} } if (subs.length > 0) { for (const sub of subs) { try { await webpush.sendNotification(sub, message); await notification.update({ pushSent: true }); logger.info(`[Notification] Push sent to user ${userId}`); } catch (err) { logger.error(`Failed to send push to ${userId}:`, err); } } } } catch (error) { logger.error(`[Notification] Failed to create notification for user ${userId}:`, error); // Continue to next user even if one fails } } } } export const notificationService = new NotificationService(); notificationService.configure();