Re_Backend/src/queues/tatProcessor.ts

322 lines
13 KiB
TypeScript

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<TatJobData>) {
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
}
}