1125 lines
44 KiB
TypeScript
1125 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;
|
||
}
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.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();
|
||
|