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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { return this.getRequest(requestId); } /** * Get Workflow Activities */ async getWorkflowActivities(identifier: string): Promise { 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 { 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 { 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 { // 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 { const recipients = new Set(); // 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();