Re_Backend/src/services/notification.service.ts

553 lines
20 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
const user = await User.findByPk(userId, {
attributes: [
'userId',
'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,
'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': null, // Conditional - handled separately
'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
console.log(`[DEBUG Email] No email for notification type: ${payload.type}`);
return;
}
// Check if email should be sent (admin + user preferences)
// For assignment notifications, always attempt to send email (unless explicitly disabled by admin)
// This ensures next approvers always receive email notifications
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
? 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;
await emailNotificationService.sendRequestCreated(
requestData,
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']]
});
const isMultiLevel = allLevels.length > 1;
const approverData = approverUser.toJSON();
// Add level number if available
const currentLevel = allLevels.find((l: any) => l.approverId === userId);
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: [['approvedAt', '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;
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
await emailNotificationService.sendApprovalConfirmation(
requestData,
user, // Approver who just approved
initiatorData,
isFinalApproval,
nextApprover ? nextApprover.toJSON() : undefined
);
}
break;
case 'rejection':
{
const rejectedLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'REJECTED'
}
});
await emailNotificationService.sendRejectionNotification(
requestData,
user, // Approver who rejected
initiatorData,
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
);
}
break;
case 'tat_reminder':
case 'tat_breach':
{
// Extract TAT info from metadata or payload
const tatInfo = payload.metadata?.tatInfo || {
thresholdPercentage: payload.type === 'tat_breach' ? 100 : 75,
timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
tatDeadline: payload.metadata?.tatDeadline || new Date(),
assignedDate: payload.metadata?.assignedDate || requestData.createdAt
};
if (notificationType === 'tat_breach') {
await emailNotificationService.sendTATBreached(
requestData,
user,
{
timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining,
tatDeadline: tatInfo.tatDeadline,
assignedDate: tatInfo.assignedDate
}
);
} else {
await emailNotificationService.sendTATReminder(
requestData,
user,
tatInfo
);
}
}
break;
case 'workflow_resumed':
{
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'PENDING'
},
order: [['levelNumber', 'ASC']]
});
const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null;
const resumedBy = payload.metadata?.resumedBy;
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
await emailNotificationService.sendWorkflowResumed(
requestData,
currentApprover ? currentApprover.toJSON() : user,
initiatorData,
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;
default:
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
}
}
}
export const notificationService = new NotificationService();
notificationService.configure();