pause functionality addd along with user like allreuest if admin checks personla in dashboard
This commit is contained in:
parent
1b389a8704
commit
3bf4f540b5
@ -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": [
|
||||
|
||||
@ -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
|
||||
2
build/assets/conclusionApi-Dp5idKr8.js
Normal file
2
build/assets/conclusionApi-Dp5idKr8.js
Normal 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
|
||||
@ -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"}
|
||||
1
build/assets/index-BZdlHmA5.css
Normal file
1
build/assets/index-BZdlHmA5.css
Normal file
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
64
build/assets/index-CePk9sgI.js
Normal file
64
build/assets/index-CePk9sgI.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-CePk9sgI.js.map
Normal file
1
build/assets/index-CePk9sgI.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
build/assets/redux-vendor-tbZCm13o.js
Normal file
2
build/assets/redux-vendor-tbZCm13o.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
1
build/assets/redux-vendor-tbZCm13o.js.map
Normal file
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
1
build/assets/ui-vendor-CFl7S1sk.js.map
Normal file
1
build/assets/ui-vendor-CFl7S1sk.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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({
|
||||
|
||||
134
src/controllers/pause.controller.ts
Normal file
134
src/controllers/pause.controller.ts
Normal 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();
|
||||
|
||||
25
src/jobs/pauseResumeJob.ts
Normal file
25
src/jobs/pauseResumeJob.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
35
src/migrations/20250126-add-paused-to-enum.ts
Normal file
35
src/migrations/20250126-add-paused-to-enum.ts
Normal 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.');
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
24
src/migrations/20250127-migrate-in-progress-to-pending.ts
Normal file
24
src/migrations/20250127-migrate-in-progress-to-pending.ts
Normal 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');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
26
src/queues/pauseResumeProcessor.ts
Normal file
26
src/queues/pauseResumeProcessor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
36
src/queues/pauseResumeQueue.ts
Normal file
36
src/queues/pauseResumeQueue.ts
Normal 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 };
|
||||
|
||||
61
src/queues/pauseResumeWorker.ts
Normal file
61
src/queues/pauseResumeWorker.ts
Normal 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 };
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'}`);
|
||||
});
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
531
src/services/pause.service.ts
Normal file
531
src/services/pause.service.ts
Normal 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();
|
||||
|
||||
84
src/services/pauseResumeScheduler.service.ts
Normal file
84
src/services/pauseResumeScheduler.service.ts
Normal 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();
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user