1099 lines
44 KiB
TypeScript
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();
|
|
|