Re_Backend/src/services/workflow.service.ts

2132 lines
91 KiB
TypeScript

import { WorkflowRequestModel, IWorkflowRequest } from '../models/mongoose/WorkflowRequest.schema';
import { ApprovalLevelModel } from '../models/mongoose/ApprovalLevel.schema';
import { ParticipantModel, IParticipant } from '../models/mongoose/Participant.schema';
import { UserModel } from '../models/mongoose/User.schema';
import mongoose from 'mongoose';
import dayjs from 'dayjs';
import logger from '../utils/logger';
import { notificationMongoService } from './notification.service';
import { activityMongoService } from './activity.service';
import { tatSchedulerMongoService } from './tatScheduler.service';
import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus, formatTime } from '../utils/tatTimeUtils';
const tatScheduler = tatSchedulerMongoService;
export class WorkflowServiceMongo {
private static _supportsTransactions: boolean | null = null;
/**
* Robust check if MongoDB environment supports transactions.
* Standalone instances (topology.type === 'Single') do NOT support transactions.
*/
private async getTransactionSupport(): Promise<boolean> {
if (WorkflowServiceMongo._supportsTransactions !== null) {
return WorkflowServiceMongo._supportsTransactions;
}
try {
const client = mongoose.connection.getClient();
const topologyType = (client as any).topology?.description?.type || 'Unknown';
// Typical standalone types: 'Single'.
// Replica Set types: 'ReplicaSetNoPrimary', 'ReplicaSetWithPrimary'.
// Sharded types: 'Sharded'.
const isStandalone = topologyType === 'Single';
WorkflowServiceMongo._supportsTransactions = !isStandalone;
if (isStandalone) {
logger.warn(`[WorkflowService] MongoDB is running as a Standalone server (Topology: ${topologyType}). Transactions are disabled.`);
} else {
logger.info(`[WorkflowService] MongoDB support transactions found (Topology: ${topologyType}).`);
}
} catch (error) {
logger.warn('[WorkflowService] Failed to detect MongoDB topology, defaulting to no transactions', error);
WorkflowServiceMongo._supportsTransactions = false;
}
return WorkflowServiceMongo._supportsTransactions;
}
/**
* Internal helper to find a workflow request by either UUID or request number
*/
private async findRequest(identifier: string): Promise<any> {
if (!identifier) return null;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const isUuid = uuidRegex.test(identifier);
const query = isUuid ? { requestId: identifier } : { requestNumber: identifier };
const result = await WorkflowRequestModel.findOne(query);
return result;
}
/**
* Generate request number in format: REQ-YYYY-MM-XXXX
*/
private async generateRequestNumber(): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const prefix = `REQ-${year}-${month}-`;
try {
const lastRequest = await WorkflowRequestModel.findOne({
requestNumber: { $regex: `^${prefix}` }
}).sort({ requestNumber: -1 });
let counter = 1;
if (lastRequest) {
const lastCounter = parseInt(lastRequest.requestNumber.replace(prefix, ''), 10);
if (!isNaN(lastCounter)) {
counter = lastCounter + 1;
}
}
return `${prefix}${counter.toString().padStart(4, '0')}`;
} catch (error) {
logger.error('Error generating request number:', error);
return `${prefix}${Date.now().toString().slice(-4)}`;
}
}
/**
* Create a new workflow (called by Controller)
*/
async createWorkflow(initiatorId: string, workflowData: any, requestMetadata?: any): Promise<IWorkflowRequest> {
const supportsTransactions = await this.getTransactionSupport();
const session = await mongoose.startSession();
let useTransaction = false;
if (supportsTransactions) {
try {
session.startTransaction();
useTransaction = true;
} catch (err) {
logger.warn('[WorkflowService] Failed to start transaction despite support detection', err);
}
}
try {
const requestId = require('crypto').randomUUID();
const requestNumber = await this.generateRequestNumber();
const totalTatHours = workflowData.approvalLevels.reduce((sum: number, level: any) => sum + (level.tatHours || 0), 0);
const sessionOpt = useTransaction ? { session } : {};
// 1. Create Workflow Request
const request = new WorkflowRequestModel({
requestId,
requestNumber,
initiator: {
userId: initiatorId,
email: workflowData.initiatorEmail,
name: workflowData.initiatorName,
department: workflowData.department
},
templateType: workflowData.templateType,
workflowType: workflowData.workflowType,
templateId: workflowData.templateId,
title: workflowData.title,
description: workflowData.description,
priority: workflowData.priority,
status: 'DRAFT',
workflowState: 'DRAFT',
currentLevel: 1,
totalLevels: workflowData.approvalLevels.length,
totalTatHours,
isDraft: true,
isDeleted: false,
isPaused: false,
createdAt: new Date(),
updatedAt: new Date()
});
await request.save(sessionOpt);
// 2. Create Approval Levels
const approvalLevels = workflowData.approvalLevels.map((level: any, index: number) => ({
levelId: require('crypto').randomUUID(), // Generate UUID for levelId
requestId: request.requestId, // Standardized to UUID
levelNumber: level.levelNumber,
levelName: level.levelName,
approver: {
userId: level.approverId,
email: level.approverEmail,
name: level.approverName
},
tat: {
assignedHours: level.tatHours,
assignedDays: Math.ceil(level.tatHours / 8),
elapsedHours: 0,
remainingHours: level.tatHours,
percentageUsed: 0,
isBreached: false
},
status: 'PENDING',
isFinalApprover: level.isFinalApprover || false,
alerts: { fiftyPercentSent: false, seventyFivePercentSent: false },
paused: { isPaused: false }
}));
await ApprovalLevelModel.insertMany(approvalLevels, sessionOpt);
// Set currentLevelId to the first level's UUID
if (approvalLevels.length > 0) {
const firstLevelId = approvalLevels[0].levelId;
request.currentLevelId = firstLevelId;
await request.save(sessionOpt);
}
// 3. Create Participants
if (workflowData.participants) {
const participants = workflowData.participants.map((p: any) => ({
participantId: require('crypto').randomUUID(),
requestId: request.requestId, // Standardized to UUID
userId: p.userId,
userEmail: p.userEmail,
userName: p.userName,
participantType: p.participantType,
canComment: p.canComment ?? true,
canViewDocuments: p.canViewDocuments ?? true,
canDownloadDocuments: p.canDownloadDocuments ?? false,
notificationEnabled: p.notificationEnabled ?? true,
addedBy: initiatorId,
addedAt: new Date(),
isActive: true
}));
await ParticipantModel.insertMany(participants, sessionOpt);
}
// 4. Log Activity
await activityMongoService.log({
requestId: request.requestId, // Standardized to UUID
type: 'created',
user: { userId: initiatorId, name: workflowData.initiatorName },
timestamp: new Date().toISOString(),
action: 'Request Created',
details: `Workflow ${requestNumber} created by ${workflowData.initiatorName}`,
category: 'WORKFLOW',
severity: 'INFO'
});
if (useTransaction) await session.commitTransaction();
return request;
} catch (error) {
if (useTransaction) await session.abortTransaction();
logger.error('Create Workflow Error', error);
throw error;
} finally {
session.endSession();
}
}
/**
* Approve Request Level
*/
async approveRequest(identifier: string, userId: string, comments?: string): Promise<string> {
// No transaction for now to keep it simple, or add if needed
try {
// 1. Fetch Request - handle both UUID and requestNumber
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
const currentLevelNum = request.currentLevel;
// 2. Update Current Level Status -> APPROVED
const currentLevel = await ApprovalLevelModel.findOneAndUpdate(
{ requestId: request.requestId, levelNumber: currentLevelNum }, // Standardized to UUID
{
status: 'APPROVED',
actionDate: new Date(),
comments: comments,
'approver.userId': userId, // Ensure userId is captured
'tat.actualParams.completionDate': new Date()
},
{ new: true }
);
if (!currentLevel) throw new Error(`Level ${currentLevelNum} not found`);
// Cancel current level TAT jobs
await tatScheduler.cancelTatJobs(request.requestId, currentLevel._id.toString()); // Standardized to UUID
// Fetch approver details for logging
const approver = await UserModel.findOne({ userId });
// 3. Log Activity
await activityMongoService.log({
requestId: request.requestId, // Standardized to UUID
type: 'approval',
user: { userId, email: approver?.email, name: approver?.displayName },
timestamp: new Date().toISOString(),
action: 'Approved',
details: `Approved by ${approver?.displayName || userId}. Comments: ${comments || 'None'}`,
category: 'WORKFLOW',
severity: 'INFO'
});
// 4. Send Approval Notification (to Initiator and Spectators)
const recipients = await this.getNotificationRecipients(request.requestId, userId);
await notificationMongoService.sendToUsers(recipients, {
title: 'Request Approved',
body: `Level ${currentLevelNum} approved by ${approver?.displayName}`,
type: 'approval',
requestId: request.requestId,
requestNumber: request.requestNumber,
metadata: { comments }
});
// 5. Check for Next Level
const nextLevelNum = currentLevelNum + 1;
const nextLevel = await ApprovalLevelModel.findOne({
requestId: request.requestId, // Standardized to UUID
levelNumber: nextLevelNum
});
if (nextLevel) {
// Calculate TAT end time (deadline)
const now = new Date();
const priority = (request.priority || 'STANDARD').toLowerCase();
const assignedHours = nextLevel.tat?.assignedHours || 24;
const endTime = priority === 'express'
? (await addWorkingHoursExpress(now, assignedHours)).toDate()
: (await addWorkingHours(now, assignedHours)).toDate();
// Activate Next Level with calculated endTime
await ApprovalLevelModel.updateOne(
{ requestId: request.requestId, levelNumber: nextLevelNum },
{
status: 'PENDING',
'tat.startTime': now,
'tat.endTime': endTime
}
);
// Update Parent Request
request.currentLevel = nextLevelNum;
request.status = 'IN_PROGRESS';
await request.save();
// SCHEDULE TAT for Next Level
// Use Approver ID from next level if assigned
const nextApproverId = nextLevel.approver?.userId || (nextLevel as any).approverId; // Handle both schemas
if (nextApproverId) {
await tatScheduler.scheduleTatJobs(
request.requestId, // Standardized to UUID
nextLevel._id.toString(), // Use _id as string
nextApproverId,
nextLevel.tat?.assignedHours || 24,
new Date(),
request.priority as any
);
// Send Assignment Notification
await notificationMongoService.sendToUsers([nextApproverId], {
title: 'New Request Assigned',
body: `You have a new request ${request.requestNumber} pending your approval.`,
type: 'assignment',
requestId: request.requestId,
requestNumber: request.requestNumber,
priority: request.priority as any
});
// Log assignment
// Cancel assignment activity
await activityMongoService.log({
requestId: request.requestId,
type: 'assignment',
user: { userId: nextApproverId },
timestamp: new Date().toISOString(),
action: 'Assigned',
details: `Assigned to level ${nextLevelNum} approver`,
category: 'WORKFLOW',
severity: 'INFO'
});
}
return `Approved Level ${currentLevelNum}. Moved to Level ${nextLevelNum}.`;
} else {
// No more levels -> Workflow Complete
request.status = 'APPROVED';
request.closureDate = new Date();
request.conclusionRemark = 'Workflow Completed Successfully';
await request.save();
// Log Closure
await activityMongoService.log({
requestId: request.requestId,
type: 'closed',
user: { userId: 'system', name: 'System' },
timestamp: new Date().toISOString(),
action: 'Closed',
details: 'All levels approved. Request closed.',
category: 'WORKFLOW',
severity: 'INFO'
});
// Send Closure Notification
const recipients = await this.getNotificationRecipients(request.requestId, userId);
await notificationMongoService.sendToUsers(recipients, {
title: 'Request Closed',
body: `Your request ${request.requestNumber} has been fully approved and closed.`,
type: 'closed',
requestId: request.requestId,
requestNumber: request.requestNumber,
actionRequired: false
});
return `Approved Level ${currentLevelNum}. Workflow COMPLETED.`;
}
} catch (error) {
logger.error('Approve Error', error);
throw error;
}
}
/**
* Reject Request
* (Missing from ActionService, implemented here)
*/
async rejectRequest(identifier: string, userId: string, comments: string): Promise<string> {
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
const currentLevelNum = request.currentLevel;
// 1. Update Current Level Status -> REJECTED
const currentLevel = await ApprovalLevelModel.findOneAndUpdate(
{ requestId: request.requestId, levelNumber: currentLevelNum },
{
status: 'REJECTED',
actionDate: new Date(),
comments: comments,
'approver.userId': userId
},
{ new: true }
);
if (currentLevel) {
// Cancel TAT jobs
await tatScheduler.cancelTatJobs(request.requestId, currentLevel._id.toString());
}
// 2. Update Request Status
request.status = 'REJECTED';
request.closureDate = new Date();
request.conclusionRemark = comments;
await request.save();
// Fetch rejecter
const rejecter = await UserModel.findOne({ userId });
// 3. Log Activity
await activityMongoService.log({
requestId: request.requestId,
type: 'rejection',
user: { userId, email: rejecter?.email, name: rejecter?.displayName },
timestamp: new Date().toISOString(),
action: 'Rejected',
details: `Rejected by ${rejecter?.displayName}. Reason: ${comments}`,
category: 'WORKFLOW',
severity: 'WARNING'
});
// 4. Send Rejection Notification (to Initiator and Spectators)
const recipients = await this.getNotificationRecipients(request.requestId, userId);
await notificationMongoService.sendToUsers(recipients, {
title: 'Request Rejected',
body: `Your request ${request.requestNumber} was rejected by ${rejecter?.displayName}.`,
type: 'rejection',
requestId: request.requestId,
requestNumber: request.requestNumber,
priority: 'HIGH',
metadata: { rejectionReason: comments }
});
return `Request ${request.requestNumber} REJECTED at Level ${currentLevelNum}.`;
} catch (error) {
logger.error('Reject Error', error);
throw error;
}
}
/**
* Add Participant (Approver) to Workflow
*/
async addApprover(identifier: string, email: string, addedByUserId: string): Promise<IParticipant> {
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
// Find User
const user = await UserModel.findOne({ email });
if (!user) throw new Error(`User with email ${email} not found`);
// Check if already participant
const existing = await ParticipantModel.findOne({
requestId: request.requestId, // Use UUID
userId: user.userId
});
if (existing) {
// If existing but inactive, reactivate
if (!existing.isActive) {
existing.isActive = true;
existing.participantType = 'APPROVER';
await existing.save();
return existing;
}
// If existing spectator, upgrade to approver
if (existing.participantType === 'SPECTATOR') {
existing.participantType = 'APPROVER';
await existing.save();
return existing;
}
return existing;
}
// Create new participant
const participant = await ParticipantModel.create({
participantId: require('crypto').randomUUID(),
requestId: request.requestId, // Use UUID
userId: user.userId,
userEmail: user.email,
userName: user.displayName,
participantType: 'APPROVER',
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: addedByUserId,
addedAt: new Date(),
isActive: true
});
// Log Activity
await activityMongoService.log({
requestId: request.requestId, // Use UUID
type: 'participant_added',
user: { userId: addedByUserId, name: 'User' }, // Ideally fetch addedBy user details
timestamp: new Date().toISOString(),
action: 'Approver Added',
details: `Added ${user.displayName} as additional approver`,
category: 'WORKFLOW',
severity: 'INFO'
});
// Send Notification to new Participant
await notificationMongoService.sendToUsers([user.userId], {
title: 'Request Assigned (Ad-hoc)',
body: `You have been added as an additional approver for ${request.requestNumber}`,
type: 'participant_added',
requestId: request.requestId,
requestNumber: request.requestNumber,
priority: request.priority as any,
metadata: { addedBy: addedByUserId }
});
return participant;
} catch (error) {
logger.error('Add Approver Error', error);
throw error;
}
}
/**
* Add Participant (Spectator) to Workflow
*/
async addSpectator(identifier: string, email: string, addedByUserId: string): Promise<IParticipant> {
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
// Find User
const user = await UserModel.findOne({ email });
if (!user) throw new Error(`User with email ${email} not found`);
// Check if already participant
const existing = await ParticipantModel.findOne({
requestId: request.requestId, // Use UUID
userId: user.userId
});
if (existing) {
if (!existing.isActive) {
existing.isActive = true;
// Keep previous role if higher than spectator? Or reset?
// Usually spectators are just viewers, so if they were approver, maybe keep as approver?
// For now, if re-adding as spectator, force spectator unless they are already active approver
if (existing.participantType !== 'APPROVER') {
existing.participantType = 'SPECTATOR';
}
await existing.save();
return existing;
}
// Already active
return existing;
}
// Create new participant
const participant = await ParticipantModel.create({
participantId: require('crypto').randomUUID(),
requestId: request.requestId, // Use UUID
userId: user.userId,
userEmail: user.email,
userName: user.displayName,
participantType: 'SPECTATOR',
canComment: true,
canViewDocuments: true,
canDownloadDocuments: false, // Spectators usually can't download by default policy, or make configurable
notificationEnabled: true,
addedBy: addedByUserId,
addedAt: new Date(),
isActive: true
});
// Log Activity
await activityMongoService.log({
requestId: request.requestId, // Use UUID
type: 'participant_added',
user: { userId: addedByUserId, name: 'User' },
timestamp: new Date().toISOString(),
action: 'Spectator Added',
details: `Added ${user.displayName} as spectator`,
category: 'WORKFLOW',
severity: 'INFO'
});
// Send Notification to new Spectator
await notificationMongoService.sendToUsers([user.userId], {
title: 'Added as Spectator',
body: `You have been added as a spectator for ${request.requestNumber}`,
type: 'spectator_added',
requestId: request.requestId,
requestNumber: request.requestNumber,
priority: 'LOW',
metadata: { addedBy: addedByUserId }
});
return participant;
} catch (error) {
logger.error('Add Spectator Error', error);
throw error;
}
}
/**
* Skip Approver at a specific level
*/
async skipApprover(identifier: string, levelId: string, reason: string, userId: string): Promise<any> {
const supportsTransactions = await this.getTransactionSupport();
const session = await mongoose.startSession();
let useTransaction = false;
if (supportsTransactions) {
try {
session.startTransaction();
useTransaction = true;
} catch (err) {
logger.warn('[WorkflowService] Failed to start transaction despite support detection', err);
}
}
const sessionOpt = useTransaction ? { session } : {};
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
const level = await ApprovalLevelModel.findOne({ levelId, requestId: request.requestId }).session(useTransaction ? session : null);
if (!level) throw new Error('Approval level not found');
if (level.status !== 'PENDING' && level.status !== 'IN_PROGRESS') {
throw new Error(`Cannot skip level in ${level.status} status`);
}
// 1. Mark current level as SKIPPED
level.status = 'SKIPPED';
level.actionDate = new Date();
level.comments = parseReason(reason);
// Don't change approver ID, just mark skipped
await level.save(sessionOpt);
// Helper to handle reason formatting if needed
function parseReason(r: string) { return r ? `Skipped: ${r}` : 'Skipped by admin/initiator'; }
// 2. Identify Next Level logic (similar to approveRequest but simpler)
const currentLevelNum = level.levelNumber;
const nextLevelNum = currentLevelNum + 1;
// Log Activity
await activityMongoService.log({
requestId: request.requestId,
type: 'skipped',
user: { userId, name: 'User' },
timestamp: new Date().toISOString(),
action: `Level ${currentLevelNum} Skipped`,
details: `Level ${currentLevelNum} skipped. Reason: ${reason}`,
category: 'WORKFLOW',
severity: 'WARNING'
});
// Find Next Level
const nextLevel = await ApprovalLevelModel.findOne({
requestId: request.requestId,
levelNumber: nextLevelNum
}).session(useTransaction ? session : null);
if (nextLevel) {
// Calculate TAT end time (deadline)
const now = new Date();
const priority = (request.priority || 'STANDARD').toLowerCase();
const assignedHours = nextLevel.tat?.assignedHours || 24;
const endTime = priority === 'express'
? (await addWorkingHoursExpress(now, assignedHours)).toDate()
: (await addWorkingHours(now, assignedHours)).toDate();
// Activate Next Level
nextLevel.status = 'PENDING';
nextLevel.tat.startTime = now;
nextLevel.tat.endTime = endTime;
await nextLevel.save(sessionOpt);
request.currentLevel = nextLevelNum;
request.status = 'IN_PROGRESS';
await request.save(sessionOpt);
// Schedule TAT for next level (if outside transaction)
// Note: Scheduler operations usually don't support sessions directly depending on implementation
// We commit first then schedule
} else {
// Workflow Complete
request.status = 'APPROVED';
request.closureDate = new Date();
request.conclusionRemark = 'Workflow Completed (skipped final level)';
await request.save(sessionOpt);
}
if (useTransaction) await session.commitTransaction();
// 3. Post-transaction side effects (Notifications, Scheduling)
if (nextLevel) {
const nextApproverId = nextLevel.approver?.userId;
if (nextApproverId) {
await tatScheduler.scheduleTatJobs(
request.requestId, // Standardized to UUID
nextLevel._id.toString(),
nextApproverId,
nextLevel.tat?.assignedHours || 24,
new Date(),
request.priority as any
);
await notificationMongoService.sendToUsers([nextApproverId], {
title: 'New Request Assigned (Skipped Previous)',
body: `Previous level was skipped. You have a new request ${request.requestNumber} pending.`,
type: 'assignment',
requestId: request.requestId,
requestNumber: request.requestNumber,
priority: request.priority as any
});
// Log assignment
await activityMongoService.log({
requestId: request.requestId,
type: 'assignment',
user: { userId: nextApproverId },
timestamp: new Date().toISOString(),
action: 'Assigned',
details: `Assigned to level ${nextLevelNum} approver`,
category: 'WORKFLOW',
severity: 'INFO'
});
}
} else {
// Closure Notification
await notificationMongoService.sendToUsers([request.initiator.userId], {
title: 'Request Closed',
body: `Your request ${request.requestNumber} has been closed (final level skipped).`,
type: 'closed',
requestId: request.requestId,
requestNumber: request.requestNumber,
actionRequired: false
});
}
return level;
} catch (error) {
if (useTransaction) await session.abortTransaction();
logger.error('Skip Approver Error', error);
throw error;
} finally {
session.endSession();
}
}
/**
* Add or Replace Approver at specific Level (Ad-hoc) with Level Shifting
* - If level doesn't exist: Create new level
* - If level exists: Shift existing approver to next level and insert new approver
*/
async addApproverAtLevel(identifier: string, email: string, targetLevel: number, tatHours: number, addedByUserId: string): Promise<any> {
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
const user = await UserModel.findOne({ email });
if (!user) throw new Error(`User ${email} not found`);
const existingLevel = await ApprovalLevelModel.findOne({ requestId: request.requestId, levelNumber: targetLevel });
if (!existingLevel) {
// Case 1: Level doesn't exist - Create new level
const newLevel = new ApprovalLevelModel({
levelId: require('crypto').randomUUID(),
requestId: request.requestId,
levelNumber: targetLevel,
levelName: `Level ${targetLevel} Approval`,
approver: {
userId: user.userId,
email: user.email,
name: user.displayName || user.email
},
tat: {
assignedHours: tatHours,
assignedDays: Math.ceil(tatHours / 24),
elapsedHours: 0,
remainingHours: tatHours,
percentageUsed: 0,
isBreached: false
},
status: 'PENDING',
isFinalApprover: true, // New level is final by default
alerts: { fiftyPercentSent: false, seventyFivePercentSent: false },
paused: { isPaused: false }
});
await newLevel.save();
// Update previous level's isFinalApprover to false
const previousLevel = await ApprovalLevelModel.findOne({
requestId: request.requestId,
levelNumber: targetLevel - 1
});
if (previousLevel) {
previousLevel.isFinalApprover = false;
await previousLevel.save();
}
// Update workflow totalLevels and totalTatHours
request.totalLevels = targetLevel;
request.totalTatHours += tatHours;
await request.save();
// Add as participant
await this.addApprover(request.requestId, email, addedByUserId);
// Log Activity
await activityMongoService.log({
requestId: request.requestId,
type: 'modification',
user: { userId: addedByUserId, name: 'User' },
timestamp: new Date().toISOString(),
action: 'Approval Level Added',
details: `New approval level ${targetLevel} added with approver ${user.displayName}`,
category: 'WORKFLOW',
severity: 'INFO'
});
return newLevel;
} else {
// Case 2: Level exists - Shift existing approver to next level
if (existingLevel.status === 'APPROVED' || existingLevel.status === 'SKIPPED') {
throw new Error('Cannot modify completed level');
}
// Get all levels at or after the target level
const levelsToShift = await ApprovalLevelModel.find({
requestId: request.requestId,
levelNumber: { $gte: targetLevel }
}).sort({ levelNumber: -1 }); // Sort descending to shift from bottom up
// Shift all levels down by 1
for (const level of levelsToShift) {
level.levelNumber += 1;
level.levelName = `Level ${level.levelNumber} Approval`;
await level.save();
}
// Create new level at target position
const newLevel = new ApprovalLevelModel({
levelId: require('crypto').randomUUID(),
requestId: request.requestId,
levelNumber: targetLevel,
levelName: `Level ${targetLevel} Approval`,
approver: {
userId: user.userId,
email: user.email,
name: user.displayName || user.email
},
tat: {
assignedHours: tatHours,
assignedDays: Math.ceil(tatHours / 24),
elapsedHours: 0,
remainingHours: tatHours,
percentageUsed: 0,
isBreached: false
},
status: 'PENDING',
isFinalApprover: false, // Not final since we shifted others down
alerts: { fiftyPercentSent: false, seventyFivePercentSent: false },
paused: { isPaused: false }
});
await newLevel.save();
// Update workflow totalLevels and totalTatHours
request.totalLevels += 1;
request.totalTatHours += tatHours;
await request.save();
// Add as participant
await this.addApprover(request.requestId, email, addedByUserId);
// Log Activity
await activityMongoService.log({
requestId: request.requestId,
type: 'modification',
user: { userId: addedByUserId, name: 'User' },
timestamp: new Date().toISOString(),
action: 'Approver Inserted',
details: `Approver ${user.displayName} inserted at level ${targetLevel}, existing approvers shifted down`,
category: 'WORKFLOW',
severity: 'INFO'
});
return newLevel;
}
} catch (error) {
logger.error('Add Approver At Level Error', error);
throw error;
}
}
/**
* Parse date range string to Date objects
*/
private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): { start: Date; end: Date } | null {
if (dateRange === 'custom' && startDate && endDate) {
return {
start: dayjs(startDate).startOf('day').toDate(),
end: dayjs(endDate).endOf('day').toDate()
};
}
if (!dateRange || dateRange === 'all') return null;
const now = dayjs();
switch (dateRange) {
case 'today':
return { start: now.startOf('day').toDate(), end: now.endOf('day').toDate() };
case 'week':
return { start: now.startOf('week').toDate(), end: now.endOf('week').toDate() };
case 'month':
return { start: now.startOf('month').toDate(), end: now.endOf('month').toDate() };
case 'quarter':
const quarterStartMonth = Math.floor(now.month() / 3) * 3;
return {
start: now.month(quarterStartMonth).startOf('month').toDate(),
end: now.month(quarterStartMonth + 2).endOf('month').toDate()
};
case 'year':
return { start: now.startOf('year').toDate(), end: now.endOf('year').toDate() };
default:
// If it's not a known keyword, try parsing it as a number of days
const days = parseInt(dateRange, 10);
if (!isNaN(days)) {
return {
start: now.subtract(days, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate()
};
}
return null;
}
}
async listWorkflows(page: number, limit: number, filters: any) {
return this.listWorkflowsInternal(page, limit, filters, undefined, 'all');
}
async listMyRequests(userId: string, page: number, limit: number, filters: any) {
return this.listWorkflowsInternal(page, limit, filters, userId, 'my_requests');
}
async listParticipantRequests(userId: string, page: number, limit: number, filters: any) {
return this.listWorkflowsInternal(page, limit, filters, userId, 'participant');
}
async listMyInitiatedRequests(userId: string, page: number, limit: number, filters: any) {
return this.listWorkflowsInternal(page, limit, filters, userId, 'initiated');
}
async listOpenForMe(userId: string, page: number, limit: number, filters: any, sortBy?: string, sortOrder?: string) {
return this.listWorkflowsInternal(page, limit, filters, userId, 'open_for_me', sortBy, sortOrder);
}
async listClosedByMe(userId: string, page: number, limit: number, filters: any, sortBy?: string, sortOrder?: string) {
return this.listWorkflowsInternal(page, limit, filters, userId, 'closed_by_me', sortBy, sortOrder);
}
private async listWorkflowsInternal(page: number, limit: number, filters: any, userId?: string, listType: string = 'all', sortBy?: string, sortOrder: string = 'desc') {
const skip = (page - 1) * limit;
const now = new Date();
// 1. Build Base Match Stage
const matchStage: any = { isDeleted: false };
// Handle Draft Visibility:
// - Allow drafts if specifically requested via status='DRAFT'
// - Allow drafts if viewing user's own 'initiated' requests
// - Otherwise, exclude drafts by default
if (filters.status && filters.status.toUpperCase() === 'DRAFT') {
matchStage.isDraft = true;
} else if (listType === 'initiated') {
// Initiated view shows both drafts and active requests
// Unless a specific status filter is already applied
if (!filters.status || filters.status === 'all') {
matchStage.isDraft = { $in: [true, false] };
} else {
matchStage.isDraft = false;
}
} else {
matchStage.isDraft = false;
}
if (filters.search) matchStage.$text = { $search: filters.search };
// 1.1 Handle Lifecycle Filter (Open vs Closed)
if (filters.lifecycle && filters.lifecycle !== 'all') {
const lifecycle = filters.lifecycle.toLowerCase();
if (lifecycle === 'open') {
matchStage.workflowState = 'OPEN';
} else if (lifecycle === 'closed') {
matchStage.workflowState = 'CLOSED';
}
}
// 1.2 Handle Outcome Status Filter
if (filters.status && filters.status !== 'all') {
const status = filters.status.toUpperCase();
if (status === 'PENDING') {
// Pending outcome usually means in-progress in the OPEN state
matchStage.status = { $in: ['PENDING', 'IN_PROGRESS'] };
} else if (status === 'DRAFT') {
matchStage.isDraft = true;
} else if (status === 'CLOSED') {
// "CLOSED" as a status is now deprecated in favor of Lifecycle Filter
// But if legacy code still sends it, we map it to CLOSED state
matchStage.workflowState = 'CLOSED';
} else {
matchStage.status = status;
}
}
if (filters.priority && filters.priority !== 'all') matchStage.priority = filters.priority.toUpperCase();
if (filters.templateType && filters.templateType !== 'all') matchStage.templateType = filters.templateType.toUpperCase();
if (filters.initiator) matchStage['initiator.userId'] = filters.initiator;
if (filters.department && filters.department !== 'all') matchStage['initiator.department'] = filters.department;
// Date Range Logic
const range = this.parseDateRange(filters.dateRange, filters.startDate, filters.endDate);
if (range) {
matchStage.createdAt = {
$gte: range.start,
$lte: range.end
};
} else if (filters.startDate && filters.endDate) {
matchStage.createdAt = {
$gte: new Date(filters.startDate),
$lte: new Date(filters.endDate)
};
}
const pipeline: any[] = [];
// 2. Handle List Type Filtering (Involvement)
if (listType === 'initiated' && userId) {
matchStage['initiator.userId'] = userId;
} else if (listType === 'my_requests' && userId) {
// Involved as participant/approver but NOT initiator
matchStage['initiator.userId'] = { $ne: userId };
pipeline.push({
$lookup: {
from: 'participants',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'involvement'
}
});
matchStage['involvement.userId'] = userId;
} else if (listType === 'participant' && userId) {
// Involved in ANY capacity
pipeline.push({
$lookup: {
from: 'participants',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'involvement'
}
});
matchStage.$or = [
{ 'initiator.userId': userId },
{ 'involvement.userId': userId }
];
} else if (listType === 'open_for_me' && userId) {
// Current approver OR spectator OR initiator awaiting closure
pipeline.push({
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLevelId: "$currentLevelId", currLvl: "$currentLevel" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$requestId", "$$reqId"] },
// Use currentLevelId if available, otherwise fall back to levelNumber
{
$or: [
{ $eq: ["$levelId", "$$currLevelId"] },
{
$and: [
{ $eq: [{ $type: "$$currLevelId" }, "missing"] },
{ $eq: ["$levelNumber", "$$currLvl"] }
]
}
]
}
]
}
}
}
],
as: 'active_step'
}
}, {
$lookup: {
from: 'participants',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'membership'
}
});
matchStage.$or = [
{ 'active_step.0.approver.userId': userId },
{ $and: [{ 'initiator.userId': userId }, { status: 'APPROVED' }] },
{ $and: [{ 'membership.userId': userId }, { 'membership.participantType': 'SPECTATOR' }] }
];
// Only show non-closed for "open for me"
matchStage.workflowState = { $ne: 'CLOSED' };
matchStage.status = { $in: ['PENDING', 'IN_PROGRESS', 'PAUSED', 'APPROVED'] };
} else if (listType === 'closed_by_me' && userId) {
// Past approver or spectator AND status is CLOSED or REJECTED
pipeline.push({
$lookup: {
from: 'participants',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'membership'
}
});
matchStage['membership.userId'] = userId;
matchStage.$or = [
{ workflowState: 'CLOSED' },
{ workflowState: { $exists: false }, status: { $in: ['CLOSED', 'REJECTED'] } }
];
}
// CRITICAL: Add match stage AFTER lookups so active_step and membership arrays exist
pipeline.push({ $match: matchStage });
// 3. Deep Filters (Approver Name, Level Status)
if (filters.approverName) {
const approverRegex = { $regex: filters.approverName, $options: 'i' };
const approverMatch = {
$or: [
{ 'approver.name': approverRegex },
{ 'approver.userId': filters.approverName }
]
};
if (filters.approverType === 'current') {
// Filter by CURRENT level approver name or ID
pipeline.push(
{
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLvl: "$currentLevel" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$requestId", "$$reqId"] },
{ $eq: ["$levelNumber", "$$currLvl"] }
]
}
}
}
],
as: 'current_step_filter'
}
},
{
$match: {
$or: [
{ 'current_step_filter.approver.name': approverRegex },
{ 'current_step_filter.approver.userId': filters.approverName }
]
}
}
);
} else {
// Search in ANY level (approverType === 'any' or undefined)
pipeline.push(
{
$lookup: {
from: 'approval_levels',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'matches_approvers'
}
},
{
$match: {
$or: [
{ 'matches_approvers.approver.name': approverRegex },
{ 'matches_approvers.approver.userId': filters.approverName }
]
}
}
);
}
}
if (filters.levelStatus && filters.levelNumber) {
pipeline.push(
{
$lookup: {
from: 'approval_levels',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'matches_level'
}
},
{ $match: { 'matches_level': { $elemMatch: { levelNumber: parseInt(filters.levelNumber), status: filters.levelStatus.toUpperCase() } } } }
);
}
if (filters.slaCompliance && filters.slaCompliance !== 'all') {
pipeline.push({
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLevelId: "$currentLevelId", currLvl: "$currentLevel" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$requestId", "$$reqId"] },
{
$or: [
{ $eq: ["$levelId", "$$currLevelId"] },
{
$and: [
{ $eq: [{ $type: "$$currLevelId" }, "missing"] },
{ $eq: ["$levelNumber", "$$currLvl"] }
]
}
]
}
]
}
}
}
],
as: 'active_sla_step'
}
});
if (filters.slaCompliance === 'breached') {
pipeline.push({ $match: { 'active_sla_step.tat.isBreached': true } });
} else if (filters.slaCompliance === 'on_track') {
pipeline.push({ $match: { 'active_sla_step.tat.isBreached': false } });
}
}
// 4. Sort & Pagination
const sortField = sortBy || 'createdAt';
const sortDir = sortOrder?.toLowerCase() === 'asc' ? 1 : -1;
pipeline.push(
{ $sort: { [sortField]: sortDir } },
{ $skip: skip },
{ $limit: limit }
);
// 5. Join Preview Data (Active Step)
pipeline.push({
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLvl: "$currentLevel" },
pipeline: [
{ $match: { $expr: { $and: [{ $eq: ["$requestId", "$$reqId"] }, { $eq: ["$levelNumber", "$$currLvl"] }] } } },
{ $project: { levelNumber: 1, status: 1, approver: 1, tat: 1 } }
],
as: 'current_approval_step'
}
});
// 6. Projection
pipeline.push({
$project: {
requestId: 1,
requestNumber: 1,
title: 1,
description: 1,
status: 1,
workflowState: 1,
priority: 1,
workflowType: 1,
templateType: 1,
templateId: 1,
currentLevel: 1,
totalLevels: 1,
totalTatHours: 1,
isPaused: "$flags.isPaused",
initiator: 1,
department: "$initiator.department",
// Root-level dates (Flattened)
submittedAt: "$submissionDate",
createdAt: "$createdAt",
closureDate: "$closureDate",
updatedAt: "$updatedAt",
// Conclusion
conclusionRemark: "$conclusionRemark",
// KPI Calculations
agingDays: { $dateDiff: { startDate: "$createdAt", endDate: "$$NOW", unit: "day" } },
completionPercentage: {
$cond: {
if: { $gt: ["$totalLevels", 0] },
then: {
$multiply: [{ $divide: ["$currentLevel", "$totalLevels"] }, 100]
},
else: 0
}
},
// Active Step Info
currentStep: { $arrayElemAt: ["$current_approval_step", 0] }
}
});
const results = await WorkflowRequestModel.aggregate(pipeline);
// Calculate real-time TAT for currentStep in each result
const { calculateElapsedWorkingHours } = require('../utils/tatTimeUtils');
for (const result of results) {
if (result.currentStep && result.currentStep.tat?.startTime) {
const currentStep = result.currentStep;
const status = currentStep.status;
// Only calculate for active levels
if (status === 'PENDING' || status === 'IN_PROGRESS') {
try {
const priority = (result.priority || 'STANDARD').toString().toLowerCase();
// Build pause info if needed
const pauseInfo = result.isPaused ? {
isPaused: true,
pausedAt: currentStep.paused?.pausedAt,
pauseElapsedHours: currentStep.paused?.elapsedHoursBeforePause,
pauseResumeDate: currentStep.paused?.resumedAt
} : undefined;
// Calculate elapsed hours
const elapsedHours = await calculateElapsedWorkingHours(
currentStep.tat.startTime,
now,
priority,
pauseInfo
);
// Update TAT values
const assignedHours = currentStep.tat?.assignedHours || 0;
currentStep.tat.elapsedHours = elapsedHours;
currentStep.tat.remainingHours = Math.max(0, assignedHours - elapsedHours);
currentStep.tat.percentageUsed = assignedHours > 0 ? Math.round(Math.min(100, (elapsedHours / assignedHours) * 100) * 100) / 100 : 0;
// Calculate SLA status (deadline fallback)
let deadline = currentStep.tat?.endTime;
if (!deadline && currentStep.tat?.startTime) {
deadline = priority === 'express'
? (await addWorkingHoursExpress(currentStep.tat.startTime, assignedHours)).toDate()
: (await addWorkingHours(currentStep.tat.startTime, assignedHours)).toDate();
}
// Add nested sla object for frontend compatibility
currentStep.sla = {
elapsedHours: elapsedHours,
remainingHours: Math.max(0, assignedHours - elapsedHours),
percentageUsed: currentStep.tat.percentageUsed,
deadline: deadline || null,
isPaused: !!pauseInfo,
status: currentStep.tat?.isBreached ? 'breached' : 'on_track',
remainingText: formatTime(Math.max(0, assignedHours - elapsedHours)),
elapsedText: formatTime(elapsedHours)
};
} catch (error) {
logger.error('[listWorkflows] TAT calculation error:', error);
}
}
}
// Calculate request-level TAT (overall workflow TAT)
if (result.submittedAt && result.status !== 'CLOSED' && result.status !== 'REJECTED' && result.status !== 'APPROVED') {
try {
const priority = (result.priority || 'STANDARD').toString().toLowerCase();
const totalTatHours = result.totalTatHours || 0;
// Calculate total elapsed hours from submission to now
const requestElapsedHours = await calculateElapsedWorkingHours(
new Date(result.submittedAt),
now,
priority
);
const requestRemainingHours = Math.max(0, totalTatHours - requestElapsedHours);
const requestPercentageUsed = totalTatHours > 0
? Math.round(Math.min(100, (requestElapsedHours / totalTatHours) * 100) * 100) / 100
: 0;
// Calculate overall workflow deadline
const workflowDeadline = priority === 'express'
? (await addWorkingHoursExpress(result.submittedAt, totalTatHours)).toDate()
: (await addWorkingHours(result.submittedAt, totalTatHours)).toDate();
// Add request-level SLA for overall workflow progress
result.sla = {
elapsedHours: requestElapsedHours,
remainingHours: requestRemainingHours,
percentageUsed: requestPercentageUsed,
deadline: workflowDeadline,
isPaused: result.isPaused || false,
status: requestPercentageUsed >= 100 ? 'breached' : 'on_track',
remainingText: formatTime(requestRemainingHours),
elapsedText: formatTime(requestElapsedHours)
};
// Add currentApprover info (from currentStep if available)
if (result.currentStep) {
result.currentApprover = {
userId: result.currentStep.approver?.userId,
email: result.currentStep.approver?.email,
name: result.currentStep.approver?.name,
levelStartTime: result.currentStep.tat?.startTime,
tatHours: result.currentStep.tat?.assignedHours?.toString() || '0.00',
isPaused: result.isPaused || false,
pauseElapsedHours: null,
sla: result.currentStep.sla || result.sla
};
// Add currentLevelSLA (same as currentStep.sla or request sla)
result.currentLevelSLA = result.currentStep.sla || result.sla;
}
// Add summary object
result.summary = {
approvedLevels: Math.max(0, result.currentLevel - 1),
totalLevels: result.totalLevels,
sla: result.sla
};
} catch (error) {
logger.error('[listWorkflows] Request-level TAT calculation error:', error);
}
}
}
// 7. Total Count (Optimized)
let total = 0;
const needsAggCount = !!(filters.approverName || filters.levelStatus || filters.slaCompliance || listType === 'my_requests' || listType === 'participant' || listType === 'open_for_me' || listType === 'closed_by_me');
if (needsAggCount) {
const countPipeline = [...pipeline].filter(s => !s.$sort && !s.$skip && !s.$limit && !s.$project && !s.$lookup || (s.$lookup && (s.$lookup.from === 'participants' || s.$lookup.from === 'approval_levels')));
// Re-adding necessary lookups for match
countPipeline.push({ $count: 'total' });
const countRes = await WorkflowRequestModel.aggregate(countPipeline);
total = countRes[0]?.total || 0;
} else {
total = await WorkflowRequestModel.countDocuments(matchStage);
}
return {
data: results,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit)
}
};
}
/**
* Get Single Request Details (Internal)
*/
async getRequest(identifier: string) {
const request = await this.findRequest(identifier);
if (!request) return null;
const requestObj = request.toJSON();
const requestId = requestObj.requestId; // UUID
// Fetch Levels
const levels = await ApprovalLevelModel.find({ requestId }).sort({ levelNumber: 1 });
// Fetch Activities
const rawActivities = await activityMongoService.getActivitiesForRequest(requestId);
// Transform activities to ensure action and type fields exist
const activities = rawActivities.map((activity: any) => {
const activityObj = activity.toJSON ? activity.toJSON() : activity;
return {
...activityObj,
type: activityObj.activityType || 'ACTIVITY',
action: activityObj.title || activityObj.activityType || 'Activity'
};
});
// Flatten ALL fields for legacy PostgreSQL response format
return {
requestId: requestObj.requestId,
requestNumber: requestObj.requestNumber,
title: requestObj.title,
description: requestObj.description,
status: requestObj.status,
priority: requestObj.priority,
workflowType: requestObj.workflowType,
templateType: requestObj.templateType,
templateId: requestObj.templateId,
currentLevel: requestObj.currentLevel,
currentLevelId: requestObj.currentLevelId,
totalLevels: requestObj.totalLevels,
totalTatHours: requestObj.totalTatHours,
isPaused: requestObj.isPaused || false,
initiator: requestObj.initiator,
department: requestObj.initiator?.department,
// Flattened date fields (matching PostgreSQL column names)
submittedAt: requestObj.submissionDate,
createdAt: requestObj.createdAt,
closureDate: requestObj.closureDate,
updatedAt: requestObj.updatedAt,
// Flattened flag fields
isDraft: requestObj.isDraft || false,
isDeleted: requestObj.isDeleted || false,
// Flattened conclusion fields
conclusionRemark: requestObj.conclusionRemark,
aiGeneratedSummary: requestObj.aiGeneratedConclusion,
approvalLevels: levels,
activities: activities
};
}
/**
* Get Workflow by Identifier (aliased for Controller)
*/
async getWorkflowById(requestId: string): Promise<any> {
return this.getRequest(requestId);
}
/**
* Get Workflow Activities
*/
async getWorkflowActivities(identifier: string): Promise<any[]> {
const request = await this.findRequest(identifier);
if (!request) return [];
return await activityMongoService.getActivitiesForRequest(request.requestId); // Use UUID
}
/**
* Get Detailed Request View (PostgreSQL-style format)
*/
async getWorkflowDetails(identifier: string) {
const request = await this.findRequest(identifier);
if (!request) return null;
const requestObj = request.toJSON();
const now = new Date();
// Fetch all related data
const [levels, participants, rawActivities, documents, initiator] = await Promise.all([
ApprovalLevelModel.find({ requestId: requestObj.requestId }).sort({ levelNumber: 1 }), // Standardized to UUID
ParticipantModel.find({ requestId: requestObj.requestId, isActive: true }), // Standardized to UUID
activityMongoService.getActivitiesForRequest(requestObj.requestId), // Standardized to UUID
require('../models/mongoose/Document.schema').DocumentModel.find({ requestId: requestObj.requestId, isDeleted: false }), // Fetch documents
UserModel.findOne({ userId: requestObj.initiator.userId })
]);
// Transform activities to ensure frontend compatibility
const activities = rawActivities.map((activity: any) => {
const activityObj = activity.toJSON ? activity.toJSON() : activity;
return {
user: activityObj.userName || 'System',
type: activityObj.activityType || 'ACTIVITY',
action: activityObj.title || activityObj.activityType || 'Activity',
details: activityObj.activityDescription || '',
timestamp: activityObj.createdAt,
category: activityObj.activityCategory,
severity: activityObj.severity,
metadata: activityObj.metadata
};
});
// Build workflow object (flattened dates and flags)
const workflow = {
requestId: requestObj.requestId, // Use UUID
requestNumber: requestObj.requestNumber,
initiatorId: requestObj.initiator.userId,
templateType: requestObj.templateType,
workflowType: requestObj.workflowType,
templateId: requestObj.templateId,
title: requestObj.title,
description: requestObj.description,
priority: requestObj.priority,
status: requestObj.status,
workflowState: requestObj.workflowState || 'OPEN',
currentLevel: requestObj.currentLevel,
totalLevels: requestObj.totalLevels,
totalTatHours: requestObj.totalTatHours?.toString() || '0.00',
submissionDate: requestObj.submissionDate,
closureDate: requestObj.closureDate,
conclusionRemark: requestObj.conclusionRemark,
aiGeneratedConclusion: requestObj.aiGeneratedConclusion,
isDraft: requestObj.isDraft || false,
isDeleted: requestObj.isDeleted || false,
isPaused: requestObj.isPaused || false,
pausedAt: requestObj.pausedAt,
pausedBy: requestObj.pausedBy,
pauseReason: requestObj.pauseReason,
pauseResumeDate: requestObj.pauseResumeDate,
pauseTatSnapshot: null,
createdAt: requestObj.createdAt,
updatedAt: requestObj.updatedAt,
created_at: requestObj.createdAt,
updated_at: requestObj.updatedAt,
initiator: initiator ? initiator.toJSON() : requestObj.initiator
};
// Build approvals array (flatten TAT info) with real-time TAT calculation
const approvals = await Promise.all(levels.map(async (level: any) => {
const levelObj = level.toJSON();
// Calculate real-time TAT for active levels
let elapsedHours = levelObj.tat?.elapsedHours || 0;
let remainingHours = levelObj.tat?.remainingHours || 0;
let tatPercentageUsed = levelObj.tat?.percentageUsed || 0;
// Only calculate for PENDING or IN_PROGRESS levels with a start time
if ((levelObj.status === 'PENDING' || levelObj.status === 'IN_PROGRESS') && levelObj.tat?.startTime) {
try {
const { calculateElapsedWorkingHours } = require('../utils/tatTimeUtils');
const priority = (requestObj.priority || 'STANDARD').toString().toLowerCase();
// Build pause info if level was paused/resumed
const isCurrentlyPaused = levelObj.paused?.isPaused === true;
const wasResumed = !isCurrentlyPaused &&
(levelObj.paused?.elapsedHoursBeforePause !== undefined && levelObj.paused?.elapsedHoursBeforePause !== null) &&
(levelObj.paused?.resumedAt !== undefined && levelObj.paused?.resumedAt !== null);
const pauseInfo = isCurrentlyPaused ? {
isPaused: true,
pausedAt: levelObj.paused?.pausedAt,
pauseElapsedHours: levelObj.paused?.elapsedHoursBeforePause,
pauseResumeDate: levelObj.paused?.resumedAt
} : wasResumed ? {
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number(levelObj.paused?.elapsedHoursBeforePause),
pauseResumeDate: levelObj.paused?.resumedAt
} : undefined;
// Calculate elapsed hours
elapsedHours = await calculateElapsedWorkingHours(
levelObj.tat.startTime,
now,
priority,
pauseInfo
);
// Calculate deadline on-the-fly if missing
let levelEndTime = levelObj.tat?.endTime;
const assignedHours = levelObj.tat?.assignedHours || 24;
if (!levelEndTime && levelObj.tat?.startTime) {
levelEndTime = priority === 'express'
? (await addWorkingHoursExpress(levelObj.tat.startTime, assignedHours)).toDate()
: (await addWorkingHours(levelObj.tat.startTime, assignedHours)).toDate();
}
// Calculate remaining and percentage
remainingHours = Math.max(0, assignedHours - elapsedHours);
tatPercentageUsed = assignedHours > 0 ? Math.round(Math.min(100, (elapsedHours / assignedHours) * 100) * 100) / 100 : 0;
// Update the level object for the response
levelObj.tat.endTime = levelEndTime;
} catch (error) {
console.error('[getWorkflowDetails] TAT calculation error:', error);
// Fall back to stored values on error
}
}
return {
levelId: levelObj.levelId,
requestId: requestObj.requestId, // Use UUID
levelNumber: levelObj.levelNumber,
levelName: levelObj.levelName,
approverId: levelObj.approver?.userId,
approverEmail: levelObj.approver?.email,
approverName: levelObj.approver?.name,
tatHours: levelObj.tat?.assignedHours?.toString() || '0.00',
tatDays: levelObj.tat?.assignedDays || 0,
status: levelObj.status,
levelStartTime: levelObj.tat?.startTime,
levelEndTime: levelObj.tat?.endTime,
actionDate: levelObj.actionDate,
comments: levelObj.comments,
rejectionReason: levelObj.rejectionReason,
breachReason: levelObj.tat?.breachReason,
isFinalApprover: levelObj.isFinalApprover || false,
elapsedHours: elapsedHours,
remainingHours: remainingHours,
tatPercentageUsed: tatPercentageUsed,
tat50AlertSent: levelObj.alerts?.fiftyPercentSent || false,
tat75AlertSent: levelObj.alerts?.seventyFivePercentSent || false,
tatBreached: levelObj.tat?.isBreached || false,
tatStartTime: levelObj.tat?.startTime,
isPaused: levelObj.paused?.isPaused || false,
pausedAt: levelObj.paused?.pausedAt,
pausedBy: levelObj.paused?.pausedBy,
pauseReason: levelObj.paused?.reason,
pauseResumeDate: levelObj.paused?.resumeDate,
pauseTatStartTime: levelObj.paused?.tatSnapshot?.startTime,
pauseElapsedHours: levelObj.paused?.elapsedHoursBeforePause,
createdAt: levelObj.createdAt,
updatedAt: levelObj.updatedAt,
created_at: levelObj.createdAt,
updated_at: levelObj.updatedAt,
// Nested SLA object for backward compatibility
sla: (levelObj.status === 'PENDING' || levelObj.status === 'IN_PROGRESS') ? {
elapsedHours: elapsedHours,
remainingHours: remainingHours,
percentageUsed: tatPercentageUsed,
deadline: levelObj.tat?.endTime || null,
isPaused: levelObj.paused?.isPaused || false,
status: levelObj.tat?.isBreached ? 'breached' : 'on_track',
remainingText: formatTime(remainingHours),
elapsedText: formatTime(elapsedHours)
} : null
};
}));
// Build summary
const currentLevelData = levels.find((l: any) => l.levelNumber === requestObj.currentLevel);
const currentApprovalData = approvals.find((a: any) => a.levelNumber === requestObj.currentLevel);
// Calculate request-level TAT (overall workflow progress)
let requestLevelSLA = null;
if (requestObj.submissionDate && requestObj.status !== 'CLOSED' && requestObj.status !== 'REJECTED' && requestObj.status !== 'APPROVED') {
try {
const priority = (requestObj.priority || 'STANDARD').toString().toLowerCase();
const totalTatHours = parseFloat(requestObj.totalTatHours || '0.00'); // Ensure totalTatHours is a number
const { calculateElapsedWorkingHours } = require('../utils/tatTimeUtils');
const requestElapsedHours = await calculateElapsedWorkingHours(
requestObj.submissionDate,
new Date(),
priority
);
const requestRemainingHours = Math.max(0, totalTatHours - requestElapsedHours);
const requestPercentageUsed = totalTatHours > 0
? Math.round(Math.min(100, (requestElapsedHours / totalTatHours) * 100) * 100) / 100
: 0;
// Calculate overall workflow deadline
const workflowDeadline = priority === 'express'
? (await addWorkingHoursExpress(requestObj.submissionDate, totalTatHours)).toDate()
: (await addWorkingHours(requestObj.submissionDate, totalTatHours)).toDate();
requestLevelSLA = {
elapsedHours: requestElapsedHours,
remainingHours: requestRemainingHours,
percentageUsed: requestPercentageUsed,
status: requestPercentageUsed >= 100 ? 'breached' : 'on_track',
isPaused: requestObj.isPaused || false, // Use requestObj.isPaused for workflow level
deadline: workflowDeadline,
elapsedText: formatTime(requestElapsedHours),
remainingText: formatTime(requestRemainingHours)
};
} catch (error) {
console.error('[getWorkflowDetails] Request-level TAT calculation error:', error);
}
}
const summary = {
requestNumber: requestObj.requestNumber,
title: requestObj.title,
status: requestObj.status,
priority: requestObj.priority,
submittedAt: requestObj.submissionDate,
totalLevels: requestObj.totalLevels,
currentLevel: requestObj.currentLevel,
approvedLevels: Math.max(0, requestObj.currentLevel - 1),
currentApprover: currentLevelData ? {
userId: currentLevelData.approver?.userId,
email: currentLevelData.approver?.email,
name: currentLevelData.approver?.name
} : null,
sla: requestLevelSLA || (currentApprovalData ? {
elapsedHours: currentApprovalData.elapsedHours,
remainingHours: currentApprovalData.remainingHours,
percentageUsed: currentApprovalData.tatPercentageUsed,
status: currentApprovalData.tatBreached ? 'breached' : 'on_track', // Corrected to 'on_track'
isPaused: currentApprovalData.isPaused,
deadline: currentApprovalData.levelEndTime || null,
elapsedText: formatTime(currentApprovalData.elapsedHours),
remainingText: formatTime(currentApprovalData.remainingHours)
} : null)
};
// Return PostgreSQL-style structured response
return {
workflow,
approvals,
participants: participants.map((p: any) => p.toJSON()),
documents: documents.map((d: any) => d.toJSON()),
activities,
summary,
tatAlerts: [] // TODO: Fetch from TAT alerts collection when implemented
};
}
/**
* Check if user has access
*/
async checkUserRequestAccess(userId: string, identifier: string): Promise<{ hasAccess: boolean; reason?: string }> {
const workflow = await this.findRequest(identifier);
if (!workflow) return { hasAccess: false, reason: 'Request not found' };
// 1. Check if initiator
if (workflow.initiator?.userId === userId) return { hasAccess: true };
// 2. Check if participant (approver or spectator)
const participant = await ParticipantModel.findOne({ requestId: workflow.requestId, userId }); // Use UUID
if (participant) return { hasAccess: true };
// 3. Admin Check (simplified)
const user = await UserModel.findOne({ userId });
if (user && (user as any).role === 'ADMIN') return { hasAccess: true };
return { hasAccess: false, reason: 'Access denied' };
}
/**
* Update Workflow (Draft)
*/
async updateWorkflow(requestId: string, updateData: any): Promise<IWorkflowRequest | null> {
const workflow = await this.findRequest(requestId);
if (!workflow) throw new Error('Workflow not found');
if (!workflow.isDraft) throw new Error('Cannot update a submitted workflow');
Object.assign(workflow, updateData);
workflow.updatedAt = new Date();
return await workflow.save();
}
/**
* Submit Workflow (Draft -> Pending)
*/
async submitWorkflow(requestId: string): Promise<IWorkflowRequest | null> {
const workflow = await this.findRequest(requestId);
if (!workflow) throw new Error('Workflow not found');
if (!workflow.isDraft) throw new Error('Workflow is already submitted');
workflow.isDraft = false;
workflow.status = 'PENDING';
workflow.workflowState = 'OPEN';
workflow.submissionDate = new Date();
await workflow.save();
// Fetch Level 1 to get assigned hours
const level1 = await ApprovalLevelModel.findOne({ requestId: workflow.requestId, levelNumber: 1 });
if (!level1) throw new Error('Level 1 not found');
// Calculate Level 1 end time (deadline)
const now = new Date();
const priority = (workflow.priority || 'STANDARD').toLowerCase();
const assignedHours = level1.tat?.assignedHours || 24;
const endTime = priority === 'express'
? (await addWorkingHoursExpress(now, assignedHours)).toDate()
: (await addWorkingHours(now, assignedHours)).toDate();
// Activate Level 1
const activatedLevel1 = await ApprovalLevelModel.findOneAndUpdate(
{ requestId: workflow.requestId, levelNumber: 1 },
{
status: 'PENDING',
'tat.startTime': now,
'tat.endTime': endTime
},
{ new: true }
);
if (activatedLevel1) {
const approverId = activatedLevel1.approver?.userId;
if (approverId) {
// Schedule TAT
await tatScheduler.scheduleTatJobs(
workflow.requestId,
activatedLevel1._id.toString(),
approverId,
activatedLevel1.tat?.assignedHours || 24,
now,
workflow.priority as any
);
// Notify Approver
await notificationMongoService.sendToUsers([approverId], {
title: 'New Request Assigned',
body: `You have a new request ${workflow.requestNumber} pending your approval.`,
type: 'assignment',
requestId: workflow.requestId,
requestNumber: workflow.requestNumber,
priority: workflow.priority as any
});
// Log Assignment Activity
await activityMongoService.log({
requestId: workflow.requestId,
type: 'assignment',
user: { userId: 'SYSTEM' },
timestamp: new Date().toISOString(),
action: 'Request Assigned',
details: `Request assigned to Level 1 approver: ${activatedLevel1.approver?.name}`,
category: 'WORKFLOW',
severity: 'INFO',
metadata: {
levelNumber: 1,
approverName: activatedLevel1.approver?.name,
approverId: approverId
}
});
}
}
// Log Submit Activity
await activityMongoService.log({
requestId: workflow.requestId, // Standardized to UUID
type: 'submitted',
user: { userId: workflow.initiator.userId, name: workflow.initiator.name },
timestamp: new Date().toISOString(),
action: 'Request Submitted',
details: `Workflow ${workflow.requestNumber} submitted by ${workflow.initiator.name}`,
category: 'WORKFLOW',
severity: 'INFO'
});
// Notify Initiator and Spectators of submission
const recipients = await this.getNotificationRecipients(workflow.requestId, '');
await notificationMongoService.sendToUsers(recipients, {
title: 'Request Submitted',
body: `Your request ${workflow.requestNumber} has been successfully submitted.`,
type: 'request_submitted',
requestId: workflow.requestId,
requestNumber: workflow.requestNumber,
priority: workflow.priority as any
});
return workflow;
}
async addAdHocApprover(identifier: string, insertAtLevel: number, newApproverData: any): Promise<string> {
// Implementation from ActionService...
try {
const request = await this.findRequest(identifier);
if (!request) throw new Error('Request not found');
const requestId = request.requestId;
if (insertAtLevel <= request.currentLevel) {
throw new Error('Cannot insert approver at already passed/active level.');
}
await ApprovalLevelModel.updateMany(
{ requestId, levelNumber: { $gte: insertAtLevel } }, // Use UUID
{ $inc: { levelNumber: 1 } }
);
// Calculate TAT end time
const now = new Date();
const priority = (request.priority || 'STANDARD').toLowerCase();
const endHours = 24;
const endTime = priority === 'express'
? (await addWorkingHoursExpress(now, endHours)).toDate()
: (await addWorkingHours(now, endHours)).toDate();
await ApprovalLevelModel.create({
levelId: new mongoose.Types.ObjectId().toString(),
requestId, // Use UUID
levelNumber: insertAtLevel,
levelName: 'Ad-hoc Approver',
approver: {
userId: newApproverData.userId,
name: newApproverData.name,
email: newApproverData.email
},
tat: {
assignedHours: endHours,
assignedDays: 1,
startTime: now,
endTime: endTime,
elapsedHours: 0,
remainingHours: endHours,
percentageUsed: 0,
isBreached: false
},
status: 'PENDING',
alerts: { fiftyPercentSent: false, seventyFivePercentSent: false },
paused: { isPaused: false }
});
request.totalLevels = (request.totalLevels || 0) + 1;
await request.save();
// Log activity
await activityMongoService.log({
requestId, // Use UUID
type: 'assignment',
user: { userId: 'system' }, // or authenticated user
timestamp: new Date().toISOString(),
action: 'Ad-hoc Approver Added',
details: `Added new approver at Level ${insertAtLevel}`,
category: 'WORKFLOW',
severity: 'INFO'
});
return `Added new approver at Level ${insertAtLevel}. Subsequent levels shifted.`;
} catch (error) {
throw error;
}
}
/**
* KPI Metrics
*/
async getDepartmentTATMetrics() {
return await WorkflowRequestModel.aggregate([
{
$lookup: {
from: 'approval_levels',
localField: 'requestId', // Join on UUID
foreignField: 'requestId',
as: 'levels'
}
},
{ $unwind: "$levels" },
{ $match: { "levels.status": "APPROVED" } },
{
$group: {
_id: "$initiator.department",
avgTatHours: { $avg: "$levels.tat.elapsedHours" },
maxTatHours: { $max: "$levels.tat.elapsedHours" },
totalApprovals: { $sum: 1 },
breaches: {
$sum: { $cond: ["$levels.tat.isBreached", 1, 0] }
}
}
},
{
$project: {
department: "$_id",
avgTatHours: { $round: ["$avgTatHours", 1] },
breachRate: {
$multiply: [
{ $divide: ["$breaches", "$totalApprovals"] },
100
]
}
}
}
]);
}
/**
* Get all participants for a request to notify them of updates
* Returns an array of userIds including initiator and spectators
*/
async getNotificationRecipients(requestId: string, excludeUserId?: string): Promise<string[]> {
const recipients = new Set<string>();
// 1. Get request to find initiator
const request = await this.findRequest(requestId);
if (request && request.initiator?.userId) {
recipients.add(request.initiator.userId);
}
// 2. Get all active spectators
const spectators = await ParticipantModel.find({
requestId,
participantType: 'SPECTATOR',
isActive: true
});
for (const spectator of spectators) {
if (spectator.userId) {
recipients.add(spectator.userId);
}
}
// 3. Remove the excluded user (e.g., the one who performed the action)
if (excludeUserId) {
recipients.delete(excludeUserId);
}
return Array.from(recipients);
}
}
export const workflowServiceMongo = new WorkflowServiceMongo();