765 lines
29 KiB
TypeScript
765 lines
29 KiB
TypeScript
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<void> {
|
|
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<any> {
|
|
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<number> {
|
|
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<WorkflowRequest[]> {
|
|
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();
|
|
|