import webpush from 'web-push'; import logger, { logNotificationEvent } from '@utils/logger'; import { Subscription } from '@models/Subscription'; import { Notification } from '@models/Notification'; import { shouldSendEmail, shouldSendEmailWithOverride, shouldSendInAppNotification, EmailNotificationType } from '../emailtemplates/emailPreferences.helper'; 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}`); } /** * Get all subscriptions for a user */ async getUserSubscriptions(userId: string) { try { const subscriptions = await Subscription.findAll({ where: { userId }, attributes: ['subscriptionId', 'endpoint', 'userAgent', 'createdAt'] }); return subscriptions; } catch (error) { logger.error(`[Notification] Failed to get subscriptions for user ${userId}:`, error); return []; } } /** * Remove expired/invalid subscription from database and memory cache */ private async removeExpiredSubscription(userId: string, endpoint: string) { try { // Remove from database await Subscription.destroy({ where: { endpoint } }); logger.info(`[Notification] Removed expired subscription from DB for user ${userId}, endpoint: ${endpoint.substring(0, 50)}...`); // Remove from memory cache const list = this.userIdToSubscriptions.get(userId) || []; const filtered = list.filter((s) => s.endpoint !== endpoint); if (filtered.length !== list.length) { this.userIdToSubscriptions.set(userId, filtered); logger.info(`[Notification] Removed expired subscription from memory cache for user ${userId}`); } } catch (error) { logger.error(`[Notification] Failed to remove expired subscription for user ${userId}:`, error); } } /** * Check if error indicates expired/invalid subscription * webpush returns status codes: 410 (Gone), 404 (Not Found), 403 (Forbidden) */ private isExpiredSubscriptionError(err: any): boolean { const statusCode = err?.statusCode || err?.status || err?.response?.statusCode; // 410 Gone = subscription expired // 404 Not Found = subscription doesn't exist // 403 Forbidden = subscription invalid return statusCode === 410 || statusCode === 404 || statusCode === 403; } /** * Send notification to users - saves to DB, sends via push/socket, and emails * Respects user notification preferences for all channels * Automatically sends email for applicable notification types */ async sendToUsers(userIds: string[], payload: NotificationPayload) { const message = JSON.stringify(payload); const { User } = require('@models/User'); for (const userId of userIds) { try { // Fetch user preferences const user = await User.findByPk(userId, { attributes: [ 'userId', 'emailNotificationsEnabled', 'pushNotificationsEnabled', 'inAppNotificationsEnabled' ] }); if (!user) { logger.warn(`[Notification] User ${userId} not found, skipping notification`); continue; } const sentVia: string[] = []; // 1. Check admin + user preferences for in-app notifications const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general'); logger.info(`[Notification] In-app notification check for user ${userId}:`, { canSendInApp, inAppNotificationsEnabled: user.inAppNotificationsEnabled, notificationType: payload.type, willCreate: canSendInApp && user.inAppNotificationsEnabled }); let notification: any = null; if (canSendInApp && user.inAppNotificationsEnabled) { try { 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: ['IN_APP'], emailSent: false, smsSent: false, pushSent: false } as any); sentVia.push('IN_APP'); logger.info(`[Notification] ✅ Created in-app notification for user ${userId}: ${payload.title} (ID: ${(notification as any).notificationId})`); // 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}`); } else { logger.warn(`[Notification] emitToUser function not available`); } } catch (socketError) { logger.warn(`[Notification] Socket emit failed (not critical):`, socketError); } } catch (notificationError) { logger.error(`[Notification] ❌ Failed to create in-app notification for user ${userId}:`, notificationError); // Continue - don't block other notification channels } // 3. Send push notification (if enabled and user has subscriptions) if (user.pushNotificationsEnabled && canSendInApp && notification) { 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 }); sentVia.push('PUSH'); logNotificationEvent('sent', { userId, channel: 'push', type: payload.type, requestId: payload.requestId, }); } catch (err: any) { // Check if subscription is expired/invalid if (this.isExpiredSubscriptionError(err)) { logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`); await this.removeExpiredSubscription(userId, sub.endpoint); } else { logNotificationEvent('failed', { userId, channel: 'push', type: payload.type, requestId: payload.requestId, error: err, }); } } } } } else { logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`); } } else { if (!canSendInApp) { logger.info(`[Notification] In-app notifications disabled by admin/user for user ${userId}, type: ${payload.type}`); } else { logger.info(`[Notification] In-app notifications disabled for user ${userId}`); } } // 4. Send email notification for applicable types (async, don't wait) console.log(`[DEBUG] Checking email for notification type: ${payload.type}`); this.sendEmailNotification(userId, user, payload).catch(emailError => { console.error(`[Notification] Email sending failed for user ${userId}:`, emailError); logger.error(`[Notification] Email sending failed for user ${userId}:`, emailError); // Don't throw - email failure shouldn't block notification }); } catch (error) { logger.error(`[Notification] Failed to create notification for user ${userId}:`, error); // Continue to next user even if one fails } } } /** * Send email notification based on notification type * Only sends for notification types that warrant email */ private async sendEmailNotification(userId: string, user: any, payload: NotificationPayload): Promise { console.log(`[DEBUG Email] Notification type: ${payload.type}, userId: ${userId}`); // Import email service (lazy load to avoid circular dependencies) const { emailNotificationService } = await import('./emailNotification.service'); const { EmailNotificationType } = await import('../emailtemplates/emailPreferences.helper'); // Map notification type to email type and check if email should be sent const emailTypeMap: Record = { 'request_submitted': EmailNotificationType.REQUEST_CREATED, 'assignment': EmailNotificationType.APPROVAL_REQUEST, 'approval': EmailNotificationType.REQUEST_APPROVED, 'rejection': EmailNotificationType.REQUEST_REJECTED, 'tat_reminder': EmailNotificationType.TAT_REMINDER, 'tat_breach': EmailNotificationType.TAT_BREACHED, 'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED, 'closed': EmailNotificationType.REQUEST_CLOSED, // These don't get emails (in-app only) 'mention': null, 'comment': null, 'document_added': null, 'status_change': null, 'ai_conclusion_generated': null, 'summary_generated': null, 'workflow_paused': null, // Conditional - handled separately 'pause_retriggered': null }; const emailType = emailTypeMap[payload.type || '']; console.log(`[DEBUG Email] Email type mapped: ${emailType}`); if (!emailType) { // This notification type doesn't warrant email console.log(`[DEBUG Email] No email for notification type: ${payload.type}`); return; } // Check if email should be sent (admin + user preferences) // For assignment notifications, always attempt to send email (unless explicitly disabled by admin) // This ensures next approvers always receive email notifications const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach' ? await shouldSendEmailWithOverride(userId, emailType) // Critical emails : payload.type === 'assignment' ? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery : await shouldSendEmail(userId, emailType); // Regular emails console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`); if (!shouldSend) { console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`); logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`); return; } logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`); // Trigger email based on notification type // Email service will fetch additional data as needed console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`); try { await this.triggerEmailByType(payload.type || '', userId, payload, user); } catch (error) { console.error(`[DEBUG Email] Error triggering email:`, error); logger.error(`[Email] Failed to trigger email for type ${payload.type}:`, error); } } /** * Trigger appropriate email based on notification type */ private async triggerEmailByType( notificationType: string, userId: string, payload: NotificationPayload, user: any ): Promise { const { emailNotificationService } = await import('./emailNotification.service'); const { WorkflowRequest, User, ApprovalLevel } = await import('@models/index'); // Fetch request data if requestId is provided if (!payload.requestId) { logger.warn(`[Email] No requestId in payload for type ${notificationType}`); return; } const request = await WorkflowRequest.findByPk(payload.requestId); if (!request) { logger.warn(`[Email] Request ${payload.requestId} not found`); return; } const requestData = request.toJSON(); // Fetch initiator user const initiator = await User.findByPk(requestData.initiatorId); if (!initiator) { logger.warn(`[Email] Initiator not found for request ${payload.requestId}`); return; } const initiatorData = initiator.toJSON(); switch (notificationType) { case 'request_submitted': { const firstLevel = await ApprovalLevel.findOne({ where: { requestId: payload.requestId, levelNumber: 1 } }); const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null; await emailNotificationService.sendRequestCreated( requestData, initiatorData, firstApprover ? firstApprover.toJSON() : null ); } break; case 'assignment': { // Fetch the approver user (the one being assigned) const approverUser = await User.findByPk(userId); if (!approverUser) { logger.warn(`[Email] Approver user ${userId} not found`); return; } const allLevels = await ApprovalLevel.findAll({ where: { requestId: payload.requestId }, order: [['levelNumber', 'ASC']] }); const isMultiLevel = allLevels.length > 1; const approverData = approverUser.toJSON(); // Add level number if available const currentLevel = allLevels.find((l: any) => l.approverId === userId); if (currentLevel) { (approverData as any).levelNumber = (currentLevel as any).levelNumber; } await emailNotificationService.sendApprovalRequest( requestData, approverData, initiatorData, isMultiLevel, isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined ); } break; case 'approval': { const approvedLevel = await ApprovalLevel.findOne({ where: { requestId: payload.requestId, status: 'APPROVED' }, order: [['approvedAt', 'DESC']] }); const allLevels = await ApprovalLevel.findAll({ where: { requestId: payload.requestId }, order: [['levelNumber', 'ASC']] }); const approvedCount = allLevels.filter((l: any) => l.status === 'APPROVED').length; const isFinalApproval = approvedCount === allLevels.length; const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING'); const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; await emailNotificationService.sendApprovalConfirmation( requestData, user, // Approver who just approved initiatorData, isFinalApproval, nextApprover ? nextApprover.toJSON() : undefined ); } break; case 'rejection': { const rejectedLevel = await ApprovalLevel.findOne({ where: { requestId: payload.requestId, status: 'REJECTED' } }); await emailNotificationService.sendRejectionNotification( requestData, user, // Approver who rejected initiatorData, (rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided' ); } break; case 'tat_reminder': case 'tat_breach': { // Extract TAT info from metadata or payload const tatInfo = payload.metadata?.tatInfo || { thresholdPercentage: payload.type === 'tat_breach' ? 100 : 75, timeRemaining: payload.metadata?.timeRemaining || 'Unknown', tatDeadline: payload.metadata?.tatDeadline || new Date(), assignedDate: payload.metadata?.assignedDate || requestData.createdAt }; if (notificationType === 'tat_breach') { await emailNotificationService.sendTATBreached( requestData, user, { timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining, tatDeadline: tatInfo.tatDeadline, assignedDate: tatInfo.assignedDate } ); } else { await emailNotificationService.sendTATReminder( requestData, user, tatInfo ); } } break; case 'workflow_resumed': { const currentLevel = await ApprovalLevel.findOne({ where: { requestId: payload.requestId, status: 'PENDING' }, order: [['levelNumber', 'ASC']] }); const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null; const resumedBy = payload.metadata?.resumedBy; const pauseDuration = payload.metadata?.pauseDuration || 'Unknown'; await emailNotificationService.sendWorkflowResumed( requestData, currentApprover ? currentApprover.toJSON() : user, initiatorData, resumedBy, pauseDuration ); } break; case 'closed': { const closureData = { conclusionRemark: payload.metadata?.conclusionRemark, workNotesCount: payload.metadata?.workNotesCount || 0, documentsCount: payload.metadata?.documentsCount || 0 }; await emailNotificationService.sendRequestClosed( requestData, user, closureData ); } break; default: logger.info(`[Email] No email configured for notification type: ${notificationType}`); } } } export const notificationService = new NotificationService(); notificationService.configure();