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, { logWorkflowEvent, logAIEvent } from '@utils/logger'; import { Op } from 'sequelize'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; import { emitToRequestRoom } from '../realtime/socket'; import { DealerClaimService } from './dealerClaim.service'; export class ApprovalService { async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): 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); if (!wf) return null; const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); const isPaused = (wf as any).isPaused || (level as any).isPaused; // If paused, resume automatically when approving/rejecting (requirement 3.6) if (isPaused) { const { pauseService } = await import('./pause.service'); try { await pauseService.resumeWorkflow(level.requestId, _userId); logger.info(`[Approval] Auto-resumed paused workflow ${level.requestId} when ${action.action === 'APPROVE' ? 'approving' : 'rejecting'}`); } catch (pauseError) { logger.warn(`[Approval] Failed to auto-resume paused workflow:`, pauseError); // Continue with approval/rejection even if resume fails } } const now = new Date(); // Calculate elapsed hours using working hours logic (with pause handling) // Case 1: Level is currently paused (isPaused = true) // Case 2: Level was paused and resumed (isPaused = false but pauseElapsedHours and pauseResumeDate exist) const isPausedLevel = (level as any).isPaused; const wasResumed = !isPausedLevel && (level as any).pauseElapsedHours !== null && (level as any).pauseElapsedHours !== undefined && (level as any).pauseResumeDate !== null; const pauseInfo = isPausedLevel ? { // Level is currently paused - return frozen elapsed hours at pause time isPaused: true, pausedAt: (level as any).pausedAt, pauseElapsedHours: (level as any).pauseElapsedHours, pauseResumeDate: (level as any).pauseResumeDate } : wasResumed ? { // Level was paused but has been resumed - add pre-pause elapsed hours + time since resume isPaused: false, pausedAt: null, pauseElapsedHours: Number((level as any).pauseElapsedHours), // Pre-pause elapsed hours pauseResumeDate: (level as any).pauseResumeDate // Actual resume timestamp } : undefined; const elapsedHours = await calculateElapsedWorkingHours( level.levelStartTime || level.createdAt, now, priority, pauseInfo ); 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 } } ); logWorkflowEvent('approved', level.requestId, { level: level.levelNumber, isFinalApproval: true, status: '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.`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); // Generate AI conclusion remark ASYNCHRONOUSLY (don't wait) // This runs in the background without blocking the approval response (async () => { 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'); const { getConfigValue } = await import('./configReader.service'); // Check if AI features and remark generation are enabled in admin config const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true'; const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true'; if (aiEnabled && remarkGenerationEnabled && aiService.isAvailable()) { logAIEvent('request', { requestId: level.requestId, action: 'conclusion_generation_started', }); // 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) => { const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null ? Number(l.tatPercentageUsed) : (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0); return { 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), tatPercentageUsed: tatPercentage }; }), 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); logAIEvent('response', { requestId: level.requestId, action: 'conclusion_generation_completed', }); // 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', ipAddress: undefined, // System-generated, no IP userAgent: undefined // System-generated, no user agent }); } else { // Log why AI generation was skipped if (!aiEnabled) { logger.info(`[Approval] AI features disabled in admin config, skipping conclusion generation for ${level.requestId}`); } else if (!remarkGenerationEnabled) { logger.info(`[Approval] AI remark generation disabled in admin config, skipping for ${level.requestId}`); } else if (!aiService.isAvailable()) { logger.warn(`[Approval] AI service unavailable for ${level.requestId}, skipping conclusion generation`); } } // Auto-generate RequestSummary after final approval (system-level generation) // This makes the summary immediately available when user views the approved request try { const { summaryService } = await import('./summary.service'); const summary = await summaryService.createSummary(level.requestId, 'system', { isSystemGeneration: true }); logger.info(`[Approval] ✅ Auto-generated summary ${(summary as any).summaryId} for approved request ${level.requestId}`); // Log summary generation activity activityService.log({ requestId: level.requestId, type: 'summary_generated', user: { userId: 'system', name: 'System' }, timestamp: new Date().toISOString(), action: 'Summary Auto-Generated', details: 'Request summary auto-generated after final approval', ipAddress: undefined, userAgent: undefined }); } catch (summaryError: any) { // Log but don't fail - initiator can regenerate later logger.error(`[Approval] Failed to auto-generate summary for ${level.requestId}:`, summaryError.message); } } catch (aiError) { logAIEvent('error', { requestId: level.requestId, action: 'conclusion_generation_failed', error: aiError, }); // Silent failure - initiator can write manually // Still try to generate summary even if AI conclusion failed try { const { summaryService } = await import('./summary.service'); const summary = await summaryService.createSummary(level.requestId, 'system', { isSystemGeneration: true }); logger.info(`[Approval] ✅ Auto-generated summary ${(summary as any).summaryId} for approved request ${level.requestId} (without AI conclusion)`); } catch (summaryError: any) { logger.error(`[Approval] Failed to auto-generate summary for ${level.requestId}:`, summaryError.message); } } })().catch(err => { // Catch any unhandled promise rejections logger.error(`[Approval] Unhandled error in background AI generation:`, err); }); // Notify initiator and all participants (including spectators) about approval // Spectators are CC'd for transparency, similar to email CC 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); // Includes spectators } // Send notification to initiator (with action required) const initiatorId = (wf as any).initiatorId; await notificationService.sendToUsers([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 }); // Send notification to all participants/spectators (for transparency, no action required) const participantUserIds = Array.from(targetUserIds).filter(id => id !== initiatorId); if (participantUserIds.length > 0) { await notificationService.sendToUsers(participantUserIds, { title: `Request Approved`, body: `Request "${(wf as any).title}" has been fully approved. The initiator will 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: 'MEDIUM', actionRequired: false }); } logger.info(`[Approval] ✅ Final approval complete for ${level.requestId}. Initiator and ${participants.length} participant(s) notified.`); } } else { // Not final - move to next level // Check if workflow is paused - if so, don't advance if ((wf as any).isPaused || (wf as any).status === 'PAUSED') { logger.warn(`[Approval] Cannot advance workflow ${level.requestId} - workflow is paused`); throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.'); } const nextLevelNumber = (level.levelNumber || 0) + 1; const nextLevel = await ApprovalLevel.findOne({ where: { requestId: level.requestId, levelNumber: nextLevelNumber } }); if (nextLevel) { // Check if next level is paused - if so, don't activate it if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') { logger.warn(`[Approval] Cannot activate next level ${nextLevelNumber} - level is paused`); throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.'); } // 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}`); // Check if this is Department Lead approval in a claim management workflow // Activity Creation is now an activity log only, not an approval step const workflowType = (wf as any)?.workflowType; const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT'; // Check if current level is Department Lead (by levelName, not hardcoded step number) const currentLevelName = (level.levelName || '').toLowerCase(); const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3; // Check if current level is Requestor Claim Approval (Step 5, was Step 6) const currentLevelNameForStep5 = (level.levelName || '').toLowerCase(); const isRequestorClaimApproval = currentLevelNameForStep5.includes('requestor') && (currentLevelNameForStep5.includes('claim') || currentLevelNameForStep5.includes('approval')) || level.levelNumber === 5; if (isClaimManagement && isDeptLeadApproval) { // Activity Creation is now an activity log only - process it automatically logger.info(`[Approval] Department Lead approved for claim management workflow. Processing Activity Creation as activity log.`); try { const dealerClaimService = new DealerClaimService(); await dealerClaimService.processActivityCreation(level.requestId); logger.info(`[Approval] Activity Creation activity logged for request ${level.requestId}`); } catch (activityError) { logger.error(`[Approval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError); // Don't fail the Department Lead approval if Activity Creation logging fails - log and continue } } else if (isClaimManagement && isRequestorClaimApproval) { // E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`); } if (wf && nextLevel) { // Normal flow - notify next approver (skip for auto-steps) // Check if it's an auto-step by checking approverEmail or levelName // Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are no longer approval steps const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' || (nextLevel as any).approverName === 'System Auto-Process'; // Log approval activity 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}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); // Log assignment activity for next level (when it becomes active) // IMPORTANT: Skip notifications and assignment logging for system/auto-steps // System steps are any step with system@royalenfield.com // Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps // These steps are processed automatically and should NOT trigger notifications if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') { // Additional checks: ensure approverEmail and approverName are not system-related // This prevents notifications to system accounts even if they pass other checks const approverEmail = (nextLevel as any).approverEmail || ''; const approverName = (nextLevel as any).approverName || ''; const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com' || approverEmail.toLowerCase().includes('system'); const isSystemName = approverName.toLowerCase() === 'system auto-process' || approverName.toLowerCase().includes('system'); // EXCLUDE all system-related steps from notifications // Only send notifications to real users, NOT system processes if (!isSystemEmail && !isSystemName) { // Send notification to next approver (only for real users, not system processes) // This will send both in-app and email notifications const nextApproverId = (nextLevel as any).approverId; const nextApproverName = (nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver'; logger.info(`[Approval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`); await notificationService.sendToUsers([ nextApproverId ], { title: `Action required: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`); // Log assignment activity for the next approver activityService.log({ requestId: level.requestId, type: 'assignment', user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Assigned to approver', details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); } else { logger.info(`[Approval] Skipping notification for system process: ${approverEmail} at level ${nextLevelNumber}`); } } else { logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`); } // Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management) const workflowType = (wf as any)?.workflowType; const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT'; const levelName = (level.levelName || '').toLowerCase(); const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1; const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5; if (isClaimManagement && (isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) { const stepMessage = isDealerProposalApproval ? 'Dealer proposal has been submitted and is now under review.' : 'Dealer completion documents have been submitted and are now under review.'; await notificationService.sendToUsers([(wf as any).initiatorId], { title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted', body: `Your claim request "${(wf as any).title}" - ${stepMessage}`, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, type: 'approval', priority: 'MEDIUM', actionRequired: false }); logger.info(`[Approval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'} approval in claim management workflow`); } } } 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}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); } } } } else if (action.action === 'REJECT') { // Rejection - mark workflow as REJECTED (closure will happen when initiator finalizes conclusion) await WorkflowRequest.update( { status: WorkflowStatus.REJECTED // Note: closureDate will be set when initiator finalizes the conclusion }, { 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 } } } ); logWorkflowEvent('rejected', level.requestId, { level: level.levelNumber, status: 'REJECTED', message: 'Awaiting closure from initiator', }); // Log rejection activity first (so it's included in AI context) if (wf) { 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'}. Awaiting closure from initiator.`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); } // 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}` }); } // Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved) // This runs in the background without blocking the rejection response (async () => { 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'); const { getConfigValue } = await import('./configReader.service'); // Check if AI features and remark generation are enabled in admin config const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true'; const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true'; if (!aiEnabled || !remarkGenerationEnabled) { logger.info(`[Approval] AI conclusion generation skipped for rejected request ${level.requestId} (AI disabled)`); return; } // Check if AI service is available const { aiService: aiSvc } = await import('./ai.service'); if (!aiSvc.isAvailable()) { logger.warn(`[Approval] AI service unavailable for rejected request ${level.requestId}`); return; } // Gather context for AI generation (similar to approved flow) 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 (include rejection reason) const context = { requestTitle: (wf as any).title, requestDescription: (wf as any).description, requestNumber: (wf as any).requestNumber, priority: (wf as any).priority, rejectionReason: action.rejectionReason || action.comments || 'No reason provided', rejectedBy: level.approverName || level.approverEmail, approvalFlow: approvalLevels.map((l: any) => { const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null ? Number(l.tatPercentageUsed) : (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0); return { 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), tatPercentageUsed: tatPercentage }; }), 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 })) }; logger.info(`[Approval] Generating AI conclusion for rejected request ${level.requestId}...`); // Generate AI conclusion (will adapt to rejection context) const aiResult = await aiSvc.generateConclusionRemark(context); // Create or update conclusion remark let conclusionInstance = await ConclusionRemark.findOne({ where: { requestId: level.requestId } }); const conclusionData = { aiGeneratedRemark: aiResult.remark, aiModelUsed: aiResult.provider, aiConfidenceScore: aiResult.confidence, approvalSummary: { totalLevels: approvalLevels.length, rejectedLevel: level.levelNumber, rejectedBy: level.approverName || level.approverEmail, rejectionReason: action.rejectionReason || action.comments }, documentSummary: { totalDocuments: documents.length, documentNames: documents.map((d: any) => d.originalFileName || d.fileName) }, keyDiscussionPoints: aiResult.keyPoints, generatedAt: new Date() }; if (conclusionInstance) { await conclusionInstance.update(conclusionData as any); logger.info(`[Approval] ✅ AI conclusion updated for rejected request ${level.requestId}`); } else { await ConclusionRemark.create({ requestId: level.requestId, ...conclusionData, finalRemark: null, editedBy: null, isEdited: false, editCount: 0, finalizedAt: null } as any); logger.info(`[Approval] ✅ AI conclusion generated for rejected request ${level.requestId}`); } } catch (error: any) { logger.error(`[Approval] Failed to generate AI conclusion for rejected request ${level.requestId}:`, error); // Don't fail the rejection if AI generation fails } })(); } logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`); // Emit real-time update to all users viewing this request emitToRequestRoom(level.requestId, 'request:updated', { requestId: level.requestId, requestNumber: (wf as any)?.requestNumber, action: action.action, levelNumber: level.levelNumber, timestamp: now.toISOString() }); 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'); } } }