269 lines
11 KiB
TypeScript
269 lines
11 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 { calculateElapsedHours, calculateTATPercentage } from '@utils/helpers';
|
|
import logger from '@utils/logger';
|
|
import { Op } from 'sequelize';
|
|
import { notificationService } from './notification.service';
|
|
import { activityService } from './activity.service';
|
|
import { tatSchedulerService } from './tatScheduler.service';
|
|
|
|
export class ApprovalService {
|
|
async approveLevel(levelId: string, action: ApprovalAction, _userId: string): Promise<ApprovalLevel | null> {
|
|
try {
|
|
const level = await ApprovalLevel.findByPk(levelId);
|
|
if (!level) return null;
|
|
|
|
const now = new Date();
|
|
const elapsedHours = calculateElapsedHours(level.levelStartTime || level.createdAt, now);
|
|
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
|
|
}
|
|
|
|
// Load workflow for titles and initiator
|
|
const wf = await WorkflowRequest.findByPk(level.requestId);
|
|
|
|
// Handle approval - move to next level or close workflow
|
|
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 } }
|
|
);
|
|
logger.info(`Final approver approved. Workflow ${level.requestId} closed as APPROVED`);
|
|
// Notify initiator
|
|
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}`
|
|
});
|
|
}
|
|
} else {
|
|
// Not final - move to next level
|
|
const nextLevelNumber = (level.levelNumber || 0) + 1;
|
|
const nextLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId: level.requestId,
|
|
levelNumber: nextLevelNumber
|
|
}
|
|
});
|
|
|
|
if (nextLevel) {
|
|
// 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}`);
|
|
// Notify next approver
|
|
if (wf && nextLevel) {
|
|
await notificationService.sendToUsers([ (nextLevel as any).approverId ], {
|
|
title: `Action required: ${(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 forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`
|
|
});
|
|
}
|
|
} 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}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (action.action === 'REJECT') {
|
|
// Rejection - close workflow and mark all remaining levels as skipped
|
|
await WorkflowRequest.update(
|
|
{
|
|
status: WorkflowStatus.REJECTED,
|
|
closureDate: now
|
|
},
|
|
{ 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 }
|
|
}
|
|
}
|
|
);
|
|
|
|
logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} closed as REJECTED`);
|
|
// 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}`
|
|
});
|
|
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'}`
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`);
|
|
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');
|
|
}
|
|
}
|
|
}
|