2132 lines
91 KiB
TypeScript
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();
|