Re_Backend/_archive/services/dealerClaimApproval.service.ts

968 lines
47 KiB
TypeScript

/**
* 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<ApprovalLevel | null> {
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<ApprovalLevel | null> {
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<ApprovalLevel | null> {
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<ApprovalLevel | null> {
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<ApprovalLevel[]> {
return await ApprovalLevel.findAll({
where: { requestId },
order: [['levelNumber', 'ASC']]
});
}
}