pause functionality addd along with user like allreuest if admin checks personla in dashboard

This commit is contained in:
laxmanhalaki 2025-11-27 17:55:40 +05:30
parent 1b389a8704
commit 3bf4f540b5
43 changed files with 2325 additions and 205 deletions

View File

@ -219,6 +219,19 @@
},
"description": "Ensure user exists in database - creates if not exists"
}
},
{
"name": "Get Public Configurations",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/users/configurations",
"host": ["{{baseUrl}}"],
"path": ["users", "configurations"]
},
"description": "Get public configurations (document policy, workflow sharing, TAT settings)"
}
}
]
},
@ -239,7 +252,7 @@
}
},
{
"name": "List My Requests",
"name": "List My Requests (DEPRECATED)",
"request": {
"method": "GET",
"header": [],
@ -248,7 +261,75 @@
"host": ["{{baseUrl}}"],
"path": ["workflows", "my"]
},
"description": "Get workflows initiated by current user"
"description": "DEPRECATED - Use /participant-requests instead. Get workflows where user is participant"
}
},
{
"name": "List Participant Requests",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/workflows/participant-requests?page=1&limit=10",
"host": ["{{baseUrl}}"],
"path": ["workflows", "participant-requests"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "10",
"description": "Items per page"
},
{
"key": "status",
"value": "",
"description": "Filter by status (optional)",
"disabled": true
},
{
"key": "priority",
"value": "",
"description": "Filter by priority (optional)",
"disabled": true
}
]
},
"description": "Get all requests where user is initiator OR participant (approver/spectator) - for regular users' All Requests page"
}
},
{
"name": "List My Initiated Requests",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/workflows/my-initiated?page=1&limit=10",
"host": ["{{baseUrl}}"],
"path": ["workflows", "my-initiated"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "10",
"description": "Items per page"
},
{
"key": "status",
"value": "",
"description": "Filter by status (optional)",
"disabled": true
}
]
},
"description": "Get only requests where current user is the initiator - for My Requests page"
}
},
{
@ -509,6 +590,109 @@
},
"description": "Get activity log/history for a workflow"
}
},
{
"name": "Pause Workflow",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n // Reason for pausing the workflow\n \"reason\": \"Waiting for additional documentation from vendor\",\n \n // Expected resume date (optional)\n \"expectedResumeDate\": \"2024-12-30T00:00:00Z\"\n}"
},
"url": {
"raw": "{{baseUrl}}/workflows/:id/pause",
"host": ["{{baseUrl}}"],
"path": ["workflows", ":id", "pause"],
"variable": [
{
"key": "id",
"value": "REQ-2024-0001"
}
]
},
"description": "Pause a workflow (approver only). Sets status to PAUSED."
}
},
{
"name": "Resume Workflow",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n // Reason for resuming (optional)\n \"reason\": \"Documentation received, continuing approval process\"\n}"
},
"url": {
"raw": "{{baseUrl}}/workflows/:id/resume",
"host": ["{{baseUrl}}"],
"path": ["workflows", ":id", "resume"],
"variable": [
{
"key": "id",
"value": "REQ-2024-0001"
}
]
},
"description": "Resume a paused workflow (approver who paused or initiator)"
}
},
{
"name": "Retrigger Pause",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n // Message to approver requesting resume\n \"message\": \"Documents have been uploaded. Please review and resume the workflow.\"\n}"
},
"url": {
"raw": "{{baseUrl}}/workflows/:id/pause/retrigger",
"host": ["{{baseUrl}}"],
"path": ["workflows", ":id", "pause", "retrigger"],
"variable": [
{
"key": "id",
"value": "REQ-2024-0001"
}
]
},
"description": "Initiator requests approver to resume paused workflow (sends notification)"
}
},
{
"name": "Get Pause Details",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/workflows/:id/pause",
"host": ["{{baseUrl}}"],
"path": ["workflows", ":id", "pause"],
"variable": [
{
"key": "id",
"value": "REQ-2024-0001"
}
]
},
"description": "Get pause details for a workflow (pause history, current pause status)"
}
}
]
},
@ -1312,6 +1496,137 @@
},
"description": "Get priority distribution statistics"
}
},
{
"name": "Get Lifecycle Report",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/dashboard/reports/lifecycle?dateRange=month",
"host": ["{{baseUrl}}"],
"path": ["dashboard", "reports", "lifecycle"],
"query": [
{
"key": "dateRange",
"value": "month",
"description": "Date range: today, week, month, quarter, year, all"
},
{
"key": "startDate",
"value": "",
"description": "Custom start date (ISO format)",
"disabled": true
},
{
"key": "endDate",
"value": "",
"description": "Custom end date (ISO format)",
"disabled": true
}
]
},
"description": "Get request lifecycle report with stage-wise breakdown"
}
},
{
"name": "Get Activity Log Report",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/dashboard/reports/activity-log?page=1&limit=50",
"host": ["{{baseUrl}}"],
"path": ["dashboard", "reports", "activity-log"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "50",
"description": "Items per page"
},
{
"key": "dateRange",
"value": "month",
"description": "Date range filter",
"disabled": true
},
{
"key": "userId",
"value": "",
"description": "Filter by user ID",
"disabled": true
}
]
},
"description": "Get enhanced user activity log report with detailed actions"
}
},
{
"name": "Get Workflow Aging Report",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/dashboard/reports/workflow-aging",
"host": ["{{baseUrl}}"],
"path": ["dashboard", "reports", "workflow-aging"]
},
"description": "Get workflow aging report showing requests by age bucket"
}
},
{
"name": "Get Departments Metadata",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/dashboard/metadata/departments",
"host": ["{{baseUrl}}"],
"path": ["dashboard", "metadata", "departments"]
},
"description": "Get list of all departments (for filtering)"
}
},
{
"name": "Get Requests By Approver",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/dashboard/requests/by-approver?approverId=approver-uuid-here&page=1&limit=10",
"host": ["{{baseUrl}}"],
"path": ["dashboard", "requests", "by-approver"],
"query": [
{
"key": "approverId",
"value": "approver-uuid-here",
"description": "Approver's user ID"
},
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "10",
"description": "Items per page"
},
{
"key": "status",
"value": "",
"description": "Filter by status (optional)",
"disabled": true
}
]
},
"description": "Get requests handled by a specific approver (for performance analysis)"
}
}
]
},
@ -1747,6 +2062,190 @@
}
]
},
{
"name": "Summaries",
"item": [
{
"name": "Create Summary",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n // Request ID of the closed workflow\n \"requestId\": \"request-uuid-here\",\n \n // Summary title (optional - defaults to request title)\n \"title\": \"Summary: Purchase Order Approval\",\n \n // Summary content/notes (optional - can be AI generated)\n \"content\": \"This purchase order was approved with all requirements met.\",\n \n // Whether to generate AI summary\n \"generateAISummary\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/summaries",
"host": ["{{baseUrl}}"],
"path": ["summaries"]
},
"description": "Create a summary for a closed workflow request (initiator only)"
}
},
{
"name": "List My Summaries",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/my?page=1&limit=10",
"host": ["{{baseUrl}}"],
"path": ["summaries", "my"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "10",
"description": "Items per page"
}
]
},
"description": "List summaries created by current user"
}
},
{
"name": "List Shared Summaries",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/shared?page=1&limit=10",
"host": ["{{baseUrl}}"],
"path": ["summaries", "shared"],
"query": [
{
"key": "page",
"value": "1",
"description": "Page number"
},
{
"key": "limit",
"value": "10",
"description": "Items per page"
}
]
},
"description": "List summaries shared with current user"
}
},
{
"name": "Get Summary By Request ID",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/request/:requestId",
"host": ["{{baseUrl}}"],
"path": ["summaries", "request", ":requestId"],
"variable": [
{
"key": "requestId",
"value": "request-uuid-here",
"description": "Request ID to get summary for"
}
]
},
"description": "Get summary by workflow request ID"
}
},
{
"name": "Get Summary Details",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/:summaryId",
"host": ["{{baseUrl}}"],
"path": ["summaries", ":summaryId"],
"variable": [
{
"key": "summaryId",
"value": "summary-uuid-here",
"description": "Summary ID"
}
]
},
"description": "Get summary details by summary ID"
}
},
{
"name": "Share Summary",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n // Array of user IDs to share with\n \"userIds\": [\"user-uuid-1\", \"user-uuid-2\"],\n \n // Optional message to include\n \"message\": \"Please review this workflow summary\",\n \n // Optional: Share with all participants of the original request\n \"shareWithParticipants\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/summaries/:summaryId/share",
"host": ["{{baseUrl}}"],
"path": ["summaries", ":summaryId", "share"],
"variable": [
{
"key": "summaryId",
"value": "summary-uuid-here"
}
]
},
"description": "Share summary with other users"
}
},
{
"name": "Get Shared Recipients",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/:summaryId/recipients",
"host": ["{{baseUrl}}"],
"path": ["summaries", ":summaryId", "recipients"],
"variable": [
{
"key": "summaryId",
"value": "summary-uuid-here"
}
]
},
"description": "Get list of users this summary has been shared with"
}
},
{
"name": "Mark Shared Summary as Viewed",
"request": {
"method": "PATCH",
"header": [],
"url": {
"raw": "{{baseUrl}}/summaries/shared/:sharedSummaryId/view",
"host": ["{{baseUrl}}"],
"path": ["summaries", "shared", ":sharedSummaryId", "view"],
"variable": [
{
"key": "sharedSummaryId",
"value": "shared-summary-uuid-here",
"description": "Shared summary record ID"
}
]
},
"description": "Mark a shared summary as viewed by current user"
}
}
]
},
{
"name": "Debug & Testing",
"item": [

View File

@ -1,2 +0,0 @@
import{a as t}from"./index-BvTcQxNO.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-0pr8l1kE.js";import"./socket-vendor-TjCxX7sJ.js";import"./router-vendor-1fSSvDCY.js";async function l(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function m(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function d(n){return(await t.get(`/conclusions/${n}`)).data.data}export{m as finalizeConclusion,l as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-Ch9SaBrQ.js.map

View File

@ -0,0 +1,2 @@
import{a as t}from"./index-CePk9sgI.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CFl7S1sk.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-1fSSvDCY.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-Dp5idKr8.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-Ch9SaBrQ.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"0PAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
{"version":3,"file":"conclusionApi-Dp5idKr8.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,14 +52,15 @@
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-BvTcQxNO.js"></script>
<script type="module" crossorigin src="/assets/index-CePk9sgI.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-C2EbRL2a.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-0pr8l1kE.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CFl7S1sk.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-1fSSvDCY.js">
<link rel="stylesheet" crossorigin href="/assets/index-zQ0sgzTQ.css">
<link rel="stylesheet" crossorigin href="/assets/index-BZdlHmA5.css">
</head>
<body>
<div id="root"></div>

View File

@ -132,9 +132,18 @@ export class AuthController {
res.cookie('accessToken', newAccessToken, cookieOptions);
ResponseHandler.success(res, {
accessToken: newAccessToken
}, 'Token refreshed successfully');
// SECURITY: In production, don't return token in response body
// Token is securely stored in httpOnly cookie
if (isProduction) {
ResponseHandler.success(res, {
message: 'Token refreshed successfully'
}, 'Token refreshed successfully');
} else {
// Development: Include token for debugging
ResponseHandler.success(res, {
accessToken: newAccessToken
}, 'Token refreshed successfully');
}
} catch (error) {
logger.error('Token refresh failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -351,14 +360,26 @@ export class AuthController {
hasUser: !!result.user,
hasAccessToken: !!result.accessToken,
hasRefreshToken: !!result.refreshToken,
isProduction,
});
ResponseHandler.success(res, {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.oktaIdToken // Include id_token for frontend logout
}, 'Token exchange successful');
// SECURITY: In production, don't return tokens in response body
// Tokens are securely stored in httpOnly cookies
if (isProduction) {
ResponseHandler.success(res, {
user: result.user,
// idToken needed for Okta logout - stored briefly in sessionStorage
idToken: result.oktaIdToken
}, 'Token exchange successful');
} else {
// Development: Include tokens for debugging and different-port setup
ResponseHandler.success(res, {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.oktaIdToken
}, 'Token exchange successful');
}
} catch (error) {
logger.error('Token exchange failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';

View File

@ -52,6 +52,7 @@ export class DashboardController {
const approverType = req.query.approverType as 'current' | 'any' | undefined;
const search = req.query.search as string | undefined;
const slaCompliance = req.query.slaCompliance as string | undefined;
const viewAsUser = req.query.viewAsUser === 'true'; // When true, treat admin as normal user
const stats = await this.dashboardService.getRequestStats(
userId,
@ -65,7 +66,8 @@ export class DashboardController {
approver,
approverType,
search,
slaCompliance
slaCompliance,
viewAsUser
);
res.json({

View File

@ -0,0 +1,134 @@
import { Response } from 'express';
import { pauseService } from '@services/pause.service';
import { ResponseHandler } from '@utils/responseHandler';
import type { AuthenticatedRequest } from '../types/express';
import { z } from 'zod';
// Validation schemas
const pauseWorkflowSchema = z.object({
levelId: z.string().uuid().optional().nullable(),
reason: z.string().min(1, 'Reason is required').max(1000, 'Reason must be less than 1000 characters'),
resumeDate: z.string().datetime().or(z.date())
});
const resumeWorkflowSchema = z.object({
// No body required for resume
});
export class PauseController {
/**
* Pause a workflow
* POST /workflows/:id/pause
*/
async pauseWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
ResponseHandler.error(res, 'Unauthorized', 401);
return;
}
// Validate request body
const validated = pauseWorkflowSchema.parse(req.body);
const resumeDate = validated.resumeDate instanceof Date
? validated.resumeDate
: new Date(validated.resumeDate);
const result = await pauseService.pauseWorkflow(
id,
validated.levelId || null,
userId,
validated.reason,
resumeDate
);
ResponseHandler.success(res, {
workflow: result.workflow,
level: result.level
}, 'Workflow paused successfully', 200);
} catch (error: any) {
if (error instanceof z.ZodError) {
ResponseHandler.error(res, 'Validation failed', 400, error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join('; '));
return;
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to pause workflow', 400, errorMessage);
}
}
/**
* Resume a paused workflow
* POST /workflows/:id/resume
*/
async resumeWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
ResponseHandler.error(res, 'Unauthorized', 401);
return;
}
const result = await pauseService.resumeWorkflow(id, userId);
ResponseHandler.success(res, {
workflow: result.workflow,
level: result.level
}, 'Workflow resumed successfully', 200);
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to resume workflow', 400, errorMessage);
}
}
/**
* Retrigger pause (initiator requests approver to resume)
* POST /workflows/:id/pause/retrigger
*/
async retriggerPause(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
ResponseHandler.error(res, 'Unauthorized', 401);
return;
}
await pauseService.retriggerPause(id, userId);
ResponseHandler.success(res, null, 'Pause retrigger request sent successfully', 200);
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to retrigger pause', 400, errorMessage);
}
}
/**
* Get pause details for a workflow
* GET /workflows/:id/pause
*/
async getPauseDetails(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const pauseDetails = await pauseService.getPauseDetails(id);
if (!pauseDetails) {
ResponseHandler.success(res, { isPaused: false }, 'Workflow is not paused', 200);
return;
}
ResponseHandler.success(res, pauseDetails, 'Pause details retrieved successfully', 200);
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to get pause details', 400, errorMessage);
}
}
}
export const pauseController = new PauseController();

View File

@ -0,0 +1,25 @@
import { pauseResumeSchedulerService } from '../services/pauseResumeScheduler.service';
import { pauseResumeWorker } from '../queues/pauseResumeWorker';
import logger from '../utils/logger';
/**
* Initialize pause resume job system using BullMQ (Redis)
* This schedules a recurring job that runs every hour to auto-resume paused workflows
*/
export async function startPauseResumeJob(): Promise<void> {
try {
// Initialize the worker (listens for jobs)
if (pauseResumeWorker) {
logger.info('[Pause Resume Job] ✅ Worker initialized');
} else {
logger.warn('[Pause Resume Job] ⚠️ Worker not available (Redis may not be connected)');
}
// Schedule the recurring auto-resume job
await pauseResumeSchedulerService.scheduleAutoResumeJob();
} catch (error: any) {
logger.error('[Pause Resume Job] Failed to start pause resume job system:', error);
// Don't throw - allow server to start even if Redis is not available
}
}

View File

@ -0,0 +1,73 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add pause fields to approval_levels table
// Note: The 'PAUSED' enum value is added in a separate migration (20250126-add-paused-to-enum.ts)
await queryInterface.addColumn('approval_levels', 'is_paused', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
});
await queryInterface.addColumn('approval_levels', 'paused_at', {
type: DataTypes.DATE,
allowNull: true
});
await queryInterface.addColumn('approval_levels', 'paused_by', {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
}
});
await queryInterface.addColumn('approval_levels', 'pause_reason', {
type: DataTypes.TEXT,
allowNull: true
});
await queryInterface.addColumn('approval_levels', 'pause_resume_date', {
type: DataTypes.DATE,
allowNull: true
});
await queryInterface.addColumn('approval_levels', 'pause_tat_start_time', {
type: DataTypes.DATE,
allowNull: true,
comment: 'Original TAT start time before pause'
});
await queryInterface.addColumn('approval_levels', 'pause_elapsed_hours', {
type: DataTypes.DECIMAL(10, 2),
allowNull: true,
comment: 'Elapsed hours at pause time'
});
// Create index on is_paused for faster queries
await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "approval_levels_is_paused" ON "approval_levels" ("is_paused");'
);
// Create index on pause_resume_date for auto-resume job
await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "approval_levels_pause_resume_date" ON "approval_levels" ("pause_resume_date") WHERE "is_paused" = true;'
);
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('approval_levels', 'pause_elapsed_hours');
await queryInterface.removeColumn('approval_levels', 'pause_tat_start_time');
await queryInterface.removeColumn('approval_levels', 'pause_resume_date');
await queryInterface.removeColumn('approval_levels', 'pause_reason');
await queryInterface.removeColumn('approval_levels', 'paused_by');
await queryInterface.removeColumn('approval_levels', 'paused_at');
await queryInterface.removeColumn('approval_levels', 'is_paused');
// Note: PostgreSQL doesn't support removing enum values directly
// To fully rollback, you would need to recreate the enum type
// This is a limitation of PostgreSQL enums
// For now, we'll leave 'PAUSED' in the enum even after rollback
}

