553 lines
20 KiB
TypeScript
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();
|
|
|