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 { calculateTATPercentage } from '@utils/helpers'; import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils'; 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; // Get workflow to determine priority for working hours calculation const wf = await WorkflowRequest.findByPk(level.requestId); const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); const now = new Date(); // Calculate elapsed hours using working hours logic (matches frontend) const elapsedHours = await calculateElapsedWorkingHours(level.levelStartTime || level.createdAt, now, priority); 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 } // Handle approval - move to next level or close workflow (wf already loaded above) 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`); // Log final approval activity first (so it's included in AI context) 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}. Awaiting conclusion remark from initiator.` }); // Generate AI conclusion remark try { const { aiService } = await import('./ai.service'); const { ConclusionRemark } = await import('@models/index'); const { ApprovalLevel } = await import('@models/ApprovalLevel'); const { WorkNote } = await import('@models/WorkNote'); const { Document } = await import('@models/Document'); const { Activity } = await import('@models/Activity'); if (aiService.isAvailable()) { logger.info(`[Approval] Generating AI conclusion for ${level.requestId}...`); // Gather context for AI generation const approvalLevels = await ApprovalLevel.findAll({ where: { requestId: level.requestId }, order: [['levelNumber', 'ASC']] }); const workNotes = await WorkNote.findAll({ where: { requestId: level.requestId }, order: [['createdAt', 'ASC']], limit: 20 }); const documents = await Document.findAll({ where: { requestId: level.requestId }, order: [['uploadedAt', 'DESC']] }); const activities = await Activity.findAll({ where: { requestId: level.requestId }, order: [['createdAt', 'ASC']], limit: 50 }); // Build context object const context = { requestTitle: (wf as any).title, requestDescription: (wf as any).description, requestNumber: (wf as any).requestNumber, priority: (wf as any).priority, approvalFlow: approvalLevels.map((l: any) => ({ levelNumber: l.levelNumber, approverName: l.approverName, status: l.status, comments: l.comments, actionDate: l.actionDate, tatHours: Number(l.tatHours || 0), elapsedHours: Number(l.elapsedHours || 0) })), workNotes: workNotes.map((note: any) => ({ userName: note.userName, message: note.message, createdAt: note.createdAt })), documents: documents.map((doc: any) => ({ fileName: doc.originalFileName || doc.fileName, uploadedBy: doc.uploadedBy, uploadedAt: doc.uploadedAt })), activities: activities.map((activity: any) => ({ type: activity.activityType, action: activity.activityDescription, details: activity.activityDescription, timestamp: activity.createdAt })) }; const aiResult = await aiService.generateConclusionRemark(context); // Save to database await ConclusionRemark.create({ requestId: level.requestId, aiGeneratedRemark: aiResult.remark, aiModelUsed: aiResult.provider, aiConfidenceScore: aiResult.confidence, finalRemark: null, editedBy: null, isEdited: false, editCount: 0, approvalSummary: { totalLevels: approvalLevels.length, approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length, averageTatUsage: approvalLevels.reduce((sum: number, l: any) => sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1) }, documentSummary: { totalDocuments: documents.length, documentNames: documents.map((d: any) => d.originalFileName || d.fileName) }, keyDiscussionPoints: aiResult.keyPoints, generatedAt: new Date(), finalizedAt: null } as any); logger.info(`[Approval] ✅ AI conclusion generated for ${level.requestId}`); // Log activity activityService.log({ requestId: level.requestId, type: 'ai_conclusion_generated', user: { userId: 'system', name: 'System' }, timestamp: new Date().toISOString(), action: 'AI Conclusion Generated', details: 'AI-powered conclusion remark generated for review by initiator' }); } else { logger.warn(`[Approval] AI service unavailable for ${level.requestId}, skipping conclusion generation`); } } catch (aiError) { logger.error(`[Approval] Failed to generate AI conclusion:`, aiError); // Don't fail the approval if AI generation fails - initiator can write manually } // Notify initiator about approval and pending conclusion step if (wf) { await notificationService.sendToUsers([ (wf as any).initiatorId ], { title: `Request Approved - Closure Pending`, body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'approval_pending_closure', priority: 'HIGH', actionRequired: true }); logger.info(`[Approval] ✅ Final approval complete for ${level.requestId}. Initiator notified to finalize conclusion.`); } } 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'); } } }