/** * Dealer Claim Approval Service * * Dedicated approval service for dealer claim workflows (CLAIM_MANAGEMENT). * Handles dealer claim-specific logic including: * - Dynamic approver support (additional approvers added between steps) * - Activity Creation processing * - Dealer-specific notifications * * This service is separate from ApprovalService to prevent conflicts with custom workflows. */ import { ApprovalLevel } from '@models/ApprovalLevel'; import { WorkflowRequest } from '@models/WorkflowRequest'; 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'; import { DealerClaimService } from './dealerClaim.service'; import { emitToRequestRoom } from '../realtime/socket'; export class DealerClaimApprovalService { /** * Approve a level in a dealer claim workflow * Handles dealer claim-specific logic including dynamic approvers and activity creation */ 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 a claim management workflow const workflowType = (wf as any)?.workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { logger.warn(`[DealerClaimApproval] Attempted to use DealerClaimApprovalService for non-claim-management workflow ${level.requestId}. Workflow type: ${workflowType}`); throw new Error('DealerClaimApprovalService can only be used for CLAIM_MANAGEMENT workflows'); } 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 if (isPaused) { const { pauseService } = await import('./pause.service'); try { await pauseService.resumeWorkflow(level.requestId, userId); logger.info(`[DealerClaimApproval] Auto-resumed paused workflow ${level.requestId} when ${action.action === 'APPROVE' ? 'approving' : 'rejecting'}`); } catch (pauseError) { logger.warn(`[DealerClaimApproval] 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) 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 as any).levelStartTime || (level as any).tatStartTime || now, now, priority, pauseInfo ); const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours); // Handle rejection if (action.action === 'REJECT') { return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now); } // Update level status and elapsed time for approval await level.update({ status: ApprovalStatus.APPROVED, actionDate: now, levelEndTime: now, elapsedHours: elapsedHours, tatPercentageUsed: tatPercentage, comments: action.comments || undefined }); // Check if this is the final approver const allLevels = await ApprovalLevel.findAll({ where: { requestId: level.requestId } }); const approvedCount = allLevels.filter((l: any) => l.status === ApprovalStatus.APPROVED).length; const isFinalApprover = approvedCount === allLevels.length; if (isFinalApprover) { // Final approval - close workflow await WorkflowRequest.update( { status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: level.levelNumber || 0 }, { where: { requestId: level.requestId } } ); // Notify all participants const participants = await import('@models/Participant').then(m => m.Participant.findAll({ where: { requestId: level.requestId, isActive: true } })); if (participants && participants.length > 0) { const participantIds = participants.map((p: any) => p.userId).filter(Boolean); await notificationService.sendToUsers(participantIds, { title: `Request Approved: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'approval', priority: 'MEDIUM' }); logger.info(`[DealerClaimApproval] Final approval complete. ${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(`[DealerClaimApproval] 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 (supports dynamically added approvers) // Strategy: First try sequential, then find next PENDING level if sequential doesn't exist const currentLevelNumber = level.levelNumber || 0; logger.info(`[DealerClaimApproval] Finding next level after level ${currentLevelNumber} for request ${level.requestId}`); // First, try sequential approach let nextLevel = await ApprovalLevel.findOne({ where: { requestId: level.requestId, levelNumber: currentLevelNumber + 1 } }); // If sequential level doesn't exist, search for next PENDING level // This handles cases where additional approvers are added dynamically between steps if (!nextLevel) { logger.info(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} not found, searching for next PENDING level (dynamic approvers)`); nextLevel = await ApprovalLevel.findOne({ where: { requestId: level.requestId, levelNumber: { [Op.gt]: currentLevelNumber }, status: ApprovalStatus.PENDING }, order: [['levelNumber', 'ASC']] }); if (nextLevel) { logger.info(`[DealerClaimApproval] Using fallback level ${nextLevel.levelNumber} (${(nextLevel as any).levelName || 'unnamed'})`); } } else if (nextLevel.status !== ApprovalStatus.PENDING) { // Sequential level exists but not PENDING - check if it's already approved/rejected if (nextLevel.status === ApprovalStatus.APPROVED || nextLevel.status === ApprovalStatus.REJECTED) { logger.warn(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} already ${nextLevel.status}. Skipping activation.`); nextLevel = null; // Don't activate an already completed level } else { // Level exists but in unexpected status - log warning but proceed logger.warn(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level.`); } } const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null; if (nextLevel) { logger.info(`[DealerClaimApproval] 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(`[DealerClaimApproval] 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(`[DealerClaimApproval] 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 { 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 ); logger.info(`[DealerClaimApproval] TAT jobs scheduled for next level ${nextLevelNumber} (Priority: ${workflowPriority})`); } catch (tatError) { logger.error(`[DealerClaimApproval] Failed to schedule TAT jobs for next level:`, tatError); // Don't fail the approval if TAT scheduling fails } // Update workflow current level if (nextLevelNumber !== null) { await WorkflowRequest.update( { currentLevel: nextLevelNumber }, { where: { requestId: level.requestId } } ); logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); } // Handle dealer claim-specific step processing const currentLevelName = (level.levelName || '').toLowerCase(); const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3; const isRequestorClaimApproval = currentLevelName.includes('requestor') && (currentLevelName.includes('claim') || currentLevelName.includes('approval')) || level.levelNumber === 5; if (isDeptLeadApproval) { // Activity Creation is now an activity log only - process it automatically logger.info(`[DealerClaimApproval] Department Lead approved. Processing Activity Creation as activity log.`); try { const dealerClaimService = new DealerClaimService(); await dealerClaimService.processActivityCreation(level.requestId); logger.info(`[DealerClaimApproval] Activity Creation activity logged for request ${level.requestId}`); } catch (activityError) { logger.error(`[DealerClaimApproval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError); // Don't fail the Department Lead approval if Activity Creation logging fails } } else if (isRequestorClaimApproval) { // E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook logger.info(`[DealerClaimApproval] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`); } // 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 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 - ALWAYS send notification when there's a next level if (wf && nextLevel) { const nextApproverId = (nextLevel as any).approverId; const nextApproverEmail = (nextLevel as any).approverEmail || ''; const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver'; // Check if it's an auto-step or system process const isAutoStep = nextApproverEmail === 'system@royalenfield.com' || (nextLevel as any).approverName === 'System Auto-Process' || nextApproverId === 'system'; const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@royalenfield.com' || nextApproverEmail.toLowerCase().includes('system'); const isSystemName = nextApproverName.toLowerCase() === 'system auto-process' || nextApproverName.toLowerCase().includes('system'); // Only send notifications to real users, NOT system processes if (!isAutoStep && !isSystemEmail && !isSystemName && nextApproverId && nextApproverId !== 'system') { try { logger.info(`[DealerClaimApproval] 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(`[DealerClaimApproval] ✅ Assignment notification sent successfully to ${nextApproverName} (${nextApproverId}) for level ${nextLevelNumber}`); // Log assignment activity for the next approver await 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 }); } catch (notifError) { logger.error(`[DealerClaimApproval] ❌ Failed to send notification to next approver ${nextApproverId} at level ${nextLevelNumber}:`, notifError); // Don't throw - continue with workflow even if notification fails } } else { logger.info(`[DealerClaimApproval] ⚠️ Skipping notification for system/auto-step: ${nextApproverEmail} (${nextApproverId}) at level ${nextLevelNumber}`); } // Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents) 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 ((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(`[DealerClaimApproval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'}`); } } } else { // No next level found but not final approver - this shouldn't happen logger.warn(`[DealerClaimApproval] No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`); 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, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'approval', priority: 'MEDIUM' }); } } } // 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() }); logger.info(`[DealerClaimApproval] Approval level ${levelId} ${action.action.toLowerCase()}ed and socket event emitted`); return level; } catch (error) { logger.error('[DealerClaimApproval] Error approving level:', error); throw error; } } /** * Handle rejection (internal method called from approveLevel) */ private async handleRejection( level: ApprovalLevel, action: ApprovalAction, userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }, elapsedHours?: number, tatPercentage?: number, now?: Date ): Promise { const rejectionNow = now || new Date(); const wf = await WorkflowRequest.findByPk(level.requestId); if (!wf) return null; // Update level status await level.update({ status: ApprovalStatus.REJECTED, actionDate: rejectionNow, levelEndTime: rejectionNow, elapsedHours: elapsedHours || 0, tatPercentageUsed: tatPercentage || 0, comments: action.comments || action.rejectionReason || undefined }); // Close workflow await WorkflowRequest.update( { status: WorkflowStatus.REJECTED, closureDate: rejectionNow }, { where: { requestId: level.requestId } } ); // Log rejection activity 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'}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); // Notify initiator and participants const participants = await import('@models/Participant').then(m => m.Participant.findAll({ where: { requestId: level.requestId, isActive: true } })); const userIdsToNotify = [(wf as any).initiatorId]; if (participants && participants.length > 0) { participants.forEach((p: any) => { if (p.userId && p.userId !== (wf as any).initiatorId) { userIdsToNotify.push(p.userId); } }); } await notificationService.sendToUsers(userIdsToNotify, { title: `Request Rejected: ${(wf as any).requestNumber}`, body: `${(wf as any).title} - Rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'rejection', priority: 'HIGH' }); // Emit real-time update to all users viewing this request emitToRequestRoom(level.requestId, 'request:updated', { requestId: level.requestId, requestNumber: (wf as any)?.requestNumber, action: 'REJECT', levelNumber: level.levelNumber, timestamp: rejectionNow.toISOString() }); return level; } /** * Reject a level in a dealer claim workflow (legacy method - kept for backward compatibility) */ async rejectLevel( levelId: string, reason: string, comments: string, userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null } ): Promise { try { const level = await ApprovalLevel.findByPk(levelId); if (!level) return null; const wf = await WorkflowRequest.findByPk(level.requestId); if (!wf) return null; // Verify this is a claim management workflow const workflowType = (wf as any)?.workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { logger.warn(`[DealerClaimApproval] Attempted to use DealerClaimApprovalService for non-claim-management workflow ${level.requestId}. Workflow type: ${workflowType}`); throw new Error('DealerClaimApprovalService can only be used for CLAIM_MANAGEMENT workflows'); } const now = new Date(); // Calculate elapsed hours const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); 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; // Use the internal handleRejection method const elapsedHours = await calculateElapsedWorkingHours( (level as any).levelStartTime || (level as any).tatStartTime || now, now, priority, pauseInfo ); const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours); return await this.handleRejection( level, { action: 'REJECT', comments: comments || reason, rejectionReason: reason || comments }, userId, requestMetadata, elapsedHours, tatPercentage, now ); } catch (error) { logger.error('[DealerClaimApproval] Error rejecting level:', error); throw error; } } /** * Get current approval level for a request */ async getCurrentApprovalLevel(requestId: string): Promise { const workflow = await WorkflowRequest.findByPk(requestId); if (!workflow) return null; const currentLevel = (workflow as any).currentLevel; if (!currentLevel) return null; return await ApprovalLevel.findOne({ where: { requestId, levelNumber: currentLevel } }); } /** * Get all approval levels for a request */ async getApprovalLevels(requestId: string): Promise { return await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); } }