import { tatQueue } from '../queues/tatQueue'; import { calculateDelay, addWorkingHours, addCalendarHours } from '@utils/tatTimeUtils'; import { getTatThresholds } from './configReader.service'; import dayjs from 'dayjs'; import logger from '@utils/logger'; import { Priority } from '../types/common.types'; export class TatSchedulerService { /** * Schedule TAT notification jobs for an approval level * @param requestId - The workflow request ID * @param levelId - The approval level ID * @param approverId - The approver user ID * @param tatDurationHours - TAT duration in hours * @param startTime - Optional start time (defaults to now) * @param priority - Request priority (EXPRESS = 24/7, STANDARD = working hours only) */ async scheduleTatJobs( requestId: string, levelId: string, approverId: string, tatDurationHours: number, startTime?: Date, priority: Priority = Priority.STANDARD ): Promise { try { // Check if tatQueue is available if (!tatQueue) { logger.warn(`[TAT Scheduler] TAT queue not available (Redis not connected). Skipping TAT job scheduling.`); return; } const now = startTime || new Date(); const isExpress = priority === Priority.EXPRESS; // Get current thresholds from database configuration const thresholds = await getTatThresholds(); // Calculate milestone times using configured thresholds // EXPRESS mode: 24/7 calculation (includes holidays, weekends, non-working hours) // STANDARD mode: Working hours only (excludes holidays, weekends, non-working hours) let threshold1Time: Date; let threshold2Time: Date; let breachTime: Date; if (isExpress) { // EXPRESS: 24/7 calculation - no exclusions threshold1Time = addCalendarHours(now, tatDurationHours * (thresholds.first / 100)).toDate(); threshold2Time = addCalendarHours(now, tatDurationHours * (thresholds.second / 100)).toDate(); breachTime = addCalendarHours(now, tatDurationHours).toDate(); logger.info(`[TAT Scheduler] Using EXPRESS mode (24/7) - no holiday/weekend exclusions`); } else { // STANDARD: Working hours only, excludes holidays const t1 = await addWorkingHours(now, tatDurationHours * (thresholds.first / 100)); const t2 = await addWorkingHours(now, tatDurationHours * (thresholds.second / 100)); const tBreach = await addWorkingHours(now, tatDurationHours); threshold1Time = t1.toDate(); threshold2Time = t2.toDate(); breachTime = tBreach.toDate(); logger.info(`[TAT Scheduler] Using STANDARD mode - excludes holidays, weekends, non-working hours`); } logger.info(`[TAT Scheduler] Calculating TAT milestones for request ${requestId}, level ${levelId}`); logger.info(`[TAT Scheduler] Priority: ${priority}, TAT Hours: ${tatDurationHours}`); logger.info(`[TAT Scheduler] Start: ${dayjs(now).format('YYYY-MM-DD HH:mm')}`); logger.info(`[TAT Scheduler] Threshold 1 (${thresholds.first}%): ${dayjs(threshold1Time).format('YYYY-MM-DD HH:mm')}`); logger.info(`[TAT Scheduler] Threshold 2 (${thresholds.second}%): ${dayjs(threshold2Time).format('YYYY-MM-DD HH:mm')}`); logger.info(`[TAT Scheduler] Breach (100%): ${dayjs(breachTime).format('YYYY-MM-DD HH:mm')}`); const jobs = [ { type: 'threshold1' as const, threshold: thresholds.first, delay: calculateDelay(threshold1Time), targetTime: threshold1Time }, { type: 'threshold2' as const, threshold: thresholds.second, delay: calculateDelay(threshold2Time), targetTime: threshold2Time }, { type: 'breach' as const, threshold: 100, delay: calculateDelay(breachTime), targetTime: breachTime } ]; for (const job of jobs) { // Skip if the time has already passed if (job.delay === 0) { logger.warn(`[TAT Scheduler] Skipping ${job.type} (${job.threshold}%) for level ${levelId} - time already passed`); continue; } await tatQueue.add( job.type, { type: job.type, threshold: job.threshold, // Store actual threshold percentage in job data requestId, levelId, approverId }, { delay: job.delay, jobId: `tat-${job.type}-${requestId}-${levelId}`, // Generic job ID removeOnComplete: true, removeOnFail: false } ); logger.info( `[TAT Scheduler] Scheduled ${job.type} (${job.threshold}%) for level ${levelId} ` + `(delay: ${Math.round(job.delay / 1000 / 60)} minutes, ` + `target: ${dayjs(job.targetTime).format('YYYY-MM-DD HH:mm')})` ); } logger.info(`[TAT Scheduler] ✅ TAT jobs scheduled for request ${requestId}, approver ${approverId}`); } catch (error) { logger.error(`[TAT Scheduler] Failed to schedule TAT jobs:`, error); throw error; } } /** * Cancel TAT jobs for a specific approval level * Useful when an approver acts before TAT expires * @param requestId - The workflow request ID * @param levelId - The approval level ID */ async cancelTatJobs(requestId: string, levelId: string): Promise { try { // Check if tatQueue is available if (!tatQueue) { logger.warn(`[TAT Scheduler] TAT queue not available. Skipping job cancellation.`); return; } // Use generic job names that don't depend on threshold percentages const jobIds = [ `tat-threshold1-${requestId}-${levelId}`, `tat-threshold2-${requestId}-${levelId}`, `tat-breach-${requestId}-${levelId}` ]; for (const jobId of jobIds) { try { const job = await tatQueue.getJob(jobId); if (job) { await job.remove(); logger.info(`[TAT Scheduler] Cancelled job ${jobId}`); } } catch (error) { // Job might not exist, which is fine logger.debug(`[TAT Scheduler] Job ${jobId} not found (may have already been processed)`); } } logger.info(`[TAT Scheduler] ✅ TAT jobs cancelled for level ${levelId}`); } catch (error) { logger.error(`[TAT Scheduler] Failed to cancel TAT jobs:`, error); // Don't throw - cancellation failure shouldn't break the workflow } } /** * Cancel all TAT jobs for a workflow request * @param requestId - The workflow request ID */ async cancelAllTatJobsForRequest(requestId: string): Promise { try { // Check if tatQueue is available if (!tatQueue) { logger.warn(`[TAT Scheduler] TAT queue not available. Skipping job cancellation.`); return; } const jobs = await tatQueue.getJobs(['delayed', 'waiting']); const requestJobs = jobs.filter(job => job.data.requestId === requestId); for (const job of requestJobs) { await job.remove(); logger.info(`[TAT Scheduler] Cancelled job ${job.id}`); } logger.info(`[TAT Scheduler] ✅ All TAT jobs cancelled for request ${requestId}`); } catch (error) { logger.error(`[TAT Scheduler] Failed to cancel all TAT jobs:`, error); // Don't throw - cancellation failure shouldn't break the workflow } } } export const tatSchedulerService = new TatSchedulerService();