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'; // Note: DealerClaimService import removed - dealer claim approvals are handled by DealerClaimApprovalService 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; // Verify this is NOT a claim management workflow (should use DealerClaimApprovalService) const workflowType = (wf as any)?.workflowType; if (workflowType === 'CLAIM_MANAGEMENT') { logger.error(`[Approval] Attempted to use ApprovalService for CLAIM_MANAGEMENT workflow ${level.requestId}. Use DealerClaimApprovalService instead.`); throw new Error('ApprovalService cannot be used for CLAIM_MANAGEMENT workflows. Use DealerClaimApprovalService instead.'); } 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') { // Check if this is final approval: either isFinalApprover flag is set OR all levels are approved // This handles cases where additional approvers are added after initial approval const allLevels = await ApprovalLevel.findAll({ where: { requestId: level.requestId }, order: [['levelNumber', 'ASC']] }); const approvedLevelsCount = allLevels.filter((l: any) => l.status === 'APPROVED').length; const totalLevels = allLevels.length; const isAllLevelsApproved = approvedLevelsCount === totalLevels; const isFinalApproval = level.isFinalApprover || isAllLevelsApproved; if (isFinalApproval) { // 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', detectedBy: level.isFinalApprover ? 'isFinalApprover flag' : 'all levels approved check' }); // 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); // Check if conclusion already exists (e.g., from previous final approval before additional approver was added) const existingConclusion = await ConclusionRemark.findOne({ where: { requestId: level.requestId } }); if (existingConclusion) { // Update existing conclusion with new AI-generated remark (regenerated with updated context) await existingConclusion.update({ aiGeneratedRemark: aiResult.remark, aiModelUsed: aiResult.provider, aiConfidenceScore: aiResult.confidence, // Preserve finalRemark if it was already finalized // Only reset if it wasn't finalized yet finalRemark: (existingConclusion as any).finalizedAt ? (existingConclusion as any).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(), // Preserve finalizedAt if it was already finalized finalizedAt: (existingConclusion as any).finalizedAt || null } as any); logger.info(`[Approval] Updated existing AI conclusion for request ${level.requestId} with regenerated content (includes new approver)`); } else { // Create new conclusion 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: null as any, name: 'System' }, // Use null instead of 'system' for UUID field 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: null as any, name: 'System' }, // Use null instead of 'system' for UUID field 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 about final approval (triggers email) const initiatorId = (wf as any).initiatorId; await notificationService.sendToUsers([initiatorId], { title: `Request Approved - All Approvals Complete`, body: `Your request "${(wf as any).title}" has been fully approved by all approvers. 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', 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.'); } // Find the next PENDING level // Custom workflows use strict sequential ordering (levelNumber + 1) to maintain intended order // This ensures custom workflows work predictably and don't skip levels const currentLevelNumber = level.levelNumber || 0; logger.info(`[Approval] Finding next level after level ${currentLevelNumber} for request ${level.requestId} (Custom workflow)`); // Use strict sequential approach for custom workflows const nextLevel = await ApprovalLevel.findOne({ where: { requestId: level.requestId, levelNumber: currentLevelNumber + 1 } }); if (!nextLevel) { logger.info(`[Approval] Sequential level ${currentLevelNumber + 1} not found for custom workflow - this may be the final approval`); } else if (nextLevel.status !== ApprovalStatus.PENDING) { // Sequential level exists but not PENDING - log warning but proceed logger.warn(`[Approval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level to maintain workflow order.`); } const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null; if (nextLevel) { logger.info(`[Approval] Found next level: ${nextLevelNumber} (${(nextLevel as any).levelName || 'unnamed'}), approver: ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'unknown'}, status: ${nextLevel.status}`); } else { logger.info(`[Approval] No next level found after level ${currentLevelNumber} - this may be the final approval`); } 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 (only if nextLevelNumber is not null) if (nextLevelNumber !== null) { await WorkflowRequest.update( { currentLevel: nextLevelNumber }, { where: { requestId: level.requestId } } ); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); } else { logger.warn(`Approved level ${level.levelNumber} but no next level found - workflow may be complete`); } // Note: Dealer claim-specific logic (Activity Creation, E-Invoice) is handled by DealerClaimApprovalService // This service is for custom workflows only // 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 }); // Notify initiator about the approval (triggers email for regular workflows) if (wf) { await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Request Approved - Level ${level.levelNumber}`, body: `Your request "${(wf as any).title}" has been approved by ${level.approverName || level.approverEmail} and forwarded to the next approver.`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'approval', priority: 'MEDIUM' }); } // Notify next approver if (wf && nextLevel) { // Check if it's an auto-step by checking approverEmail or levelName // 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 const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com' || (nextLevel as any).approverName === 'System Auto-Process' || (nextLevel as any).approverId === 'system'; // IMPORTANT: Skip notifications and assignment logging for system/auto-steps // System steps are any step with system@royalenfield.com // Only send notifications to real users, NOT system processes 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}`); } // Note: Dealer-specific notifications (proposal/completion submissions) are handled by DealerClaimApprovalService } } 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}`); // Use current level number since there's no next level (workflow is complete) await WorkflowRequest.update( { status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: level.levelNumber || 0 }, { 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); } // Send notification to initiator with type 'rejection' to trigger email await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Rejected: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'rejection', priority: 'HIGH', metadata: { rejectionReason: action.rejectionReason || action.comments || 'No reason provided' } }); // Send notification to other participants (spectators) for transparency (no email, just in-app) const participantUserIds = Array.from(targetUserIds).filter(id => id !== (wf as any).initiatorId); if (participantUserIds.length > 0) { await notificationService.sendToUsers(participantUserIds, { title: `Rejected: ${(wf as any).requestNumber}`, body: `Request "${(wf as any).title}" has been rejected.`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'status_change', // Use status_change to avoid triggering emails for participants priority: 'MEDIUM' }); } } // 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'); } } }