import { WorkflowRequest } from '@models/WorkflowRequest'; import { ApprovalLevel } from '@models/ApprovalLevel'; import { User } from '@models/User'; import { ApprovalStatus, WorkflowStatus } from '../types/common.types'; import { Op } from 'sequelize'; import logger from '@utils/logger'; import { tatSchedulerService } from './tatScheduler.service'; import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import dayjs from 'dayjs'; import { emitToRequestRoom } from '../realtime/socket'; export class PauseService { /** * Pause a workflow at a specific approval level * @param requestId - The workflow request ID * @param levelId - The approval level ID to pause (optional, pauses current level if not provided) * @param userId - The user ID who is pausing * @param reason - Reason for pausing * @param resumeDate - Date when workflow should auto-resume (max 1 month from now) */ async pauseWorkflow( requestId: string, levelId: string | null, userId: string, reason: string, resumeDate: Date ): Promise<{ workflow: WorkflowRequest; level: ApprovalLevel | null }> { try { // Validate resume date (max 1 month from now) const now = new Date(); const maxResumeDate = dayjs(now).add(1, 'month').toDate(); if (resumeDate > maxResumeDate) { throw new Error('Resume date cannot be more than 1 month from now'); } if (resumeDate <= now) { throw new Error('Resume date must be in the future'); } // Get workflow const workflow = await WorkflowRequest.findByPk(requestId); if (!workflow) { throw new Error('Workflow not found'); } // Check if already paused if ((workflow as any).isPaused) { throw new Error('Workflow is already paused'); } // Get current approval level let level: ApprovalLevel | null = null; if (levelId) { level = await ApprovalLevel.findByPk(levelId); if (!level || (level as any).requestId !== requestId) { throw new Error('Approval level not found or does not belong to this workflow'); } } else { // Get current active level level = await ApprovalLevel.findOne({ where: { requestId, status: { [Op.in]: [ApprovalStatus.PENDING, ApprovalStatus.IN_PROGRESS] } }, order: [['levelNumber', 'ASC']] }); } if (!level) { throw new Error('No active approval level found to pause'); } // Verify user is either the approver for this level OR the initiator const isApprover = (level as any).approverId === userId; const isInitiator = (workflow as any).initiatorId === userId; if (!isApprover && !isInitiator) { throw new Error('Only the assigned approver or the initiator can pause this workflow'); } // Check if level is already paused if ((level as any).isPaused) { throw new Error('This approval level is already paused'); } // Calculate elapsed hours before pause const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); // Check if this level was previously paused and resumed // If so, we need to account for the previous pauseElapsedHours // IMPORTANT: Convert to number to avoid string concatenation (DB returns DECIMAL as string) const previousPauseElapsedHours = Number((level as any).pauseElapsedHours || 0); const previousResumeDate = (level as any).pauseResumeDate; const originalTatStartTime = (level as any).pauseTatStartTime || (level as any).levelStartTime || (level as any).tatStartTime || (level as any).createdAt; let elapsedHours: number; let levelStartTimeForCalculation: Date; if (previousPauseElapsedHours > 0 && previousResumeDate) { // This is a second (or subsequent) pause // Calculate: previous elapsed hours + time from resume to now levelStartTimeForCalculation = previousResumeDate; // Start from last resume time const timeSinceResume = await calculateElapsedWorkingHours(levelStartTimeForCalculation, now, priority); elapsedHours = previousPauseElapsedHours + Number(timeSinceResume); logger.info(`[Pause] Second pause detected - Previous elapsed: ${previousPauseElapsedHours}h, Since resume: ${timeSinceResume}h, Total: ${elapsedHours}h`); } else { // First pause - calculate from original start time levelStartTimeForCalculation = originalTatStartTime; elapsedHours = await calculateElapsedWorkingHours(levelStartTimeForCalculation, now, priority); } // Store TAT snapshot const tatSnapshot = { levelId: (level as any).levelId, levelNumber: (level as any).levelNumber, elapsedHours: Number(elapsedHours), remainingHours: Math.max(0, Number((level as any).tatHours) - elapsedHours), tatPercentageUsed: (Number((level as any).tatHours) > 0 ? Math.min(100, Math.round((elapsedHours / Number((level as any).tatHours)) * 100)) : 0), pausedAt: now.toISOString(), originalTatStartTime: originalTatStartTime // Always use the original start time, not the resume time }; // Update approval level with pause information await level.update({ isPaused: true, pausedAt: now, pausedBy: userId, pauseReason: reason, pauseResumeDate: resumeDate, pauseTatStartTime: originalTatStartTime, // Always preserve the original start time pauseElapsedHours: elapsedHours, status: ApprovalStatus.PAUSED }); // Update workflow with pause information // Store the current status before pausing so we can restore it on resume const currentWorkflowStatus = (workflow as any).status; const currentLevel = (workflow as any).currentLevel || (level as any).levelNumber; await workflow.update({ isPaused: true, pausedAt: now, pausedBy: userId, pauseReason: reason, pauseResumeDate: resumeDate, pauseTatSnapshot: { ...tatSnapshot, previousStatus: currentWorkflowStatus, // Store previous status for resume previousCurrentLevel: currentLevel // Store current level to prevent advancement }, status: WorkflowStatus.PAUSED // Note: We do NOT update currentLevel here - it should stay at the paused level }); // Cancel TAT jobs for this level await tatSchedulerService.cancelTatJobs(requestId, (level as any).levelId); // Get user details for notifications const user = await User.findByPk(userId); const userName = (user as any)?.displayName || (user as any)?.email || 'User'; // Get initiator const initiator = await User.findByPk((workflow as any).initiatorId); const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; // Send notifications const requestNumber = (workflow as any).requestNumber; const title = (workflow as any).title; // Notify initiator only if someone else (approver) paused the request // Skip notification if initiator paused their own request if (!isInitiator) { await notificationService.sendToUsers([(workflow as any).initiatorId], { title: 'Workflow Paused', body: `Your request "${title}" has been paused by ${userName}. Reason: ${reason}. Will resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'workflow_paused', priority: 'HIGH', actionRequired: false, metadata: { pauseReason: reason, resumeDate: resumeDate.toISOString(), pausedBy: userId } }); } // Notify the user who paused (confirmation) - no email for self-action await notificationService.sendToUsers([userId], { title: 'Workflow Paused Successfully', body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'status_change', // Use status_change to avoid email for self-action priority: 'MEDIUM', actionRequired: false }); // If initiator paused, notify the current approver if (isInitiator && (level as any).approverId) { const approver = await User.findByPk((level as any).approverId); const approverUserId = (level as any).approverId; await notificationService.sendToUsers([approverUserId], { title: 'Workflow Paused by Initiator', body: `Request "${title}" has been paused by the initiator (${userName}). Reason: ${reason}. Will resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'workflow_paused', priority: 'HIGH', actionRequired: false, metadata: { pauseReason: reason, resumeDate: resumeDate.toISOString(), pausedBy: userId } }); } // Log activity await activityService.log({ requestId, type: 'paused', user: { userId, name: userName }, timestamp: now.toISOString(), action: 'Workflow Paused', details: `Workflow paused by ${userName} at level ${(level as any).levelNumber}. Reason: ${reason}. Will resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`, metadata: { levelId: (level as any).levelId, levelNumber: (level as any).levelNumber, resumeDate: resumeDate.toISOString() } }); logger.info(`[Pause] Workflow ${requestId} paused at level ${(level as any).levelNumber} by ${userId}`); // Schedule dedicated auto-resume job for this workflow try { const { pauseResumeQueue } = require('../queues/pauseResumeQueue'); if (pauseResumeQueue && resumeDate) { const delay = resumeDate.getTime() - now.getTime(); if (delay > 0) { const jobId = `resume-${requestId}-${(level as any).levelId}`; await pauseResumeQueue.add( 'auto-resume-workflow', { type: 'auto-resume-workflow', requestId, levelId: (level as any).levelId, scheduledResumeDate: resumeDate.toISOString() }, { jobId, delay, // Exact delay in milliseconds until resume time removeOnComplete: true, removeOnFail: false } ); logger.info(`[Pause] Scheduled dedicated auto-resume job ${jobId} for ${resumeDate.toISOString()} (delay: ${Math.round(delay / 1000 / 60)} minutes)`); } else { logger.warn(`[Pause] Resume date ${resumeDate.toISOString()} is in the past, skipping job scheduling`); } } } catch (queueError) { logger.warn(`[Pause] Could not schedule dedicated auto-resume job:`, queueError); // Continue with pause even if job scheduling fails (hourly check will handle it as fallback) } // Emit real-time update to all users viewing this request emitToRequestRoom(requestId, 'request:updated', { requestId, requestNumber: (workflow as any).requestNumber, action: 'PAUSE', levelNumber: (level as any).levelNumber, timestamp: now.toISOString() }); return { workflow, level }; } catch (error: any) { logger.error(`[Pause] Failed to pause workflow:`, error); throw error; } } /** * Resume a paused workflow * @param requestId - The workflow request ID * @param userId - The user ID who is resuming (optional, for manual resume) * @param notes - Optional notes for the resume action */ async resumeWorkflow(requestId: string, userId?: string, notes?: string): Promise<{ workflow: WorkflowRequest; level: ApprovalLevel | null }> { try { const now = new Date(); // Get workflow const workflow = await WorkflowRequest.findByPk(requestId); if (!workflow) { throw new Error('Workflow not found'); } // Check if paused if (!(workflow as any).isPaused) { throw new Error('Workflow is not paused'); } // Get paused level const level = await ApprovalLevel.findOne({ where: { requestId, isPaused: true }, order: [['levelNumber', 'ASC']] }); if (!level) { throw new Error('Paused approval level not found'); } // Verify user has permission (if manual resume) // Both initiator and current approver can resume the workflow if (userId) { const isApprover = (level as any).approverId === userId; const isInitiator = (workflow as any).initiatorId === userId; if (!isApprover && !isInitiator) { throw new Error('Only the assigned approver or the initiator can resume this workflow'); } } // Calculate remaining TAT from resume time const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); const pauseElapsedHours = Number((level as any).pauseElapsedHours || 0); const tatHours = Number((level as any).tatHours); const remainingHours = Math.max(0, tatHours - pauseElapsedHours); // Get which alerts have already been sent (to avoid re-sending on resume) const tat50AlertSent = (level as any).tat50AlertSent || false; const tat75AlertSent = (level as any).tat75AlertSent || false; const tatBreached = (level as any).tatBreached || false; // Update approval level - resume TAT // IMPORTANT: Keep pauseElapsedHours and store resumedAt (pauseResumeDate repurposed) // This allows SLA calculation to correctly add pre-pause elapsed time await level.update({ isPaused: false, pausedAt: null as any, pausedBy: null as any, pauseReason: null as any, pauseResumeDate: now, // Store actual resume time (repurposed from scheduled resume date) // pauseTatStartTime: null as any, // Keep original TAT start time for reference // pauseElapsedHours is intentionally NOT cleared - needed for SLA calculations status: ApprovalStatus.IN_PROGRESS, tatStartTime: now, // Reset TAT start time to now for new elapsed calculation levelStartTime: now // This is the new start time from resume }); // Cancel any scheduled auto-resume job (if exists) try { const { pauseResumeQueue } = require('../queues/pauseResumeQueue'); if (pauseResumeQueue) { // Try to remove job by specific ID pattern first (more efficient) const jobId = `resume-${requestId}-${(level as any).levelId}`; try { const specificJob = await pauseResumeQueue.getJob(jobId); if (specificJob) { await specificJob.remove(); logger.info(`[Pause] Cancelled scheduled auto-resume job ${jobId} for workflow ${requestId}`); } } catch (err) { // Job might not exist, which is fine } // Also check for any other jobs for this request (fallback for old jobs) const scheduledJobs = await pauseResumeQueue.getJobs(['delayed', 'waiting']); const otherJobs = scheduledJobs.filter((job: any) => job.data.requestId === requestId && job.id !== jobId ); for (const job of otherJobs) { await job.remove(); logger.info(`[Pause] Cancelled legacy auto-resume job ${job.id} for workflow ${requestId}`); } } } catch (queueError) { logger.warn(`[Pause] Could not cancel scheduled auto-resume job:`, queueError); // Continue with resume even if job cancellation fails } // Update workflow - restore previous status or default to PENDING const pauseSnapshot = (workflow as any).pauseTatSnapshot || {}; const previousStatus = pauseSnapshot.previousStatus || WorkflowStatus.PENDING; await workflow.update({ isPaused: false, pausedAt: null as any, pausedBy: null as any, pauseReason: null as any, pauseResumeDate: null as any, pauseTatSnapshot: null as any, status: previousStatus // Restore previous status (PENDING or IN_PROGRESS) }); // Reschedule TAT jobs from resume time - only for alerts that haven't been sent yet if (remainingHours > 0) { // Calculate which thresholds are still pending based on remaining time const percentageUsedAtPause = tatHours > 0 ? (pauseElapsedHours / tatHours) * 100 : 0; // Only schedule jobs for thresholds that: // 1. Haven't been sent yet // 2. Haven't been passed yet (based on percentage used at pause) await tatSchedulerService.scheduleTatJobsOnResume( requestId, (level as any).levelId, (level as any).approverId, remainingHours, // Remaining TAT hours now, // Start from now priority as any, { // Pass which alerts were already sent tat50AlertSent: tat50AlertSent, tat75AlertSent: tat75AlertSent, tatBreached: tatBreached, // Pass percentage used at pause to determine which thresholds are still relevant percentageUsedAtPause: percentageUsedAtPause } ); } // Get user details const resumeUser = userId ? await User.findByPk(userId) : null; const resumeUserName = resumeUser ? ((resumeUser as any)?.displayName || (resumeUser as any)?.email || 'User') : 'System (Auto-resume)'; // Get initiator and paused by user const initiator = await User.findByPk((workflow as any).initiatorId); const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; const pausedByUser = (workflow as any).pausedBy ? await User.findByPk((workflow as any).pausedBy) : null; const pausedByName = pausedByUser ? ((pausedByUser as any)?.displayName || (pausedByUser as any)?.email || 'User') : 'Unknown'; const requestNumber = (workflow as any).requestNumber; const title = (workflow as any).title; const initiatorId = (workflow as any).initiatorId; const approverId = (level as any).approverId; const isResumedByInitiator = userId === initiatorId; const isResumedByApprover = userId === approverId; // Calculate pause duration const pausedAt = (level as any).pausedAt || (workflow as any).pausedAt; const pauseDurationMs = pausedAt ? now.getTime() - new Date(pausedAt).getTime() : 0; const pauseDurationHours = Math.round((pauseDurationMs / (1000 * 60 * 60)) * 100) / 100; // Round to 2 decimal places const pauseDuration = pauseDurationHours > 0 ? `${pauseDurationHours} hours` : 'less than 1 hour'; // Notify initiator only if someone else resumed (or auto-resume) // Skip if initiator resumed their own request if (!isResumedByInitiator) { await notificationService.sendToUsers([initiatorId], { title: 'Workflow Resumed', body: `Your request "${title}" has been resumed ${userId ? `by ${resumeUserName}` : 'automatically'}.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'workflow_resumed', priority: 'HIGH', actionRequired: false, metadata: { resumedBy: userId ? { userId, name: resumeUserName } : null, pauseDuration: pauseDuration } }); } // Notify approver only if someone else resumed (or auto-resume) // Skip if approver resumed the request themselves if (!isResumedByApprover && approverId) { await notificationService.sendToUsers([approverId], { title: 'Workflow Resumed', body: `Request "${title}" has been resumed ${userId ? `by ${resumeUserName}` : 'automatically'}. Please continue with your review.`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'workflow_resumed', priority: 'HIGH', actionRequired: true, metadata: { resumedBy: userId ? { userId, name: resumeUserName } : null, pauseDuration: pauseDuration } }); } // Send confirmation to the user who resumed (if manual resume) - no email for self-action if (userId) { await notificationService.sendToUsers([userId], { title: 'Workflow Resumed Successfully', body: `You have resumed request "${title}". ${isResumedByApprover ? 'Please continue with your review.' : ''}`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'status_change', // Use status_change to avoid email for self-action priority: 'MEDIUM', actionRequired: isResumedByApprover }); } // Log activity with notes const resumeDetails = notes ? `Workflow resumed ${userId ? `by ${resumeUserName}` : 'automatically'} at level ${(level as any).levelNumber}. Notes: ${notes}` : `Workflow resumed ${userId ? `by ${resumeUserName}` : 'automatically'} at level ${(level as any).levelNumber}.`; await activityService.log({ requestId, type: 'resumed', user: userId ? { userId, name: resumeUserName } : undefined, timestamp: now.toISOString(), action: 'Workflow Resumed', details: resumeDetails, metadata: { levelId: (level as any).levelId, levelNumber: (level as any).levelNumber, wasAutoResume: !userId, notes: notes || null } }); logger.info(`[Pause] Workflow ${requestId} resumed ${userId ? `by ${userId}` : 'automatically'}`); // Emit real-time update to all users viewing this request emitToRequestRoom(requestId, 'request:updated', { requestId, requestNumber: (workflow as any).requestNumber, action: 'RESUME', levelNumber: (level as any).levelNumber, timestamp: now.toISOString() }); return { workflow, level }; } catch (error: any) { logger.error(`[Pause] Failed to resume workflow:`, error); throw error; } } /** * Cancel pause (for retrigger scenario - initiator requests approver to resume) * This sends a notification to the approver who paused it * @param requestId - The workflow request ID * @param userId - The initiator user ID */ async retriggerPause(requestId: string, userId: string): Promise { try { const workflow = await WorkflowRequest.findByPk(requestId); if (!workflow) { throw new Error('Workflow not found'); } if (!(workflow as any).isPaused) { throw new Error('Workflow is not paused'); } // Verify user is initiator if ((workflow as any).initiatorId !== userId) { throw new Error('Only the initiator can retrigger a pause'); } const pausedBy = (workflow as any).pausedBy; if (!pausedBy) { throw new Error('Cannot retrigger - no approver found who paused this workflow'); } // Get user details const initiator = await User.findByPk(userId); const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; // Get approver details (who paused the workflow) const approver = await User.findByPk(pausedBy); const approverName = (approver as any)?.displayName || (approver as any)?.email || 'Approver'; const requestNumber = (workflow as any).requestNumber; const title = (workflow as any).title; // Notify approver who paused it await notificationService.sendToUsers([pausedBy], { title: 'Pause Retrigger Request', body: `${initiatorName} is requesting you to cancel the pause and resume work on request "${title}".`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'pause_retrigger_request', priority: 'HIGH', actionRequired: true }); // Log activity with approver name await activityService.log({ requestId, type: 'pause_retriggered', user: { userId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Pause Retrigger Requested', details: `${initiatorName} requested ${approverName} to cancel the pause and resume work.`, metadata: { pausedBy, approverName } }); logger.info(`[Pause] Pause retrigger requested for workflow ${requestId} by initiator ${userId}`); } catch (error: any) { logger.error(`[Pause] Failed to retrigger pause:`, error); throw error; } } /** * Get pause details for a workflow */ async getPauseDetails(requestId: string): Promise { try { const workflow = await WorkflowRequest.findByPk(requestId); if (!workflow) { throw new Error('Workflow not found'); } if (!(workflow as any).isPaused) { return null; } const level = await ApprovalLevel.findOne({ where: { requestId, isPaused: true } }); const pausedByUser = (workflow as any).pausedBy ? await User.findByPk((workflow as any).pausedBy, { attributes: ['userId', 'email', 'displayName'] }) : null; return { isPaused: true, pausedAt: (workflow as any).pausedAt, pausedBy: pausedByUser ? { userId: (pausedByUser as any).userId, email: (pausedByUser as any).email, name: (pausedByUser as any).displayName || (pausedByUser as any).email } : null, pauseReason: (workflow as any).pauseReason, pauseResumeDate: (workflow as any).pauseResumeDate, level: level ? { levelId: (level as any).levelId, levelNumber: (level as any).levelNumber, approverName: (level as any).approverName } : null }; } catch (error: any) { logger.error(`[Pause] Failed to get pause details:`, error); throw error; } } /** * Check and auto-resume paused workflows whose resume date has passed * This is called by a scheduled job */ async checkAndResumePausedWorkflows(): Promise { try { const now = new Date(); // Find all paused workflows where resume date has passed // Handle backward compatibility: workflow_type column may not exist in old environments let pausedWorkflows: WorkflowRequest[]; try { pausedWorkflows = await WorkflowRequest.findAll({ where: { isPaused: true, pauseResumeDate: { [Op.lte]: now } } }); } catch (error: any) { // If error is due to missing workflow_type column, use raw query if (error.message?.includes('workflow_type') || (error.message?.includes('column') && error.message?.includes('does not exist'))) { logger.warn('[Pause] workflow_type column not found, using raw query for backward compatibility'); const { sequelize } = await import('../config/database'); const { QueryTypes } = await import('sequelize'); const results = await sequelize.query(` SELECT request_id, is_paused, pause_resume_date FROM workflow_requests WHERE is_paused = true AND pause_resume_date <= :now `, { replacements: { now }, type: QueryTypes.SELECT }); // Convert to WorkflowRequest-like objects // results is an array of objects from SELECT query pausedWorkflows = (results as any[]).map((r: any) => ({ requestId: r.request_id, isPaused: r.is_paused, pauseResumeDate: r.pause_resume_date })) as any; } else { throw error; // Re-throw if it's a different error } } let resumedCount = 0; for (const workflow of pausedWorkflows) { try { await this.resumeWorkflow((workflow as any).requestId); resumedCount++; } catch (error: any) { logger.error(`[Pause] Failed to auto-resume workflow ${(workflow as any).requestId}:`, error); // Continue with other workflows } } if (resumedCount > 0) { logger.info(`[Pause] Auto-resumed ${resumedCount} workflow(s)`); } return resumedCount; } catch (error: any) { logger.error(`[Pause] Failed to check and resume paused workflows:`, error); throw error; } } /** * Get all paused workflows (for admin/reporting) */ async getPausedWorkflows(): Promise { try { return await WorkflowRequest.findAll({ where: { isPaused: true }, order: [['pausedAt', 'DESC']] }); } catch (error: any) { logger.error(`[Pause] Failed to get paused workflows:`, error); throw error; } } } export const pauseService = new PauseService();