View File

@ -0,0 +1,59 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add pause fields to workflow_requests table
await queryInterface.addColumn('workflow_requests', 'is_paused', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
});
await queryInterface.addColumn('workflow_requests', 'paused_at', {
type: DataTypes.DATE,
allowNull: true
});
await queryInterface.addColumn('workflow_requests', 'paused_by', {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
}
});
await queryInterface.addColumn('workflow_requests', 'pause_reason', {
type: DataTypes.TEXT,
allowNull: true
});
await queryInterface.addColumn('workflow_requests', 'pause_resume_date', {
type: DataTypes.DATE,
allowNull: true
});
await queryInterface.addColumn('workflow_requests', 'pause_tat_snapshot', {
type: DataTypes.JSONB,
allowNull: true
});
// Create index on is_paused for faster queries
await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "workflow_requests_is_paused" ON "workflow_requests" ("is_paused");'
);
// Create index on pause_resume_date for auto-resume job
await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "workflow_requests_pause_resume_date" ON "workflow_requests" ("pause_resume_date") WHERE "is_paused" = true;'
);
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.removeColumn('workflow_requests', 'pause_tat_snapshot');
await queryInterface.removeColumn('workflow_requests', 'pause_resume_date');
await queryInterface.removeColumn('workflow_requests', 'pause_reason');
await queryInterface.removeColumn('workflow_requests', 'paused_by');
await queryInterface.removeColumn('workflow_requests', 'paused_at');
await queryInterface.removeColumn('workflow_requests', 'is_paused');
}

