- |
+ |
Dear ${data.initiatorName},
@@ -46,47 +49,47 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
-
- Request Summary
+ |
+ Request Summary
-
+
- |
+ |
Request ID:
|
-
+ |
${data.requestId}
|
- |
+ |
Approved By:
|
-
+ |
${data.approverName}
|
- |
+ |
Approved On:
|
-
+ |
${data.approvalDate}
|
- |
+ |
Time:
|
-
+ |
${data.approvalTime}
|
- |
+ |
Request Type:
|
-
+ |
${data.requestType}
|
diff --git a/src/emailtemplates/approvalRequest.template.ts b/src/emailtemplates/approvalRequest.template.ts
index 192eb64..295d4f2 100644
--- a/src/emailtemplates/approvalRequest.template.ts
+++ b/src/emailtemplates/approvalRequest.template.ts
@@ -22,7 +22,7 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
-
+
${getEmailHeader(getBrandedHeader({
title: 'Approval Request',
diff --git a/src/emailtemplates/approverSkipped.template.ts b/src/emailtemplates/approverSkipped.template.ts
index 78ec4b0..3619bdf 100644
--- a/src/emailtemplates/approverSkipped.template.ts
+++ b/src/emailtemplates/approverSkipped.template.ts
@@ -12,14 +12,17 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
-
+
+
+
Approver Skipped
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: 'Approval Level Skipped',
...HeaderStyles.infoSecondary
diff --git a/src/emailtemplates/helpers.ts b/src/emailtemplates/helpers.ts
index 03b213f..944c98d 100644
--- a/src/emailtemplates/helpers.ts
+++ b/src/emailtemplates/helpers.ts
@@ -146,7 +146,7 @@ export function wrapRichText(htmlContent: string): string {
/**
* Generate all email styles (responsive + rich text)
- * Optimized for screens up to 600px width
+ * Desktop-first design (optimized for browser) with mobile responsive breakpoints
*/
export function getResponsiveStyles(): string {
return `
@@ -173,6 +173,86 @@ export function getResponsiveStyles(): string {
border-collapse: collapse !important;
}
+ /* Desktop-first base styles */
+ .email-container {
+ width: 95%;
+ max-width: 1200px;
+ }
+
+ .email-content {
+ padding: 50px 40px;
+ }
+
+ .email-header {
+ padding: 40px 40px 35px;
+ }
+
+ .email-footer {
+ padding: 30px 40px;
+ }
+
+ /* Desktop typography */
+ .header-title {
+ font-size: 24px;
+ letter-spacing: 0.5px;
+ line-height: 1.3;
+ }
+
+ .header-subtitle {
+ font-size: 14px;
+ }
+
+ /* Desktop detail tables - side by side */
+ .detail-table {
+ width: 100%;
+ }
+
+ .detail-table td {
+ font-size: 15px;
+ padding: 10px 0;
+ display: table-cell;
+ width: auto;
+ vertical-align: top;
+ }
+
+ .detail-label {
+ width: 200px;
+ font-weight: 600;
+ color: #666666;
+ }
+
+ .detail-box {
+ padding: 30px;
+ }
+
+ /* Desktop button styles */
+ .cta-button {
+ display: inline-block;
+ padding: 16px 45px;
+ font-size: 16px;
+ min-width: 220px;
+ }
+
+ /* Tablet responsive styles */
+ @media only screen and (max-width: 1200px) {
+ .email-container {
+ width: 95% !important;
+ max-width: 95% !important;
+ }
+
+ .email-content {
+ padding: 40px 30px !important;
+ }
+
+ .email-header {
+ padding: 35px 30px 30px !important;
+ }
+
+ .email-footer {
+ padding: 25px 30px !important;
+ }
+ }
+
/* Mobile responsive styles */
@media only screen and (max-width: 600px) {
/* Container adjustments */
@@ -180,11 +260,12 @@ export function getResponsiveStyles(): string {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
+ border-radius: 0 !important;
}
/* Header adjustments */
.email-header {
- padding: 25px 15px 30px !important;
+ padding: 25px 20px 30px !important;
}
/* Content adjustments */
@@ -207,7 +288,7 @@ export function getResponsiveStyles(): string {
/* Typography adjustments */
.header-title {
font-size: 20px !important;
- letter-spacing: 1px !important;
+ letter-spacing: 0.5px !important;
line-height: 1.4 !important;
}
@@ -215,21 +296,22 @@ export function getResponsiveStyles(): string {
font-size: 12px !important;
}
- /* Detail tables */
+ /* Detail tables - stack on mobile */
.detail-box {
padding: 20px 15px !important;
}
.detail-table td {
- font-size: 13px !important;
- padding: 6px 0 !important;
+ font-size: 14px !important;
+ padding: 8px 0 !important;
display: block !important;
width: 100% !important;
}
.detail-label {
font-weight: 600 !important;
- margin-bottom: 2px !important;
+ margin-bottom: 4px !important;
+ width: 100% !important;
}
/* Button adjustments */
@@ -240,27 +322,28 @@ export function getResponsiveStyles(): string {
padding: 16px 20px !important;
font-size: 16px !important;
box-sizing: border-box !important;
+ min-width: auto !important;
}
/* Section adjustments */
.info-section {
- padding: 15px !important;
+ padding: 18px 15px !important;
margin-bottom: 20px !important;
}
.section-title {
- font-size: 15px !important;
+ font-size: 16px !important;
}
.section-text {
- font-size: 13px !important;
+ font-size: 14px !important;
line-height: 1.6 !important;
}
/* List items */
.info-section ul {
- padding-left: 15px !important;
- font-size: 13px !important;
+ padding-left: 20px !important;
+ font-size: 14px !important;
}
.info-section li {
diff --git a/src/emailtemplates/multiApproverRequest.template.ts b/src/emailtemplates/multiApproverRequest.template.ts
index e362110..b1d1027 100644
--- a/src/emailtemplates/multiApproverRequest.template.ts
+++ b/src/emailtemplates/multiApproverRequest.template.ts
@@ -22,7 +22,7 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
-
+
${getEmailHeader(getBrandedHeader({
title: 'Multi-Level Approval Request',
diff --git a/src/emailtemplates/participantAdded.template.ts b/src/emailtemplates/participantAdded.template.ts
index 42f538c..093b965 100644
--- a/src/emailtemplates/participantAdded.template.ts
+++ b/src/emailtemplates/participantAdded.template.ts
@@ -12,14 +12,17 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
-
+
+
+
Added to Request
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: `You've Been Added as ${data.participantRole}`,
...HeaderStyles.info
diff --git a/src/emailtemplates/rejectionNotification.template.ts b/src/emailtemplates/rejectionNotification.template.ts
index 32eeb23..87bdb05 100644
--- a/src/emailtemplates/rejectionNotification.template.ts
+++ b/src/emailtemplates/rejectionNotification.template.ts
@@ -12,14 +12,17 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
-
+
+
+
Request Rejected
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: 'Request Rejected',
...HeaderStyles.error
diff --git a/src/emailtemplates/requestClosed.template.ts b/src/emailtemplates/requestClosed.template.ts
index 6038777..783fa9d 100644
--- a/src/emailtemplates/requestClosed.template.ts
+++ b/src/emailtemplates/requestClosed.template.ts
@@ -22,7 +22,7 @@ export function getRequestClosedEmail(data: RequestClosedData): string {
-
+
${getEmailHeader(getBrandedHeader({
title: 'Request Closed',
...HeaderStyles.complete
diff --git a/src/emailtemplates/requestCreated.template.ts b/src/emailtemplates/requestCreated.template.ts
index 69eddf9..0b3a5bd 100644
--- a/src/emailtemplates/requestCreated.template.ts
+++ b/src/emailtemplates/requestCreated.template.ts
@@ -22,7 +22,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
-
+
${getEmailHeader(getBrandedHeader({
title: 'Request Created Successfully',
@@ -31,7 +31,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
- |
+ |
Dear ${data.initiatorName},
@@ -43,55 +43,55 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
-
- Request Summary
+ |
+ Request Summary
-
+
- |
+ |
Request ID:
|
-
+ |
${data.requestId}
|
- |
+ |
Title:
|
-
+ |
${data.requestTitle || 'N/A'}
|
- |
+ |
Request Type:
|
-
+ |
${data.requestType}
|
- |
+ |
Priority:
|
-
+ |
${data.priority}
|
- |
+ |
Created On:
|
-
+ |
${data.requestDate} at ${data.requestTime}
|
- |
+ |
Total Approvers:
|
-
+ |
${data.totalApprovers}
|
diff --git a/src/emailtemplates/tatBreached.template.ts b/src/emailtemplates/tatBreached.template.ts
index 2e87362..c9173a3 100644
--- a/src/emailtemplates/tatBreached.template.ts
+++ b/src/emailtemplates/tatBreached.template.ts
@@ -3,7 +3,7 @@
*/
import { TATBreachedData } from './types';
-import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
+import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getTATBreachedEmail(data: TATBreachedData): string {
@@ -12,14 +12,17 @@ export function getTATBreachedEmail(data: TATBreachedData): string {
-
+
+
+
TAT Breached - Urgent Action Required
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: 'TAT Breached',
subtitle: 'Immediate Action Required',
diff --git a/src/emailtemplates/tatReminder.template.ts b/src/emailtemplates/tatReminder.template.ts
index e5244f1..0274468 100644
--- a/src/emailtemplates/tatReminder.template.ts
+++ b/src/emailtemplates/tatReminder.template.ts
@@ -52,7 +52,7 @@ export function getTATReminderEmail(data: TATReminderData): string {
-
+
${getEmailHeader(getBrandedHeader({
title: 'TAT Reminder',
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
diff --git a/src/emailtemplates/workflowPaused.template.ts b/src/emailtemplates/workflowPaused.template.ts
index 3aa4569..7ae4058 100644
--- a/src/emailtemplates/workflowPaused.template.ts
+++ b/src/emailtemplates/workflowPaused.template.ts
@@ -12,14 +12,17 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
-
+
+
+
Workflow Paused
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: 'Workflow Paused',
...HeaderStyles.neutral
diff --git a/src/emailtemplates/workflowResumed.template.ts b/src/emailtemplates/workflowResumed.template.ts
index 2de906e..79f7648 100644
--- a/src/emailtemplates/workflowResumed.template.ts
+++ b/src/emailtemplates/workflowResumed.template.ts
@@ -3,7 +3,7 @@
*/
import { WorkflowResumedData } from './types';
-import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
+import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
@@ -12,14 +12,17 @@ export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
-
+
+
+
Workflow Resumed
+ ${getResponsiveStyles()}
-
+
${getEmailHeader(getBrandedHeader({
title: 'Workflow Resumed',
...HeaderStyles.success
diff --git a/src/queues/tatProcessor.ts b/src/queues/tatProcessor.ts
index ce5f0a1..ceabaf0 100644
--- a/src/queues/tatProcessor.ts
+++ b/src/queues/tatProcessor.ts
@@ -210,6 +210,13 @@ export async function handleTatJob(job: Job) {
type === 'threshold2' ? 'HIGH' :
'MEDIUM';
+ // Format time remaining/overdue for email
+ const timeRemainingText = remainingHours > 0
+ ? `${remainingHours.toFixed(1)} hours remaining`
+ : type === 'breach'
+ ? `${Math.abs(remainingHours).toFixed(1)} hours overdue`
+ : 'Time exceeded';
+
// Send notification to approver (with error handling to prevent job failure)
try {
await notificationService.sendToUsers([approverId], {
@@ -220,7 +227,17 @@ export async function handleTatJob(job: Job) {
url: `/request/${requestNumber}`,
type: type,
priority: notificationPriority,
- actionRequired: type === 'breach' || type === 'threshold2' // Require action for critical alerts
+ actionRequired: type === 'breach' || type === 'threshold2', // Require action for critical alerts
+ metadata: {
+ thresholdPercentage: thresholdPercentage,
+ tatInfo: {
+ thresholdPercentage: thresholdPercentage,
+ timeRemaining: timeRemainingText,
+ tatDeadline: expectedCompletionTime,
+ assignedDate: levelStartTime,
+ timeOverdue: type === 'breach' ? timeRemainingText : undefined
+ }
+ }
});
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
} catch (notificationError: any) {
diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts
index 772ab0e..d3f2d64 100644
--- a/src/services/ai.service.ts
+++ b/src/services/ai.service.ts
@@ -115,11 +115,66 @@ class AIService {
const streamingResp = await generativeModel.generateContent(request);
const response = streamingResp.response;
+ // Log full response structure for debugging if empty
+ if (!response.candidates || response.candidates.length === 0) {
+ logger.error('[AI Service] No candidates in Vertex AI response:', {
+ response: JSON.stringify(response, null, 2),
+ promptLength: prompt.length,
+ model: this.model
+ });
+ throw new Error('Vertex AI returned no candidates. The response may have been blocked by safety filters.');
+ }
+
+ const candidate = response.candidates[0];
+
+ // Check for safety ratings or blocked reasons
+ if (candidate.safetyRatings && candidate.safetyRatings.length > 0) {
+ const blockedRatings = candidate.safetyRatings.filter((rating: any) =>
+ rating.probability === 'HIGH' || rating.probability === 'MEDIUM'
+ );
+ if (blockedRatings.length > 0) {
+ logger.warn('[AI Service] Vertex AI safety filters triggered:', {
+ ratings: blockedRatings.map((r: any) => ({
+ category: r.category,
+ probability: r.probability
+ })),
+ finishReason: candidate.finishReason
+ });
+ }
+ }
+
+ // Check finish reason
+ if (candidate.finishReason && candidate.finishReason !== 'STOP') {
+ logger.warn('[AI Service] Vertex AI finish reason:', {
+ finishReason: candidate.finishReason,
+ safetyRatings: candidate.safetyRatings
+ });
+ }
+
// Extract text from response
- const text = response.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ const text = candidate.content?.parts?.[0]?.text || '';
if (!text) {
- throw new Error('Empty response from Vertex AI');
+ // Log detailed response structure for debugging
+ logger.error('[AI Service] Empty text in Vertex AI response:', {
+ candidate: JSON.stringify(candidate, null, 2),
+ finishReason: candidate.finishReason,
+ safetyRatings: candidate.safetyRatings,
+ promptLength: prompt.length,
+ promptPreview: prompt.substring(0, 200) + '...',
+ model: this.model
+ });
+
+ // Provide more helpful error message
+ if (candidate.finishReason === 'SAFETY') {
+ throw new Error('Vertex AI blocked the response due to safety filters. The prompt may contain content that violates safety policies.');
+ } else if (candidate.finishReason === 'MAX_TOKENS') {
+ throw new Error('Vertex AI response was truncated due to token limit.');
+ } else if (candidate.finishReason === 'RECITATION') {
+ throw new Error('Vertex AI blocked the response due to recitation concerns.');
+ } else {
+ throw new Error(`Empty response from Vertex AI. Finish reason: ${candidate.finishReason || 'UNKNOWN'}`);
+ }
}
return text;
diff --git a/src/services/email.service.ts b/src/services/email.service.ts
index 780e69b..930726b 100644
--- a/src/services/email.service.ts
+++ b/src/services/email.service.ts
@@ -121,24 +121,42 @@ export class EmailService {
try {
const info = await this.transporter!.sendMail(mailOptions);
+ if (!info || !info.messageId) {
+ throw new Error('Email sent but no messageId returned');
+ }
+
const result: { messageId: string; previewUrl?: string } = {
messageId: info.messageId
};
// If using test account, generate preview URL
if (this.useTestAccount) {
- const previewUrl = nodemailer.getTestMessageUrl(info);
- result.previewUrl = previewUrl || undefined;
-
- // Always log to console for visibility
- console.log('\n' + '='.repeat(80));
- console.log(`📧 EMAIL PREVIEW (${options.subject})`);
- console.log(`To: ${recipients}`);
- console.log(`Preview URL: ${previewUrl}`);
- console.log('='.repeat(80) + '\n');
-
- logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
- logger.info(`📧 Preview URL: ${previewUrl}`);
+ try {
+ const previewUrl = nodemailer.getTestMessageUrl(info);
+
+ if (previewUrl) {
+ result.previewUrl = previewUrl;
+
+ // Always log to console for visibility
+ console.log('\n' + '='.repeat(80));
+ console.log(`📧 EMAIL PREVIEW (${options.subject})`);
+ console.log(`To: ${recipients}`);
+ console.log(`Preview URL: ${previewUrl}`);
+ console.log(`Message ID: ${info.messageId}`);
+ console.log('='.repeat(80) + '\n');
+
+ logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
+ logger.info(`📧 Preview URL: ${previewUrl}`);
+ } else {
+ logger.warn(`⚠️ Email sent but preview URL not available. Message ID: ${info.messageId}`);
+ logger.warn(`💡 This can happen if the email service is rate-limited or the message hasn't been processed yet.`);
+ }
+ } catch (previewError: any) {
+ logger.error(`❌ Failed to generate preview URL:`, previewError);
+ logger.warn(`⚠️ Email was sent successfully (Message ID: ${info.messageId}) but preview URL generation failed.`);
+ logger.warn(`💡 You can try sending the email again to get a new preview URL.`);
+ // Don't throw - email was sent successfully, just preview URL failed
+ }
} else {
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
}
diff --git a/src/services/emailNotification.service.ts b/src/services/emailNotification.service.ts
index 4b540b4..3b5045b 100644
--- a/src/services/emailNotification.service.ts
+++ b/src/services/emailNotification.service.ts
@@ -348,12 +348,27 @@ export class EmailNotificationService {
: 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: requestData.initiatorName || 'Initiator',
+ initiatorName: initiatorName,
assignedDate: this.formatDate(tatInfo.assignedDate),
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
timeRemaining: tatInfo.timeRemaining,
@@ -404,12 +419,27 @@ export class EmailNotificationService {
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: requestData.initiatorName || 'Initiator',
+ initiatorName: initiatorName,
priority: requestData.priority || 'MEDIUM',
assignedDate: this.formatDate(tatInfo.assignedDate),
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
@@ -446,6 +476,15 @@ export class EmailNotificationService {
pauseDuration: string
): Promise {
try {
+ // Validate approver data has email
+ if (!approverData || !approverData.email) {
+ logger.warn(`[Email] Cannot send Workflow Resumed email: approver email missing`, {
+ approverData: approverData ? { userId: approverData.userId, displayName: approverData.displayName } : null,
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
const canSend = await shouldSendEmail(
approverData.userId,
EmailNotificationType.WORKFLOW_RESUMED
@@ -495,6 +534,75 @@ export class EmailNotificationService {
}
}
+ /**
+ * Send Workflow Resumed Email to Initiator
+ */
+ async sendWorkflowResumedToInitiator(
+ requestData: any,
+ initiatorData: any,
+ approverData: any,
+ resumedByData: any,
+ pauseDuration: string
+ ): Promise {
+ try {
+ // Validate initiator data has email
+ if (!initiatorData || !initiatorData.email) {
+ logger.warn(`[Email] Cannot send Workflow Resumed email to initiator: email missing`, {
+ initiatorData: initiatorData ? { userId: initiatorData.userId, displayName: initiatorData.displayName } : null,
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
+ const canSend = await shouldSendEmail(
+ initiatorData.userId,
+ EmailNotificationType.WORKFLOW_RESUMED
+ );
+
+ if (!canSend) {
+ logger.info(`Email skipped (preferences): Workflow Resumed for initiator ${initiatorData.email}`);
+ return;
+ }
+
+ const isAutoResumed = !resumedByData || resumedByData.userId === 'system' || !resumedByData.userId;
+ const resumedByText = isAutoResumed
+ ? 'automatically'
+ : `by ${resumedByData.displayName || resumedByData.email || resumedByData.name || 'User'}`;
+
+ const data: WorkflowResumedData = {
+ recipientName: initiatorData.displayName || initiatorData.email,
+ requestId: requestData.requestNumber,
+ requestTitle: requestData.title,
+ resumedByText,
+ resumedDate: this.formatDate(new Date()),
+ resumedTime: this.formatTime(new Date()),
+ pausedDuration: pauseDuration,
+ currentApprover: approverData?.displayName || approverData?.email || 'Current Approver',
+ newTATDeadline: requestData.tatDeadline
+ ? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline)
+ : 'To be determined',
+ isApprover: false, // This is for initiator
+ viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
+ companyName: CompanyInfo.name
+ };
+
+ const html = getWorkflowResumedEmail(data);
+ const subject = `[${requestData.requestNumber}] Workflow Resumed`;
+
+ const result = await emailService.sendEmail({
+ to: initiatorData.email,
+ subject,
+ html
+ });
+
+ if (result.previewUrl) {
+ logger.info(`📧 Workflow Resumed Email Preview (Initiator): ${result.previewUrl}`);
+ }
+ } catch (error) {
+ logger.error('Failed to send Workflow Resumed email to initiator:', error);
+ }
+ }
+
/**
* 8. Send Request Closed Email
*/
@@ -523,11 +631,26 @@ export class EmailNotificationService {
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: requestData.initiatorName || 'Initiator',
+ initiatorName: initiatorName,
createdDate: this.formatDate(requestData.createdAt),
closedDate: this.formatDate(requestData.closedAt || new Date()),
closedTime: this.formatTime(requestData.closedAt || new Date()),
@@ -638,6 +761,15 @@ export class EmailNotificationService {
resumeDate: Date | string
): Promise {
try {
+ // Validate recipient data has email
+ if (!recipientData || !recipientData.email) {
+ logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
+ recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.WORKFLOW_PAUSED
diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts
index 8d7e663..9dbd727 100644
--- a/src/services/notification.service.ts
+++ b/src/services/notification.service.ts
@@ -123,10 +123,12 @@ class NotificationService {
for (const userId of userIds) {
try {
- // Fetch user preferences
+ // Fetch user preferences and email data
const user = await User.findByPk(userId, {
attributes: [
'userId',
+ 'email',
+ 'displayName',
'emailNotificationsEnabled',
'pushNotificationsEnabled',
'inAppNotificationsEnabled'
@@ -266,6 +268,10 @@ class NotificationService {
'rejection': EmailNotificationType.REQUEST_REJECTED,
'tat_reminder': EmailNotificationType.TAT_REMINDER,
'tat_breach': EmailNotificationType.TAT_BREACHED,
+ 'threshold1': EmailNotificationType.TAT_REMINDER, // 50% TAT reminder
+ 'threshold2': EmailNotificationType.TAT_REMINDER, // 75% TAT reminder
+ 'breach': EmailNotificationType.TAT_BREACHED, // 100% TAT breach
+ 'tat_breach_initiator': EmailNotificationType.TAT_BREACHED, // Breach notification to initiator
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
'closed': EmailNotificationType.REQUEST_CLOSED,
// These don't get emails (in-app only)
@@ -277,6 +283,7 @@ class NotificationService {
'summary_generated': null,
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
+ 'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
'pause_retriggered': null
};
@@ -291,7 +298,11 @@ class NotificationService {
}
// Check if email should be sent (admin + user preferences)
- const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
+ // Critical emails: rejection, tat_breach, breach
+ const isCriticalEmail = payload.type === 'rejection' ||
+ payload.type === 'tat_breach' ||
+ payload.type === 'breach';
+ const shouldSend = isCriticalEmail
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
: await shouldSendEmail(userId, emailType); // Regular emails
@@ -459,12 +470,34 @@ class NotificationService {
where: {
requestId: payload.requestId,
status: 'REJECTED'
- }
+ },
+ order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
});
+ // Get the approver who rejected from the rejected level
+ let approverData = user; // Fallback to user if we can't find the approver
+ if (rejectedLevel) {
+ const approverUser = await User.findByPk((rejectedLevel as any).approverId);
+ if (approverUser) {
+ approverData = approverUser.toJSON();
+ // Add rejection metadata
+ (approverData as any).rejectedAt = (rejectedLevel as any).actionDate;
+ (approverData as any).comments = (rejectedLevel as any).comments;
+ } else {
+ // If user not found, use approver info from the level itself
+ approverData = {
+ userId: (rejectedLevel as any).approverId,
+ displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
+ email: (rejectedLevel as any).approverEmail || 'unknown@royalenfield.com',
+ rejectedAt: (rejectedLevel as any).actionDate,
+ comments: (rejectedLevel as any).comments
+ };
+ }
+ }
+
await emailNotificationService.sendRejectionNotification(
requestData,
- user, // Approver who rejected
+ approverData, // Approver who rejected
initiatorData,
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
);
@@ -472,38 +505,13 @@ class NotificationService {
break;
case 'tat_reminder':
+ case 'threshold1':
+ case 'threshold2':
case 'tat_breach':
+ case 'breach':
+ case 'tat_breach_initiator':
{
- // Extract TAT info from metadata or payload
- const tatInfo = payload.metadata?.tatInfo || {
- thresholdPercentage: payload.type === 'tat_breach' ? 100 : 75,
- timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
- tatDeadline: payload.metadata?.tatDeadline || new Date(),
- assignedDate: payload.metadata?.assignedDate || requestData.createdAt
- };
-
- if (notificationType === 'tat_breach') {
- await emailNotificationService.sendTATBreached(
- requestData,
- user,
- {
- timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining,
- tatDeadline: tatInfo.tatDeadline,
- assignedDate: tatInfo.assignedDate
- }
- );
- } else {
- await emailNotificationService.sendTATReminder(
- requestData,
- user,
- tatInfo
- );
- }
- }
- break;
-
- case 'workflow_resumed':
- {
+ // Get the approver from the current level (the one who needs to take action)
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
@@ -512,17 +520,158 @@ class NotificationService {
order: [['levelNumber', 'ASC']]
});
- const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null;
+ // Get approver data - prefer from level, fallback to user
+ let approverData = user; // Fallback
+ if (currentLevel) {
+ const approverUser = await User.findByPk((currentLevel as any).approverId);
+ if (approverUser) {
+ approverData = approverUser.toJSON();
+ } else {
+ // If user not found, use approver info from the level itself
+ approverData = {
+ userId: (currentLevel as any).approverId,
+ displayName: (currentLevel as any).approverName || 'Unknown Approver',
+ email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
+ };
+ }
+ }
+
+ // Determine threshold percentage based on notification type
+ let thresholdPercentage = 75; // Default
+ if (notificationType === 'threshold1') {
+ thresholdPercentage = 50;
+ } else if (notificationType === 'threshold2') {
+ thresholdPercentage = 75;
+ } else if (notificationType === 'breach' || notificationType === 'tat_breach' || notificationType === 'tat_breach_initiator') {
+ thresholdPercentage = 100;
+ } else if (payload.metadata?.thresholdPercentage) {
+ thresholdPercentage = payload.metadata.thresholdPercentage;
+ }
+
+ // Extract TAT info from metadata or payload
+ const tatInfo = payload.metadata?.tatInfo || {
+ thresholdPercentage: thresholdPercentage,
+ timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
+ tatDeadline: payload.metadata?.tatDeadline || new Date(),
+ assignedDate: payload.metadata?.assignedDate || requestData.createdAt
+ };
+
+ // Update threshold percentage if not in tatInfo
+ if (!payload.metadata?.tatInfo) {
+ tatInfo.thresholdPercentage = thresholdPercentage;
+ }
+
+ // Handle breach notifications (to approver or initiator)
+ if (notificationType === 'breach' || notificationType === 'tat_breach') {
+ // Breach notification to approver
+ if (approverData && approverData.email) {
+ await emailNotificationService.sendTATBreached(
+ requestData,
+ approverData,
+ {
+ timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining || 'Exceeded',
+ tatDeadline: tatInfo.tatDeadline,
+ assignedDate: tatInfo.assignedDate
+ }
+ );
+ }
+ } else if (notificationType === 'tat_breach_initiator') {
+ // Breach notification to initiator
+ if (initiatorData && initiatorData.email) {
+ // For initiator, we can use a simpler notification or the same breach template
+ // For now, skip email to initiator on breach (they get in-app notification)
+ // Or we could create a separate initiator breach email template
+ logger.info(`[Email] Breach notification to initiator - in-app only for now`);
+ }
+ } else {
+ // TAT reminder (threshold1, threshold2, or tat_reminder)
+ if (approverData && approverData.email) {
+ await emailNotificationService.sendTATReminder(
+ requestData,
+ approverData,
+ tatInfo
+ );
+ }
+ }
+ }
+ break;
+
+ case 'workflow_resumed':
+ {
+ // Get current level to determine approver
+ const currentLevel = await ApprovalLevel.findOne({
+ where: {
+ requestId: payload.requestId,
+ status: 'PENDING'
+ },
+ order: [['levelNumber', 'ASC']]
+ });
+
+ // Get approver data from current level
+ let approverData = null;
+ if (currentLevel) {
+ const approverUser = await User.findByPk((currentLevel as any).approverId);
+ if (approverUser) {
+ approverData = approverUser.toJSON();
+ } else {
+ // Use approver info from level
+ approverData = {
+ userId: (currentLevel as any).approverId,
+ displayName: (currentLevel as any).approverName || 'Unknown Approver',
+ email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
+ };
+ }
+ }
+
const resumedBy = payload.metadata?.resumedBy;
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
- await emailNotificationService.sendWorkflowResumed(
- requestData,
- currentApprover ? currentApprover.toJSON() : user,
- initiatorData,
- resumedBy,
- pauseDuration
- );
+ // Convert user to plain object if needed
+ const userData = user.toJSON ? user.toJSON() : user;
+
+ // Determine if the recipient is the approver or initiator
+ const isApprover = approverData && userData.userId === approverData.userId;
+ const isInitiator = userData.userId === initiatorData.userId;
+
+ // Ensure user has email
+ if (!userData.email) {
+ logger.warn(`[Email] Cannot send Workflow Resumed email: user email missing`, {
+ userId: userData.userId,
+ displayName: userData.displayName,
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
+ // Send appropriate email based on recipient role
+ if (isApprover) {
+ // Recipient is the approver - send approver email
+ await emailNotificationService.sendWorkflowResumed(
+ requestData,
+ userData,
+ initiatorData,
+ resumedBy,
+ pauseDuration
+ );
+ } else if (isInitiator) {
+ // Recipient is the initiator - send initiator email
+ await emailNotificationService.sendWorkflowResumedToInitiator(
+ requestData,
+ userData,
+ approverData,
+ resumedBy,
+ pauseDuration
+ );
+ } else {
+ // Recipient is neither approver nor initiator (spectator) - send initiator-style email
+ await emailNotificationService.sendWorkflowResumedToInitiator(
+ requestData,
+ userData,
+ approverData,
+ resumedBy,
+ pauseDuration
+ );
+ }
}
break;
@@ -547,9 +696,9 @@ class NotificationService {
const skippedLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
- isSkipped: true
+ status: 'SKIPPED'
},
- order: [['skippedAt', 'DESC']]
+ order: [['levelEndTime', 'DESC'], ['actionDate', 'DESC']]
});
const nextLevel = await ApprovalLevel.findOne({
@@ -576,14 +725,112 @@ class NotificationService {
}
break;
+ case 'pause_retrigger_request':
+ {
+ // This is when initiator requests approver to resume a paused workflow
+ // Treat it similar to workflow_paused but with different messaging
+ const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
+ const resumeDate = payload.metadata?.resumeDate || new Date();
+
+ // Get recipient data (the approver who paused it)
+ let recipientData = user;
+ if (!recipientData || !recipientData.email) {
+ // Try to get from paused level
+ const pausedLevel = await ApprovalLevel.findOne({
+ where: {
+ requestId: payload.requestId,
+ isPaused: true
+ },
+ order: [['levelNumber', 'ASC']]
+ });
+
+ if (pausedLevel) {
+ const approverUser = await User.findByPk((pausedLevel as any).approverId);
+ if (approverUser) {
+ recipientData = approverUser.toJSON();
+ } else {
+ recipientData = {
+ userId: (pausedLevel as any).approverId,
+ displayName: (pausedLevel as any).approverName || 'Unknown Approver',
+ email: (pausedLevel as any).approverEmail || 'unknown@royalenfield.com'
+ };
+ }
+ }
+ }
+
+ // Ensure email exists before sending
+ if (!recipientData || !recipientData.email) {
+ logger.warn(`[Email] Cannot send Pause Retrigger Request email: recipient email missing`, {
+ recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
+ // Use workflow paused email template but with retrigger context
+ await emailNotificationService.sendWorkflowPaused(
+ requestData,
+ recipientData,
+ pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
+ `Initiator has requested to resume this workflow. Please review and resume if appropriate.`,
+ resumeDate
+ );
+ }
+ break;
+
case 'workflow_paused':
{
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
const resumeDate = payload.metadata?.resumeDate || new Date();
+ // Get recipient data - prefer from user, ensure it has email
+ let recipientData = user;
+ if (!recipientData || !recipientData.email) {
+ // If user object doesn't have email, try to get from current level
+ const currentLevel = await ApprovalLevel.findOne({
+ where: {
+ requestId: payload.requestId,
+ status: 'PENDING'
+ },
+ order: [['levelNumber', 'ASC']]
+ });
+
+ if (currentLevel) {
+ const approverUser = await User.findByPk((currentLevel as any).approverId);
+ if (approverUser) {
+ recipientData = approverUser.toJSON();
+ } else {
+ // Use approver info from level
+ recipientData = {
+ userId: (currentLevel as any).approverId,
+ displayName: (currentLevel as any).approverName || 'Unknown User',
+ email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
+ };
+ }
+ } else {
+ // If no current level, try to get from initiator
+ const initiatorUser = await User.findByPk(requestData.initiatorId);
+ if (initiatorUser) {
+ recipientData = initiatorUser.toJSON();
+ } else {
+ logger.warn(`[Email] Cannot send Workflow Paused email: no recipient found for request ${payload.requestId}`);
+ return;
+ }
+ }
+ }
+
+ // Ensure email exists before sending
+ if (!recipientData.email) {
+ logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
+ recipientData: { userId: recipientData.userId, displayName: recipientData.displayName },
+ requestNumber: requestData.requestNumber
+ });
+ return;
+ }
+
await emailNotificationService.sendWorkflowPaused(
requestData,
- user,
+ recipientData,
pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
payload.metadata?.pauseReason || 'Not provided',
resumeDate
diff --git a/src/services/pause.service.ts b/src/services/pause.service.ts
index 56cb2a6..c66c982 100644
--- a/src/services/pause.service.ts
+++ b/src/services/pause.service.ts
@@ -458,6 +458,12 @@ export class PauseService {
const isResumedByInitiator = userId === initiatorId;
const isResumedByApprover = userId === approverId;
+ // Calculate pause duration
+ const pausedAt = (level as any).pausedAt || (workflow as any).pausedAt;
+ const pauseDurationMs = pausedAt ? now.getTime() - new Date(pausedAt).getTime() : 0;
+ const pauseDurationHours = Math.round((pauseDurationMs / (1000 * 60 * 60)) * 100) / 100; // Round to 2 decimal places
+ const pauseDuration = pauseDurationHours > 0 ? `${pauseDurationHours} hours` : 'less than 1 hour';
+
// Notify initiator only if someone else resumed (or auto-resume)
// Skip if initiator resumed their own request
if (!isResumedByInitiator) {
| | | | | | | | | | | | | | |