819 lines
28 KiB
TypeScript
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();
|
|
|