Re_Backend/src/services/approval.service.ts

795 lines
38 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 Step 3 approval in a claim management workflow, and next level is Step 4 (auto-step)
const workflowType = (wf as any)?.workflowType;
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
const isStep3Approval = level.levelNumber === 3;
const isStep4Next = nextLevelNumber === 4;
const isStep6Approval = level.levelNumber === 6;
const isStep7Next = nextLevelNumber === 7;
if (isClaimManagement && isStep3Approval && isStep4Next && nextLevel) {
// Step 4 is an auto-step - process it automatically
logger.info(`[Approval] Step 3 approved for claim management workflow. Auto-processing Step 4: Activity Creation`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processActivityCreation(level.requestId);
logger.info(`[Approval] Step 4 auto-processing completed for request ${level.requestId}`);
} catch (step4Error) {
logger.error(`[Approval] Error auto-processing Step 4 for request ${level.requestId}:`, step4Error);
// Don't fail the Step 3 approval if Step 4 processing fails - log and continue
}
} else if (isClaimManagement && isStep6Approval && isStep7Next && nextLevel && nextLevel.approverEmail === 'system@royalenfield.com') {
// Step 7 is an auto-step - process it automatically after Step 6 approval
logger.info(`[Approval] Step 6 approved for claim management workflow. Auto-processing Step 7: E-Invoice Generation`);
try {
const dealerClaimService = new DealerClaimService();
await dealerClaimService.processEInvoiceGeneration(level.requestId);
logger.info(`[Approval] Step 7 auto-processing completed for request ${level.requestId}`);
// Skip notification for system auto-processed step
return updatedLevel;
} catch (step7Error) {
logger.error(`[Approval] Error auto-processing Step 7 for request ${level.requestId}:`, step7Error);
// Don't fail the Step 6 approval if Step 7 processing fails - log and continue
}
} else if (wf && nextLevel) {
// Normal flow - notify next approver (skip for auto-steps)
// Check if it's an auto-step by checking approverEmail or levelName
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|| (nextLevel as any).approverName === 'System Auto-Process'
|| (nextLevel as any).levelName === 'Activity Creation'
|| (nextLevel as any).levelName === 'E-Invoice Generation';
// 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)
// Skip notifications and assignment logging for system/auto-steps
if (!isAutoStep && (nextLevel as any).approverId && (nextLevel as any).approverId !== 'system') {
// Additional check: ensure approverEmail is not a system email
const approverEmail = (nextLevel as any).approverEmail || '';
const isSystemEmail = approverEmail.toLowerCase() === 'system@royalenfield.com'
|| approverEmail.toLowerCase().includes('system');
if (!isSystemEmail) {
// Send notification to next approver (only for real users, not system processes)
await notificationService.sendToUsers([ (nextLevel as any).approverId ], {
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
});
// 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 ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'approver'} 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}`);
}
}
} 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');
}
}
}