/** * 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, getSpectatorAddedEmail, getApproverSkippedEmail, getRequestClosedEmail, getDealerProposalSubmittedEmail, getDealerProposalRequiredEmail, getDealerCompletionRequiredEmail, getActivityCreatedEmail, getCompletionDocumentsSubmittedEmail, getEInvoiceGeneratedEmail, getCreditNoteSentEmail, getAdditionalDocumentAddedEmail, getViewDetailsLink, CompanyInfo, RequestCreatedData, ApprovalRequestData, MultiApproverRequestData, ApprovalConfirmationData, RejectionNotificationData, TATReminderData, TATBreachedData, WorkflowPausedData, WorkflowResumedData, ParticipantAddedData, SpectatorAddedData, ApproverSkippedData, RequestClosedData, DealerProposalSubmittedData, DealerProposalRequiredData, ActivityCreatedData, CompletionDocumentsSubmittedData, EInvoiceGeneratedData, CreditNoteSentData, AdditionalDocumentAddedData, 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, requestTitle: requestData.title, 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, requestTitle: requestData.title, 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); } } /** * 11. Send Spectator Added Email */ async sendSpectatorAdded( requestData: any, spectatorData: any, addedByData?: any, initiatorData?: any ): Promise { try { const canSend = await shouldSendEmail( spectatorData.userId, EmailNotificationType.SPECTATOR_ADDED ); if (!canSend) { logger.info(`Email skipped (preferences): Spectator Added for ${spectatorData.email}`); return; } // Get initiator name let initiatorName = 'Initiator'; if (initiatorData) { initiatorName = initiatorData.displayName || initiatorData.email || 'Initiator'; } else if (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 spectator added email: ${error}`); } } // Get added by name let addedByName: string | undefined; if (addedByData) { addedByName = addedByData.displayName || addedByData.email; } // Get participant to check when they were added const { Participant } = await import('@models/index'); const participant = await Participant.findOne({ where: { requestId: requestData.requestId, userId: spectatorData.userId } }); const addedDate = participant ? this.formatDate((participant as any).addedAt || new Date()) : this.formatDate(new Date()); const addedTime = participant ? this.formatTime((participant as any).addedAt || new Date()) : this.formatTime(new Date()); const data: SpectatorAddedData = { recipientName: spectatorData.displayName || spectatorData.email, spectatorName: spectatorData.displayName || spectatorData.email, addedByName: addedByName, initiatorName: initiatorName, requestId: requestData.requestNumber, requestTitle: requestData.title, requestType: requestData.templateType || requestData.workflowType || undefined, currentStatus: requestData.status || undefined, addedDate: addedDate, addedTime: addedTime, requestDescription: requestData.description || undefined, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getSpectatorAddedEmail(data); const subject = `[${requestData.requestNumber}] Added as Spectator`; const result = await emailService.sendEmail({ to: spectatorData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Spectator Added Email Preview: ${result.previewUrl}`); } logger.info(`✅ Spectator Added email sent to ${spectatorData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Spectator Added email:`, error); throw error; } } /** * 12. Send Dealer Proposal Required Email */ async sendDealerProposalRequired( requestData: any, dealerData: any, initiatorData: any, claimData?: any ): Promise { try { const canSend = await shouldSendEmail( dealerData.userId, EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences ); if (!canSend) { logger.info(`Email skipped (preferences): Dealer Proposal Required for ${dealerData.email}`); return; } // Calculate due date from TAT if available let dueDate: string | undefined; if (claimData?.tatHours) { const dueDateObj = dayjs().add(claimData.tatHours, 'hour'); dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A'); } const data: DealerProposalRequiredData = { recipientName: dealerData.displayName || dealerData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, dealerName: dealerData.displayName || dealerData.email || claimData?.dealerName || 'Dealer', initiatorName: initiatorData.displayName || initiatorData.email, activityName: claimData?.activityName || requestData.title, activityType: claimData?.activityType || 'N/A', activityDate: claimData?.activityDate ? this.formatDate(claimData.activityDate) : undefined, location: claimData?.location, estimatedBudget: claimData?.estimatedBudget, requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', tatHours: claimData?.tatHours, dueDate: dueDate, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getDealerProposalRequiredEmail(data); const subject = `[${requestData.requestNumber}] Proposal Required - ${data.activityName}`; const result = await emailService.sendEmail({ to: dealerData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Dealer Proposal Required Email Preview: ${result.previewUrl}`); } logger.info(`✅ Dealer Proposal Required email sent to ${dealerData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Dealer Proposal Required email:`, error); throw error; } } /** * 12b. Send Dealer Completion Documents Required Email */ async sendDealerCompletionRequired( requestData: any, dealerData: any, initiatorData: any, claimData?: any ): Promise { try { const canSend = await shouldSendEmail( dealerData.userId, EmailNotificationType.APPROVAL_REQUEST // Use approval_request type for preferences ); if (!canSend) { logger.info(`Email skipped (preferences): Dealer Completion Required for ${dealerData.email}`); return; } // Calculate due date from TAT if available let dueDate: string | undefined; if (claimData?.tatHours) { const dueDateObj = dayjs().add(claimData.tatHours, 'hour'); dueDate = dueDateObj.format('MMMM D, YYYY [at] h:mm A'); } const data: DealerProposalRequiredData = { recipientName: dealerData.displayName || dealerData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, dealerName: dealerData.displayName || dealerData.email || claimData?.dealerName || 'Dealer', initiatorName: initiatorData.displayName || initiatorData.email, activityName: claimData?.activityName || requestData.title, activityType: claimData?.activityType || 'N/A', activityDate: claimData?.activityDate ? this.formatDate(claimData.activityDate) : undefined, location: claimData?.location, estimatedBudget: claimData?.estimatedBudget, requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', tatHours: claimData?.tatHours, dueDate: dueDate, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getDealerCompletionRequiredEmail(data); const subject = `[${requestData.requestNumber}] Completion Documents Required - ${data.activityName}`; const result = await emailService.sendEmail({ to: dealerData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Dealer Completion Required Email Preview: ${result.previewUrl}`); } logger.info(`✅ Dealer Completion Required email sent to ${dealerData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Dealer Completion Required email:`, error); throw error; } } /** * 13. Send Dealer Proposal Submitted Email */ async sendDealerProposalSubmitted( requestData: any, dealerData: any, recipientData: any, proposalData: any, nextApproverData?: any ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.DEALER_PROPOSAL_SUBMITTED ); if (!canSend) { logger.info(`Email skipped (preferences): Dealer Proposal Submitted for ${recipientData.email}`); return; } // Format cost breakdown summary if available let costBreakupSummary: string | undefined; if (proposalData.costBreakup && Array.isArray(proposalData.costBreakup) && proposalData.costBreakup.length > 0) { costBreakupSummary = ''; proposalData.costBreakup.forEach((item: any) => { costBreakupSummary += ``; }); costBreakupSummary += '
DescriptionAmount
${item.description || ''}₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
'; } // Check if next approver is the recipient (initiator reviewing their own request) const isNextApproverInitiator = proposalData.nextApproverIsInitiator || (nextApproverData && nextApproverData.userId === recipientData.userId); const data: DealerProposalSubmittedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, dealerName: dealerData.displayName || dealerData.email || dealerData.name, activityName: requestData.activityName || requestData.title, activityType: requestData.activityType || 'N/A', proposalBudget: proposalData.totalEstimatedBudget || proposalData.proposalBudget || 0, expectedCompletionDate: proposalData.expectedCompletionDate || 'Not specified', dealerComments: proposalData.dealerComments, costBreakupSummary: costBreakupSummary, submittedDate: this.formatDate(proposalData.submittedAt || new Date()), submittedTime: this.formatTime(proposalData.submittedAt || new Date()), nextApproverName: isNextApproverInitiator ? undefined // Don't show next approver name if it's the recipient themselves : (nextApproverData?.displayName || nextApproverData?.email || (proposalData.nextApproverIsAdditional ? 'Additional Approver' : undefined)), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getDealerProposalSubmittedEmail(data); const subject = `[${requestData.requestNumber}] Proposal Submitted - ${data.activityName}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Dealer Proposal Submitted Email Preview: ${result.previewUrl}`); } logger.info(`✅ Dealer Proposal Submitted email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Dealer Proposal Submitted email:`, error); throw error; } } /** * 14. Send Activity Created Email */ async sendActivityCreated( requestData: any, recipientData: any, activityData: any ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.ACTIVITY_CREATED ); if (!canSend) { logger.info(`Email skipped (preferences): Activity Created for ${recipientData.email}`); return; } const data: ActivityCreatedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, activityName: activityData.activityName || requestData.title, activityType: activityData.activityType || 'N/A', activityDate: activityData.activityDate ? this.formatDate(activityData.activityDate) : undefined, location: activityData.location || 'Not specified', dealerName: activityData.dealerName || 'Dealer', dealerCode: activityData.dealerCode, initiatorName: activityData.initiatorName || 'Initiator', departmentLeadName: activityData.departmentLeadName, ioNumber: activityData.ioNumber, createdDate: this.formatDate(new Date()), createdTime: this.formatTime(new Date()), nextSteps: activityData.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution.', viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getActivityCreatedEmail(data); const subject = `[${requestData.requestNumber}] Activity Created - ${data.activityName}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Activity Created Email Preview: ${result.previewUrl}`); } logger.info(`✅ Activity Created email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Activity Created email:`, error); throw error; } } /** * 15. Send Completion Documents Submitted Email */ async sendCompletionDocumentsSubmitted( requestData: any, dealerData: any, recipientData: any, completionData: any, nextApproverData?: any ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.COMPLETION_DOCUMENTS_SUBMITTED ); if (!canSend) { logger.info(`Email skipped (preferences): Completion Documents Submitted for ${recipientData.email}`); return; } // Format expense breakdown summary if available let expenseBreakdown: string | undefined; if (completionData.closedExpenses && Array.isArray(completionData.closedExpenses) && completionData.closedExpenses.length > 0) { expenseBreakdown = ''; completionData.closedExpenses.forEach((item: any) => { expenseBreakdown += ``; }); expenseBreakdown += '
DescriptionAmount
${item.description || ''}₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
'; } // Check if next approver is the recipient (initiator reviewing their own request) const isNextApproverInitiator = completionData.nextApproverIsInitiator || (nextApproverData && nextApproverData.userId === recipientData.userId); const data: CompletionDocumentsSubmittedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, dealerName: dealerData.displayName || dealerData.email || dealerData.name, activityName: requestData.activityName || requestData.title, activityCompletionDate: completionData.activityCompletionDate ? this.formatDate(completionData.activityCompletionDate) : 'Not specified', numberOfParticipants: completionData.numberOfParticipants, totalClosedExpenses: completionData.totalClosedExpenses || 0, expenseBreakdown: expenseBreakdown, documentsCount: completionData.documentsCount, submittedDate: this.formatDate(completionData.submittedAt || new Date()), submittedTime: this.formatTime(completionData.submittedAt || new Date()), nextApproverName: isNextApproverInitiator ? undefined // Don't show next approver name if it's the recipient themselves : (nextApproverData?.displayName || nextApproverData?.email || (completionData.nextApproverIsAdditional ? 'Additional Approver' : undefined)), viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getCompletionDocumentsSubmittedEmail(data); const subject = `[${requestData.requestNumber}] Completion Documents Submitted - ${data.activityName}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Completion Documents Submitted Email Preview: ${result.previewUrl}`); } logger.info(`✅ Completion Documents Submitted email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Completion Documents Submitted email:`, error); throw error; } } /** * 16. Send E-Invoice Generated Email */ async sendEInvoiceGenerated( requestData: any, recipientData: any, invoiceData: any ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.EINVOICE_GENERATED ); if (!canSend) { logger.info(`Email skipped (preferences): E-Invoice Generated for ${recipientData.email}`); return; } const data: EInvoiceGeneratedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, invoiceNumber: invoiceData.invoiceNumber || invoiceData.eInvoiceNumber || 'N/A', invoiceDate: invoiceData.invoiceDate ? this.formatDate(invoiceData.invoiceDate) : this.formatDate(new Date()), dmsNumber: invoiceData.dmsNumber, invoiceAmount: invoiceData.amount || invoiceData.invoiceAmount || 0, dealerName: invoiceData.dealerName || requestData.dealerName || 'Dealer', dealerCode: invoiceData.dealerCode || requestData.dealerCode, activityName: requestData.activityName || requestData.title, ioNumber: invoiceData.ioNumber || requestData.ioNumber, generatedDate: this.formatDate(invoiceData.generatedAt || new Date()), generatedTime: this.formatTime(invoiceData.generatedAt || new Date()), downloadLink: invoiceData.downloadLink, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getEInvoiceGeneratedEmail(data); const subject = `[${requestData.requestNumber}] E-Invoice Generated - ${data.invoiceNumber}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 E-Invoice Generated Email Preview: ${result.previewUrl}`); } logger.info(`✅ E-Invoice Generated email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send E-Invoice Generated email:`, error); throw error; } } /** * 17. Send Credit Note Sent Email */ async sendCreditNoteSent( requestData: any, recipientData: any, creditNoteData: any ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.CREDIT_NOTE_SENT ); if (!canSend) { logger.info(`Email skipped (preferences): Credit Note Sent for ${recipientData.email}`); return; } const data: CreditNoteSentData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, requestNumber: requestData.requestNumber, creditNoteNumber: creditNoteData.creditNoteNumber || 'N/A', creditNoteDate: creditNoteData.creditNoteDate ? this.formatDate(creditNoteData.creditNoteDate) : this.formatDate(new Date()), creditNoteAmount: creditNoteData.creditNoteAmount || 0, dealerName: creditNoteData.dealerName || requestData.dealerName || 'Dealer', dealerCode: creditNoteData.dealerCode || requestData.dealerCode, dealerEmail: creditNoteData.dealerEmail || requestData.dealerEmail || '', activityName: requestData.activityName || requestData.title, reason: creditNoteData.reason || 'Claim settlement', invoiceNumber: creditNoteData.invoiceNumber, sentDate: this.formatDate(creditNoteData.sentAt || new Date()), sentTime: this.formatTime(creditNoteData.sentAt || new Date()), downloadLink: creditNoteData.downloadLink, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getCreditNoteSentEmail(data); const subject = `[${requestData.requestNumber}] Credit Note Sent - ${data.creditNoteNumber}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Credit Note Sent Email Preview: ${result.previewUrl}`); } logger.info(`✅ Credit Note Sent email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Credit Note Sent email:`, error); throw error; } } /** * 18. Send Additional Document Added Email */ async sendAdditionalDocumentAdded( requestData: any, recipientData: any, documentData: { documentName: string; fileSize: number; addedByName: string; source?: string; // 'Documents Tab' or 'Work Notes' } ): Promise { try { const canSend = await shouldSendEmail( recipientData.userId, EmailNotificationType.ADDITIONAL_DOCUMENT_ADDED ); if (!canSend) { logger.info(`Email skipped (preferences): Additional Document Added for ${recipientData.email}`); return; } // Format file size const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }; const data: AdditionalDocumentAddedData = { recipientName: recipientData.displayName || recipientData.email, requestId: requestData.requestNumber, requestTitle: requestData.title, documentName: documentData.documentName, fileSize: formatFileSize(documentData.fileSize), addedByName: documentData.addedByName, addedDate: this.formatDate(new Date()), addedTime: this.formatTime(new Date()), requestNumber: requestData.requestNumber, source: documentData.source, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; const html = getAdditionalDocumentAddedEmail(data); const subject = `[${requestData.requestNumber}] Additional Document Added - ${documentData.documentName}`; const result = await emailService.sendEmail({ to: recipientData.email, subject, html }); if (result.previewUrl) { logger.info(`📧 Additional Document Added Email Preview: ${result.previewUrl}`); } logger.info(`✅ Additional Document Added email sent to ${recipientData.email} for request ${requestData.requestNumber}`); } catch (error) { logger.error(`Failed to send Additional Document Added email:`, error); // Don't throw - email failure shouldn't block document upload } } } // Singleton instance export const emailNotificationService = new EmailNotificationService();