View File

@ -0,0 +1,35 @@
import { QueryInterface } from 'sequelize';
/**
* Migration to add 'PAUSED' value to enum_approval_status enum type
* This is required for the pause workflow feature
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add 'PAUSED' to the enum_approval_status enum type
// PostgreSQL doesn't support IF NOT EXISTS for ALTER TYPE ADD VALUE,
// so we check if it exists first
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'PAUSED'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_approval_status')
) THEN
ALTER TYPE enum_approval_status ADD VALUE 'PAUSED';
END IF;
END$$;
`);
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Note: PostgreSQL doesn't support removing enum values directly
// To fully rollback, you would need to:
// 1. Create a new enum without 'PAUSED'
// 2. Update all columns to use the new enum
// 3. Drop the old enum
// This is complex and risky, so we'll leave 'PAUSED' in the enum
// even after rollback. This is a limitation of PostgreSQL enums.
console.log('[Migration] Note: Cannot remove enum values in PostgreSQL. PAUSED will remain in enum_approval_status.');
}

View File

@ -0,0 +1,35 @@
import { QueryInterface } from 'sequelize';
/**
* Migration to add 'PAUSED' value to enum_workflow_status enum type
* This allows workflows to have a PAUSED status in addition to the isPaused boolean flag
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Add 'PAUSED' to the enum_workflow_status enum type
// PostgreSQL doesn't support IF NOT EXISTS for ALTER TYPE ADD VALUE,
// so we check if it exists first
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'PAUSED'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_workflow_status')
) THEN
ALTER TYPE enum_workflow_status ADD VALUE 'PAUSED';
END IF;
END$$;
`);
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Note: PostgreSQL doesn't support removing enum values directly
// To fully rollback, you would need to:
// 1. Create a new enum without 'PAUSED'
// 2. Update all columns to use the new enum
// 3. Drop the old enum
// This is complex and risky, so we'll leave 'PAUSED' in the enum
// even after rollback. This is a limitation of PostgreSQL enums.
console.log('[Migration] Note: Cannot remove enum values in PostgreSQL. PAUSED will remain in enum_workflow_status.');
}

View File

@ -0,0 +1,24 @@
import { QueryInterface } from 'sequelize';
/**
* Migration to update any workflow requests with IN_PROGRESS status to PENDING
* Since IN_PROGRESS is essentially the same as PENDING for workflow requests
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Update any workflow requests with IN_PROGRESS status to PENDING
await queryInterface.sequelize.query(`
UPDATE workflow_requests
SET status = 'PENDING'
WHERE status = 'IN_PROGRESS';
`);
console.log('[Migration] Updated IN_PROGRESS workflow requests to PENDING');
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Note: We cannot reliably restore IN_PROGRESS status since we don't know
// which requests were originally IN_PROGRESS vs PENDING
// This migration is one-way
console.log('[Migration] Cannot rollback - IN_PROGRESS to PENDING migration is one-way');
}

View File

@ -29,11 +29,18 @@ interface ApprovalLevelAttributes {
tat75AlertSent: boolean;
tatBreached: boolean;
tatStartTime?: Date;
isPaused: boolean;
pausedAt?: Date;
pausedBy?: string;
pauseReason?: string;
pauseResumeDate?: Date;
pauseTatStartTime?: Date;
pauseElapsedHours?: number;
createdAt: Date;
updatedAt: Date;
}
interface ApprovalLevelCreationAttributes extends Optional<ApprovalLevelAttributes, 'levelId' | 'levelName' | 'levelStartTime' | 'levelEndTime' | 'actionDate' | 'comments' | 'rejectionReason' | 'breachReason' | 'tat50AlertSent' | 'tat75AlertSent' | 'tatBreached' | 'tatStartTime' | 'tatDays' | 'createdAt' | 'updatedAt'> {}
interface ApprovalLevelCreationAttributes extends Optional<ApprovalLevelAttributes, 'levelId' | 'levelName' | 'levelStartTime' | 'levelEndTime' | 'actionDate' | 'comments' | 'rejectionReason' | 'breachReason' | 'tat50AlertSent' | 'tat75AlertSent' | 'tatBreached' | 'tatStartTime' | 'tatDays' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatStartTime' | 'pauseElapsedHours' | 'createdAt' | 'updatedAt'> {}
class ApprovalLevel extends Model<ApprovalLevelAttributes, ApprovalLevelCreationAttributes> implements ApprovalLevelAttributes {
public levelId!: string;
@ -60,6 +67,13 @@ class ApprovalLevel extends Model<ApprovalLevelAttributes, ApprovalLevelCreation
public tat75AlertSent!: boolean;
public tatBreached!: boolean;
public tatStartTime?: Date;
public isPaused!: boolean;
public pausedAt?: Date;
public pausedBy?: string;
public pauseReason?: string;
public pauseResumeDate?: Date;
public pauseTatStartTime?: Date;
public pauseElapsedHours?: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -127,7 +141,7 @@ ApprovalLevel.init(
// Database will auto-calculate this value - do NOT pass it during INSERT/UPDATE operations
},
status: {
type: DataTypes.ENUM('PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'SKIPPED'),
type: DataTypes.ENUM('PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'SKIPPED', 'PAUSED'),
defaultValue: 'PENDING'
},
levelStartTime: {
@ -200,6 +214,45 @@ ApprovalLevel.init(
allowNull: true,
field: 'tat_start_time'
},
isPaused: {
type: DataTypes.BOOLEAN,
defaultValue: false,
field: 'is_paused'
},
pausedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'paused_at'
},
pausedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'paused_by',
references: {
model: 'users',
key: 'user_id'
}
},
pauseReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'pause_reason'
},
pauseResumeDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'pause_resume_date'
},
pauseTatStartTime: {
type: DataTypes.DATE,
allowNull: true,
field: 'pause_tat_start_time'
},
pauseElapsedHours: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true,
field: 'pause_elapsed_hours'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -21,11 +21,17 @@ interface WorkflowRequestAttributes {
aiGeneratedConclusion?: string;
isDraft: boolean;
isDeleted: boolean;
isPaused: boolean;
pausedAt?: Date;
pausedBy?: string;
pauseReason?: string;
pauseResumeDate?: Date;
pauseTatSnapshot?: any;
createdAt: Date;
updatedAt: Date;
}
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'createdAt' | 'updatedAt'> {}
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'createdAt' | 'updatedAt'> {}
class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes {
public requestId!: string;
@ -45,6 +51,12 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
public aiGeneratedConclusion?: string;
public isDraft!: boolean;
public isDeleted!: boolean;
public isPaused!: boolean;
public pausedAt?: Date;
public pausedBy?: string;
public pauseReason?: string;
public pauseResumeDate?: Date;
public pauseTatSnapshot?: any;
public createdAt!: Date;
public updatedAt!: Date;
@ -144,6 +156,40 @@ WorkflowRequest.init(
defaultValue: false,
field: 'is_deleted'
},
isPaused: {
type: DataTypes.BOOLEAN,
defaultValue: false,
field: 'is_paused'
},
pausedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'paused_at'
},
pausedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'paused_by',
references: {
model: 'users',
key: 'user_id'
}
},
pauseReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'pause_reason'
},
pauseResumeDate: {
type: DataTypes.DATE,
allowNull: true,
field: 'pause_resume_date'
},
pauseTatSnapshot: {
type: DataTypes.JSONB,
allowNull: true,
field: 'pause_tat_snapshot'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -0,0 +1,26 @@
import { Job } from 'bullmq';
import { pauseService } from '../services/pause.service';
import logger from '../utils/logger';
export async function handlePauseResumeJob(job: Job): Promise<void> {
try {
const { type } = job.data;
if (type === 'check_and_resume') {
logger.info(`[Pause Resume Processor] Processing auto-resume check job ${job.id}`);
const resumedCount = await pauseService.checkAndResumePausedWorkflows();
if (resumedCount > 0) {
logger.info(`[Pause Resume Processor] Auto-resumed ${resumedCount} workflow(s)`);
} else {
logger.debug('[Pause Resume Processor] No workflows to auto-resume');
}
} else {
logger.warn(`[Pause Resume Processor] Unknown job type: ${type}`);
}
} catch (error: any) {
logger.error(`[Pause Resume Processor] Failed to process job ${job.id}:`, error);
throw error; // Re-throw to trigger retry mechanism
}
}

View File

@ -0,0 +1,36 @@
import { Queue } from 'bullmq';
import { sharedRedisConnection } from './redisConnection';
import logger from '@utils/logger';
let pauseResumeQueue: Queue | null = null;
try {
// Use shared Redis connection for both Queue and Worker
pauseResumeQueue = new Queue('pauseResumeQueue', {
connection: sharedRedisConnection,
defaultJobOptions: {
removeOnComplete: {
age: 86400, // Keep completed jobs for 24 hours
count: 1000
},
removeOnFail: false,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
}
}
});
pauseResumeQueue.on('error', (error) => {
logger.error('[Pause Resume Queue] Queue error:', error);
});
logger.info('[Pause Resume Queue] ✅ Queue initialized');
} catch (error) {
logger.error('[Pause Resume Queue] Failed to initialize:', error);
pauseResumeQueue = null;
}
export { pauseResumeQueue };

View File

@ -0,0 +1,61 @@
import { Worker } from 'bullmq';
import { sharedRedisConnection } from './redisConnection';
import { handlePauseResumeJob } from './pauseResumeProcessor';
import logger from '@utils/logger';
let pauseResumeWorker: Worker | null = null;
try {
pauseResumeWorker = new Worker('pauseResumeQueue', handlePauseResumeJob, {
connection: sharedRedisConnection,
concurrency: 1, // Process one at a time to avoid race conditions
autorun: true,
limiter: {
max: 1,
duration: 1000
}
});
if (pauseResumeWorker) {
pauseResumeWorker.on('ready', () => {
logger.info('[Pause Resume Worker] ✅ Ready and listening for pause resume jobs');
});
pauseResumeWorker.on('active', (job) => {
logger.info(`[Pause Resume Worker] Processing: ${job.name} (${job.id})`);
});
pauseResumeWorker.on('completed', (job) => {
logger.info(`[Pause Resume Worker] Completed: ${job.name} (${job.id})`);
});
pauseResumeWorker.on('failed', (job, err) => {
logger.error(`[Pause Resume Worker] Failed: ${job?.name} (${job?.id})`, err.message);
});
pauseResumeWorker.on('error', (err) => {
logger.error('[Pause Resume Worker] Error:', err.message);
});
}
} catch (workerError: any) {
logger.error('[Pause Resume Worker] Failed to create worker:', workerError);
pauseResumeWorker = null;
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (pauseResumeWorker) {
logger.info('[Pause Resume Worker] SIGTERM received, closing worker...');
await pauseResumeWorker.close();
}
});
process.on('SIGINT', async () => {
if (pauseResumeWorker) {
logger.info('[Pause Resume Worker] SIGINT received, closing worker...');
await pauseResumeWorker.close();
}
});
export { pauseResumeWorker };

View File

@ -59,6 +59,12 @@ export async function handleTatJob(job: Job<TatJobData>) {
let alertType: TatAlertType;
let thresholdPercentage: number;
// Check if level is paused - skip TAT processing if paused
if ((approvalLevel as any).isPaused) {
logger.info(`[TAT Processor] Skipping ${type} notification - level ${levelId} is paused`);
return;
}
const tatHours = Number((approvalLevel as any).tatHours || 0);
const levelStartTime = (approvalLevel as any).levelStartTime || (approvalLevel as any).createdAt;
const now = new Date();
@ -66,7 +72,16 @@ export async function handleTatJob(job: Job<TatJobData>) {
// FIXED: Use proper working hours calculation instead of calendar hours
// This respects working hours (9 AM - 6 PM), excludes weekends for STANDARD priority, and excludes holidays
const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase();
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority);
// Pass pause information if available
const pauseInfo = (approvalLevel as any).isPaused ? {
isPaused: (approvalLevel as any).isPaused,
pausedAt: (approvalLevel as any).pausedAt,
pauseElapsedHours: (approvalLevel as any).pauseElapsedHours,
pauseResumeDate: (approvalLevel as any).pauseResumeDate
} : undefined;
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority, pauseInfo);
const remainingHours = Math.max(0, tatHours - elapsedHours);
// Calculate expected completion time using proper working hours calculation

View File

@ -17,6 +17,7 @@ import { Activity } from '@models/Activity';
import { WorkflowService } from '../services/workflow.service';
import { WorkNoteController } from '../controllers/worknote.controller';
import { workNoteService } from '../services/worknote.service';
import { pauseController } from '../controllers/pause.controller';
import logger from '@utils/logger';
const router = Router();
@ -458,4 +459,36 @@ router.post('/:id/approvers/at-level',
})
);
// Pause workflow routes
// POST /workflows/:id/pause - Pause a workflow (approver only)
router.post('/:id/pause',
authenticateToken,
requireParticipantTypes(['APPROVER']), // Only approvers can pause
validateParams(workflowParamsSchema),
asyncHandler(pauseController.pauseWorkflow.bind(pauseController))
);
// POST /workflows/:id/resume - Resume a paused workflow (approver who paused or initiator)
router.post('/:id/resume',
authenticateToken,
requireParticipantTypes(['APPROVER', 'INITIATOR']),
validateParams(workflowParamsSchema),
asyncHandler(pauseController.resumeWorkflow.bind(pauseController))
);
// POST /workflows/:id/pause/retrigger - Retrigger pause (initiator requests approver to resume)
router.post('/:id/pause/retrigger',
authenticateToken,
requireParticipantTypes(['INITIATOR']), // Only initiator can retrigger
validateParams(workflowParamsSchema),
asyncHandler(pauseController.retriggerPause.bind(pauseController))
);
// GET /workflows/:id/pause - Get pause details
router.get('/:id/pause',
authenticateToken,
validateParams(workflowParamsSchema),
asyncHandler(pauseController.getPauseDetails.bind(pauseController))
);
export default router;

View File

@ -112,6 +112,12 @@ async function runMigrations(): Promise<void> {
const m19 = require('../migrations/20251121-add-ai-model-configs');
const m20 = require('../migrations/20250122-create-request-summaries');
const m21 = require('../migrations/20250122-create-shared-summaries');
const m22 = require('../migrations/20250123-update-request-number-format');
const m23 = require('../migrations/20250126-add-paused-to-enum');
const m24 = require('../migrations/20250126-add-paused-to-workflow-status-enum');
const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests');
const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -136,6 +142,12 @@ async function runMigrations(): Promise<void> {
{ name: '20251121-add-ai-model-configs', module: m19 },
{ name: '20250122-create-request-summaries', module: m20 },
{ name: '20250122-create-shared-summaries', module: m21 },
{ name: '20250123-update-request-number-format', module: m22 },
{ name: '20250126-add-paused-to-enum', module: m23 },
{ name: '20250126-add-paused-to-workflow-status-enum', module: m24 },
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
];
const queryInterface = sequelize.getQueryInterface();

View File

@ -6,6 +6,8 @@ import { logTatConfig } from './config/tat.config';
import { logSystemConfig } from './config/system.config';
import { initializeHolidaysCache } from './utils/tatTimeUtils';
import { seedDefaultConfigurations } from './services/configSeed.service';
import { startPauseResumeJob } from './jobs/pauseResumeJob';
import './queues/pauseResumeWorker'; // Initialize pause resume worker
const PORT: number = parseInt(process.env.PORT || '5000', 10);
@ -29,6 +31,9 @@ const startServer = async (): Promise<void> => {
// Silently fall back to weekends-only TAT calculation
}
// Start scheduled jobs
startPauseResumeJob();
server.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
});

View File

@ -5,7 +5,7 @@ export const SYSTEM_EVENT_REQUEST_ID = '00000000-0000-0000-0000-000000000001';
export type ActivityEntry = {
requestId: string;
type: 'created' | 'submitted' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning' | 'ai_conclusion_generated' | 'closed' | 'login';
type: 'created' | 'submitted' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning' | 'ai_conclusion_generated' | 'closed' | 'login' | 'paused' | 'resumed' | 'pause_retriggered';
user?: { userId: string; name?: string; email?: string };
timestamp: string;
action: string;
@ -34,7 +34,10 @@ class ActivityService {
'reminder': 'SYSTEM',
'ai_conclusion_generated': 'SYSTEM',
'closed': 'WORKFLOW',
'login': 'AUTHENTICATION'
'login': 'AUTHENTICATION',
'paused': 'WORKFLOW',
'resumed': 'WORKFLOW',
'pause_retriggered': 'WORKFLOW'
};
return categoryMap[type] || 'OTHER';
}
@ -53,7 +56,10 @@ class ActivityService {
'document_added': 'INFO',
'assignment': 'INFO',
'reminder': 'INFO',
'ai_conclusion_generated': 'INFO'
'ai_conclusion_generated': 'INFO',
'paused': 'WARNING',
'resumed': 'INFO',
'pause_retriggered': 'INFO'
};
return severityMap[type] || 'INFO';
}

View File

@ -20,11 +20,39 @@ export class ApprovalService {
// Get workflow to determine priority for working hours calculation
const wf = await WorkflowRequest.findByPk(level.requestId);
if (!wf) return null;
const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase();
const isPaused = (wf as any).isPaused || (level as any).isPaused;
// If paused, resume automatically when approving/rejecting (requirement 3.6)
if (isPaused) {
const { pauseService } = await import('./pause.service');
try {
await pauseService.resumeWorkflow(level.requestId, _userId);
logger.info(`[Approval] Auto-resumed paused workflow ${level.requestId} when ${action.action === 'APPROVE' ? 'approving' : 'rejecting'}`);
} catch (pauseError) {
logger.warn(`[Approval] Failed to auto-resume paused workflow:`, pauseError);
// Continue with approval/rejection even if resume fails
}
}
const now = new Date();
// Calculate elapsed hours using working hours logic (matches frontend)
const elapsedHours = await calculateElapsedWorkingHours(level.levelStartTime || level.createdAt, now, priority);
// Calculate elapsed hours using working hours logic (with pause handling)
const pauseInfo = (level as any).isPaused ? {
isPaused: (level as any).isPaused,
pausedAt: (level as any).pausedAt,
pauseElapsedHours: (level as any).pauseElapsedHours,
pauseResumeDate: (level as any).pauseResumeDate
} : undefined;
const elapsedHours = await calculateElapsedWorkingHours(
level.levelStartTime || level.createdAt,
now,
priority,
pauseInfo
);
const tatPercentage = calculateTATPercentage(elapsedHours, level.tatHours);
const updateData = {
@ -276,6 +304,12 @@ export class ApprovalService {
}
} else {
// Not final - move to next level
// Check if workflow is paused - if so, don't advance
if ((wf as any).isPaused || (wf as any).status === 'PAUSED') {
logger.warn(`[Approval] Cannot advance workflow ${level.requestId} - workflow is paused`);
throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.');
}
const nextLevelNumber = (level.levelNumber || 0) + 1;
const nextLevel = await ApprovalLevel.findOne({
where: {
@ -285,6 +319,12 @@ export class ApprovalService {
});
if (nextLevel) {
// Check if next level is paused - if so, don't activate it
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
logger.warn(`[Approval] Cannot activate next level ${nextLevelNumber} - level is paused`);
throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.');
}
// Activate next level
await nextLevel.update({
status: ApprovalStatus.IN_PROGRESS,

View File

@ -18,6 +18,33 @@ interface DateRangeFilter {
}
export class DashboardService {
/**
* Build user-level filter clause that includes all requests where user is involved:
* - As initiator (created the request)
* - As approver (in any approval level)
* - As participant/spectator
*
* @param workflowAlias - The alias used for workflow_requests table (e.g., 'wf')
* @returns SQL clause to filter requests for user-level view
*/
private buildUserLevelFilter(workflowAlias: string = 'wf'): string {
return `
AND (
${workflowAlias}.initiator_id = :userId
OR EXISTS (
SELECT 1 FROM approval_levels al_user
WHERE al_user.request_id = ${workflowAlias}.request_id
AND al_user.approver_id = :userId
)
OR EXISTS (
SELECT 1 FROM participants p_user
WHERE p_user.request_id = ${workflowAlias}.request_id
AND p_user.user_id = :userId
)
)
`;
}
/**
* Parse date range string to Date objects
*/
@ -158,7 +185,7 @@ export class DashboardService {
const statusUpper = status.toUpperCase();
if (statusUpper === 'PENDING') {
// Pending includes both PENDING and IN_PROGRESS
filterConditions += ` AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS')`;
filterConditions += ` AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS')`; // IN_PROGRESS legacy support
} else if (statusUpper === 'CLOSED') {
filterConditions += ` AND wf.status = 'CLOSED'`;
} else if (statusUpper === 'REJECTED') {
@ -251,7 +278,7 @@ export class DashboardService {
}
// Organization Level: Admin/Management see ALL requests across organization
// Personal Level: Regular users see only requests they INITIATED
// Personal Level: Regular users see requests where they are INVOLVED (initiator, approver, or participant)
// Note: If dateRange is provided, filter by submission_date. Otherwise, show all requests.
// For pending/open requests, if no date range, count ALL pending requests regardless of creation date
// For approved/rejected/closed, if date range is provided, count only those submitted in date range
@ -259,11 +286,28 @@ export class DashboardService {
? `wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL`
: `1=1`; // No date filter - show all requests
// Build user-level filter: Include requests where user is initiator, approver, or participant
const userLevelFilter = !isAdmin ? `
AND (
wf.initiator_id = :userId
OR EXISTS (
SELECT 1 FROM approval_levels al_user
WHERE al_user.request_id = wf.request_id
AND al_user.approver_id = :userId
)
OR EXISTS (
SELECT 1 FROM participants p_user
WHERE p_user.request_id = wf.request_id
AND p_user.user_id = :userId
)
)
` : '';
let whereClauseForAllRequests = `
WHERE ${dateFilterClause}
AND wf.is_draft = false
AND (wf.is_deleted IS NULL OR wf.is_deleted = false)
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
${userLevelFilter}
${filterConditions}
`;
@ -278,14 +322,14 @@ export class DashboardService {
AND wf.is_draft = false
AND (wf.is_deleted IS NULL OR wf.is_deleted = false)
AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS')
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
${userLevelFilter}
${filterConditions.replace(/AND \(wf\.status = 'PENDING' OR wf\.status = 'IN_PROGRESS'\)|AND wf\.status = 'PENDING'|AND wf\.status = 'IN_PROGRESS'/g, '').trim()}
`;
// Clean up any double ANDs
whereClauseForPending = whereClauseForPending.replace(/\s+AND\s+AND/g, ' AND');
// Get total, approved, rejected, and closed requests
// Get total, approved, rejected, closed, and paused requests
// If date range is applied, only count requests submitted in that range
// If no date range, count all requests matching other filters
const result = await sequelize.query(`
@ -293,7 +337,8 @@ export class DashboardService {
COUNT(*)::int AS total_requests,
COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_requests,
COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected_requests,
COUNT(CASE WHEN wf.status = 'CLOSED' THEN 1 END)::int AS closed_requests
COUNT(CASE WHEN wf.status = 'CLOSED' THEN 1 END)::int AS closed_requests,
COUNT(CASE WHEN wf.is_paused = true THEN 1 END)::int AS paused_requests
FROM workflow_requests wf
${whereClauseForAllRequests}
`, {
@ -301,20 +346,25 @@ export class DashboardService {
type: QueryTypes.SELECT
});
// Get ALL pending/open requests
// Get ALL pending/open requests (excluding paused)
// Organization Level (Admin): All pending requests across organization
// Personal Level (Regular User): Only pending requests they initiated
// If no date range, count all pending requests regardless of submission date
const pendingWhereClause = whereClauseForPending.replace(
/AND \(wf\.status = 'PENDING' OR wf\.status = 'IN_PROGRESS'\)/,
`AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS') AND (wf.is_paused IS NULL OR wf.is_paused = false)`
);
const pendingResult = await sequelize.query(`
SELECT COUNT(*)::int AS open_requests
FROM workflow_requests wf
${whereClauseForPending}
${pendingWhereClause}
`, {
replacements,
type: QueryTypes.SELECT
});
// Get draft count separately (with filters)
// For user-level, drafts are only visible to the initiator (not to approvers/participants)
let draftWhereClause = `WHERE wf.is_draft = true ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} ${filterConditions}`;
const draftResult = await sequelize.query(`
SELECT COUNT(*)::int AS draft_count
@ -331,10 +381,11 @@ export class DashboardService {
return {
totalRequests: stats.total_requests || 0,
openRequests: pending.open_requests || 0, // All pending requests regardless of creation date
openRequests: pending.open_requests || 0, // All pending requests regardless of creation date (excluding paused)
approvedRequests: stats.approved_requests || 0,
rejectedRequests: stats.rejected_requests || 0,
closedRequests: stats.closed_requests || 0,
pausedRequests: stats.paused_requests || 0,
draftRequests: drafts.draft_count || 0,
changeFromPrevious: {
total: '+0',
@ -2284,7 +2335,7 @@ export class DashboardService {
let statusFilter = '';
if (status && status !== 'all') {
if (status === 'pending') {
statusFilter = `AND wf.status IN ('PENDING', 'IN_PROGRESS')`;
statusFilter = `AND wf.status IN ('PENDING', 'IN_PROGRESS')`; // IN_PROGRESS legacy support
} else {
statusFilter = `AND wf.status = :statusFilter`;
replacements.statusFilter = status.toUpperCase();

View File

@ -0,0 +1,531 @@
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';
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 the approver for this level
if ((level as any).approverId !== userId) {
throw new Error('Only the assigned approver 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();
const levelStartTime = (level as any).levelStartTime || (level as any).tatStartTime || (level as any).createdAt;
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, 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: levelStartTime
};
// Update approval level with pause information
await level.update({
isPaused: true,
pausedAt: now,
pausedBy: userId,
pauseReason: reason,
pauseResumeDate: resumeDate,
pauseTatStartTime: levelStartTime,
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
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
});
// Notify approver (self)
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: 'workflow_paused',
priority: 'MEDIUM',
actionRequired: false
});
// 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}`);
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)
*/
async resumeWorkflow(requestId: string, userId?: 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)
// Note: Initiators cannot resume directly - they must use retrigger to request approver to resume
// Exception: When skipping approver (requirement 3.7), initiator can cancel pause
if (userId) {
const pausedBy = (workflow as any).pausedBy;
if (pausedBy !== userId) {
// Only the approver who paused can resume directly
// Initiators should use retrigger to request resume (requirement 3.5)
throw new Error('Only the approver who paused this workflow can resume it. Initiators should use the retrigger option to request the approver to resume.');
}
}
// 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);
// Update approval level - resume TAT
await level.update({
isPaused: false,
pausedAt: null as any,
pausedBy: null as any,
pauseReason: null as any,
pauseResumeDate: null as any,
pauseTatStartTime: null as any,
pauseElapsedHours: null as any,
status: ApprovalStatus.IN_PROGRESS,
tatStartTime: now, // Reset TAT start time to now
levelStartTime: now
});
// 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
if (remainingHours > 0) {
await tatSchedulerService.scheduleTatJobs(
requestId,
(level as any).levelId,
(level as any).approverId,
remainingHours, // Use remaining hours, not original TAT
now,
priority as any
);
}
// 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;
// Notify initiator
await notificationService.sendToUsers([(workflow as any).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
});
// Notify approver
await notificationService.sendToUsers([(level as any).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
});
// Log activity
await activityService.log({
requestId,
type: 'resumed',
user: userId ? { userId, name: resumeUserName } : undefined,
timestamp: now.toISOString(),
action: 'Workflow Resumed',
details: `Workflow resumed ${userId ? `by ${resumeUserName}` : 'automatically'} at level ${(level as any).levelNumber}.`,
metadata: {
levelId: (level as any).levelId,
levelNumber: (level as any).levelNumber,
wasAutoResume: !userId
}
});
logger.info(`[Pause] Workflow ${requestId} resumed ${userId ? `by ${userId}` : 'automatically'}`);
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
const pausedWorkflows = await WorkflowRequest.findAll({
where: {
isPaused: true,
pauseResumeDate: {
[Op.lte]: now
}
}
});
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();

View File

@ -0,0 +1,84 @@
import { pauseResumeQueue } from '../queues/pauseResumeQueue';
import logger from '../utils/logger';
export class PauseResumeSchedulerService {
/**
* Schedule the recurring auto-resume check job
* This creates a repeating job that runs every hour
*/
async scheduleAutoResumeJob(): Promise<void> {
try {
// Check if pauseResumeQueue is available
if (!pauseResumeQueue) {
logger.warn(`[Pause Resume Scheduler] Queue not available (Redis not connected). Skipping job scheduling.`);
return;
}
// Check if job already exists
const existingJobs = await pauseResumeQueue.getJobs(['delayed', 'waiting', 'active']);
const autoResumeJob = existingJobs.find(job => job.name === 'check_and_resume' && job.data.type === 'check_and_resume');
if (autoResumeJob) {
logger.info('[Pause Resume Scheduler] Auto-resume job already scheduled');
return;
}
// Calculate delay until next hour (e.g., if it's 2:30 PM, delay is 30 minutes)
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(nextHour.getHours() + 1);
nextHour.setMinutes(0);
nextHour.setSeconds(0);
nextHour.setMilliseconds(0);
const delayMs = nextHour.getTime() - now.getTime();
// Schedule the first job to run at the next hour
await pauseResumeQueue.add(
'check_and_resume',
{ type: 'check_and_resume' },
{
delay: delayMs,
jobId: 'pause-resume-recurring',
repeat: {
pattern: '0 * * * *', // Every hour at minute 0 (cron pattern)
tz: 'Asia/Kolkata' // Adjust timezone as needed
},
removeOnComplete: {
age: 86400, // Keep for 24 hours
count: 1000
},
removeOnFail: false
}
);
logger.info(`[Pause Resume Scheduler] ✅ Auto-resume job scheduled (runs every hour, first run in ${Math.round(delayMs / 1000 / 60)} minutes)`);
} catch (error) {
logger.error(`[Pause Resume Scheduler] Failed to schedule auto-resume job:`, error);
throw error;
}
}
/**
* Cancel the auto-resume recurring job
*/
async cancelAutoResumeJob(): Promise<void> {
try {
if (!pauseResumeQueue) {
logger.warn(`[Pause Resume Scheduler] Queue not available. Skipping job cancellation.`);
return;
}
const job = await pauseResumeQueue.getJob('pause-resume-recurring');
if (job) {
await job.remove();
logger.info('[Pause Resume Scheduler] Cancelled auto-resume job');
}
} catch (error) {
logger.error(`[Pause Resume Scheduler] Failed to cancel auto-resume job:`, error);
}
}
}
export const pauseResumeSchedulerService = new PauseResumeSchedulerService();

View File

@ -143,6 +143,54 @@ export class WorkflowService {
throw new Error('Cannot skip future approval levels');
}
// Cancel pause if workflow is paused (requirement 3.7)
// When initiator skips a paused approver, the pause is negated and workflow resumes automatically
if ((workflow as any).isPaused || (workflow as any).status === 'PAUSED') {
try {
// Get the paused level (should be the level being skipped)
const pausedLevel = await ApprovalLevel.findOne({
where: {
requestId,
isPaused: true
}
});
// Cancel pause on the workflow (the level will be marked as skipped below)
const previousStatus = (workflow as any).pauseTatSnapshot?.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 (should be PENDING)
});
// If the paused level is the one being skipped, clear its pause fields
// (it will be marked as SKIPPED below, so no need to restore to PENDING)
if (pausedLevel && (pausedLevel as any).levelId === levelId) {
await pausedLevel.update({
isPaused: false,
pausedAt: null as any,
pausedBy: null as any,
pauseReason: null as any,
pauseResumeDate: null as any,
pauseTatStartTime: null as any,
pauseElapsedHours: null as any
});
}
logger.info(`[Workflow] Pause cancelled and workflow resumed when approver was skipped for request ${requestId}`);
// Reload workflow to get updated state after resume
await workflow.reload();
} catch (pauseError) {
logger.warn(`[Workflow] Failed to cancel pause when skipping approver:`, pauseError);
// Continue with skip even if pause cancellation fails
}
}
// Mark as skipped
await level.update({
status: ApprovalStatus.SKIPPED,
@ -169,7 +217,7 @@ export class WorkflowService {
// Cancel TAT jobs for skipped level
await tatSchedulerService.cancelTatJobs(requestId, levelId);
// Move to next level
const nextLevelNumber = levelNumber + 1;
const nextLevel = await ApprovalLevel.findOne({
@ -177,6 +225,12 @@ export class WorkflowService {
});
if (nextLevel) {
// Check if next level is paused - if so, don't activate it
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
logger.warn(`[Workflow] Cannot activate next level ${nextLevelNumber} - level is paused`);
throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.');
}
const now = new Date();
await nextLevel.update({
status: ApprovalStatus.IN_PROGRESS,
@ -470,15 +524,21 @@ export class WorkflowService {
// NOTE: NO initiator exclusion here - admin sees ALL requests
// Apply status filter (pending, approved, rejected, closed)
// Apply status filter (pending, approved, rejected, closed, paused)
if (filters?.status && filters.status !== 'all') {
const statusUpper = filters.status.toUpperCase();
if (statusUpper === 'PENDING') {
// Pending includes both PENDING and IN_PROGRESS
// Pending requests (not paused)
whereConditions.push({
status: 'PENDING',
isPaused: false
});
} else if (statusUpper === 'PAUSED') {
// Paused requests - can filter by status or isPaused flag
whereConditions.push({
[Op.or]: [
{ status: 'PENDING' },
{ status: 'IN_PROGRESS' }
{ status: 'PAUSED' },
{ isPaused: true }
]
});
} else if (statusUpper === 'CLOSED') {
@ -715,7 +775,7 @@ export class WorkflowService {
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: (wf as any).requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any },
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any }, // IN_PROGRESS for approval levels, not workflows
},
order: [['levelNumber', 'ASC']],
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
@ -910,11 +970,11 @@ export class WorkflowService {
if (filters?.status && filters.status !== 'all') {
const statusUpper = filters.status.toUpperCase();
if (statusUpper === 'PENDING') {
// Pending includes both PENDING and IN_PROGRESS
// Pending requests only (IN_PROGRESS is treated as PENDING)
whereConditions.push({
[Op.or]: [
{ status: 'PENDING' },
{ status: 'IN_PROGRESS' }
{ status: 'IN_PROGRESS' } // Legacy support - will be migrated to PENDING
]
});
} else if (statusUpper === 'CLOSED') {
@ -1129,9 +1189,12 @@ export class WorkflowService {
}
/**
* List requests where user is a PARTICIPANT (not initiator) for REGULAR USERS - "All Requests" page
* List ALL requests where user is INVOLVED for REGULAR USERS - "All Requests" page
* This is a dedicated method for regular users' "All Requests" screen
* Shows only requests where user is approver or spectator, EXCLUDES initiator requests
* Shows requests where user is:
* - Initiator (created the request)
* - Approver (in any approval level)
* - Participant/spectator
* Completely separate from listWorkflows (admin) to avoid interference
*/
async listParticipantRequests(
@ -1154,10 +1217,17 @@ export class WorkflowService {
) {
const offset = (page - 1) * limit;
// Find all request IDs where user is a participant (NOT initiator):
// 1. As approver (in any approval level)
// 2. As participant/spectator
// NOTE: Exclude requests where user is initiator (those are shown in "My Requests" page)
// Find all request IDs where user is INVOLVED in any capacity:
// 1. As initiator (created the request)
// 2. As approver (in any approval level)
// 3. As participant/spectator
// Get requests where user is the initiator
const initiatorRequests = await WorkflowRequest.findAll({
where: { initiatorId: userId, isDraft: false },
attributes: ['requestId'],
});
const initiatorRequestIds = initiatorRequests.map((r: any) => r.requestId);
// Get requests where user is an approver (in any approval level)
const approverLevels = await ApprovalLevel.findAll({
@ -1173,8 +1243,9 @@ export class WorkflowService {
});
const participantRequestIds = participants.map((p: any) => p.requestId);
// Combine request IDs where user is participant (approver or spectator)
// Combine ALL request IDs where user is involved (initiator + approver + spectator)
const allRequestIds = Array.from(new Set([
...initiatorRequestIds,
...approverRequestIds,
...participantRequestIds
]));
@ -1182,11 +1253,7 @@ export class WorkflowService {
// Build where clause with filters
const whereConditions: any[] = [];
// ALWAYS exclude requests where user is initiator (for regular users only)
// This ensures "All Requests" only shows participant requests, not initiator requests
whereConditions.push({ initiatorId: { [Op.ne]: userId } });
// Filter by request IDs where user is involved as participant (approver or spectator)
// Filter by request IDs where user is involved in any capacity
if (allRequestIds.length > 0) {
whereConditions.push({ requestId: { [Op.in]: allRequestIds } });
} else {
@ -1202,11 +1269,11 @@ export class WorkflowService {
if (filters?.status && filters.status !== 'all') {
const statusUpper = filters.status.toUpperCase();
if (statusUpper === 'PENDING') {
// Pending includes both PENDING and IN_PROGRESS
// Pending requests only (IN_PROGRESS is treated as PENDING)
whereConditions.push({
[Op.or]: [
{ status: 'PENDING' },
{ status: 'IN_PROGRESS' }
{ status: 'IN_PROGRESS' } // Legacy support - will be migrated to PENDING
]
});
} else if (statusUpper === 'CLOSED') {
@ -1610,10 +1677,18 @@ export class WorkflowService {
async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) {
const offset = (page - 1) * limit;
// Find all pending/in-progress approval levels across requests ordered by levelNumber
// Find all pending/in-progress/paused approval levels across requests ordered by levelNumber
// Include PAUSED status so paused requests where user is the current approver are shown
const pendingLevels = await ApprovalLevel.findAll({
where: {
status: { [Op.in]: [ApprovalStatus.PENDING as any, (ApprovalStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', 'PENDING', 'IN_PROGRESS'] as any },
status: { [Op.in]: [
ApprovalStatus.PENDING as any,
(ApprovalStatus as any).IN_PROGRESS ?? 'IN_PROGRESS',
ApprovalStatus.PAUSED as any,
'PENDING',
'IN_PROGRESS',
'PAUSED'
] as any },
},
order: [['requestId', 'ASC'], ['levelNumber', 'ASC']],
attributes: ['requestId', 'levelNumber', 'approverId'],
@ -1678,22 +1753,52 @@ export class WorkflowService {
});
}
// Add status condition
// Add status condition - include PAUSED so paused requests are shown
baseConditions.push({
status: { [Op.in]: [
WorkflowStatus.PENDING as any,
(WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS',
WorkflowStatus.APPROVED as any,
'PENDING',
'IN_PROGRESS',
'APPROVED'
] as any }
[Op.or]: [
{
status: { [Op.in]: [
WorkflowStatus.PENDING as any,
WorkflowStatus.APPROVED as any,
WorkflowStatus.PAUSED as any,
'PENDING',
'IN_PROGRESS', // Legacy support - will be migrated to PENDING
'APPROVED',
'PAUSED'
] as any }
},
// Also include requests with isPaused = true (even if status is PENDING)
{
isPaused: true
}
]
});
// Apply status filter if provided (overrides default status filter)
if (filters?.status && filters.status !== 'all') {
baseConditions.pop(); // Remove default status
baseConditions.push({ status: filters.status.toUpperCase() });
const statusUpper = filters.status.toUpperCase();
baseConditions.pop(); // Remove default status condition
if (statusUpper === 'PAUSED') {
// For paused filter, include both PAUSED status and isPaused flag
baseConditions.push({
[Op.or]: [
{ status: 'PAUSED' },
{ isPaused: true }
]
});
} else {
// For other statuses, filter normally but exclude paused requests
baseConditions.push({
[Op.and]: [
{ status: statusUpper },
{ [Op.or]: [
{ isPaused: { [Op.is]: null } },
{ isPaused: false }
]}
]
});
}
}
// Apply priority filter
@ -2178,14 +2283,45 @@ export class WorkflowService {
if (!workflow) return null;
// Compute current approver and SLA summary (same logic used in lists)
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any },
},
order: [['levelNumber', 'ASC']],
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
});
// When paused, use the workflow's currentLevel field directly to get the paused level
// Otherwise, find the first PENDING/IN_PROGRESS level
const workflowCurrentLevel = (workflow as any).currentLevel;
const isPaused = (workflow as any).isPaused || (workflow as any).status === 'PAUSED';
let currentLevel: ApprovalLevel | null = null;
if (isPaused && workflowCurrentLevel) {
// When paused, get the level at the workflow's currentLevel (the paused level)
// This ensures we show SLA for the paused approver, not the next one
currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
levelNumber: workflowCurrentLevel,
},
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
});
} else {
// When not paused, find the first active level (exclude PAUSED to avoid showing wrong level)
currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any },
},
order: [['levelNumber', 'ASC']],
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
});
}
// Fallback: if currentLevel not found but workflow has currentLevel, use it
if (!currentLevel && workflowCurrentLevel) {
currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
levelNumber: workflowCurrentLevel,
},
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
});
}
const totalTat = Number((workflow as any).totalTatHours || 0);
let percent = 0;
@ -2209,7 +2345,8 @@ export class WorkflowService {
priority: (workflow as any).priority,
submittedAt: (workflow as any).submissionDate,
totalLevels: (workflow as any).totalLevels,
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
// When paused, ensure we use the paused level's number, not the next level
currentLevel: currentLevel ? (currentLevel as any).levelNumber : (isPaused ? workflowCurrentLevel : null),
currentApprover: currentLevel ? {
userId: (currentLevel as any).approverId,
email: (currentLevel as any).approverEmail,
@ -2360,16 +2497,26 @@ export class WorkflowService {
const updatedApprovals = await Promise.all(approvals.map(async (approval: any) => {
const status = (approval.status || '').toString().toUpperCase();
const approvalData = approval.toJSON();
const isPausedLevel = status === 'PAUSED' || approval.isPaused;
// Calculate SLA for active approvals (pending/in-progress)
if (status === 'PENDING' || status === 'IN_PROGRESS') {
// Calculate SLA for active approvals (pending/in-progress/paused)
// Include PAUSED so we show SLA for the paused approver, not the next one
if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
const levelStartTime = approval.levelStartTime || approval.tatStartTime || approval.createdAt;
const tatHours = Number(approval.tatHours || 0);
if (levelStartTime && tatHours > 0) {
try {
// Prepare pause info for SLA calculation if level is paused
const pauseInfo = isPausedLevel ? {
isPaused: true,
pausedAt: approval.pausedAt,
pauseElapsedHours: approval.pauseElapsedHours,
pauseResumeDate: approval.pauseResumeDate
} : undefined;
// Get comprehensive SLA status from backend utility
const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority);
const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority, null, pauseInfo);
// Return updated approval with comprehensive SLA data
return {
@ -2385,10 +2532,10 @@ export class WorkflowService {
return {
...approvalData,
sla: {
elapsedHours: 0,
elapsedHours: isPausedLevel ? (approval.pauseElapsedHours || 0) : 0,
remainingHours: tatHours,
percentageUsed: 0,
isPaused: false,
isPaused: isPausedLevel,
status: 'on_track',
remainingText: `${tatHours}h`,
elapsedText: '0h'

View File

@ -6,10 +6,10 @@ export enum Priority {
export enum WorkflowStatus {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
CLOSED = 'CLOSED'
CLOSED = 'CLOSED',
PAUSED = 'PAUSED'
}
export enum ApprovalStatus {
@ -17,7 +17,8 @@ export enum ApprovalStatus {
IN_PROGRESS = 'IN_PROGRESS',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
SKIPPED = 'SKIPPED'
SKIPPED = 'SKIPPED',
PAUSED = 'PAUSED'
}
export enum ParticipantType {

View File

@ -465,7 +465,8 @@ export async function calculateSLAStatus(
levelStartTime: Date | string,
tatHours: number,
priority: string = 'standard',
endDate?: Date | string | null
endDate?: Date | string | null,
pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null }
) {
await loadWorkingHoursCache();
await loadHolidaysCache();
@ -474,21 +475,42 @@ export async function calculateSLAStatus(
// Use provided endDate if available (for completed requests), otherwise use current time
const endTime = endDate ? dayjs(endDate) : dayjs();
// Calculate elapsed working hours
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority);
// Calculate elapsed working hours (with pause handling)
const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority, pauseInfo);
const remainingHours = Math.max(0, tatHours - elapsedHours);
const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0;
// Calculate deadline based on priority
// EXPRESS: All days (Mon-Sun) but working hours only (9 AM - 6 PM)
// STANDARD: Weekdays only (Mon-Fri) and working hours (9 AM - 6 PM)
const deadline = priority === 'express'
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
: (await addWorkingHours(levelStartTime, tatHours)).toDate();
// If paused, adjust deadline calculation to account for pause time
let deadline: Date;
if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) {
// If paused, deadline should be calculated from original start + TAT
// But we need to account for the pause duration
const remainingTatAfterPause = tatHours - pauseInfo.pauseElapsedHours;
if (pauseInfo.pauseResumeDate) {
// Calculate deadline from resume date with remaining TAT
deadline = priority === 'express'
? (await addWorkingHoursExpress(pauseInfo.pauseResumeDate, remainingTatAfterPause)).toDate()
: (await addWorkingHours(pauseInfo.pauseResumeDate, remainingTatAfterPause)).toDate();
} else {
// Still paused, use original calculation
deadline = priority === 'express'
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
: (await addWorkingHours(levelStartTime, tatHours)).toDate();
}
} else {
deadline = priority === 'express'
? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate()
: (await addWorkingHours(levelStartTime, tatHours)).toDate();
}
// Check if currently paused (outside working hours)
// Check if currently paused (workflow pause or outside working hours)
// For completed requests (with endDate), it's not paused
const isPaused = endDate ? false : !(await isCurrentlyWorkingTime(priority));
const isPaused = endDate
? false
: (pauseInfo?.isPaused === true || !(await isCurrentlyWorkingTime(priority)));
// Determine status
let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
@ -533,22 +555,43 @@ export async function calculateSLAStatus(
* @param startDate - Start time (when level was assigned)
* @param endDate - End time (defaults to now)
* @param priority - 'express' or 'standard' (express includes weekends, standard excludes)
* @param pauseInfo - Optional pause information { isPaused: boolean, pausedAt?: Date, pauseElapsedHours?: number, pauseResumeDate?: Date }
* @returns Elapsed working hours (with decimal precision)
*/
export async function calculateElapsedWorkingHours(
startDate: Date | string,
endDateParam: Date | string | null = null,
priority: string = 'standard'
priority: string = 'standard',
pauseInfo?: { isPaused: boolean; pausedAt?: Date | string | null; pauseElapsedHours?: number; pauseResumeDate?: Date | string | null }
): Promise<number> {
await loadWorkingHoursCache();
await loadHolidaysCache();
let start = dayjs(startDate);
// Handle pause: if paused, use elapsed hours at pause time
if (pauseInfo?.isPaused && pauseInfo.pauseElapsedHours !== undefined) {
// If currently paused, return the elapsed hours at pause time
// No additional time accumulates while paused
return pauseInfo.pauseElapsedHours;
}
// If was paused but now resumed, calculate from resume date
let actualStartDate = startDate;
let prePauseElapsed = 0;
if (pauseInfo?.pauseResumeDate && pauseInfo.pauseElapsedHours !== undefined) {
// Was paused, now resumed
// Use elapsed hours at pause + time from resume to end
prePauseElapsed = pauseInfo.pauseElapsedHours;
actualStartDate = pauseInfo.pauseResumeDate;
}
let start = dayjs(actualStartDate);
const end = dayjs(endDateParam || new Date());
// In test mode, use raw minutes for 1:1 conversion
if (isTestMode()) {
return end.diff(start, 'minute') / 60;
const postResumeHours = end.diff(start, 'minute') / 60;
return prePauseElapsed + postResumeHours;
}
const config = workingHoursCache || {
@ -663,7 +706,8 @@ export async function calculateElapsedWorkingHours(
const hours = totalWorkingMinutes / 60;
return hours;
// Add pre-pause elapsed hours if resumed
return prePauseElapsed + hours;
}
/**

View File

@ -30,7 +30,7 @@ export const updateWorkflowSchema = z.object({
title: z.string().min(1).max(500).optional(),
description: z.string().min(1).optional(),
priority: z.enum(['STANDARD', 'EXPRESS'] as const).optional(),
status: z.enum(['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'CLOSED'] as const).optional(),
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'CLOSED', 'PAUSED'] as const).optional(),
conclusionRemark: z.string().optional(),
// For draft updates - allow updating approval levels and participants
approvalLevels: z.array(z.object({
@ -86,7 +86,7 @@ export const workflowParamsSchema = z.object({
export const workflowQuerySchema = z.object({
page: z.string().transform(Number).pipe(z.number().int().min(1)).optional(),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).optional(),
status: z.enum(['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'CLOSED'] as const).optional(),
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'CLOSED', 'PAUSED'] as const).optional(),
priority: z.enum(['STANDARD', 'EXPRESS'] as const).optional(),
sortBy: z.string().optional(),
sortOrder: z.enum(['ASC', 'DESC'] as const).optional(),