836 lines
41 KiB
TypeScript
836 lines
41 KiB
TypeScript
import { ApprovalLevel } from '@models/ApprovalLevel';
|
|
import { WorkflowRequest } from '@models/WorkflowRequest';
|
|
import { Participant } from '@models/Participant';
|
|
import { TatAlert } from '@models/TatAlert';
|
|
import { ApprovalAction } from '../types/approval.types';
|
|
import { ApprovalStatus, WorkflowStatus } from '../types/common.types';
|
|
import { calculateTATPercentage } from '@utils/helpers';
|
|
import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils';
|
|
import logger, { logWorkflowEvent, logAIEvent } from '@utils/logger';
|
|
import { Op } from 'sequelize';
|
|
import { notificationService } from './notification.service';
|
|
import { activityService } from './activity.service';
|
|
import { tatSchedulerService } from './tatScheduler.service';
|
|
import { emitToRequestRoom } from '../realtime/socket';
|
|
import { DealerClaimService } from './dealerClaim.service';
|
|
|
|
export class ApprovalService {
|
|
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;
|
|
|
|
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
|
|
const isPaused = (wf as any).isPaused || (level as any).isPaused;
|
|
|
|
// If paused, resume automatically when approving/rejecting (requirement 3.6)
|
|
if (isPaused) {
|
|
const { pauseService } = await import('./pause.service');
|
|
try {
|
|
await pauseService.resumeWorkflow(level.requestId, _userId);
|
|
logger.info(`[Approval] Auto-resumed paused workflow ${level.requestId} when ${action.action === 'APPROVE' ? 'approving' : 'rejecting'}`);
|
|
} catch (pauseError) {
|
|
logger.warn(`[Approval] Failed to auto-resume paused workflow:`, pauseError);
|
|
// Continue with approval/rejection even if resume fails
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
// Calculate elapsed hours using working hours logic (with pause handling)
|
|
// Case 1: Level is currently paused (isPaused = true)
|
|
// Case 2: Level was paused and resumed (isPaused = false but pauseElapsedHours and pauseResumeDate exist)
|
|
const isPausedLevel = (level as any).isPaused;
|
|
const wasResumed = !isPausedLevel &&
|
|
(level as any).pauseElapsedHours !== null &&
|
|
(level as any).pauseElapsedHours !== undefined &&
|
|
(level as any).pauseResumeDate !== null;
|
|
|
|
const pauseInfo = isPausedLevel ? {
|
|
// Level is currently paused - return frozen elapsed hours at pause time
|
|
isPaused: true,
|
|
pausedAt: (level as any).pausedAt,
|
|
pauseElapsedHours: (level as any).pauseElapsedHours,
|
|
pauseResumeDate: (level as any).pauseResumeDate
|
|
} : wasResumed ? {
|
|
// Level was paused but has been resumed - add pre-pause elapsed hours + time since resume
|
|
isPaused: false,
|
|
pausedAt: null,
|
|
pauseElapsedHours: Number((level as any).pauseElapsedHours), // Pre-pause elapsed hours
|
|
pauseResumeDate: (level as any).pauseResumeDate // Actual resume timestamp
|
|
} : undefined;
|
|
|
|
const elapsedHours = await calculateElapsedWorkingHours(
|
|
level.levelStartTime || level.createdAt,
|
|
now,
|
|
priority,
|
|
pauseInfo
|
|
);
|
|
const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours);
|
|
|
|
const updateData = {
|
|
status: action.action === 'APPROVE' ? ApprovalStatus.APPROVED : ApprovalStatus.REJECTED,
|
|
actionDate: now,
|
|
levelEndTime: now,
|
|
elapsedHours,
|
|
tatPercentageUsed: tatPercentage,
|
|
comments: action.comments,
|
|
rejectionReason: action.rejectionReason
|
|
};
|
|
|
|
const updatedLevel = await level.update(updateData);
|
|
|
|
// Cancel TAT jobs for the current level since it's been actioned
|
|
try {
|
|
await tatSchedulerService.cancelTatJobs(level.requestId, level.levelId);
|
|
logger.info(`[Approval] TAT jobs cancelled for level ${level.levelId}`);
|
|
} catch (tatError) {
|
|
logger.error(`[Approval] Failed to cancel TAT jobs:`, tatError);
|
|
// Don't fail the approval if TAT cancellation fails
|
|
}
|
|
|
|
// Update TAT alerts for this level to mark completion status
|
|
try {
|
|
const wasOnTime = elapsedHours <= level.tatHours;
|
|
await TatAlert.update(
|
|
{
|
|
wasCompletedOnTime: wasOnTime,
|
|
completionTime: now
|
|
},
|
|
{
|
|
where: { levelId: level.levelId }
|
|
}
|
|
);
|
|
logger.info(`[Approval] TAT alerts updated for level ${level.levelId} - Completed ${wasOnTime ? 'on time' : 'late'}`);
|
|
} catch (tatAlertError) {
|
|
logger.error(`[Approval] Failed to update TAT alerts:`, tatAlertError);
|
|
// Don't fail the approval if TAT alert update fails
|
|
}
|
|
|
|
// Handle approval - move to next level or close workflow (wf already loaded above)
|
|
if (action.action === 'APPROVE') {
|
|
if (level.isFinalApprover) {
|
|
// Final approver - close workflow as APPROVED
|
|
await WorkflowRequest.update(
|
|
{
|
|
status: WorkflowStatus.APPROVED,
|
|
closureDate: now,
|
|
currentLevel: (level.levelNumber || 0) + 1
|
|
},
|
|
{ where: { requestId: level.requestId } }
|
|
);
|
|
logWorkflowEvent('approved', level.requestId, {
|
|
level: level.levelNumber,
|
|
isFinalApproval: true,
|
|
status: 'APPROVED',
|
|
});
|
|
|
|
// Log final approval activity first (so it's included in AI context)
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'approval',
|
|
user: { userId: level.approverId, name: level.approverName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Approved',
|
|
details: `Request approved and finalized by ${level.approverName || level.approverEmail}. Awaiting conclusion remark from initiator.`,
|
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
userAgent: requestMetadata?.userAgent || undefined
|
|
});
|
|
|
|
// Generate AI conclusion remark ASYNCHRONOUSLY (don't wait)
|
|
// This runs in the background without blocking the approval response
|
|
(async () => {
|
|
try {
|
|
const { aiService } = await import('./ai.service');
|
|
const { ConclusionRemark } = await import('@models/index');
|
|
const { ApprovalLevel } = await import('@models/ApprovalLevel');
|
|
const { WorkNote } = await import('@models/WorkNote');
|
|
const { Document } = await import('@models/Document');
|
|
const { Activity } = await import('@models/Activity');
|
|
const { getConfigValue } = await import('./configReader.service');
|
|
|
|
// Check if AI features and remark generation are enabled in admin config
|
|
const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
|
|
if (aiEnabled && remarkGenerationEnabled && aiService.isAvailable()) {
|
|
logAIEvent('request', {
|
|
requestId: level.requestId,
|
|
action: 'conclusion_generation_started',
|
|
});
|
|
|
|
// Gather context for AI generation
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
const workNotes = await WorkNote.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 20
|
|
});
|
|
|
|
const documents = await Document.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['uploadedAt', 'DESC']]
|
|
});
|
|
|
|
const activities = await Activity.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 50
|
|
});
|
|
|
|
// Build context object
|
|
const context = {
|
|
requestTitle: (wf as any).title,
|
|
requestDescription: (wf as any).description,
|
|
requestNumber: (wf as any).requestNumber,
|
|
priority: (wf as any).priority,
|
|
approvalFlow: approvalLevels.map((l: any) => {
|
|
const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null
|
|
? Number(l.tatPercentageUsed)
|
|
: (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0);
|
|
return {
|
|
levelNumber: l.levelNumber,
|
|
approverName: l.approverName,
|
|
status: l.status,
|
|
comments: l.comments,
|
|
actionDate: l.actionDate,
|
|
tatHours: Number(l.tatHours || 0),
|
|
elapsedHours: Number(l.elapsedHours || 0),
|
|
tatPercentageUsed: tatPercentage
|
|
};
|
|
}),
|
|
workNotes: workNotes.map((note: any) => ({
|
|
userName: note.userName,
|
|
message: note.message,
|
|
createdAt: note.createdAt
|
|
})),
|
|
documents: documents.map((doc: any) => ({
|
|
fileName: doc.originalFileName || doc.fileName,
|
|
uploadedBy: doc.uploadedBy,
|
|
uploadedAt: doc.uploadedAt
|
|
})),
|
|
activities: activities.map((activity: any) => ({
|
|
type: activity.activityType,
|
|
action: activity.activityDescription,
|
|
details: activity.activityDescription,
|
|
timestamp: activity.createdAt
|
|
}))
|
|
};
|
|
|
|
const aiResult = await aiService.generateConclusionRemark(context);
|
|
|
|
// Save to database
|
|
await ConclusionRemark.create({
|
|
requestId: level.requestId,
|
|
aiGeneratedRemark: aiResult.remark,
|
|
aiModelUsed: aiResult.provider,
|
|
aiConfidenceScore: aiResult.confidence,
|
|
finalRemark: null,
|
|
editedBy: null,
|
|
isEdited: false,
|
|
editCount: 0,
|
|
approvalSummary: {
|
|
totalLevels: approvalLevels.length,
|
|
approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length,
|
|
averageTatUsage: approvalLevels.reduce((sum: number, l: any) =>
|
|
sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1)
|
|
},
|
|
documentSummary: {
|
|
totalDocuments: documents.length,
|
|
documentNames: documents.map((d: any) => d.originalFileName || d.fileName)
|
|
},
|
|
keyDiscussionPoints: aiResult.keyPoints,
|
|
generatedAt: new Date(),
|
|
finalizedAt: null
|
|
} as any);
|
|
|
|
logAIEvent('response', {
|
|
requestId: level.requestId,
|
|
action: 'conclusion_generation_completed',
|
|
});
|
|
|
|
// Log activity
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'ai_conclusion_generated',
|
|
user: { userId: 'system', name: 'System' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'AI Conclusion Generated',
|
|
details: 'AI-powered conclusion remark generated for review by initiator',
|
|
ipAddress: undefined, // System-generated, no IP
|
|
userAgent: undefined // System-generated, no user agent
|
|
});
|
|
} else {
|
|
// Log why AI generation was skipped
|
|
if (!aiEnabled) {
|
|
logger.info(`[Approval] AI features disabled in admin config, skipping conclusion generation for ${level.requestId}`);
|
|
} else if (!remarkGenerationEnabled) {
|
|
logger.info(`[Approval] AI remark generation disabled in admin config, skipping for ${level.requestId}`);
|
|
} else if (!aiService.isAvailable()) {
|
|
logger.warn(`[Approval] AI service unavailable for ${level.requestId}, skipping conclusion generation`);
|
|
}
|
|
}
|
|
|
|
// Auto-generate RequestSummary after final approval (system-level generation)
|
|
// This makes the summary immediately available when user views the approved request
|
|
try {
|
|
const { summaryService } = await import('./summary.service');
|
|
const summary = await summaryService.createSummary(level.requestId, 'system', {
|
|
isSystemGeneration: true
|
|
});
|
|
logger.info(`[Approval] ✅ Auto-generated summary ${(summary as any).summaryId} for approved request ${level.requestId}`);
|
|
|
|
// Log summary generation activity
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'summary_generated',
|
|
user: { userId: 'system', name: 'System' },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Summary Auto-Generated',
|
|
details: 'Request summary auto-generated after final approval',
|
|
ipAddress: undefined,
|
|
userAgent: undefined
|
|
});
|
|
} catch (summaryError: any) {
|
|
// Log but don't fail - initiator can regenerate later
|
|
logger.error(`[Approval] Failed to auto-generate summary for ${level.requestId}:`, summaryError.message);
|
|
}
|
|
|
|
} catch (aiError) {
|
|
logAIEvent('error', {
|
|
requestId: level.requestId,
|
|
action: 'conclusion_generation_failed',
|
|
error: aiError,
|
|
});
|
|
// Silent failure - initiator can write manually
|
|
|
|
// Still try to generate summary even if AI conclusion failed
|
|
try {
|
|
const { summaryService } = await import('./summary.service');
|
|
const summary = await summaryService.createSummary(level.requestId, 'system', {
|
|
isSystemGeneration: true
|
|
});
|
|
logger.info(`[Approval] ✅ Auto-generated summary ${(summary as any).summaryId} for approved request ${level.requestId} (without AI conclusion)`);
|
|
} catch (summaryError: any) {
|
|
logger.error(`[Approval] Failed to auto-generate summary for ${level.requestId}:`, summaryError.message);
|
|
}
|
|
}
|
|
})().catch(err => {
|
|
// Catch any unhandled promise rejections
|
|
logger.error(`[Approval] Unhandled error in background AI generation:`, err);
|
|
});
|
|
|
|
// Notify initiator and all participants (including spectators) about approval
|
|
// Spectators are CC'd for transparency, similar to email CC
|
|
if (wf) {
|
|
const participants = await Participant.findAll({
|
|
where: { requestId: level.requestId }
|
|
});
|
|
const targetUserIds = new Set<string>();
|
|
targetUserIds.add((wf as any).initiatorId);
|
|
for (const p of participants as any[]) {
|
|
targetUserIds.add(p.userId); // Includes spectators
|
|
}
|
|
|
|
// Send notification to initiator (with action required)
|
|
const initiatorId = (wf as any).initiatorId;
|
|
await notificationService.sendToUsers([initiatorId], {
|
|
title: `Request Approved - Closure Pending`,
|
|
body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
requestId: level.requestId,
|
|
url: `/request/${(wf as any).requestNumber}`,
|
|
type: 'approval_pending_closure',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
|
|
// Send notification to all participants/spectators (for transparency, no action required)
|
|
const participantUserIds = Array.from(targetUserIds).filter(id => id !== initiatorId);
|
|
if (participantUserIds.length > 0) {
|
|
await notificationService.sendToUsers(participantUserIds, {
|
|
title: `Request Approved`,
|
|
body: `Request "${(wf as any).title}" has been fully approved. The initiator will finalize the conclusion remark to close the request.`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
requestId: level.requestId,
|
|
url: `/request/${(wf as any).requestNumber}`,
|
|
type: 'approval_pending_closure',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false
|
|
});
|
|
}
|
|
|
|
logger.info(`[Approval] ✅ Final approval complete for ${level.requestId}. Initiator and ${participants.length} participant(s) notified.`);
|
|
}
|
|
} else {
|
|
// Not final - move to next level
|
|
// Check if workflow is paused - if so, don't advance
|
|
if ((wf as any).isPaused || (wf as any).status === 'PAUSED') {
|
|
logger.warn(`[Approval] Cannot advance workflow ${level.requestId} - workflow is paused`);
|
|
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
|
|
}
|
|
|
|
const nextLevelNumber = (level.levelNumber || 0) + 1;
|
|
const nextLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId: level.requestId,
|
|
levelNumber: nextLevelNumber
|
|
}
|
|
});
|
|
|
|
if (nextLevel) {
|
|
// Check if next level is paused - if so, don't activate it
|
|
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
|
|
logger.warn(`[Approval] Cannot activate next level ${nextLevelNumber} - level is paused`);
|
|
throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.');
|
|
}
|
|
|
|
// Activate next level
|
|
await nextLevel.update({
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: now,
|
|
tatStartTime: now
|
|
});
|
|
|
|
// Schedule TAT jobs for the next level
|
|
try {
|
|
// Get workflow priority for TAT calculation
|
|
const workflowPriority = (wf as any)?.priority || 'STANDARD';
|
|
|
|
await tatSchedulerService.scheduleTatJobs(
|
|
level.requestId,
|
|
(nextLevel as any).levelId,
|
|
(nextLevel as any).approverId,
|
|
Number((nextLevel as any).tatHours),
|
|
now,
|
|
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
|
|
);
|
|
logger.info(`[Approval] TAT jobs scheduled for next level ${nextLevelNumber} (Priority: ${workflowPriority})`);
|
|
} catch (tatError) {
|
|
logger.error(`[Approval] Failed to schedule TAT jobs for next level:`, tatError);
|
|
// Don't fail the approval if TAT scheduling fails
|
|
}
|
|
|
|
// Update workflow current level
|
|
await WorkflowRequest.update(
|
|
{ currentLevel: nextLevelNumber },
|
|
{ where: { requestId: level.requestId } }
|
|
);
|
|
|
|
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
|
|
|
// Check if this is Department Lead approval in a claim management workflow
|
|
// Activity Creation is now an activity log only, not an approval step
|
|
const workflowType = (wf as any)?.workflowType;
|
|
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
|
|
|
// Check if current level is Department Lead (by levelName, not hardcoded step number)
|
|
const currentLevelName = (level.levelName || '').toLowerCase();
|
|
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
|
|
|
|
// Check if current level is Requestor Claim Approval (Step 5, was Step 6)
|
|
const currentLevelNameForStep5 = (level.levelName || '').toLowerCase();
|
|
const isRequestorClaimApproval = currentLevelNameForStep5.includes('requestor') &&
|
|
(currentLevelNameForStep5.includes('claim') || currentLevelNameForStep5.includes('approval')) ||
|
|
level.levelNumber === 5;
|
|
|
|
if (isClaimManagement && isDeptLeadApproval) {
|
|
// Activity Creation is now an activity log only - process it automatically
|
|
logger.info(`[Approval] Department Lead approved for claim management workflow. Processing Activity Creation as activity log.`);
|
|
try {
|
|
const dealerClaimService = new DealerClaimService();
|
|
await dealerClaimService.processActivityCreation(level.requestId);
|
|
logger.info(`[Approval] Activity Creation activity logged for request ${level.requestId}`);
|
|
} catch (activityError) {
|
|
logger.error(`[Approval] Error processing Activity Creation activity for request ${level.requestId}:`, activityError);
|
|
// Don't fail the Department Lead approval if Activity Creation logging fails - log and continue
|
|
}
|
|
} else if (isClaimManagement && isRequestorClaimApproval) {
|
|
// E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook
|
|
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
|
}
|
|
|
|
if (wf && nextLevel) {
|
|
// Normal flow - notify next approver (skip for auto-steps)
|
|
// Check if it's an auto-step by checking approverEmail or levelName
|
|
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are no longer approval steps
|
|
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|
|
|| (nextLevel as any).approverName === 'System Auto-Process';
|
|
|
|
// 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
|
|
});
|
|
|
|
// Log assignment activity for next level (when it becomes active)
|
|
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
|
// System steps are any step with system@royalenfield.com
|
|
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only, not approval steps
|
|
// These steps are processed automatically and should NOT trigger notifications
|
|
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
|
|
// Additional checks: ensure approverEmail and approverName are not system-related
|
|
// This prevents notifications to system accounts even if they pass other checks
|
|
const approverEmail = (nextLevel as any).approverEmail || '';
|
|
const approverName = (nextLevel as any).approverName || '';
|
|
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|
|
|| approverEmail.toLowerCase().includes('system');
|
|
const isSystemName = approverName.toLowerCase() === 'system auto-process'
|
|
|| approverName.toLowerCase().includes('system');
|
|
|
|
// EXCLUDE all system-related steps from notifications
|
|
// Only send notifications to real users, NOT system processes
|
|
if (!isSystemEmail && !isSystemName) {
|
|
// Send notification to next approver (only for real users, not system processes)
|
|
// This will send both in-app and email notifications
|
|
const nextApproverId = (nextLevel as any).approverId;
|
|
const nextApproverName = (nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver';
|
|
|
|
logger.info(`[Approval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
|
|
|
|
await notificationService.sendToUsers([ nextApproverId ], {
|
|
title: `Action required: ${(wf as any).requestNumber}`,
|
|
body: `${(wf as any).title}`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
requestId: (wf as any).requestId,
|
|
url: `/request/${(wf as any).requestNumber}`,
|
|
type: 'assignment',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
|
|
logger.info(`[Approval] Assignment notification sent successfully to ${nextApproverName} for level ${nextLevelNumber}`);
|
|
|
|
// Log assignment activity for the next approver
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'assignment',
|
|
user: { userId: level.approverId, name: level.approverName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Assigned to approver',
|
|
details: `Request assigned to ${nextApproverName} for ${(nextLevel as any).levelName || `level ${nextLevelNumber}`}`,
|
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
userAgent: requestMetadata?.userAgent || undefined
|
|
});
|
|
} else {
|
|
logger.info(`[Approval] Skipping notification for system process: ${approverEmail} at level ${nextLevelNumber}`);
|
|
}
|
|
} else {
|
|
logger.info(`[Approval] Skipping notification for auto-step at level ${nextLevelNumber}`);
|
|
}
|
|
|
|
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management)
|
|
const workflowType = (wf as any)?.workflowType;
|
|
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
|
const levelName = (level.levelName || '').toLowerCase();
|
|
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
|
|
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
|
|
|
|
if (isClaimManagement && (isDealerProposalApproval || isDealerCompletionApproval) && (wf as any).initiatorId) {
|
|
const stepMessage = isDealerProposalApproval
|
|
? 'Dealer proposal has been submitted and is now under review.'
|
|
: 'Dealer completion documents have been submitted and are now under review.';
|
|
|
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
|
title: isDealerProposalApproval ? 'Proposal Submitted' : 'Completion Documents Submitted',
|
|
body: `Your claim request "${(wf as any).title}" - ${stepMessage}`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
requestId: (wf as any).requestId,
|
|
url: `/request/${(wf as any).requestNumber}`,
|
|
type: 'approval',
|
|
priority: 'MEDIUM',
|
|
actionRequired: false
|
|
});
|
|
|
|
logger.info(`[Approval] Sent notification to initiator for ${isDealerProposalApproval ? 'Dealer Proposal Submission' : 'Dealer Completion Documents'} approval in claim management workflow`);
|
|
}
|
|
}
|
|
} else {
|
|
// No next level found but not final approver - this shouldn't happen
|
|
logger.warn(`No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`);
|
|
await WorkflowRequest.update(
|
|
{
|
|
status: WorkflowStatus.APPROVED,
|
|
closureDate: now,
|
|
currentLevel: nextLevelNumber
|
|
},
|
|
{ where: { requestId: level.requestId } }
|
|
);
|
|
if (wf) {
|
|
await notificationService.sendToUsers([ (wf as any).initiatorId ], {
|
|
title: `Approved: ${(wf as any).requestNumber}`,
|
|
body: `${(wf as any).title}`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
url: `/request/${(wf as any).requestNumber}`
|
|
});
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'approval',
|
|
user: { userId: level.approverId, name: level.approverName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Approved',
|
|
details: `Request approved and finalized by ${level.approverName || level.approverEmail}`,
|
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
userAgent: requestMetadata?.userAgent || undefined
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (action.action === 'REJECT') {
|
|
// Rejection - mark workflow as REJECTED (closure will happen when initiator finalizes conclusion)
|
|
await WorkflowRequest.update(
|
|
{
|
|
status: WorkflowStatus.REJECTED
|
|
// Note: closureDate will be set when initiator finalizes the conclusion
|
|
},
|
|
{ where: { requestId: level.requestId } }
|
|
);
|
|
|
|
// Mark all pending levels as skipped
|
|
await ApprovalLevel.update(
|
|
{
|
|
status: ApprovalStatus.SKIPPED,
|
|
levelEndTime: now
|
|
},
|
|
{
|
|
where: {
|
|
requestId: level.requestId,
|
|
status: ApprovalStatus.PENDING,
|
|
levelNumber: { [Op.gt]: level.levelNumber }
|
|
}
|
|
}
|
|
);
|
|
|
|
logWorkflowEvent('rejected', level.requestId, {
|
|
level: level.levelNumber,
|
|
status: 'REJECTED',
|
|
message: 'Awaiting closure from initiator',
|
|
});
|
|
|
|
// Log rejection activity first (so it's included in AI context)
|
|
if (wf) {
|
|
activityService.log({
|
|
requestId: level.requestId,
|
|
type: 'rejection',
|
|
user: { userId: level.approverId, name: level.approverName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Rejected',
|
|
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}. Awaiting closure from initiator.`,
|
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
userAgent: requestMetadata?.userAgent || undefined
|
|
});
|
|
}
|
|
|
|
// Notify initiator and all participants
|
|
if (wf) {
|
|
const participants = await Participant.findAll({ where: { requestId: level.requestId } });
|
|
const targetUserIds = new Set<string>();
|
|
targetUserIds.add((wf as any).initiatorId);
|
|
for (const p of participants as any[]) {
|
|
targetUserIds.add(p.userId);
|
|
}
|
|
await notificationService.sendToUsers(Array.from(targetUserIds), {
|
|
title: `Rejected: ${(wf as any).requestNumber}`,
|
|
body: `${(wf as any).title}`,
|
|
requestNumber: (wf as any).requestNumber,
|
|
url: `/request/${(wf as any).requestNumber}`
|
|
});
|
|
}
|
|
|
|
// Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved)
|
|
// This runs in the background without blocking the rejection response
|
|
(async () => {
|
|
try {
|
|
const { aiService } = await import('./ai.service');
|
|
const { ConclusionRemark } = await import('@models/index');
|
|
const { ApprovalLevel } = await import('@models/ApprovalLevel');
|
|
const { WorkNote } = await import('@models/WorkNote');
|
|
const { Document } = await import('@models/Document');
|
|
const { Activity } = await import('@models/Activity');
|
|
const { getConfigValue } = await import('./configReader.service');
|
|
|
|
// Check if AI features and remark generation are enabled in admin config
|
|
const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true';
|
|
|
|
if (!aiEnabled || !remarkGenerationEnabled) {
|
|
logger.info(`[Approval] AI conclusion generation skipped for rejected request ${level.requestId} (AI disabled)`);
|
|
return;
|
|
}
|
|
|
|
// Check if AI service is available
|
|
const { aiService: aiSvc } = await import('./ai.service');
|
|
if (!aiSvc.isAvailable()) {
|
|
logger.warn(`[Approval] AI service unavailable for rejected request ${level.requestId}`);
|
|
return;
|
|
}
|
|
|
|
// Gather context for AI generation (similar to approved flow)
|
|
const approvalLevels = await ApprovalLevel.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
const workNotes = await WorkNote.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 20
|
|
});
|
|
|
|
const documents = await Document.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['uploadedAt', 'DESC']]
|
|
});
|
|
|
|
const activities = await Activity.findAll({
|
|
where: { requestId: level.requestId },
|
|
order: [['createdAt', 'ASC']],
|
|
limit: 50
|
|
});
|
|
|
|
// Build context object (include rejection reason)
|
|
const context = {
|
|
requestTitle: (wf as any).title,
|
|
requestDescription: (wf as any).description,
|
|
requestNumber: (wf as any).requestNumber,
|
|
priority: (wf as any).priority,
|
|
rejectionReason: action.rejectionReason || action.comments || 'No reason provided',
|
|
rejectedBy: level.approverName || level.approverEmail,
|
|
approvalFlow: approvalLevels.map((l: any) => {
|
|
const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null
|
|
? Number(l.tatPercentageUsed)
|
|
: (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0);
|
|
return {
|
|
levelNumber: l.levelNumber,
|
|
approverName: l.approverName,
|
|
status: l.status,
|
|
comments: l.comments,
|
|
actionDate: l.actionDate,
|
|
tatHours: Number(l.tatHours || 0),
|
|
elapsedHours: Number(l.elapsedHours || 0),
|
|
tatPercentageUsed: tatPercentage
|
|
};
|
|
}),
|
|
workNotes: workNotes.map((note: any) => ({
|
|
userName: note.userName,
|
|
message: note.message,
|
|
createdAt: note.createdAt
|
|
})),
|
|
documents: documents.map((doc: any) => ({
|
|
fileName: doc.originalFileName || doc.fileName,
|
|
uploadedBy: doc.uploadedBy,
|
|
uploadedAt: doc.uploadedAt
|
|
})),
|
|
activities: activities.map((activity: any) => ({
|
|
type: activity.activityType,
|
|
action: activity.activityDescription,
|
|
details: activity.activityDescription,
|
|
timestamp: activity.createdAt
|
|
}))
|
|
};
|
|
|
|
logger.info(`[Approval] Generating AI conclusion for rejected request ${level.requestId}...`);
|
|
|
|
// Generate AI conclusion (will adapt to rejection context)
|
|
const aiResult = await aiSvc.generateConclusionRemark(context);
|
|
|
|
// Create or update conclusion remark
|
|
let conclusionInstance = await ConclusionRemark.findOne({ where: { requestId: level.requestId } });
|
|
|
|
const conclusionData = {
|
|
aiGeneratedRemark: aiResult.remark,
|
|
aiModelUsed: aiResult.provider,
|
|
aiConfidenceScore: aiResult.confidence,
|
|
approvalSummary: {
|
|
totalLevels: approvalLevels.length,
|
|
rejectedLevel: level.levelNumber,
|
|
rejectedBy: level.approverName || level.approverEmail,
|
|
rejectionReason: action.rejectionReason || action.comments
|
|
},
|
|
documentSummary: {
|
|
totalDocuments: documents.length,
|
|
documentNames: documents.map((d: any) => d.originalFileName || d.fileName)
|
|
},
|
|
keyDiscussionPoints: aiResult.keyPoints,
|
|
generatedAt: new Date()
|
|
};
|
|
|
|
if (conclusionInstance) {
|
|
await conclusionInstance.update(conclusionData as any);
|
|
logger.info(`[Approval] ✅ AI conclusion updated for rejected request ${level.requestId}`);
|
|
} else {
|
|
await ConclusionRemark.create({
|
|
requestId: level.requestId,
|
|
...conclusionData,
|
|
finalRemark: null,
|
|
editedBy: null,
|
|
isEdited: false,
|
|
editCount: 0,
|
|
finalizedAt: null
|
|
} as any);
|
|
logger.info(`[Approval] ✅ AI conclusion generated for rejected request ${level.requestId}`);
|
|
}
|
|
} catch (error: any) {
|
|
logger.error(`[Approval] Failed to generate AI conclusion for rejected request ${level.requestId}:`, error);
|
|
// Don't fail the rejection if AI generation fails
|
|
}
|
|
})();
|
|
}
|
|
|
|
logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`);
|
|
|
|
// Emit real-time update to all users viewing this request
|
|
emitToRequestRoom(level.requestId, 'request:updated', {
|
|
requestId: level.requestId,
|
|
requestNumber: (wf as any)?.requestNumber,
|
|
action: action.action,
|
|
levelNumber: level.levelNumber,
|
|
timestamp: now.toISOString()
|
|
});
|
|
|
|
return updatedLevel;
|
|
} catch (error) {
|
|
logger.error(`Failed to ${action.action.toLowerCase()} level ${levelId}:`, error);
|
|
throw new Error(`Failed to ${action.action.toLowerCase()} level`);
|
|
}
|
|
}
|
|
|
|
async getCurrentApprovalLevel(requestId: string): Promise<ApprovalLevel | null> {
|
|
try {
|
|
return await ApprovalLevel.findOne({
|
|
where: { requestId, status: ApprovalStatus.PENDING },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to get current approval level for ${requestId}:`, error);
|
|
throw new Error('Failed to get current approval level');
|
|
}
|
|
}
|
|
|
|
async getApprovalLevels(requestId: string): Promise<ApprovalLevel[]> {
|
|
try {
|
|
return await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to get approval levels for ${requestId}:`, error);
|
|
throw new Error('Failed to get approval levels');
|
|
}
|
|
}
|
|
}
|