/** * 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 { User } from '@models/User'; 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 { notificationMongoService } from './notification.mongo.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; import { DealerClaimService } from './dealerClaim.service'; import { emitToRequestRoom } from '../realtime/socket'; export class DealerClaimApprovalService { // Use lazy initialization to avoid circular dependency private getDealerClaimService(): DealerClaimService { return new DealerClaimService(); } /** * 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); } logger.info(`[DealerClaimApproval] Approving level ${levelId} with action:`, JSON.stringify(action)); // Robust comment extraction const approvalComment = action.comments || (action as any).comment || ''; // Update level status and elapsed time for approval FIRST // Only save snapshot if the update succeeds await level.update({ status: ApprovalStatus.APPROVED, actionDate: now, levelEndTime: now, elapsedHours: elapsedHours, tatPercentageUsed: tatPercentage, comments: approvalComment || undefined }); // Check if this is a dealer submission (proposal or completion) - these have their own snapshot types const levelName = (level.levelName || '').toLowerCase(); const isDealerSubmission = levelName.includes('dealer proposal') || levelName.includes('dealer completion'); // Only save APPROVE snapshot for actual approver actions (not dealer submissions) // Dealer submissions use PROPOSAL/COMPLETION snapshot types instead if (!isDealerSubmission) { try { await this.getDealerClaimService().saveApprovalHistory( level.requestId, level.levelId, level.levelNumber, 'APPROVE', approvalComment, undefined, userId ); } catch (snapshotError) { // Log error but don't fail the approval - snapshot is for audit, not critical logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError); } } // Note: We don't save workflow history for approval actions // The approval history (saveApprovalHistory) is sufficient and includes comments // Workflow movement information is included in the APPROVE snapshot's changeReason // 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 } } ); // Update the APPROVE snapshot's changeReason to include movement information // This ensures the approval snapshot shows both the approval and the movement // We don't create a separate WORKFLOW snapshot for approvals - only APPROVE snapshot try { const { DealerClaimHistory } = await import('@models/DealerClaimHistory'); const { SnapshotType } = await import('@models/DealerClaimHistory'); const approvalHistory = await DealerClaimHistory.findOne({ where: { requestId: level.requestId, approvalLevelId: level.levelId, snapshotType: SnapshotType.APPROVE }, order: [['createdAt', 'DESC']] }); if (approvalHistory) { // Use the robust approvalComment from outer scope const updatedChangeReason = approvalComment ? `Approved by ${level.approverName || level.approverEmail}, moved to next level (${nextLevelNumber}). Comment: ${approvalComment}` : `Approved by ${level.approverName || level.approverEmail}, moved to next level (${nextLevelNumber})`; await approvalHistory.update({ changeReason: updatedChangeReason }); } } catch (updateError) { // Log error but don't fail - this is just updating the changeReason for better display logger.warn(`[DealerClaimApproval] Failed to update approval history changeReason (non-critical):`, updateError); } 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(); // Check by levelName first, use levelNumber only as fallback if levelName is missing // This handles cases where additional approvers shift step numbers const hasLevelName = level.levelName && level.levelName.trim() !== ''; const isDeptLeadApproval = hasLevelName ? currentLevelName.includes('department lead') : (level.levelNumber === 3); // Only use levelNumber if levelName is missing const isRequestorClaimApproval = hasLevelName ? (currentLevelName.includes('requestor') && (currentLevelName.includes('claim') || currentLevelName.includes('approval'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing 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) { // Step 6 (System - E-Invoice Generation) is now an activity log only - process it automatically logger.info(`[DealerClaimApproval] Requestor Claim Approval approved. Triggering DMS push for E-Invoice generation.`); try { // Lazy load DealerClaimService to avoid circular dependency issues during method execution const dealerClaimService = this.getDealerClaimService(); await dealerClaimService.updateEInvoiceDetails(level.requestId); logger.info(`[DealerClaimApproval] DMS push initiated for request ${level.requestId}`); } catch (dmsError) { logger.error(`[DealerClaimApproval] Error initiating DMS push for request ${level.requestId}:`, dmsError); // Don't fail the Requestor Claim Approval if DMS push fails } } // 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 // BUT skip this if it's a dealer proposal or dealer completion step - those have special notifications below // Priority: levelName check first, then levelNumber only if levelName is missing const hasLevelNameForApproval = level.levelName && level.levelName.trim() !== ''; const levelNameForApproval = hasLevelNameForApproval && level.levelName ? level.levelName.toLowerCase() : ''; const isDealerProposalApproval = hasLevelNameForApproval ? (levelNameForApproval.includes('dealer') && levelNameForApproval.includes('proposal')) : (level.levelNumber === 1); // Only use levelNumber if levelName is missing const isDealerCompletionApproval = hasLevelNameForApproval ? (levelNameForApproval.includes('dealer') && (levelNameForApproval.includes('completion') || levelNameForApproval.includes('documents'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing // Skip sending approval notification to initiator if they are the approver // (they don't need to be notified that they approved their own request) const isApproverInitiator = level.approverId && (wf as any).initiatorId && level.approverId === (wf as any).initiatorId; if (wf && !isDealerProposalApproval && !isDealerCompletionApproval && !isApproverInitiator) { 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' }); } else if (isApproverInitiator) { logger.info(`[DealerClaimApproval] Skipping approval notification to initiator - they are the approver`); } // 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'); // Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents) // Check this BEFORE sending assignment notification to avoid duplicates // Priority: levelName check first, then levelNumber only if levelName is missing const hasLevelNameForNotification = level.levelName && level.levelName.trim() !== ''; const levelNameForNotification = hasLevelNameForNotification && level.levelName ? level.levelName.toLowerCase() : ''; const isDealerProposalApproval = hasLevelNameForNotification ? (levelNameForNotification.includes('dealer') && levelNameForNotification.includes('proposal')) : (level.levelNumber === 1); // Only use levelNumber if levelName is missing const isDealerCompletionApproval = hasLevelNameForNotification ? (levelNameForNotification.includes('dealer') && (levelNameForNotification.includes('completion') || levelNameForNotification.includes('documents'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing // Check if next approver is the initiator (to avoid duplicate notifications) const isNextApproverInitiator = nextApproverId && (wf as any).initiatorId && nextApproverId === (wf as any).initiatorId; if (isDealerProposalApproval && (wf as any).initiatorId) { // Get dealer and proposal data for the email template const { DealerClaimDetails } = await import('@models/DealerClaimDetails'); const { DealerProposalDetails } = await import('@models/DealerProposalDetails'); const { DealerProposalCostItem } = await import('@models/DealerProposalCostItem'); const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } }); const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId: level.requestId } }); // Get cost items if proposal exists let costBreakup: any[] = []; if (proposalDetails) { const proposalId = (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id; if (proposalId) { const costItems = await DealerProposalCostItem.findAll({ where: { proposalId }, order: [['itemOrder', 'ASC']] }); costBreakup = costItems.map((item: any) => ({ description: item.itemDescription || item.description, amount: Number(item.amount) || 0 })); } } // Get dealer user const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null; const dealerData = dealerUser ? dealerUser.toJSON() : { userId: level.approverId, email: level.approverEmail || '', displayName: level.approverName || level.approverEmail || 'Dealer' }; // Get next approver (could be Step 2 - Requestor Evaluation, or an additional approver if one was added between Step 1 and Step 2) // The nextLevel is already found above using dynamic logic that handles additional approvers correctly const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; // Check if next approver is an additional approver (handles cases where additional approvers are added between Step 1 and Step 2) const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : ''; const isNextAdditionalApprover = nextLevelName.includes('additional approver'); // Send proposal submitted notification with proper type and metadata // This will use the dealerProposalSubmitted template, not the multi-level approval template await notificationService.sendToUsers([(wf as any).initiatorId], { title: 'Proposal Submitted', body: `Dealer ${dealerData.displayName || dealerData.email} has submitted a proposal for your claim request "${(wf as any).title}".`, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, type: 'proposal_submitted', priority: 'MEDIUM', actionRequired: false, metadata: { dealerData: dealerData, proposalData: { totalEstimatedBudget: proposalDetails ? (proposalDetails as any).totalEstimatedBudget : 0, expectedCompletionDate: proposalDetails ? (proposalDetails as any).expectedCompletionDate : undefined, dealerComments: proposalDetails ? (proposalDetails as any).dealerComments : undefined, costBreakup: costBreakup, submittedAt: proposalDetails ? (proposalDetails as any).submittedAt : new Date(), nextApproverIsAdditional: isNextAdditionalApprover, nextApproverIsInitiator: isNextApproverInitiator }, nextApproverId: nextApproverData ? nextApproverData.userId : undefined, // Add activity information from claimDetails activityName: claimDetails ? (claimDetails as any).activityName : undefined, activityType: claimDetails ? (claimDetails as any).activityType : undefined } }); logger.info(`[DealerClaimApproval] Sent proposal_submitted notification to initiator for Dealer Proposal Submission. Next approver: ${isNextApproverInitiator ? 'Initiator (self)' : (isNextAdditionalApprover ? 'Additional Approver' : 'Step 2 (Requestor Evaluation)')}`); } else if (isDealerCompletionApproval && (wf as any).initiatorId) { // Get dealer and completion data for the email template const { DealerClaimDetails } = await import('@models/DealerClaimDetails'); const { DealerCompletionDetails } = await import('@models/DealerCompletionDetails'); const { DealerCompletionExpense } = await import('@models/DealerCompletionExpense'); const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId: level.requestId } }); // Get expense items if completion exists let closedExpenses: any[] = []; if (completionDetails) { const expenses = await DealerCompletionExpense.findAll({ where: { requestId: level.requestId }, order: [['createdAt', 'ASC']] }); closedExpenses = expenses.map((item: any) => ({ description: item.description || '', amount: Number(item.amount) || 0 })); } // Get dealer user const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null; const dealerData = dealerUser ? dealerUser.toJSON() : { userId: level.approverId, email: level.approverEmail || '', displayName: level.approverName || level.approverEmail || 'Dealer' }; // Get next approver (could be Step 5 - Requestor Claim Approval, or an additional approver if one was added between Step 4 and Step 5) const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; // Check if next approver is an additional approver (handles cases where additional approvers are added between Step 4 and Step 5) const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : ''; const isNextAdditionalApprover = nextLevelName.includes('additional approver'); // Check if next approver is the initiator (to show appropriate message in email) const isNextApproverInitiator = nextApproverData && (wf as any).initiatorId && nextApproverData.userId === (wf as any).initiatorId; // Send completion submitted notification with proper type and metadata // This will use the completionDocumentsSubmitted template, not the multi-level approval template await notificationService.sendToUsers([(wf as any).initiatorId], { title: 'Completion Documents Submitted', body: `Dealer ${dealerData.displayName || dealerData.email} has submitted completion documents for your claim request "${(wf as any).title}".`, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, type: 'completion_submitted', priority: 'MEDIUM', actionRequired: false, metadata: { dealerData: dealerData, completionData: { activityCompletionDate: completionDetails ? (completionDetails as any).activityCompletionDate : undefined, numberOfParticipants: completionDetails ? (completionDetails as any).numberOfParticipants : undefined, totalClosedExpenses: completionDetails ? (completionDetails as any).totalClosedExpenses : 0, closedExpenses: closedExpenses, documentsCount: undefined, // Documents count can be retrieved from documents table if needed submittedAt: completionDetails ? (completionDetails as any).submittedAt : new Date(), nextApproverIsAdditional: isNextAdditionalApprover, nextApproverIsInitiator: isNextApproverInitiator }, nextApproverId: nextApproverData ? nextApproverData.userId : undefined } }); logger.info(`[DealerClaimApproval] Sent completion_submitted notification to initiator for Dealer Completion Documents. Next approver: ${isNextAdditionalApprover ? 'Additional Approver' : 'Step 5 (Requestor Claim Approval)'}`); } // Only send assignment notification to next approver if: // 1. It's NOT a dealer proposal/completion step (those have special notifications above) // 2. Next approver is NOT the initiator (to avoid duplicate notifications) // 3. It's not a system/auto step if (!isDealerProposalApproval && !isDealerCompletionApproval && !isNextApproverInitiator) { 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}`); } } else { if (isDealerProposalApproval || isDealerCompletionApproval) { logger.info(`[DealerClaimApproval] ⚠️ Skipping assignment notification - dealer-specific notification already sent`); } if (isNextApproverInitiator) { logger.info(`[DealerClaimApproval] ⚠️ Skipping assignment notification - next approver is the initiator (already notified)`); } } } } 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; // Check if this is the Department Lead approval step (Step 3) // Robust check: check level name for variations and level number as fallback // Default rejection logic: Return to immediately previous approval step logger.info(`[DealerClaimApproval] Rejection for request ${level.requestId} by level ${level.levelNumber}. Finding previous step to return to.`); // Save approval history (rejection) BEFORE updating level await this.getDealerClaimService().saveApprovalHistory( level.requestId, level.levelId, level.levelNumber, 'REJECT', action.comments || '', action.rejectionReason || undefined, userId ); // Find all levels to determine previous step const allLevels = await ApprovalLevel.findAll({ where: { requestId: level.requestId }, order: [['levelNumber', 'ASC']] }); // Find the immediately previous approval level const currentLevelNumber = level.levelNumber || 0; const previousLevels = allLevels.filter(l => l.levelNumber < currentLevelNumber && l.levelNumber > 0); const previousLevel = previousLevels[previousLevels.length - 1]; // Update level status - if returning to previous step, set this level to PENDING (reset) // If no previous step (terminal rejection), set to REJECTED const newStatus = previousLevel ? ApprovalStatus.PENDING : ApprovalStatus.REJECTED; await level.update({ status: newStatus, // If resetting to PENDING, clear action details so it can be acted upon again later actionDate: previousLevel ? null : rejectionNow, levelEndTime: previousLevel ? null : rejectionNow, elapsedHours: previousLevel ? 0 : (elapsedHours || 0), tatPercentageUsed: previousLevel ? 0 : (tatPercentage || 0), comments: previousLevel ? null : (action.comments || action.rejectionReason || undefined) } as any); // If no previous level found (this is the first step), close the workflow if (!previousLevel) { logger.info(`[DealerClaimApproval] No previous level found. This is the first step. Closing workflow.`); // Capture workflow snapshot for terminal rejection await this.getDealerClaimService().saveWorkflowHistory( level.requestId, `Level ${level.levelNumber} rejected (terminal rejection - no previous step)`, userId, level.levelId, level.levelNumber, level.levelName || undefined ); // Close workflow FIRST await WorkflowRequest.update( { status: WorkflowStatus.REJECTED, closureDate: rejectionNow }, { where: { requestId: level.requestId } } ); // Capture workflow snapshot AFTER workflow is closed successfully try { await this.getDealerClaimService().saveWorkflowHistory( level.requestId, `Level ${level.levelNumber} rejected (terminal rejection - no previous step)`, userId, level.levelId, level.levelNumber, level.levelName || undefined ); } catch (snapshotError) { // Log error but don't fail the rejection - snapshot is for audit, not critical logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError); } // Log rejection activity (terminal rejection) activityService.log({ requestId: level.requestId, type: 'rejection', user: { userId: level.approverId, name: level.approverName }, timestamp: rejectionNow.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 (workflow is closed) 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' }); } else { // Return to previous step logger.info(`[DealerClaimApproval] Returning to previous level ${previousLevel.levelNumber} (${previousLevel.levelName || 'unnamed'})`); // Reset previous level to IN_PROGRESS so it can be acted upon again await previousLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: rejectionNow, tatStartTime: rejectionNow, actionDate: undefined, levelEndTime: undefined, comments: undefined, elapsedHours: 0, tatPercentageUsed: 0 }); // Update workflow status to IN_PROGRESS (remains active for rework) // Set currentLevel to previous level await WorkflowRequest.update( { status: WorkflowStatus.PENDING, currentLevel: previousLevel.levelNumber }, { where: { requestId: level.requestId } } ); // Log rejection activity (returned to previous step) activityService.log({ requestId: level.requestId, type: 'rejection', user: { userId: level.approverId, name: level.approverName }, timestamp: rejectionNow.toISOString(), action: 'Returned to Previous Step', details: `Request rejected by ${level.approverName || level.approverEmail} and returned to level ${previousLevel.levelNumber}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); // Notify the approver of the previous level if (previousLevel.approverId) { await notificationService.sendToUsers([previousLevel.approverId], { title: `Request Returned: ${(wf as any).requestNumber}`, body: `Request "${(wf as any).title}" has been returned to your level for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); } // Notify initiator when request is returned (not closed) await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Request Returned: ${(wf as any).requestNumber}`, body: `Request "${(wf as any).title}" has been returned to level ${previousLevel.levelNumber} for revision. 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', actionRequired: true }); } // 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']] }); } }