968 lines
47 KiB
TypeScript
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']]
|
|
});
|
|
}
|
|
}
|
|
|