import { ApprovalLevel } from '@models/ApprovalLevel'; import { WorkflowRequest } from '@models/WorkflowRequest'; import { Participant } from '@models/Participant'; import { TatAlert } from '@models/TatAlert'; import { ApprovalAction } from '../types/approval.types'; import { ApprovalStatus, WorkflowStatus } from '../types/common.types'; import { calculateElapsedHours, calculateTATPercentage } from '@utils/helpers'; import logger from '@utils/logger'; import { Op } from 'sequelize'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; export class ApprovalService { async approveLevel(levelId: string, action: ApprovalAction, _userId: string): Promise { try { const level = await ApprovalLevel.findByPk(levelId); if (!level) return null; const now = new Date(); const elapsedHours = calculateElapsedHours(level.levelStartTime || level.createdAt, now); const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours); const updateData = { status: action.action === 'APPROVE' ? ApprovalStatus.APPROVED : ApprovalStatus.REJECTED, actionDate: now, levelEndTime: now, elapsedHours, tatPercentageUsed: tatPercentage, comments: action.comments, rejectionReason: action.rejectionReason }; const updatedLevel = await level.update(updateData); // Cancel TAT jobs for the current level since it's been actioned try { await tatSchedulerService.cancelTatJobs(level.requestId, level.levelId); logger.info(`[Approval] TAT jobs cancelled for level ${level.levelId}`); } catch (tatError) { logger.error(`[Approval] Failed to cancel TAT jobs:`, tatError); // Don't fail the approval if TAT cancellation fails } // Update TAT alerts for this level to mark completion status try { const wasOnTime = elapsedHours <= level.tatHours; await TatAlert.update( { wasCompletedOnTime: wasOnTime, completionTime: now }, { where: { levelId: level.levelId } } ); logger.info(`[Approval] TAT alerts updated for level ${level.levelId} - Completed ${wasOnTime ? 'on time' : 'late'}`); } catch (tatAlertError) { logger.error(`[Approval] Failed to update TAT alerts:`, tatAlertError); // Don't fail the approval if TAT alert update fails } // Load workflow for titles and initiator const wf = await WorkflowRequest.findByPk(level.requestId); // Handle approval - move to next level or close workflow if (action.action === 'APPROVE') { if (level.isFinalApprover) { // Final approver - close workflow as APPROVED await WorkflowRequest.update( { status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: (level.levelNumber || 0) + 1 }, { where: { requestId: level.requestId } } ); logger.info(`Final approver approved. Workflow ${level.requestId} closed as APPROVED`); // Notify initiator if (wf) { await notificationService.sendToUsers([ (wf as any).initiatorId ], { title: `Approved: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, url: `/request/${(wf as any).requestNumber}` }); activityService.log({ requestId: level.requestId, type: 'approval', user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', details: `Request approved and finalized by ${level.approverName || level.approverEmail}` }); } } else { // Not final - move to next level const nextLevelNumber = (level.levelNumber || 0) + 1; const nextLevel = await ApprovalLevel.findOne({ where: { requestId: level.requestId, levelNumber: nextLevelNumber } }); if (nextLevel) { // Activate next level await nextLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: now, tatStartTime: now }); // Schedule TAT jobs for the next level try { // Get workflow priority for TAT calculation const workflowPriority = (wf as any)?.priority || 'STANDARD'; await tatSchedulerService.scheduleTatJobs( level.requestId, (nextLevel as any).levelId, (nextLevel as any).approverId, Number((nextLevel as any).tatHours), now, workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours) ); logger.info(`[Approval] TAT jobs scheduled for next level ${nextLevelNumber} (Priority: ${workflowPriority})`); } catch (tatError) { logger.error(`[Approval] Failed to schedule TAT jobs for next level:`, tatError); // Don't fail the approval if TAT scheduling fails } // Update workflow current level await WorkflowRequest.update( { currentLevel: nextLevelNumber }, { where: { requestId: level.requestId } } ); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); // Notify next approver if (wf && nextLevel) { await notificationService.sendToUsers([ (nextLevel as any).approverId ], { title: `Action required: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, url: `/request/${(wf as any).requestNumber}` }); activityService.log({ requestId: level.requestId, type: 'approval', user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}` }); } } else { // No next level found but not final approver - this shouldn't happen logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`); await WorkflowRequest.update( { status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: nextLevelNumber }, { where: { requestId: level.requestId } } ); if (wf) { await notificationService.sendToUsers([ (wf as any).initiatorId ], { title: `Approved: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, url: `/request/${(wf as any).requestNumber}` }); activityService.log({ requestId: level.requestId, type: 'approval', user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', details: `Request approved and finalized by ${level.approverName || level.approverEmail}` }); } } } } else if (action.action === 'REJECT') { // Rejection - close workflow and mark all remaining levels as skipped await WorkflowRequest.update( { status: WorkflowStatus.REJECTED, closureDate: now }, { where: { requestId: level.requestId } } ); // Mark all pending levels as skipped await ApprovalLevel.update( { status: ApprovalStatus.SKIPPED, levelEndTime: now }, { where: { requestId: level.requestId, status: ApprovalStatus.PENDING, levelNumber: { [Op.gt]: level.levelNumber } } } ); logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} closed as REJECTED`); // Notify initiator and all participants if (wf) { const participants = await Participant.findAll({ where: { requestId: level.requestId } }); const targetUserIds = new Set(); targetUserIds.add((wf as any).initiatorId); for (const p of participants as any[]) { targetUserIds.add(p.userId); } await notificationService.sendToUsers(Array.from(targetUserIds), { title: `Rejected: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, url: `/request/${(wf as any).requestNumber}` }); activityService.log({ requestId: level.requestId, type: 'rejection', user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Rejected', details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}` }); } } logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`); return updatedLevel; } catch (error) { logger.error(`Failed to ${action.action.toLowerCase()} level ${levelId}:`, error); throw new Error(`Failed to ${action.action.toLowerCase()} level`); } } async getCurrentApprovalLevel(requestId: string): Promise { try { return await ApprovalLevel.findOne({ where: { requestId, status: ApprovalStatus.PENDING }, order: [['levelNumber', 'ASC']] }); } catch (error) { logger.error(`Failed to get current approval level for ${requestId}:`, error); throw new Error('Failed to get current approval level'); } } async getApprovalLevels(requestId: string): Promise { try { return await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); } catch (error) { logger.error(`Failed to get approval levels for ${requestId}:`, error); throw new Error('Failed to get approval levels'); } } }