Re_Backend/_archive/services/notification.service.ts

1099 lines
44 KiB
TypeScript

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;
}
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@example.com';
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)
console.log(`[DEBUG] Checking email for notification type: ${payload.type}`);
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> {
console.log(`[DEBUG Email] Notification type: ${payload.type}, userId: ${userId}`);
// 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
};
const emailType = emailTypeMap[payload.type || ''];
console.log(`[DEBUG Email] Email type mapped: ${emailType}`);
if (!emailType) {
// This notification type doesn't warrant email
// Note: 'document_added' emails are handled separately via emailNotificationService
if (payload.type !== 'document_added') {
console.log(`[DEBUG Email] No email for notification type: ${payload.type}`);
}
return;
}
// Check if email should be sent (admin + user preferences)
// 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
: payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails
console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`);
if (!shouldSend) {
console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`);
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
console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`);
try {
await this.triggerEmailByType(payload.type || '', userId, payload, user);
} catch (error) {
console.error(`[DEBUG Email] Error triggering email:`, 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@royalenfield.com',
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: 'PENDING'
},
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@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';
// 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@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,
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();