1447 lines
53 KiB
TypeScript
1447 lines
53 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,
|
|
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<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,
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 11. Send Spectator Added Email
|
|
*/
|
|
async sendSpectatorAdded(
|
|
requestData: any,
|
|
spectatorData: any,
|
|
addedByData?: any,
|
|
initiatorData?: any
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 = '<table style="width: 100%; border-collapse: collapse;"><thead><tr style="background-color: #f8f9fa;"><th style="padding: 10px; text-align: left; border-bottom: 2px solid #e9ecef;">Description</th><th style="padding: 10px; text-align: right; border-bottom: 2px solid #e9ecef;">Amount</th></tr></thead><tbody>';
|
|
proposalData.costBreakup.forEach((item: any) => {
|
|
costBreakupSummary += `<tr><td style="padding: 8px; border-bottom: 1px solid #f0f0f0;">${item.description || ''}</td><td style="padding: 8px; text-align: right; border-bottom: 1px solid #f0f0f0;">₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td></tr>`;
|
|
});
|
|
costBreakupSummary += '</tbody></table>';
|
|
}
|
|
|
|
// 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<void> {
|
|
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<void> {
|
|
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 = '<table style="width: 100%; border-collapse: collapse;"><thead><tr style="background-color: #f8f9fa;"><th style="padding: 10px; text-align: left; border-bottom: 2px solid #e9ecef;">Description</th><th style="padding: 10px; text-align: right; border-bottom: 2px solid #e9ecef;">Amount</th></tr></thead><tbody>';
|
|
completionData.closedExpenses.forEach((item: any) => {
|
|
expenseBreakdown += `<tr><td style="padding: 8px; border-bottom: 1px solid #f0f0f0;">${item.description || ''}</td><td style="padding: 8px; text-align: right; border-bottom: 1px solid #f0f0f0;">₹${(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td></tr>`;
|
|
});
|
|
expenseBreakdown += '</tbody></table>';
|
|
}
|
|
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
|