322 lines
13 KiB
TypeScript
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
|
|
}
|
|
}
|
|
|