import { Job } from 'bullmq'; import { notificationService } from '@services/notification.service'; import { ApprovalLevel } from '@models/ApprovalLevel'; import { WorkflowRequest } from '@models/WorkflowRequest'; import { TatAlert, TatAlertType } from '@models/TatAlert'; import { activityService } from '@services/activity.service'; import logger from '@utils/logger'; import dayjs from 'dayjs'; import { calculateElapsedWorkingHours, addWorkingHours, addWorkingHoursExpress } from '@utils/tatTimeUtils'; interface TatJobData { type: 'threshold1' | 'threshold2' | 'breach'; threshold: number; // Actual percentage (e.g., 55, 80, 100) requestId: string; levelId: string; approverId: string; } /** * Handle TAT notification jobs */ export async function handleTatJob(job: Job) { const { requestId, levelId, approverId, type, threshold } = job.data; logger.info(`[TAT Processor] Processing ${type} (${threshold}%) for request ${requestId}`); try { // Get approval level and workflow details const approvalLevel = await ApprovalLevel.findOne({ where: { levelId } }); if (!approvalLevel) { logger.warn(`[TAT Processor] Approval level ${levelId} not found - likely already approved/rejected`); return; // Skip notification for non-existent level } // Check if level is still pending (not already approved/rejected) if ((approvalLevel as any).status !== 'PENDING' && (approvalLevel as any).status !== 'IN_PROGRESS') { logger.info(`[TAT Processor] Level ${levelId} is already ${(approvalLevel as any).status}. Skipping notification.`); return; } const workflow = await WorkflowRequest.findOne({ where: { requestId } }); if (!workflow) { logger.warn(`[TAT Processor] Workflow ${requestId} not found`); return; } const requestNumber = (workflow as any).requestNumber; const title = (workflow as any).title; let message = ''; let activityDetails = ''; let emoji = ''; let alertType: TatAlertType; let thresholdPercentage: number; // Check if level is paused - skip TAT processing if paused if ((approvalLevel as any).isPaused) { logger.info(`[TAT Processor] Skipping ${type} notification - level ${levelId} is paused`); return; } const tatHours = Number((approvalLevel as any).tatHours || 0); const levelStartTime = (approvalLevel as any).levelStartTime || (approvalLevel as any).createdAt || (approvalLevel as any).tatStartTime; const now = new Date(); // FIXED: Use proper working hours calculation instead of calendar hours // This respects working hours (9 AM - 6 PM), excludes weekends for STANDARD priority, and excludes holidays const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); // Pass pause information if available // IMPORTANT: Check both currently paused AND previously paused/resumed levels // For resumed levels, we need to include pauseElapsedHours and pauseResumeDate // so the calculation includes pre-pause elapsed time const isCurrentlyPaused = (approvalLevel as any).isPaused === true; const wasResumed = !isCurrentlyPaused && (approvalLevel as any).pauseElapsedHours !== null && (approvalLevel as any).pauseElapsedHours !== undefined && (approvalLevel as any).pauseResumeDate !== null; const pauseInfo = isCurrentlyPaused ? { isPaused: true, pausedAt: (approvalLevel as any).pausedAt, pauseElapsedHours: (approvalLevel as any).pauseElapsedHours, pauseResumeDate: (approvalLevel as any).pauseResumeDate } : wasResumed ? { // Level was paused but has been resumed - include pre-pause elapsed hours isPaused: false, pausedAt: null, pauseElapsedHours: Number((approvalLevel as any).pauseElapsedHours), // Pre-pause elapsed hours pauseResumeDate: (approvalLevel as any).pauseResumeDate // Actual resume timestamp } : undefined; const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority, pauseInfo); let remainingHours = Math.max(0, tatHours - elapsedHours); // Calculate expected completion time using proper working hours calculation // EXPRESS: includes weekends but only during working hours // STANDARD: excludes weekends and only during working hours const expectedCompletionTime = priority === 'express' ? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate() : (await addWorkingHours(levelStartTime, tatHours)).toDate(); switch (type) { case 'threshold1': emoji = ''; alertType = TatAlertType.TAT_50; // Keep enum for backwards compatibility thresholdPercentage = threshold; message = `${threshold}% of TAT elapsed for Request ${requestNumber}: ${title}`; activityDetails = `${threshold}% of TAT time has elapsed`; // Update TAT status in database with comprehensive tracking await ApprovalLevel.update( { tatPercentageUsed: threshold, tat50AlertSent: true, elapsedHours: elapsedHours, remainingHours: remainingHours }, { where: { levelId } } ); break; case 'threshold2': emoji = ''; alertType = TatAlertType.TAT_75; // Keep enum for backwards compatibility thresholdPercentage = threshold; message = `${threshold}% of TAT elapsed for Request ${requestNumber}: ${title}. Please take action soon.`; activityDetails = `${threshold}% of TAT time has elapsed - Escalation warning`; // Update TAT status in database with comprehensive tracking await ApprovalLevel.update( { tatPercentageUsed: threshold, tat75AlertSent: true, elapsedHours: elapsedHours, remainingHours: remainingHours }, { where: { levelId } } ); break; case 'breach': emoji = ''; alertType = TatAlertType.TAT_100; thresholdPercentage = 100; message = `TAT breached for Request ${requestNumber}: ${title}. Immediate action required!`; activityDetails = 'TAT deadline reached - Breach notification'; // When breached, ensure remaining hours is 0 (no rounding errors) // If elapsedHours >= tatHours, remainingHours should be exactly 0 remainingHours = 0; // Update TAT status in database with comprehensive tracking await ApprovalLevel.update( { tatPercentageUsed: 100, tatBreached: true, elapsedHours: elapsedHours, remainingHours: 0 // No time remaining after breach }, { where: { levelId } } ); break; } // Create TAT alert record for KPI tracking and display try { await TatAlert.create({ requestId, levelId, approverId, alertType, thresholdPercentage, tatHoursAllocated: tatHours, tatHoursElapsed: elapsedHours, tatHoursRemaining: remainingHours, levelStartTime, alertSentAt: now, expectedCompletionTime, alertMessage: message, notificationSent: true, notificationChannels: ['push'], isBreached: type === 'breach', metadata: { requestNumber, requestTitle: title, approverName: (approvalLevel as any).approverName, approverEmail: (approvalLevel as any).approverEmail, priority: (workflow as any).priority, levelNumber: (approvalLevel as any).levelNumber, testMode: process.env.TAT_TEST_MODE === 'true', tatTestMode: process.env.TAT_TEST_MODE === 'true' } } as any); logger.info(`[TAT Processor] ✅ Alert created: ${type} (${threshold}%)`); } catch (alertError: any) { logger.error(`[TAT Processor] ❌ Alert creation failed for ${type}: ${alertError.message}`); } // Determine notification priority based on TAT threshold const notificationPriority = type === 'breach' ? 'URGENT' : type === 'threshold2' ? 'HIGH' : 'MEDIUM'; // Format time remaining/overdue for email const timeRemainingText = remainingHours > 0 ? `${remainingHours.toFixed(1)} hours remaining` : type === 'breach' ? `${Math.abs(remainingHours).toFixed(1)} hours overdue` : 'Time exceeded'; // Send notification to approver (with error handling to prevent job failure) try { await notificationService.sendToUsers([approverId], { title: type === 'breach' ? 'TAT Breach Alert' : 'TAT Reminder', body: message, requestId, requestNumber, url: `/request/${requestNumber}`, type: type, priority: notificationPriority, actionRequired: type === 'breach' || type === 'threshold2', // Require action for critical alerts metadata: { thresholdPercentage: thresholdPercentage, tatInfo: { thresholdPercentage: thresholdPercentage, timeRemaining: timeRemainingText, tatDeadline: expectedCompletionTime, assignedDate: levelStartTime, timeOverdue: type === 'breach' ? timeRemainingText : undefined } } }); logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`); } catch (notificationError: any) { logger.error(`[TAT Processor] ❌ Failed to send notification to approver ${approverId} for ${type}:`, notificationError?.message || notificationError); // Don't fail the job - alert is already created, notification failure is non-critical // The alert will still be visible in the UI even if push notification fails } // If breached, also notify the initiator (workflow creator) if (type === 'breach') { const initiatorId = (workflow as any).initiatorId; if (initiatorId && initiatorId !== approverId) { try { await notificationService.sendToUsers([initiatorId], { title: 'TAT Breach - Request Delayed', body: `Your request ${requestNumber}: "${title}" has exceeded its TAT. The approver has been notified.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'tat_breach_initiator', priority: 'HIGH', actionRequired: false }); logger.info(`[TAT Processor] ✅ Breach notification sent to initiator ${initiatorId}`); } catch (initiatorNotifyError: any) { logger.error(`[TAT Processor] ❌ Failed to send breach notification to initiator ${initiatorId}:`, initiatorNotifyError?.message || initiatorNotifyError); // Don't fail the job - notification failure is non-critical } } } // Log activity (skip if it fails - don't break the TAT notification) try { await activityService.log({ requestId, type: 'sla_warning', user: { userId: null as any, name: 'System' }, // Use null instead of 'system' for UUID field timestamp: new Date().toISOString(), action: type === 'breach' ? 'TAT Breached' : 'TAT Warning', details: activityDetails }); logger.info(`[TAT Processor] Activity logged for ${type}`); } catch (activityError: any) { logger.warn(`[TAT Processor] Failed to log activity (non-critical):`, activityError.message); // Continue - activity logging failure shouldn't break TAT notification } // 🔥 CRITICAL: Emit TAT alert to frontend via socket.io for real-time updates try { const { emitToRequestRoom } = require('../realtime/socket'); if (emitToRequestRoom) { // Fetch the newly created alert to send complete data to frontend const newAlert = await TatAlert.findOne({ where: { requestId, levelId, alertType }, order: [['createdAt', 'DESC']] }); if (newAlert) { emitToRequestRoom(requestId, 'tat:alert', { alert: newAlert, requestId, levelId, type, thresholdPercentage, message }); logger.info(`[TAT Processor] ✅ TAT alert emitted to frontend via socket.io for request ${requestId}`); } } } catch (socketError) { logger.error(`[TAT Processor] Failed to emit TAT alert via socket:`, socketError); // Don't fail the job if socket emission fails } logger.info(`[TAT Processor] ✅ ${type} notification sent for request ${requestId}`); } catch (error) { logger.error(`[TAT Processor] Failed to process ${type} job:`, error); throw error; // Re-throw to trigger retry } }