Re_Backend/src/services/emailNotification.service.ts

819 lines
28 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();