/** * Email Notification Service * * High-level service for sending templated emails * Integrates: Templates + Preference Checking + Email Service */ import { emailService } from './email.service'; import { getRequestCreatedEmail, getApprovalRequestEmail, getMultiApproverRequestEmail, getApprovalConfirmationEmail, getRejectionNotificationEmail, getTATReminderEmail, getTATBreachedEmail, getWorkflowPausedEmail, getWorkflowResumedEmail, getParticipantAddedEmail, getApproverSkippedEmail, getRequestClosedEmail, getViewDetailsLink, CompanyInfo, RequestCreatedData, ApprovalRequestData, MultiApproverRequestData, ApprovalConfirmationData, RejectionNotificationData, TATReminderData, TATBreachedData, WorkflowPausedData, WorkflowResumedData, ParticipantAddedData, ApproverSkippedData, RequestClosedData, ApprovalChainItem } from '../emailtemplates'; import { shouldSendEmail, shouldSendEmailWithOverride, EmailNotificationType } from '../emailtemplates/emailPreferences.helper'; import logger from '@utils/logger'; import dayjs from 'dayjs'; export class EmailNotificationService { private frontendUrl: string; constructor() { this.frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; } /** * Helper: Format date for emails */ private formatDate(date: Date | string): string { return dayjs(date).format('MMM DD, YYYY'); } /** * Helper: Format time for emails */ private formatTime(date: Date | string): string { return dayjs(date).format('hh:mm A'); } /** * 1. Send Request Created Email */ async sendRequestCreated( requestData: any, initiatorData: any, firstApproverData: any ): Promise { try { // Check preferences const canSend = await shouldSendEmail( initiatorData.userId, EmailNotificationType.REQUEST_CREATED ); if (!canSend) { logger.info(`Email skipped (preferences): Request Created for ${initiatorData.email}`); return; } const data: RequestCreatedData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, initiatorName: initiatorData.displayName || initiatorData.email, firstApproverName: firstApproverData.displayName || firstApproverData.email, requestType: requestData.templateType || requestData.requestType || 'CUSTOM', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), totalApprovers: requestData.totalApprovers || 1, expectedTAT: requestData.tatHours || 24, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getRequestCreatedEmail(data); const subject = `[${requestData.requestNumber}] Request Created Successfully`; const result = await emailService.sendEmail({ to: initiatorData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Request Created Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Request Created email:', error); // Don't throw - email failure shouldn't block workflow } } /** * 2. Send Approval Request Email */ async sendApprovalRequest( requestData: any, approverData: any, initiatorData: any, isMultiLevel: boolean, approvalChain?: any[] ): Promise { try { // Check preferences const canSend = await shouldSendEmail( approverData.userId, EmailNotificationType.APPROVAL_REQUEST ); if (!canSend) { logger.info(`Email skipped (preferences): Approval Request for ${approverData.email}`); return; } if (isMultiLevel && approvalChain) { // Multi-level approval email const chainData: ApprovalChainItem[] = approvalChain.map((level: any) => ({ name: level.approverName || level.approverEmail, status: level.status === 'APPROVED' ? 'approved' : level.levelNumber === approverData.levelNumber ? 'current' : level.levelNumber < approverData.levelNumber ? 'pending' : 'awaiting', date: level.approvedAt ? this.formatDate(level.approvedAt) : undefined, levelNumber: level.levelNumber })); const data: MultiApproverRequestData = { recipientName: approverData.displayName || approverData.email, requestId: requestData.requestNumber, approverName: approverData.displayName || approverData.email, initiatorName: initiatorData.displayName || initiatorData.email, requestType: requestData.templateType || requestData.requestType || 'CUSTOM', requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), approverLevel: approverData.levelNumber, totalApprovers: approvalChain.length, approversList: chainData, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getMultiApproverRequestEmail(data); const subject = `[${requestData.requestNumber}] Multi-Level Approval Request - Your Turn`; const result = await emailService.sendEmail({ to: approverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Multi-Approver Request Email Preview: ${result.previewUrl}`); } } else { // Single approver email const data: ApprovalRequestData = { recipientName: approverData.displayName || approverData.email, requestId: requestData.requestNumber, approverName: approverData.displayName || approverData.email, initiatorName: initiatorData.displayName || initiatorData.email, requestType: requestData.templateType || requestData.requestType || 'CUSTOM', requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getApprovalRequestEmail(data); const subject = `[${requestData.requestNumber}] Approval Request - Action Required`; const result = await emailService.sendEmail({ to: approverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Approval Request Email Preview: ${result.previewUrl}`); } } } catch (error) { logger.error('Failed to send Approval Request email:', error); } } /** * 3. Send Approval Confirmation Email */ async sendApprovalConfirmation( requestData: any, approverData: any, initiatorData: any, isFinalApproval: boolean, nextApproverData?: any ): Promise { try { const canSend = await shouldSendEmail( initiatorData.userId, EmailNotificationType.REQUEST_APPROVED ); if (!canSend) { logger.info(`Email skipped (preferences): Approval Confirmation for ${initiatorData.email}`); return; } const data: ApprovalConfirmationData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, initiatorName: initiatorData.displayName || initiatorData.email, approverName: approverData.displayName || approverData.email, approvalDate: this.formatDate(approverData.approvedAt || new Date()), approvalTime: this.formatTime(approverData.approvedAt || new Date()), requestType: requestData.templateType || requestData.requestType || 'CUSTOM', approverComments: approverData.comments || undefined, isFinalApproval, nextApproverName: nextApproverData?.displayName || nextApproverData?.email, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getApprovalConfirmationEmail(data); const subject = `[${requestData.requestNumber}] Request Approved${isFinalApproval ? ' - All Approvals Complete' : ''}`; const result = await emailService.sendEmail({ to: initiatorData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Approval Confirmation Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Approval Confirmation email:', error); } } /** * 4. Send Rejection Notification Email (CRITICAL) */ async sendRejectionNotification( requestData: any, approverData: any, initiatorData: any, rejectionReason: string ): Promise { try { // Use override for critical emails const canSend = await shouldSendEmailWithOverride( initiatorData.userId, EmailNotificationType.REQUEST_REJECTED ); if (!canSend) { logger.info(`Email skipped (admin disabled): Rejection for ${initiatorData.email}`); return; } const data: RejectionNotificationData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, initiatorName: initiatorData.displayName || initiatorData.email, approverName: approverData.displayName || approverData.email, rejectionDate: this.formatDate(approverData.rejectedAt || new Date()), rejectionTime: this.formatTime(approverData.rejectedAt || new Date()), requestType: requestData.templateType || requestData.requestType || 'CUSTOM', rejectionReason, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getRejectionNotificationEmail(data); const subject = `[${requestData.requestNumber}] Request Rejected`; const result = await emailService.sendEmail({ to: initiatorData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Rejection Notification Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Rejection Notification email:', error); } } /** * 5. Send TAT Reminder Email (Dynamic Threshold) */ async sendTATReminder( requestData: any, approverData: any, tatInfo: { thresholdPercentage: number; timeRemaining: string; tatDeadline: Date | string; assignedDate: Date | string; } ): Promise { try { const canSend = await shouldSendEmail( approverData.userId, EmailNotificationType.TAT_REMINDER ); if (!canSend) { logger.info(`Email skipped (preferences): TAT Reminder for ${approverData.email}`); return; } // Determine urgency level based on threshold const urgencyLevel = tatInfo.thresholdPercentage >= 75 ? 'high' : tatInfo.thresholdPercentage >= 50 ? 'medium' : 'low'; // Get initiator name - try from requestData first, then fetch if needed let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator'; if (initiatorName === 'Initiator' && requestData.initiatorId) { try { const { User } = await import('@models/index'); const initiator = await User.findByPk(requestData.initiatorId); if (initiator) { const initiatorJson = initiator.toJSON(); initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator'; } } catch (error) { logger.warn(`Failed to fetch initiator for TAT reminder: ${error}`); } } const data: TATReminderData = { recipientName: approverData.displayName || approverData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, initiatorName: initiatorName, assignedDate: this.formatDate(tatInfo.assignedDate), tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline), timeRemaining: tatInfo.timeRemaining, thresholdPercentage: tatInfo.thresholdPercentage, urgencyLevel: urgencyLevel as any, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getTATReminderEmail(data); const subject = `[${requestData.requestNumber}] TAT Reminder - ${tatInfo.thresholdPercentage}% Elapsed`; const result = await emailService.sendEmail({ to: approverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 TAT Reminder (${tatInfo.thresholdPercentage}%) Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send TAT Reminder email:', error); } } /** * 6. Send TAT Breached Email (CRITICAL) */ async sendTATBreached( requestData: any, approverData: any, tatInfo: { timeOverdue: string; tatDeadline: Date | string; assignedDate: Date | string; } ): Promise { try { // Use override for critical emails const canSend = await shouldSendEmailWithOverride( approverData.userId, EmailNotificationType.TAT_BREACHED ); if (!canSend) { logger.info(`Email skipped (admin disabled): TAT Breach for ${approverData.email}`); return; } // Get initiator name - try from requestData first, then fetch if needed let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator'; if (initiatorName === 'Initiator' && requestData.initiatorId) { try { const { User } = await import('@models/index'); const initiator = await User.findByPk(requestData.initiatorId); if (initiator) { const initiatorJson = initiator.toJSON(); initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator'; } } catch (error) { logger.warn(`Failed to fetch initiator for TAT breach: ${error}`); } } const data: TATBreachedData = { recipientName: approverData.displayName || approverData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, initiatorName: initiatorName, priority: requestData.priority || 'MEDIUM', assignedDate: this.formatDate(tatInfo.assignedDate), tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline), timeOverdue: tatInfo.timeOverdue, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getTATBreachedEmail(data); const subject = `[${requestData.requestNumber}] TAT BREACHED - Immediate Action Required`; const result = await emailService.sendEmail({ to: approverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 TAT Breached Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send TAT Breached email:', error); } } /** * 7. Send Workflow Resumed Email */ async sendWorkflowResumed( requestData: any, approverData: any, initiatorData: any, resumedByData: any, pauseDuration: string ): Promise { try { // Validate approver data has email if (!approverData || !approverData.email) { logger.warn(`[Email] Cannot send Workflow Resumed email: approver email missing`, { approverData: approverData ? { userId: approverData.userId, displayName: approverData.displayName } : null, requestNumber: requestData.requestNumber }); return; } const canSend = await shouldSendEmail( approverData.userId, EmailNotificationType.WORKFLOW_RESUMED ); if (!canSend) { logger.info(`Email skipped (preferences): Workflow Resumed for ${approverData.email}`); return; } const isAutoResumed = !resumedByData || resumedByData.userId === 'system'; const resumedByText = isAutoResumed ? 'automatically' : `by ${resumedByData.displayName || resumedByData.email}`; const data: WorkflowResumedData = { recipientName: approverData.displayName || approverData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, resumedByText, resumedDate: this.formatDate(new Date()), resumedTime: this.formatTime(new Date()), pausedDuration: pauseDuration, currentApprover: approverData.displayName || approverData.email, newTATDeadline: requestData.tatDeadline ? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline) : 'To be determined', isApprover: true, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getWorkflowResumedEmail(data); const subject = `[${requestData.requestNumber}] Workflow Resumed - Action Required`; const result = await emailService.sendEmail({ to: approverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Workflow Resumed Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Workflow Resumed email:', error); } } /** * Send Workflow Resumed Email to Initiator */ async sendWorkflowResumedToInitiator( requestData: any, initiatorData: any, approverData: any, resumedByData: any, pauseDuration: string ): Promise { try { // Validate initiator data has email if (!initiatorData || !initiatorData.email) { logger.warn(`[Email] Cannot send Workflow Resumed email to initiator: email missing`, { initiatorData: initiatorData ? { userId: initiatorData.userId, displayName: initiatorData.displayName } : null, requestNumber: requestData.requestNumber }); return; } const canSend = await shouldSendEmail( initiatorData.userId, EmailNotificationType.WORKFLOW_RESUMED ); if (!canSend) { logger.info(`Email skipped (preferences): Workflow Resumed for initiator ${initiatorData.email}`); return; } const isAutoResumed = !resumedByData || resumedByData.userId === 'system' || !resumedByData.userId; const resumedByText = isAutoResumed ? 'automatically' : `by ${resumedByData.displayName || resumedByData.email || resumedByData.name || 'User'}`; const data: WorkflowResumedData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, resumedByText, resumedDate: this.formatDate(new Date()), resumedTime: this.formatTime(new Date()), pausedDuration: pauseDuration, currentApprover: approverData?.displayName || approverData?.email || 'Current Approver', newTATDeadline: requestData.tatDeadline ? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline) : 'To be determined', isApprover: false, // This is for initiator viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getWorkflowResumedEmail(data); const subject = `[${requestData.requestNumber}] Workflow Resumed`; const result = await emailService.sendEmail({ to: initiatorData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Workflow Resumed Email Preview (Initiator): ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Workflow Resumed email to initiator:', error); } } /** * 8. Send Request Closed Email */ async sendRequestClosed( requestData: any, recipientData: any, closureData: { conclusionRemark?: string; workNotesCount: number; documentsCount: number; } ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.REQUEST_CLOSED ); if (!canSend) { logger.info(`Email skipped (preferences): Request Closed for ${recipientData.email}`); return; } const createdDate = requestData.createdAt ? dayjs(requestData.createdAt) : dayjs(); const closedDate = requestData.closedAt ? dayjs(requestData.closedAt) : dayjs(); const duration = closedDate.diff(createdDate, 'day'); const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`; // Get initiator name - try from requestData first, then fetch if needed let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator'; if (initiatorName === 'Initiator' && requestData.initiatorId) { try { const { User } = await import('@models/index'); const initiator = await User.findByPk(requestData.initiatorId); if (initiator) { const initiatorJson = initiator.toJSON(); initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator'; } } catch (error) { logger.warn(`Failed to fetch initiator for closed request: ${error}`); } } const data: RequestClosedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, initiatorName: initiatorName, createdDate: this.formatDate(requestData.createdAt), closedDate: this.formatDate(requestData.closedAt || new Date()), closedTime: this.formatTime(requestData.closedAt || new Date()), totalDuration, conclusionRemark: closureData.conclusionRemark, totalApprovers: requestData.totalApprovers || 0, totalApprovals: requestData.totalApprovals || 0, workNotesCount: closureData.workNotesCount, documentsCount: closureData.documentsCount, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getRequestClosedEmail(data); const subject = `[${requestData.requestNumber}] Request Closed`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Request Closed Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Request Closed email:', error); } } /** * Send emails to multiple recipients (for Request Closed) */ async sendRequestClosedToAll( requestData: any, participants: any[], closureData: any ): Promise { logger.info(`📧 Sending Request Closed emails to ${participants.length} participants`); for (const participant of participants) { await this.sendRequestClosed(requestData, participant, closureData); // Small delay to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 100)); } } /** * 9. Send Approver Skipped Email */ async sendApproverSkipped( requestData: any, skippedApproverData: any, skippedByData: any, nextApproverData: any, skipReason: string ): Promise { try { const canSend = await shouldSendEmail( skippedApproverData.userId, EmailNotificationType.APPROVER_SKIPPED ); if (!canSend) { logger.info(`Email skipped (preferences): Approver Skipped for ${skippedApproverData.email}`); return; } const data: ApproverSkippedData = { recipientName: skippedApproverData.displayName || skippedApproverData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, skippedApproverName: skippedApproverData.displayName || skippedApproverData.email, skippedByName: skippedByData.displayName || skippedByData.email, skippedDate: this.formatDate(new Date()), skippedTime: this.formatTime(new Date()), nextApproverName: nextApproverData?.displayName || nextApproverData?.email || 'Next Approver', skipReason: skipReason || 'Not provided', viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getApproverSkippedEmail(data); const subject = `[${requestData.requestNumber}] Approver Skipped`; const result = await emailService.sendEmail({ to: skippedApproverData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Approver Skipped Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Approver Skipped email:', error); } } /** * 10. Send Workflow Paused Email */ async sendWorkflowPaused( requestData: any, recipientData: any, pausedByData: any, pauseReason: string, resumeDate: Date | string ): Promise { try { // Validate recipient data has email if (!recipientData || !recipientData.email) { logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, { recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null, requestNumber: requestData.requestNumber }); return; } const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.WORKFLOW_PAUSED ); if (!canSend) { logger.info(`Email skipped (preferences): Workflow Paused for ${recipientData.email}`); return; } const data: WorkflowPausedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, pausedByName: pausedByData?.displayName || pausedByData?.email || 'System', pausedDate: this.formatDate(new Date()), pausedTime: this.formatTime(new Date()), resumeDate: this.formatDate(resumeDate), pauseReason: pauseReason || 'Not provided', viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getWorkflowPausedEmail(data); const subject = `[${requestData.requestNumber}] Workflow Paused`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Workflow Paused Email Preview: ${result.previewUrl}`); } } catch (error) { logger.error('Failed to send Workflow Paused email:', error); } } // Add more email methods as needed... } // Singleton instance export const emailNotificationService = new EmailNotificationService();