Re_Backend/src/services/notification.service.ts

1125 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import webpush from 'web-push';
import logger, { logNotificationEvent } from '@utils/logger';
import { Subscription } from '@models/Subscription';
import { Notification } from '@models/Notification';
import {
shouldSendEmail,
shouldSendEmailWithOverride,
shouldSendInAppNotification,
EmailNotificationType
} from '../emailtemplates/emailPreferences.helper';
type PushSubscription = any; // Web Push protocol JSON
interface NotificationPayload {
title: string;
body: string;
requestId?: string;
requestNumber?: string;
url?: string;
type?: string;
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
actionRequired?: boolean;
metadata?: any;
}
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
class NotificationService {
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
const pub = vapidPublicKey || process.env.VAPID_PUBLIC_KEY || '';
const priv = vapidPrivateKey || process.env.VAPID_PRIVATE_KEY || '';
const contact = mailto || process.env.VAPID_CONTACT || `mailto:admin@${appDomain}`;
if (!pub || !priv) {
logger.warn('VAPID keys are not configured. Push notifications are disabled.');
return;
}
webpush.setVapidDetails(contact, pub, priv);
logger.info('Web Push configured');
}
async addSubscription(userId: string, subscription: PushSubscription, userAgent?: string) {
// Persist to DB (upsert by endpoint)
try {
const endpoint: string = subscription?.endpoint || '';
const keys = subscription?.keys || {};
if (!endpoint || !keys?.p256dh || !keys?.auth) throw new Error('Invalid subscription payload');
await Subscription.upsert({
userId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
userAgent: userAgent || null,
} as any);
} catch (e) {
logger.error('Failed to persist subscription', e);
}
const list = this.userIdToSubscriptions.get(userId) || [];
const already = list.find((s) => JSON.stringify(s) === JSON.stringify(subscription));
if (!already) {
list.push(subscription);
this.userIdToSubscriptions.set(userId, list);
}
logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`);
}
/**
* Get all subscriptions for a user
*/
async getUserSubscriptions(userId: string) {
try {
const subscriptions = await Subscription.findAll({
where: { userId },
attributes: ['subscriptionId', 'endpoint', 'userAgent', 'createdAt']
});
return subscriptions;
} catch (error) {
logger.error(`[Notification] Failed to get subscriptions for user ${userId}:`, error);
return [];
}
}
/**
* Remove expired/invalid subscription from database and memory cache
*/
private async removeExpiredSubscription(userId: string, endpoint: string) {
try {
// Remove from database
await Subscription.destroy({ where: { endpoint } });
logger.info(`[Notification] Removed expired subscription from DB for user ${userId}, endpoint: ${endpoint.substring(0, 50)}...`);
// Remove from memory cache
const list = this.userIdToSubscriptions.get(userId) || [];
const filtered = list.filter((s) => s.endpoint !== endpoint);
if (filtered.length !== list.length) {
this.userIdToSubscriptions.set(userId, filtered);
logger.info(`[Notification] Removed expired subscription from memory cache for user ${userId}`);
}
} catch (error) {
logger.error(`[Notification] Failed to remove expired subscription for user ${userId}:`, error);
}
}
/**
* Check if error indicates expired/invalid subscription
* webpush returns status codes: 410 (Gone), 404 (Not Found), 403 (Forbidden)
*/
private isExpiredSubscriptionError(err: any): boolean {
const statusCode = err?.statusCode || err?.status || err?.response?.statusCode;
// 410 Gone = subscription expired
// 404 Not Found = subscription doesn't exist
// 403 Forbidden = subscription invalid
return statusCode === 410 || statusCode === 404 || statusCode === 403;
}
/**
* Send notification to users - saves to DB, sends via push/socket, and emails
* Respects user notification preferences for all channels
* Automatically sends email for applicable notification types
*/
async sendToUsers(userIds: string[], payload: NotificationPayload) {
const message = JSON.stringify(payload);
const { User } = require('@models/User');
for (const userId of userIds) {
try {
// Fetch user preferences and email data
const user = await User.findByPk(userId, {
attributes: [
'userId',
'email',
'displayName',
'emailNotificationsEnabled',
'pushNotificationsEnabled',
'inAppNotificationsEnabled'
]
});
if (!user) {
logger.warn(`[Notification] User ${userId} not found, skipping notification`);
continue;
}
const sentVia: string[] = [];
// 1. Check admin + user preferences for in-app notifications
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
logger.info(`[Notification] In-app notification check for user ${userId}:`, {
canSendInApp,
inAppNotificationsEnabled: user.inAppNotificationsEnabled,
notificationType: payload.type,
willCreate: canSendInApp && user.inAppNotificationsEnabled
});
let notification: any = null;
if (canSendInApp && user.inAppNotificationsEnabled) {
try {
notification = await Notification.create({
userId,
requestId: payload.requestId,
notificationType: payload.type || 'general',
title: payload.title,
message: payload.body,
isRead: false,
priority: payload.priority || 'MEDIUM',
actionUrl: payload.url,
actionRequired: payload.actionRequired || false,
metadata: {
requestNumber: payload.requestNumber,
...payload.metadata
},
sentVia: ['IN_APP'],
emailSent: false,
smsSent: false,
pushSent: false
} as any);
sentVia.push('IN_APP');
logger.info(`[Notification] ✅ Created in-app notification for user ${userId}: ${payload.title} (ID: ${(notification as any).notificationId})`);
// 2. Emit real-time socket event for immediate delivery
try {
const { emitToUser } = require('../realtime/socket');
if (emitToUser) {
emitToUser(userId, 'notification:new', {
notification: notification.toJSON(),
...payload
});
logger.info(`[Notification] ✅ Emitted socket event to user ${userId}`);
} else {
logger.warn(`[Notification] emitToUser function not available`);
}
} catch (socketError) {
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
}
} catch (notificationError) {
logger.error(`[Notification] ❌ Failed to create in-app notification for user ${userId}:`, notificationError);
// Continue - don't block other notification channels
}
// 3. Send push notification (if enabled and user has subscriptions)
if (user.pushNotificationsEnabled && canSendInApp && notification) {
let subs = this.userIdToSubscriptions.get(userId) || [];
// Load from DB if memory empty
if (subs.length === 0) {
try {
const rows = await Subscription.findAll({ where: { userId } });
subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }));
} catch { }
}
if (subs.length > 0) {
for (const sub of subs) {
try {
await webpush.sendNotification(sub, message);
await notification.update({ pushSent: true });
sentVia.push('PUSH');
logNotificationEvent('sent', {
userId,
channel: 'push',
type: payload.type,
requestId: payload.requestId,
});
} catch (err: any) {
// Check if subscription is expired/invalid
if (this.isExpiredSubscriptionError(err)) {
logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`);
await this.removeExpiredSubscription(userId, sub.endpoint);
} else {
logNotificationEvent('failed', {
userId,
channel: 'push',
type: payload.type,
requestId: payload.requestId,
error: err,
});
}
}
}
}
} else {
logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`);
}
} else {
if (!canSendInApp) {
logger.info(`[Notification] In-app notifications disabled by admin/user for user ${userId}, type: ${payload.type}`);
} else {
logger.info(`[Notification] In-app notifications disabled for user ${userId}`);
}
}
// 4. Send email notification for applicable types (async, don't wait)
// Check email notification preferences
this.sendEmailNotification(userId, user, payload).catch(emailError => {
console.error(`[Notification] Email sending failed for user ${userId}:`, emailError);
logger.error(`[Notification] Email sending failed for user ${userId}:`, emailError);
// Don't throw - email failure shouldn't block notification
});
} catch (error) {
logger.error(`[Notification] Failed to create notification for user ${userId}:`, error);
// Continue to next user even if one fails
}
}
}
/**
* Send email notification based on notification type
* Only sends for notification types that warrant email
*/
private async sendEmailNotification(userId: string, user: any, payload: NotificationPayload): Promise<void> {
// Import email service (lazy load to avoid circular dependencies)
const { emailNotificationService } = await import('./emailNotification.service');
const { EmailNotificationType } = await import('../emailtemplates/emailPreferences.helper');
// Map notification type to email type and check if email should be sent
const emailTypeMap: Record<string, EmailNotificationType | null> = {
'request_submitted': EmailNotificationType.REQUEST_CREATED,
'assignment': EmailNotificationType.APPROVAL_REQUEST,
'approval': EmailNotificationType.REQUEST_APPROVED,
'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)
'mention': null,
'comment': null,
'document_added': null,
'status_change': null,
'ai_conclusion_generated': null,
'summary_generated': null,
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
'spectator_added': EmailNotificationType.SPECTATOR_ADDED,
// Dealer Claim Specific
'proposal_submitted': EmailNotificationType.DEALER_PROPOSAL_SUBMITTED,
'activity_created': EmailNotificationType.ACTIVITY_CREATED,
'completion_submitted': EmailNotificationType.COMPLETION_DOCUMENTS_SUBMITTED,
'einvoice_generated': EmailNotificationType.EINVOICE_GENERATED,
'credit_note_sent': EmailNotificationType.CREDIT_NOTE_SENT,
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
'pause_retriggered': null,
// Form 16 email sent in block above via same transport (Ethereal/SMTP); map kept null
'form16_26as_added': null,
'form16_success_credit_note': null,
'form16_unsuccessful': null,
'form16_debit_note': null,
'form16_alert_submit': null,
'form16_reminder': null,
};
const emailType = emailTypeMap[payload.type || ''];
// Form 16: send email via same transport as workflow (Ethereal when SMTP not set); templates come from payload
if (payload.type && payload.type.startsWith('form16_') && user?.email) {
if (user.emailNotificationsEnabled === false) {
logger.info(`[Email] Form 16 email skipped for user ${userId} (email notifications disabled)`);
return;
}
try {
const { emailService } = await import('./email.service');
const escaped = (payload.body || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br/>');
const html = `<!DOCTYPE html><html><body><p>${escaped}</p></body></html>`;
await emailService.sendEmail({
to: user.email,
subject: payload.title || 'Form 16 Notification',
html,
});
logger.info(`[Email] Form 16 email sent to ${user.email} (type: ${payload.type})`);
} catch (err) {
logger.error(`[Email] Form 16 email failed for user ${userId}:`, err);
}
return;
}
if (!emailType) {
// This notification type doesn't warrant email
// Note: 'document_added' emails are handled separately via emailNotificationService
if (payload.type !== 'document_added') {
}
return;
}
// Check if email should be sent (admin + user preferences)
// emails: rejection, tat_breach, breach
const isCriticalEmail = payload.type === 'rejection' ||
payload.type === 'tat_breach' ||
payload.type === 'breach';
const shouldSend = isCriticalEmail
? await shouldSendEmailWithOverride(userId, emailType) // emails
: payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails
if (!shouldSend) {
logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`);
return;
}
logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`);
// Trigger email based on notification type
// Email service will fetch additional data as needed
try {
await this.triggerEmailByType(payload.type || '', userId, payload, user);
} catch (error) {
logger.error(`[Email] Failed to trigger email for type ${payload.type}:`, error);
}
}
/**
* Trigger appropriate email based on notification type
*/
private async triggerEmailByType(
notificationType: string,
userId: string,
payload: NotificationPayload,
user: any
): Promise<void> {
const { emailNotificationService } = await import('./emailNotification.service');
const { WorkflowRequest, User, ApprovalLevel } = await import('@models/index');
// Fetch request data if requestId is provided
if (!payload.requestId) {
logger.warn(`[Email] No requestId in payload for type ${notificationType}`);
return;
}
const request = await WorkflowRequest.findByPk(payload.requestId);
if (!request) {
logger.warn(`[Email] Request ${payload.requestId} not found`);
return;
}
const requestData = request.toJSON();
// Fetch initiator user
const initiator = await User.findByPk(requestData.initiatorId);
if (!initiator) {
logger.warn(`[Email] Initiator not found for request ${payload.requestId}`);
return;
}
const initiatorData = initiator.toJSON();
switch (notificationType) {
case 'request_submitted':
{
const firstLevel = await ApprovalLevel.findOne({
where: { requestId: payload.requestId, levelNumber: 1 }
});
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
// Get first approver's TAT hours (not total TAT)
const firstApproverTatHours = firstLevel ? (firstLevel as any).tatHours : null;
// Add first approver's TAT to requestData for the email
const requestDataWithFirstTat = {
...requestData,
tatHours: firstApproverTatHours || (requestData as any).totalTatHours || 24
};
await emailNotificationService.sendRequestCreated(
requestDataWithFirstTat,
initiatorData,
firstApprover ? firstApprover.toJSON() : null
);
}
break;
case 'assignment':
{
// Fetch the approver user (the one being assigned)
const approverUser = await User.findByPk(userId);
if (!approverUser) {
logger.warn(`[Email] Approver user ${userId} not found`);
return;
}
const allLevels = await ApprovalLevel.findAll({
where: { requestId: payload.requestId },
order: [['levelNumber', 'ASC']]
});
// Find the level that matches this approver - PRIORITIZE PENDING LEVEL
// This ensures that if a user has multiple steps (e.g., Step 1 and Step 2),
// we pick the one that actually needs action (Step 2) rather than the first one (Step 1)
let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.status === 'PENDING');
// Fallback to any level if no pending level found (though for assignment there should be one)
if (!matchingLevel) {
matchingLevel = allLevels.find((l: any) => l.approverId === userId);
}
// Always reload from DB to ensure we have fresh levelName
const currentLevel = matchingLevel
? (await ApprovalLevel.findByPk((matchingLevel as any).levelId) || matchingLevel as any)
: null;
const workflowType = requestData.workflowType || 'CUSTOM';
logger.info(`[Email] Assignment - workflowType: ${workflowType}, approver: ${approverUser.email}, level: "${(currentLevel as any)?.levelName || 'N/A'}" (${(currentLevel as any)?.levelNumber || 'N/A'})`);
// Use factory to get the appropriate email service
const { workflowEmailServiceFactory } = await import('./workflowEmail.factory');
const workflowEmailService = workflowEmailServiceFactory.getService(workflowType);
if (workflowEmailService && workflowEmailServiceFactory.hasDedicatedService(workflowType)) {
// Use workflow-specific email service
await workflowEmailService.sendAssignmentEmail(
requestData,
approverUser,
initiatorData,
currentLevel,
allLevels
);
} else {
// Custom workflow or unknown type - use standard logic
const isMultiLevel = allLevels.length > 1;
const approverData = approverUser.toJSON();
// Add level number if available
if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
}
await emailNotificationService.sendApprovalRequest(
requestData,
approverData,
initiatorData,
isMultiLevel,
isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined
);
}
}
break;
case 'approval':
{
const approvedLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'APPROVED'
},
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
});
const allLevels = await ApprovalLevel.findAll({
where: { requestId: payload.requestId },
order: [['levelNumber', 'ASC']]
});
const approvedCount = allLevels.filter((l: any) => l.status === 'APPROVED').length;
const isFinalApproval = approvedCount === allLevels.length;
// Find next level - get the first PENDING level (handles dynamic approvers)
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
// Get next approver user data
let nextApprover = null;
if (nextLevel) {
const nextApproverUser = await User.findByPk((nextLevel as any).approverId);
if (nextApproverUser) {
nextApprover = nextApproverUser.toJSON();
} else {
// Fallback: use approverName/approverEmail from level if User not found
nextApprover = {
userId: (nextLevel as any).approverId,
displayName: (nextLevel as any).approverName || (nextLevel as any).approverEmail,
email: (nextLevel as any).approverEmail
};
}
}
// Get the approver who just approved from the approved level
let approverData = user; // Fallback to user if we can't find the approver
if (approvedLevel) {
const approverUser = await User.findByPk((approvedLevel as any).approverId);
if (approverUser) {
approverData = approverUser.toJSON();
// Add approval metadata
(approverData as any).approvedAt = (approvedLevel as any).actionDate;
(approverData as any).comments = (approvedLevel as any).comments;
}
}
// Skip sending approval confirmation email if the approver is the initiator
// (they don't need to be notified that they approved their own request)
const approverId = (approverData as any).userId || (approvedLevel as any)?.approverId;
const isApproverInitiator = approverId && initiatorData.userId && approverId === initiatorData.userId;
if (isApproverInitiator) {
logger.info(`[Email] Skipping approval confirmation email - approver is the initiator (${approverId})`);
return;
}
await emailNotificationService.sendApprovalConfirmation(
requestData,
approverData, // Approver who just approved
initiatorData,
isFinalApproval,
nextApprover // Next approver data
);
}
break;
case 'rejection':
{
const rejectedLevel = await ApprovalLevel.findOne({
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@${appDomain}`,
rejectedAt: (rejectedLevel as any).actionDate,
comments: (rejectedLevel as any).comments
};
}
}
await emailNotificationService.sendRejectionNotification(
requestData,
approverData, // Approver who rejected
initiatorData,
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
);
}
break;
case 'tat_reminder':
case 'threshold1':
case 'threshold2':
case 'tat_breach':
case 'breach':
case 'tat_breach_initiator':
{
// Get the approver from the current level (the one who needs to take action)
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'IN_PROGRESS'
},
order: [['levelNumber', 'ASC']]
});
// 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@${appDomain}`
};
}
}
// 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: 'IN_PROGRESS'
},
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@${appDomain}`
};
}
}
const resumedBy = payload.metadata?.resumedBy;
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
// 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;
case 'closed':
{
const closureData = {
conclusionRemark: payload.metadata?.conclusionRemark,
workNotesCount: payload.metadata?.workNotesCount || 0,
documentsCount: payload.metadata?.documentsCount || 0
};
await emailNotificationService.sendRequestClosed(
requestData,
user,
closureData
);
}
break;
case 'approver_skipped':
{
const skippedLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'SKIPPED'
},
order: [['levelEndTime', 'DESC'], ['actionDate', 'DESC']]
});
const nextLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'PENDING'
},
order: [['levelNumber', 'ASC']]
});
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
const skippedBy = payload.metadata?.skippedBy ? await User.findByPk(payload.metadata.skippedBy) : null;
const skippedApprover = skippedLevel ? await User.findByPk((skippedLevel as any).approverId) : null;
if (skippedApprover) {
await emailNotificationService.sendApproverSkipped(
requestData,
skippedApprover.toJSON(),
skippedBy ? skippedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
nextApprover ? nextApprover.toJSON() : null,
payload.metadata?.skipReason || (skippedLevel as any)?.skipReason || 'Not provided'
);
}
}
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@${appDomain}`
};
}
}
}
// 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@${appDomain}`
};
}
} 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,
recipientData,
pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
payload.metadata?.pauseReason || 'Not provided',
resumeDate
);
}
break;
case 'spectator_added':
{
// Get the spectator user (the one being added)
const spectatorUser = await User.findByPk(userId);
if (!spectatorUser) {
logger.warn(`[Email] Spectator user ${userId} not found`);
return;
}
// Get the user who added the spectator (if available in metadata)
const addedByUserId = payload.metadata?.addedBy;
const addedByUser = addedByUserId ? await User.findByPk(addedByUserId) : null;
await emailNotificationService.sendSpectatorAdded(
requestData,
spectatorUser.toJSON(),
addedByUser ? addedByUser.toJSON() : undefined,
initiatorData
);
}
break;
case 'proposal_submitted':
{
// Get dealer and proposal data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const proposalData = payload.metadata?.proposalData || {};
// Get activity information from metadata (not from requestData as it doesn't have these fields)
const activityName = payload.metadata?.activityName || requestData.title;
const activityType = payload.metadata?.activityType || 'N/A';
// Add activity info to requestData for the email template
const requestDataWithActivity = {
...requestData,
activityName: activityName,
activityType: activityType
};
// Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendDealerProposalSubmitted(
requestDataWithActivity,
dealerData,
user.toJSON(),
{
...proposalData,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApprover && !isNextApproverInitiator ? nextApprover.toJSON() : undefined
);
}
break;
case 'activity_created':
{
// Get activity data from metadata (should be provided by processActivityCreation)
const activityData = payload.metadata?.activityData || {
activityName: requestData.title,
activityType: 'N/A',
activityDate: payload.metadata?.activityDate,
location: payload.metadata?.location || 'Not specified',
dealerName: payload.metadata?.dealerName || 'Dealer',
dealerCode: payload.metadata?.dealerCode,
initiatorName: initiatorData.displayName || initiatorData.email,
departmentLeadName: payload.metadata?.departmentLeadName,
ioNumber: payload.metadata?.ioNumber,
nextSteps: payload.metadata?.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
};
await emailNotificationService.sendActivityCreated(
requestData,
user.toJSON(),
activityData
);
}
break;
case 'completion_submitted':
{
// Get dealer and completion data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const completionData = payload.metadata?.completionData || {};
// Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator)
const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendCompletionDocumentsSubmitted(
requestData,
dealerData,
user.toJSON(),
{
...completionData,
nextApproverIsInitiator: isNextApproverInitiator
},
nextApprover && !isNextApproverInitiator ? nextApprover.toJSON() : undefined
);
}
break;
case 'einvoice_generated':
{
// Get invoice data from metadata
const invoiceData = payload.metadata?.invoiceData || {
invoiceNumber: payload.metadata?.invoiceNumber || payload.metadata?.eInvoiceNumber,
invoiceDate: payload.metadata?.invoiceDate,
dmsNumber: payload.metadata?.dmsNumber,
amount: payload.metadata?.amount || payload.metadata?.invoiceAmount,
dealerName: payload.metadata?.dealerName,
dealerCode: payload.metadata?.dealerCode,
ioNumber: payload.metadata?.ioNumber,
generatedAt: payload.metadata?.generatedAt,
downloadLink: payload.metadata?.downloadLink
};
await emailNotificationService.sendEInvoiceGenerated(
requestData,
user.toJSON(),
invoiceData
);
}
break;
case 'credit_note_sent':
{
// Get credit note data from metadata
const creditNoteData = payload.metadata?.creditNoteData || {
creditNoteNumber: payload.metadata?.creditNoteNumber,
creditNoteDate: payload.metadata?.creditNoteDate,
creditNoteAmount: payload.metadata?.creditNoteAmount,
dealerName: payload.metadata?.dealerName,
dealerCode: payload.metadata?.dealerCode,
dealerEmail: payload.metadata?.dealerEmail,
reason: payload.metadata?.reason,
invoiceNumber: payload.metadata?.invoiceNumber,
sentAt: payload.metadata?.sentAt,
downloadLink: payload.metadata?.downloadLink
};
await emailNotificationService.sendCreditNoteSent(
requestData,
user.toJSON(),
creditNoteData
);
}
break;
default:
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
}
}
}
export const notificationService = new NotificationService();
notificationService.configure();