shard summary implemented
This commit is contained in:
parent
29f7421cd3
commit
8c602fef24
@ -1,2 +1,2 @@
|
||||
import{a as t}from"./index-B6Vs1HfP.js";import"./radix-vendor-CbkudDDo.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-i7LKlA3D.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-Bc2W59QD.js.map
|
||||
import{a as t}from"./index-I97Fx9AA.js";import"./radix-vendor-BP4rDxsU.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CHsmPIhP.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-DS6rROxV.js.map
|
||||
@ -1 +1 @@
|
||||
{"version":3,"file":"conclusionApi-Bc2W59QD.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-DS6rROxV.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"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
43
build/assets/index-I97Fx9AA.js
Normal file
43
build/assets/index-I97Fx9AA.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-I97Fx9AA.js.map
Normal file
1
build/assets/index-I97Fx9AA.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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -52,11 +52,11 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-B6Vs1HfP.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-I97Fx9AA.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CbkudDDo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-BP4rDxsU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-i7LKlA3D.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CHsmPIhP.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-1fSSvDCY.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cnw6_NIY.css">
|
||||
|
||||
92
docs/REQUEST_SUMMARY_DESIGN.md
Normal file
92
docs/REQUEST_SUMMARY_DESIGN.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Request Summary Feature - Database Design
|
||||
|
||||
## Overview
|
||||
This feature allows initiators to create and share comprehensive summaries of closed requests with other users.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### 1. `request_summaries` Table
|
||||
Stores the summary data for a closed request.
|
||||
|
||||
```sql
|
||||
CREATE TABLE request_summaries (
|
||||
summary_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE,
|
||||
initiator_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL, -- Request title
|
||||
description TEXT, -- Request description
|
||||
closing_remarks TEXT, -- Final conclusion remarks (from conclusion_remarks or manual)
|
||||
is_ai_generated BOOLEAN DEFAULT false, -- Whether closing remarks are AI-generated
|
||||
conclusion_id UUID REFERENCES conclusion_remarks(conclusion_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT uk_request_summary UNIQUE (request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_request_summaries_request_id ON request_summaries(request_id);
|
||||
CREATE INDEX idx_request_summaries_initiator_id ON request_summaries(initiator_id);
|
||||
CREATE INDEX idx_request_summaries_created_at ON request_summaries(created_at);
|
||||
```
|
||||
|
||||
### 2. `shared_summaries` Table
|
||||
Stores sharing relationships - who shared which summary with whom.
|
||||
|
||||
```sql
|
||||
CREATE TABLE shared_summaries (
|
||||
shared_summary_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
summary_id UUID NOT NULL REFERENCES request_summaries(summary_id) ON DELETE CASCADE,
|
||||
shared_by UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, -- Who shared it
|
||||
shared_with UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, -- Who can view it
|
||||
shared_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
viewed_at TIMESTAMP, -- When the recipient viewed it
|
||||
is_read BOOLEAN DEFAULT false, -- Whether recipient has viewed it
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT uk_shared_summary UNIQUE (summary_id, shared_with) -- Prevent duplicate shares
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_summaries_summary_id ON shared_summaries(summary_id);
|
||||
CREATE INDEX idx_shared_summaries_shared_by ON shared_summaries(shared_by);
|
||||
CREATE INDEX idx_shared_summaries_shared_with ON shared_summaries(shared_with);
|
||||
CREATE INDEX idx_shared_summaries_shared_at ON shared_summaries(shared_at);
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Request Closure**: When a request is closed, the initiator can create a summary
|
||||
2. **Summary Creation**:
|
||||
- Pulls data from `workflow_requests`, `approval_levels`, and `conclusion_remarks`
|
||||
- Creates a record in `request_summaries`
|
||||
3. **Sharing**:
|
||||
- Initiator selects users to share with
|
||||
- Creates records in `shared_summaries` for each recipient
|
||||
4. **Viewing**:
|
||||
- Users see shared summaries in "Shared Summary" menu
|
||||
- When viewed, `viewed_at` and `is_read` are updated
|
||||
|
||||
## Summary Content Structure
|
||||
|
||||
The summary will contain:
|
||||
- **Request Information**: Request number, title, description
|
||||
- **Initiator Details**: Name, designation, status, timestamp, remarks
|
||||
- **Approver Details** (for each level): Name, designation, status, timestamp, remarks
|
||||
- **Closing Remarks**: Final conclusion (AI-generated or manual)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Summary Management
|
||||
- `POST /api/v1/summaries` - Create summary for a closed request
|
||||
- `GET /api/v1/summaries/:summaryId` - Get summary details
|
||||
- `POST /api/v1/summaries/:summaryId/share` - Share summary with users
|
||||
- `DELETE /api/v1/summaries/:summaryId/share/:userId` - Unshare summary
|
||||
|
||||
### Shared Summaries (for recipients)
|
||||
- `GET /api/v1/summaries/shared` - List summaries shared with current user
|
||||
- `GET /api/v1/summaries/shared/:sharedSummaryId` - Get shared summary details
|
||||
- `PATCH /api/v1/summaries/shared/:sharedSummaryId/view` - Mark as viewed
|
||||
|
||||
### My Summaries (for initiators)
|
||||
- `GET /api/v1/summaries/my` - List summaries created by current user
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"start": "npm run build && npm run start:prod",
|
||||
"start": "npm run setup && npm run build && npm run start:prod",
|
||||
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"build": "tsc && tsc-alias",
|
||||
|
||||
@ -47,6 +47,9 @@ export class DashboardController {
|
||||
const priority = req.query.priority as string | undefined;
|
||||
const department = req.query.department as string | undefined;
|
||||
const initiator = req.query.initiator as string | undefined;
|
||||
const approver = req.query.approver as string | undefined;
|
||||
const approverType = req.query.approverType as 'current' | 'any' | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const stats = await this.dashboardService.getRequestStats(
|
||||
userId,
|
||||
@ -56,7 +59,10 @@ export class DashboardController {
|
||||
status,
|
||||
priority,
|
||||
department,
|
||||
initiator
|
||||
initiator,
|
||||
approver,
|
||||
approverType,
|
||||
search
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
136
src/controllers/summary.controller.ts
Normal file
136
src/controllers/summary.controller.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { summaryService } from '@services/summary.service';
|
||||
import { ResponseHandler } from '@utils/responseHandler';
|
||||
import type { AuthenticatedRequest } from '../types/express';
|
||||
|
||||
export class SummaryController {
|
||||
/**
|
||||
* Create a summary for a closed request
|
||||
* POST /api/v1/summaries
|
||||
*/
|
||||
async createSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const { requestId } = req.body;
|
||||
|
||||
if (!requestId) {
|
||||
ResponseHandler.error(res, 'requestId is required', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await summaryService.createSummary(requestId, userId);
|
||||
ResponseHandler.success(res, summary, 'Summary created successfully', 201);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to create summary', 400, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary details
|
||||
* GET /api/v1/summaries/:summaryId
|
||||
*/
|
||||
async getSummaryDetails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const { summaryId } = req.params;
|
||||
|
||||
// Check if this is a sharedSummaryId (UUID format) - if it starts with a shared summary pattern, try that first
|
||||
// For now, we'll check if it's a shared summary by trying to get it
|
||||
// If it fails, fall back to regular summary lookup
|
||||
try {
|
||||
const summary = await summaryService.getSummaryDetailsBySharedId(summaryId, userId);
|
||||
ResponseHandler.success(res, summary, 'Summary retrieved successfully');
|
||||
return;
|
||||
} catch (sharedError) {
|
||||
// If it's not a shared summary, try regular summary lookup
|
||||
const summary = await summaryService.getSummaryDetails(summaryId, userId);
|
||||
ResponseHandler.success(res, summary, 'Summary retrieved successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const statusCode = errorMessage.includes('not found') || errorMessage.includes('Access denied') ? 404 : 500;
|
||||
ResponseHandler.error(res, 'Failed to get summary details', statusCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share summary with users
|
||||
* POST /api/v1/summaries/:summaryId/share
|
||||
*/
|
||||
async shareSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const { summaryId } = req.params;
|
||||
const { userIds } = req.body;
|
||||
|
||||
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
ResponseHandler.error(res, 'userIds array is required', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedSummaries = await summaryService.shareSummary(summaryId, userId, userIds);
|
||||
ResponseHandler.success(res, sharedSummaries, 'Summary shared successfully', 201);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to share summary', 400, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List summaries shared with current user
|
||||
* GET /api/v1/summaries/shared
|
||||
*/
|
||||
async listSharedSummaries(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
const result = await summaryService.listSharedSummaries(userId, page, limit);
|
||||
ResponseHandler.success(res, result, 'Shared summaries retrieved successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to list shared summaries', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark shared summary as viewed
|
||||
* PATCH /api/v1/summaries/shared/:sharedSummaryId/view
|
||||
*/
|
||||
async markAsViewed(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const { sharedSummaryId } = req.params;
|
||||
|
||||
await summaryService.markAsViewed(sharedSummaryId, userId);
|
||||
ResponseHandler.success(res, null, 'Summary marked as viewed');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const statusCode = errorMessage.includes('not found') || errorMessage.includes('Access denied') ? 404 : 500;
|
||||
ResponseHandler.error(res, 'Failed to mark summary as viewed', statusCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List summaries created by current user
|
||||
* GET /api/v1/summaries/my
|
||||
*/
|
||||
async listMySummaries(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
const result = await summaryService.listMySummaries(userId, page, limit);
|
||||
ResponseHandler.success(res, result, 'Summaries retrieved successfully');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to list summaries', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const summaryController = new SummaryController();
|
||||
|
||||
@ -50,8 +50,27 @@ export class WorkflowController {
|
||||
ResponseHandler.error(res, 'payload is required', 400);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
const validated = validateCreateWorkflow(parsed);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (parseError) {
|
||||
ResponseHandler.error(res, 'Invalid JSON in payload', 400, parseError instanceof Error ? parseError.message : 'JSON parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = validateCreateWorkflow(parsed);
|
||||
} catch (validationError: any) {
|
||||
// Zod validation errors provide detailed information
|
||||
const errorMessage = validationError?.errors
|
||||
? validationError.errors.map((e: any) => `${e.path.join('.')}: ${e.message}`).join('; ')
|
||||
: (validationError instanceof Error ? validationError.message : 'Validation failed');
|
||||
ResponseHandler.error(res, 'Validation failed', 400, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowData = { ...validated, priority: validated.priority as Priority } as any;
|
||||
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -163,6 +182,7 @@ export class WorkflowController {
|
||||
department: req.query.department as string | undefined,
|
||||
initiator: req.query.initiator as string | undefined,
|
||||
approver: req.query.approver as string | undefined,
|
||||
approverType: req.query.approverType as 'current' | 'any' | undefined,
|
||||
slaCompliance: req.query.slaCompliance as string | undefined,
|
||||
dateRange: req.query.dateRange as string | undefined,
|
||||
startDate: req.query.startDate as string | undefined,
|
||||
@ -183,12 +203,20 @@ export class WorkflowController {
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters
|
||||
const filters = {
|
||||
search: req.query.search as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
priority: req.query.priority as string | undefined
|
||||
};
|
||||
// Extract filter parameters (same as listWorkflows)
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const priority = req.query.priority as string | undefined;
|
||||
const department = req.query.department as string | undefined;
|
||||
const initiator = req.query.initiator as string | undefined;
|
||||
const approver = req.query.approver as string | undefined;
|
||||
const approverType = req.query.approverType as 'current' | 'any' | undefined;
|
||||
const slaCompliance = req.query.slaCompliance as string | undefined;
|
||||
const dateRange = req.query.dateRange as string | undefined;
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
|
||||
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listMyRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'My requests fetched');
|
||||
@ -198,6 +226,67 @@ export class WorkflowController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List requests where user is a PARTICIPANT (not initiator) - for regular users' "All Requests" page
|
||||
* Completely separate from listWorkflows (admin) to avoid interference
|
||||
*/
|
||||
async listParticipantRequests(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters (same as listWorkflows)
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const priority = req.query.priority as string | undefined;
|
||||
const department = req.query.department as string | undefined;
|
||||
const initiator = req.query.initiator as string | undefined;
|
||||
const approver = req.query.approver as string | undefined;
|
||||
const approverType = req.query.approverType as 'current' | 'any' | undefined;
|
||||
const slaCompliance = req.query.slaCompliance as string | undefined;
|
||||
const dateRange = req.query.dateRange as string | undefined;
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
|
||||
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'Participant requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to fetch participant requests', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List requests where user is the initiator (for "My Requests" page)
|
||||
*/
|
||||
async listMyInitiatedRequests(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1);
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100);
|
||||
|
||||
// Extract filter parameters
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status as string | undefined;
|
||||
const priority = req.query.priority as string | undefined;
|
||||
const department = req.query.department as string | undefined;
|
||||
const dateRange = req.query.dateRange as string | undefined;
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
|
||||
const filters = { search, status, priority, department, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to fetch my initiated requests', 500, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async listOpenForMe(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId;
|
||||
|
||||
92
src/migrations/20250122-create-request-summaries.ts
Normal file
92
src/migrations/20250122-create-request-summaries.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Migration to create request_summaries table
|
||||
* Stores comprehensive summaries of closed workflow requests
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('request_summaries', {
|
||||
summary_id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
unique: true // One summary per request
|
||||
},
|
||||
initiator_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
closing_remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
is_ai_generated: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
conclusion_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'conclusion_remarks',
|
||||
key: 'conclusion_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.addIndex('request_summaries', ['request_id'], {
|
||||
name: 'idx_request_summaries_request_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('request_summaries', ['initiator_id'], {
|
||||
name: 'idx_request_summaries_initiator_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('request_summaries', ['created_at'], {
|
||||
name: 'idx_request_summaries_created_at'
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('request_summaries');
|
||||
}
|
||||
|
||||
99
src/migrations/20250122-create-shared-summaries.ts
Normal file
99
src/migrations/20250122-create-shared-summaries.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Migration to create shared_summaries table
|
||||
* Stores sharing relationships for request summaries
|
||||
*/
|
||||
export async function up(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.createTable('shared_summaries', {
|
||||
shared_summary_id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
summary_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'request_summaries',
|
||||
key: 'summary_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
shared_by: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
shared_with: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
shared_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
viewed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
is_read: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
// Create unique constraint to prevent duplicate shares
|
||||
await queryInterface.addConstraint('shared_summaries', {
|
||||
fields: ['summary_id', 'shared_with'],
|
||||
type: 'unique',
|
||||
name: 'uk_shared_summary'
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.addIndex('shared_summaries', ['summary_id'], {
|
||||
name: 'idx_shared_summaries_summary_id'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('shared_summaries', ['shared_by'], {
|
||||
name: 'idx_shared_summaries_shared_by'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('shared_summaries', ['shared_with'], {
|
||||
name: 'idx_shared_summaries_shared_with'
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('shared_summaries', ['shared_at'], {
|
||||
name: 'idx_shared_summaries_shared_at'
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface): Promise<void> {
|
||||
await queryInterface.dropTable('shared_summaries');
|
||||
}
|
||||
|
||||
137
src/models/RequestSummary.ts
Normal file
137
src/models/RequestSummary.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
import { WorkflowRequest } from './WorkflowRequest';
|
||||
import { User } from './User';
|
||||
import ConclusionRemark from './ConclusionRemark';
|
||||
|
||||
interface RequestSummaryAttributes {
|
||||
summaryId: string;
|
||||
requestId: string;
|
||||
initiatorId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
closingRemarks: string | null;
|
||||
isAiGenerated: boolean;
|
||||
conclusionId: string | null;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface RequestSummaryCreationAttributes
|
||||
extends Optional<RequestSummaryAttributes, 'summaryId' | 'description' | 'closingRemarks' | 'isAiGenerated' | 'conclusionId' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class RequestSummary extends Model<RequestSummaryAttributes, RequestSummaryCreationAttributes>
|
||||
implements RequestSummaryAttributes {
|
||||
public summaryId!: string;
|
||||
public requestId!: string;
|
||||
public initiatorId!: string;
|
||||
public title!: string;
|
||||
public description!: string | null;
|
||||
public closingRemarks!: string | null;
|
||||
public isAiGenerated!: boolean;
|
||||
public conclusionId!: string | null;
|
||||
public readonly createdAt!: Date;
|
||||
public readonly updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public request?: WorkflowRequest;
|
||||
public initiator?: User;
|
||||
public conclusion?: ConclusionRemark;
|
||||
}
|
||||
|
||||
RequestSummary.init(
|
||||
{
|
||||
summaryId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'summary_id'
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'request_id',
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
unique: true
|
||||
},
|
||||
initiatorId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'initiator_id',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
closingRemarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'closing_remarks'
|
||||
},
|
||||
isAiGenerated: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_ai_generated'
|
||||
},
|
||||
conclusionId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'conclusion_id',
|
||||
references: {
|
||||
model: 'conclusion_remarks',
|
||||
key: 'conclusion_id'
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'request_summaries',
|
||||
timestamps: true,
|
||||
underscored: true
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
RequestSummary.belongsTo(WorkflowRequest, {
|
||||
as: 'request',
|
||||
foreignKey: 'requestId',
|
||||
targetKey: 'requestId'
|
||||
});
|
||||
|
||||
RequestSummary.belongsTo(User, {
|
||||
as: 'initiator',
|
||||
foreignKey: 'initiatorId',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
RequestSummary.belongsTo(ConclusionRemark, {
|
||||
foreignKey: 'conclusionId',
|
||||
targetKey: 'conclusionId'
|
||||
});
|
||||
|
||||
export default RequestSummary;
|
||||
|
||||
132
src/models/SharedSummary.ts
Normal file
132
src/models/SharedSummary.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
import RequestSummary from './RequestSummary';
|
||||
import { User } from './User';
|
||||
|
||||
interface SharedSummaryAttributes {
|
||||
sharedSummaryId: string;
|
||||
summaryId: string;
|
||||
sharedBy: string;
|
||||
sharedWith: string;
|
||||
sharedAt: Date;
|
||||
viewedAt: Date | null;
|
||||
isRead: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface SharedSummaryCreationAttributes
|
||||
extends Optional<SharedSummaryAttributes, 'sharedSummaryId' | 'sharedAt' | 'viewedAt' | 'isRead' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class SharedSummary extends Model<SharedSummaryAttributes, SharedSummaryCreationAttributes>
|
||||
implements SharedSummaryAttributes {
|
||||
public sharedSummaryId!: string;
|
||||
public summaryId!: string;
|
||||
public sharedBy!: string;
|
||||
public sharedWith!: string;
|
||||
public sharedAt!: Date;
|
||||
public viewedAt!: Date | null;
|
||||
public isRead!: boolean;
|
||||
public readonly createdAt!: Date;
|
||||
public readonly updatedAt!: Date;
|
||||
|
||||
// Associations
|
||||
public summary?: RequestSummary;
|
||||
public sharedByUser?: User;
|
||||
public sharedWithUser?: User;
|
||||
}
|
||||
|
||||
SharedSummary.init(
|
||||
{
|
||||
sharedSummaryId: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
field: 'shared_summary_id'
|
||||
},
|
||||
summaryId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'summary_id',
|
||||
references: {
|
||||
model: 'request_summaries',
|
||||
key: 'summary_id'
|
||||
}
|
||||
},
|
||||
sharedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'shared_by',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
sharedWith: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
field: 'shared_with',
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'user_id'
|
||||
}
|
||||
},
|
||||
sharedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'shared_at'
|
||||
},
|
||||
viewedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'viewed_at'
|
||||
},
|
||||
isRead: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_read'
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at'
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'updated_at'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'shared_summaries',
|
||||
timestamps: true,
|
||||
underscored: true
|
||||
}
|
||||
);
|
||||
|
||||
// Associations
|
||||
SharedSummary.belongsTo(RequestSummary, {
|
||||
as: 'summary',
|
||||
foreignKey: 'summaryId',
|
||||
targetKey: 'summaryId'
|
||||
});
|
||||
|
||||
SharedSummary.belongsTo(User, {
|
||||
as: 'sharedByUser',
|
||||
foreignKey: 'sharedBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
SharedSummary.belongsTo(User, {
|
||||
as: 'sharedWithUser',
|
||||
foreignKey: 'sharedWith',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
export default SharedSummary;
|
||||
|
||||
@ -14,6 +14,8 @@ import { TatAlert } from './TatAlert';
|
||||
import { Holiday } from './Holiday';
|
||||
import { Notification } from './Notification';
|
||||
import ConclusionRemark from './ConclusionRemark';
|
||||
import RequestSummary from './RequestSummary';
|
||||
import SharedSummary from './SharedSummary';
|
||||
|
||||
// Define associations
|
||||
const defineAssociations = () => {
|
||||
@ -78,6 +80,40 @@ const defineAssociations = () => {
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
// RequestSummary associations
|
||||
// Note: belongsTo associations are defined in the model files to avoid duplicate alias conflicts
|
||||
// Only hasOne/hasMany associations are defined here
|
||||
WorkflowRequest.hasOne(RequestSummary, {
|
||||
as: 'summary',
|
||||
foreignKey: 'requestId',
|
||||
sourceKey: 'requestId'
|
||||
});
|
||||
|
||||
RequestSummary.hasMany(SharedSummary, {
|
||||
as: 'sharedSummaries',
|
||||
foreignKey: 'summaryId',
|
||||
sourceKey: 'summaryId'
|
||||
});
|
||||
|
||||
// User associations for summaries
|
||||
User.hasMany(RequestSummary, {
|
||||
as: 'createdSummaries',
|
||||
foreignKey: 'initiatorId',
|
||||
sourceKey: 'userId'
|
||||
});
|
||||
|
||||
User.hasMany(SharedSummary, {
|
||||
as: 'sharedByMe',
|
||||
foreignKey: 'sharedBy',
|
||||
sourceKey: 'userId'
|
||||
});
|
||||
|
||||
User.hasMany(SharedSummary, {
|
||||
as: 'sharedWithMe',
|
||||
foreignKey: 'sharedWith',
|
||||
sourceKey: 'userId'
|
||||
});
|
||||
|
||||
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
|
||||
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||
};
|
||||
@ -100,7 +136,9 @@ export {
|
||||
TatAlert,
|
||||
Holiday,
|
||||
Notification,
|
||||
ConclusionRemark
|
||||
ConclusionRemark,
|
||||
RequestSummary,
|
||||
SharedSummary
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import authRoutes from './auth.routes';
|
||||
import workflowRoutes from './workflow.routes';
|
||||
import summaryRoutes from './summary.routes';
|
||||
import userRoutes from './user.routes';
|
||||
import documentRoutes from './document.routes';
|
||||
import tatRoutes from './tat.routes';
|
||||
@ -36,6 +37,7 @@ router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/notifications', notificationRoutes);
|
||||
router.use('/conclusions', conclusionRoutes);
|
||||
router.use('/ai', aiRoutes);
|
||||
router.use('/summaries', summaryRoutes);
|
||||
|
||||
// TODO: Add other route modules as they are implemented
|
||||
// router.use('/approvals', approvalRoutes);
|
||||
|
||||
48
src/routes/summary.routes.ts
Normal file
48
src/routes/summary.routes.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import { summaryController } from '@controllers/summary.controller';
|
||||
import { authenticateToken } from '@middlewares/auth.middleware';
|
||||
import { asyncHandler } from '@middlewares/errorHandler.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticateToken);
|
||||
|
||||
// Create summary for a closed request
|
||||
router.post(
|
||||
'/',
|
||||
asyncHandler(summaryController.createSummary.bind(summaryController))
|
||||
);
|
||||
|
||||
// List summaries shared with current user (MUST come before /:summaryId)
|
||||
router.get(
|
||||
'/shared',
|
||||
asyncHandler(summaryController.listSharedSummaries.bind(summaryController))
|
||||
);
|
||||
|
||||
// Mark shared summary as viewed (MUST come before /:summaryId)
|
||||
router.patch(
|
||||
'/shared/:sharedSummaryId/view',
|
||||
asyncHandler(summaryController.markAsViewed.bind(summaryController))
|
||||
);
|
||||
|
||||
// List summaries created by current user (MUST come before /:summaryId)
|
||||
router.get(
|
||||
'/my',
|
||||
asyncHandler(summaryController.listMySummaries.bind(summaryController))
|
||||
);
|
||||
|
||||
// Share summary with users (MUST come before /:summaryId)
|
||||
router.post(
|
||||
'/:summaryId/share',
|
||||
asyncHandler(summaryController.shareSummary.bind(summaryController))
|
||||
);
|
||||
|
||||
// Get summary details (MUST come last - it's a catch-all for UUIDs)
|
||||
router.get(
|
||||
'/:summaryId',
|
||||
asyncHandler(summaryController.getSummaryDetails.bind(summaryController))
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -31,11 +31,25 @@ router.get('/',
|
||||
);
|
||||
|
||||
// Filtered lists
|
||||
// /my - All requests where user is a participant (not initiator) - for "All Requests" page (DEPRECATED - use /participant-requests)
|
||||
router.get('/my',
|
||||
authenticateToken,
|
||||
asyncHandler(workflowController.listMyRequests.bind(workflowController))
|
||||
);
|
||||
|
||||
// /participant-requests - All requests where user is a participant (not initiator) - for regular users' "All Requests" page
|
||||
// SEPARATE endpoint from /workflows (admin) to avoid interference
|
||||
router.get('/participant-requests',
|
||||
authenticateToken,
|
||||
asyncHandler(workflowController.listParticipantRequests.bind(workflowController))
|
||||
);
|
||||
|
||||
// /my-initiated - Only requests where user is the initiator - for "My Requests" page
|
||||
router.get('/my-initiated',
|
||||
authenticateToken,
|
||||
asyncHandler(workflowController.listMyInitiatedRequests.bind(workflowController))
|
||||
);
|
||||
|
||||
router.get('/open-for-me',
|
||||
authenticateToken,
|
||||
asyncHandler(workflowController.listOpenForMe.bind(workflowController))
|
||||
|
||||
@ -110,6 +110,8 @@ async function runMigrations(): Promise<void> {
|
||||
const m17 = require('../migrations/20251111-create-conclusion-remarks');
|
||||
const m18 = require('../migrations/20251118-add-breach-reason-to-approval-levels');
|
||||
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 migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -132,6 +134,8 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20251111-create-conclusion-remarks', module: m17 },
|
||||
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
|
||||
{ name: '20251121-add-ai-model-configs', module: m19 },
|
||||
{ name: '20250122-create-request-summaries', module: m20 },
|
||||
{ name: '20250122-create-shared-summaries', module: m21 },
|
||||
];
|
||||
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
@ -20,6 +20,8 @@ import * as m16 from '../migrations/20251111-create-notifications';
|
||||
import * as m17 from '../migrations/20251111-create-conclusion-remarks';
|
||||
import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-levels';
|
||||
import * as m19 from '../migrations/20251121-add-ai-model-configs';
|
||||
import * as m20 from '../migrations/20250122-create-request-summaries';
|
||||
import * as m21 from '../migrations/20250122-create-shared-summaries';
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@ -54,6 +56,8 @@ const migrations: Migration[] = [
|
||||
{ name: '20251111-create-conclusion-remarks', module: m17 },
|
||||
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
|
||||
{ name: '20251121-add-ai-model-configs', module: m19 },
|
||||
{ name: '20250122-create-request-summaries', module: m20 },
|
||||
{ name: '20250122-create-shared-summaries', module: m21 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -127,7 +127,10 @@ export class DashboardService {
|
||||
status?: string,
|
||||
priority?: string,
|
||||
department?: string,
|
||||
initiator?: string
|
||||
initiator?: string,
|
||||
approver?: string,
|
||||
approverType?: 'current' | 'any',
|
||||
search?: string
|
||||
) {
|
||||
const range = this.parseDateRange(dateRange, startDate, endDate);
|
||||
|
||||
@ -161,6 +164,39 @@ export class DashboardService {
|
||||
replacements.initiatorId = initiator;
|
||||
}
|
||||
|
||||
// Search filter (title, description, or requestNumber)
|
||||
if (search && search.trim()) {
|
||||
filterConditions += ` AND (
|
||||
wf.title ILIKE :search OR
|
||||
wf.description ILIKE :search OR
|
||||
wf.request_number ILIKE :search
|
||||
)`;
|
||||
replacements.search = `%${search.trim()}%`;
|
||||
}
|
||||
|
||||
// Approver filter (with current vs any logic)
|
||||
if (approver && approver !== 'all') {
|
||||
const approverTypeValue = approverType || 'current';
|
||||
if (approverTypeValue === 'current') {
|
||||
// Filter by current active approver only
|
||||
filterConditions += ` AND EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = wf.request_id
|
||||
AND al.approver_id = :approverId
|
||||
AND al.status IN ('PENDING', 'IN_PROGRESS')
|
||||
AND al.level_number = wf.current_level
|
||||
)`;
|
||||
} else {
|
||||
// Filter by any approver (past or current)
|
||||
filterConditions += ` AND EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = wf.request_id
|
||||
AND al.approver_id = :approverId
|
||||
)`;
|
||||
}
|
||||
replacements.approverId = approver;
|
||||
}
|
||||
|
||||
// Organization Level: Admin/Management see ALL requests across organization
|
||||
// Personal Level: Regular users see only requests they INITIATED
|
||||
// Note: For pending/open requests, count ALL pending requests regardless of creation date
|
||||
|
||||
733
src/services/summary.service.ts
Normal file
733
src/services/summary.service.ts
Normal file
@ -0,0 +1,733 @@
|
||||
import { RequestSummary, SharedSummary, WorkflowRequest, ApprovalLevel, User, ConclusionRemark } from '@models/index';
|
||||
import '@models/index'; // Ensure associations are loaded
|
||||
import { Op } from 'sequelize';
|
||||
import logger from '@utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export class SummaryService {
|
||||
/**
|
||||
* Create a summary for a closed request
|
||||
* Pulls data from workflow_requests, approval_levels, and conclusion_remarks
|
||||
*/
|
||||
async createSummary(requestId: string, initiatorId: string): Promise<RequestSummary> {
|
||||
try {
|
||||
// Check if request exists and is closed
|
||||
const workflow = await WorkflowRequest.findByPk(requestId, {
|
||||
include: [
|
||||
{ association: 'initiator', attributes: ['userId', 'email', 'displayName', 'designation', 'department'] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error('Workflow request not found');
|
||||
}
|
||||
|
||||
// Verify request is closed
|
||||
const status = (workflow as any).status?.toUpperCase();
|
||||
if (status !== 'APPROVED' && status !== 'REJECTED' && status !== 'CLOSED') {
|
||||
throw new Error('Request must be closed (APPROVED, REJECTED, or CLOSED) before creating summary');
|
||||
}
|
||||
|
||||
// Verify initiator owns the request
|
||||
if ((workflow as any).initiatorId !== initiatorId) {
|
||||
throw new Error('Only the initiator can create a summary for this request');
|
||||
}
|
||||
|
||||
// Check if summary already exists
|
||||
const existingSummary = await RequestSummary.findOne({
|
||||
where: { requestId }
|
||||
});
|
||||
|
||||
if (existingSummary) {
|
||||
throw new Error('Summary already exists for this request');
|
||||
}
|
||||
|
||||
// Get conclusion remarks
|
||||
const conclusion = await ConclusionRemark.findOne({
|
||||
where: { requestId }
|
||||
});
|
||||
|
||||
// Get all approval levels ordered by level number
|
||||
const approvalLevels = await ApprovalLevel.findAll({
|
||||
where: { requestId },
|
||||
order: [['levelNumber', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'approver',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Determine closing remarks
|
||||
let closingRemarks: string | null = null;
|
||||
let isAiGenerated = false;
|
||||
let conclusionId: string | null = null;
|
||||
|
||||
if (conclusion) {
|
||||
conclusionId = (conclusion as any).conclusionId;
|
||||
// Use final remark if edited, otherwise use AI-generated
|
||||
closingRemarks = (conclusion as any).finalRemark || (conclusion as any).aiGeneratedRemark || null;
|
||||
isAiGenerated = !(conclusion as any).isEdited && !!(conclusion as any).aiGeneratedRemark;
|
||||
} else {
|
||||
// Fallback to workflow's conclusion remark if no conclusion_remarks record
|
||||
closingRemarks = (workflow as any).conclusionRemark || null;
|
||||
isAiGenerated = false;
|
||||
}
|
||||
|
||||
// Create summary
|
||||
const summary = await RequestSummary.create({
|
||||
requestId,
|
||||
initiatorId,
|
||||
title: (workflow as any).title || '',
|
||||
description: (workflow as any).description || null,
|
||||
closingRemarks,
|
||||
isAiGenerated,
|
||||
conclusionId
|
||||
});
|
||||
|
||||
logger.info(`[Summary] Created summary ${(summary as any).summaryId} for request ${requestId}`);
|
||||
return summary;
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to create summary for request ${requestId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary details by sharedSummaryId (for recipients)
|
||||
*/
|
||||
async getSummaryDetailsBySharedId(sharedSummaryId: string, userId: string): Promise<any> {
|
||||
try {
|
||||
const shared = await SharedSummary.findByPk(sharedSummaryId, {
|
||||
include: [
|
||||
{
|
||||
model: RequestSummary,
|
||||
as: 'summary',
|
||||
include: [
|
||||
{
|
||||
model: WorkflowRequest,
|
||||
as: 'request',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'initiator',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'initiator',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
},
|
||||
{
|
||||
model: ConclusionRemark,
|
||||
attributes: ['conclusionId', 'aiGeneratedRemark', 'finalRemark', 'isEdited', 'generatedAt', 'finalizedAt']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!shared) {
|
||||
throw new Error('Shared summary not found');
|
||||
}
|
||||
|
||||
// Verify access
|
||||
if ((shared as any).sharedWith !== userId) {
|
||||
throw new Error('Access denied: You do not have permission to view this summary');
|
||||
}
|
||||
|
||||
const summary = (shared as any).summary;
|
||||
if (!summary) {
|
||||
throw new Error('Associated summary not found');
|
||||
}
|
||||
|
||||
const request = (summary as any).request;
|
||||
if (!request) {
|
||||
throw new Error('Associated workflow request not found');
|
||||
}
|
||||
|
||||
// Mark as viewed
|
||||
await shared.update({
|
||||
viewedAt: new Date(),
|
||||
isRead: true
|
||||
});
|
||||
|
||||
// Get all approval levels with approver details
|
||||
const approvalLevels = await ApprovalLevel.findAll({
|
||||
where: { requestId: (request as any).requestId },
|
||||
order: [['levelNumber', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'approver',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Format approver data for summary
|
||||
const approvers = approvalLevels.map((level: any) => {
|
||||
const approver = (level as any).approver || {};
|
||||
const status = (level.status || '').toString().toUpperCase();
|
||||
|
||||
// Determine remarks based on status
|
||||
let remarks: string | null = null;
|
||||
if (status === 'APPROVED') {
|
||||
remarks = level.comments || null;
|
||||
} else if (status === 'REJECTED') {
|
||||
remarks = level.rejectionReason || level.comments || null;
|
||||
} else if (status === 'SKIPPED') {
|
||||
remarks = (level as any).skipReason || 'Skipped' || null;
|
||||
}
|
||||
|
||||
// Determine timestamp
|
||||
let timestamp: Date | null = null;
|
||||
if (level.actionDate) {
|
||||
timestamp = level.actionDate;
|
||||
} else if (level.levelStartTime) {
|
||||
timestamp = level.levelStartTime;
|
||||
} else {
|
||||
timestamp = level.createdAt;
|
||||
}
|
||||
|
||||
return {
|
||||
levelNumber: level.levelNumber,
|
||||
levelName: level.levelName || `Approver ${level.levelNumber}`,
|
||||
name: approver.displayName || level.approverName || 'Unknown',
|
||||
designation: approver.designation || 'N/A',
|
||||
department: approver.department || null,
|
||||
email: approver.email || level.approverEmail || 'N/A',
|
||||
status: this.formatStatus(status),
|
||||
timestamp: timestamp,
|
||||
remarks: remarks || '—'
|
||||
};
|
||||
});
|
||||
|
||||
// Format initiator data
|
||||
const initiator = (request as any).initiator || {};
|
||||
const initiatorTimestamp = (request as any).submissionDate || (request as any).createdAt;
|
||||
|
||||
return {
|
||||
summaryId: (summary as any).summaryId,
|
||||
requestId: (request as any).requestId,
|
||||
requestNumber: (request as any).requestNumber || 'N/A',
|
||||
title: (summary as any).title || (request as any).title || '',
|
||||
description: (summary as any).description || (request as any).description || '',
|
||||
closingRemarks: (summary as any).closingRemarks || '—',
|
||||
isAiGenerated: (summary as any).isAiGenerated || false,
|
||||
createdAt: (summary as any).createdAt,
|
||||
initiator: {
|
||||
name: initiator.displayName || 'Unknown',
|
||||
designation: initiator.designation || 'N/A',
|
||||
department: initiator.department || null,
|
||||
email: initiator.email || 'N/A',
|
||||
status: 'Initiated',
|
||||
timestamp: initiatorTimestamp,
|
||||
remarks: '—'
|
||||
},
|
||||
approvers: approvers,
|
||||
workflow: {
|
||||
priority: (request as any).priority || 'STANDARD',
|
||||
status: (request as any).status || 'CLOSED',
|
||||
submissionDate: (request as any).submissionDate,
|
||||
closureDate: (request as any).closureDate
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to get summary details by shared ID ${sharedSummaryId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary details with all approver information
|
||||
*/
|
||||
async getSummaryDetails(summaryId: string, userId: string): Promise<any> {
|
||||
try {
|
||||
const summary = await RequestSummary.findByPk(summaryId, {
|
||||
include: [
|
||||
{
|
||||
model: WorkflowRequest,
|
||||
as: 'request',
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'initiator',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'initiator',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
},
|
||||
{
|
||||
model: ConclusionRemark,
|
||||
attributes: ['conclusionId', 'aiGeneratedRemark', 'finalRemark', 'isEdited', 'generatedAt', 'finalizedAt']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!summary) {
|
||||
throw new Error('Summary not found');
|
||||
}
|
||||
|
||||
const request = (summary as any).request;
|
||||
if (!request) {
|
||||
throw new Error('Associated workflow request not found');
|
||||
}
|
||||
|
||||
// Check access: user must be initiator or have been shared with
|
||||
const isInitiator = (summary as any).initiatorId === userId;
|
||||
const isShared = await SharedSummary.findOne({
|
||||
where: {
|
||||
summaryId,
|
||||
sharedWith: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!isInitiator && !isShared) {
|
||||
throw new Error('Access denied: You do not have permission to view this summary');
|
||||
}
|
||||
|
||||
// Get all approval levels with approver details
|
||||
const approvalLevels = await ApprovalLevel.findAll({
|
||||
where: { requestId: (request as any).requestId },
|
||||
order: [['levelNumber', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'approver',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation', 'department']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Format approver data for summary
|
||||
const approvers = approvalLevels.map((level: any) => {
|
||||
const approver = (level as any).approver || {};
|
||||
const status = (level.status || '').toString().toUpperCase();
|
||||
|
||||
// Determine remarks based on status
|
||||
let remarks: string | null = null;
|
||||
if (status === 'APPROVED') {
|
||||
remarks = level.comments || null;
|
||||
} else if (status === 'REJECTED') {
|
||||
remarks = level.rejectionReason || level.comments || null;
|
||||
} else if (status === 'SKIPPED') {
|
||||
remarks = (level as any).skipReason || 'Skipped' || null;
|
||||
}
|
||||
|
||||
// Determine timestamp
|
||||
let timestamp: Date | null = null;
|
||||
if (level.actionDate) {
|
||||
timestamp = level.actionDate;
|
||||
} else if (level.levelStartTime) {
|
||||
timestamp = level.levelStartTime;
|
||||
} else {
|
||||
timestamp = level.createdAt;
|
||||
}
|
||||
|
||||
return {
|
||||
levelNumber: level.levelNumber,
|
||||
levelName: level.levelName || `Approver ${level.levelNumber}`,
|
||||
name: approver.displayName || level.approverName || 'Unknown',
|
||||
designation: approver.designation || 'N/A',
|
||||
department: approver.department || null,
|
||||
email: approver.email || level.approverEmail || 'N/A',
|
||||
status: this.formatStatus(status),
|
||||
timestamp: timestamp,
|
||||
remarks: remarks || '—'
|
||||
};
|
||||
});
|
||||
|
||||
// Format initiator data
|
||||
const initiator = (request as any).initiator || {};
|
||||
const initiatorTimestamp = (request as any).submissionDate || (request as any).createdAt;
|
||||
|
||||
return {
|
||||
summaryId: (summary as any).summaryId,
|
||||
requestId: (request as any).requestId,
|
||||
requestNumber: (request as any).requestNumber || 'N/A',
|
||||
title: (summary as any).title || (request as any).title || '',
|
||||
description: (summary as any).description || (request as any).description || '',
|
||||
closingRemarks: (summary as any).closingRemarks || '—',
|
||||
isAiGenerated: (summary as any).isAiGenerated || false,
|
||||
createdAt: (summary as any).createdAt,
|
||||
initiator: {
|
||||
name: initiator.displayName || 'Unknown',
|
||||
designation: initiator.designation || 'N/A',
|
||||
department: initiator.department || null,
|
||||
email: initiator.email || 'N/A',
|
||||
status: 'Initiated',
|
||||
timestamp: initiatorTimestamp,
|
||||
remarks: '—'
|
||||
},
|
||||
approvers: approvers,
|
||||
workflow: {
|
||||
priority: (request as any).priority || 'STANDARD',
|
||||
status: (request as any).status || 'CLOSED',
|
||||
submissionDate: (request as any).submissionDate,
|
||||
closureDate: (request as any).closureDate
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to get summary details for ${summaryId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share summary with users
|
||||
* userIds can be either Okta IDs or internal UUIDs - we'll convert them to internal UUIDs
|
||||
*/
|
||||
async shareSummary(summaryId: string, sharedBy: string, userIds: string[]): Promise<SharedSummary[]> {
|
||||
try {
|
||||
// Verify summary exists and user is the initiator
|
||||
const summary = await RequestSummary.findByPk(summaryId);
|
||||
if (!summary) {
|
||||
throw new Error('Summary not found');
|
||||
}
|
||||
|
||||
if ((summary as any).initiatorId !== sharedBy) {
|
||||
throw new Error('Only the initiator can share this summary');
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
// Convert Okta IDs to internal UUIDs
|
||||
// The frontend may send Okta user IDs, but we need internal UUIDs for the database
|
||||
const { UserService } = await import('@services/user.service');
|
||||
const userService = new UserService();
|
||||
|
||||
const internalUserIds: string[] = [];
|
||||
for (const userIdOrOktaId of uniqueUserIds) {
|
||||
// Check if it's already a UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userIdOrOktaId);
|
||||
|
||||
if (isUUID) {
|
||||
// Already a UUID, verify user exists
|
||||
const user = await User.findByPk(userIdOrOktaId);
|
||||
if (!user) {
|
||||
logger.warn(`[Summary] User with UUID ${userIdOrOktaId} not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
internalUserIds.push(userIdOrOktaId);
|
||||
} else {
|
||||
// Likely an Okta ID, find user by oktaSub
|
||||
let user = await User.findOne({
|
||||
where: { oktaSub: userIdOrOktaId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// User doesn't exist in database, try to ensure they exist
|
||||
// The userIdOrOktaId is the Okta ID, we need to fetch user info and create them
|
||||
try {
|
||||
// Search for the user - we'll need to search by the Okta ID
|
||||
// Since searchUsers might return Okta users, we need to handle this
|
||||
const oktaUsers = await userService.searchUsers(userIdOrOktaId, 10);
|
||||
const oktaUser = oktaUsers.find((u: any) => {
|
||||
// Check if this is the user we're looking for
|
||||
const userOktaId = (u as any).oktaSub || (u as any).userId;
|
||||
return userOktaId === userIdOrOktaId;
|
||||
});
|
||||
|
||||
if (oktaUser) {
|
||||
// Ensure user exists in database
|
||||
const ensuredUser = await userService.ensureUserExists({
|
||||
userId: (oktaUser as any).oktaSub || (oktaUser as any).userId || userIdOrOktaId,
|
||||
email: (oktaUser as any).email,
|
||||
displayName: (oktaUser as any).displayName,
|
||||
firstName: (oktaUser as any).firstName,
|
||||
lastName: (oktaUser as any).lastName,
|
||||
department: (oktaUser as any).department,
|
||||
phone: (oktaUser as any).phone
|
||||
});
|
||||
internalUserIds.push(ensuredUser.userId);
|
||||
} else {
|
||||
// Try to find by email if userIdOrOktaId looks like an email
|
||||
if (userIdOrOktaId.includes('@')) {
|
||||
user = await User.findOne({
|
||||
where: { email: userIdOrOktaId }
|
||||
});
|
||||
if (user) {
|
||||
internalUserIds.push(user.userId);
|
||||
} else {
|
||||
logger.warn(`[Summary] User with email ${userIdOrOktaId} not found, skipping`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[Summary] User with Okta ID ${userIdOrOktaId} not found, skipping`);
|
||||
}
|
||||
}
|
||||
} catch (oktaError) {
|
||||
logger.error(`[Summary] Failed to fetch user from Okta for ${userIdOrOktaId}:`, oktaError);
|
||||
// Try to find by email if userIdOrOktaId looks like an email
|
||||
if (userIdOrOktaId.includes('@')) {
|
||||
user = await User.findOne({
|
||||
where: { email: userIdOrOktaId }
|
||||
});
|
||||
if (user) {
|
||||
internalUserIds.push(user.userId);
|
||||
} else {
|
||||
logger.warn(`[Summary] User with email ${userIdOrOktaId} not found, skipping`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[Summary] User with ID ${userIdOrOktaId} not found, skipping`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
internalUserIds.push(user.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (internalUserIds.length === 0) {
|
||||
throw new Error('No valid users found to share with');
|
||||
}
|
||||
|
||||
// Create shared summary records
|
||||
const sharedSummaries: SharedSummary[] = [];
|
||||
for (const internalUserId of internalUserIds) {
|
||||
// Skip if already shared with this user
|
||||
const existing = await SharedSummary.findOne({
|
||||
where: {
|
||||
summaryId,
|
||||
sharedWith: internalUserId
|
||||
}
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const shared = await SharedSummary.create({
|
||||
summaryId,
|
||||
sharedBy,
|
||||
sharedWith: internalUserId,
|
||||
sharedAt: new Date(),
|
||||
isRead: false
|
||||
});
|
||||
sharedSummaries.push(shared);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[Summary] Shared summary ${summaryId} with ${sharedSummaries.length} users`);
|
||||
return sharedSummaries;
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to share summary ${summaryId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List summaries shared with current user
|
||||
* userId can be either Okta ID or internal UUID - we'll convert to UUID
|
||||
*/
|
||||
async listSharedSummaries(userId: string, page: number = 1, limit: number = 20): Promise<any> {
|
||||
try {
|
||||
// Convert Okta ID to internal UUID if needed
|
||||
let internalUserId = userId;
|
||||
|
||||
// Check if it's already a UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId);
|
||||
|
||||
if (!isUUID) {
|
||||
// Likely an Okta ID, find user by oktaSub
|
||||
const user = await User.findOne({
|
||||
where: { oktaSub: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[Summary] User with Okta ID ${userId} not found for listing shared summaries`);
|
||||
// Return empty result instead of error
|
||||
return {
|
||||
data: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internalUserId = user.userId;
|
||||
} else {
|
||||
// Verify UUID user exists
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
logger.warn(`[Summary] User with UUID ${userId} not found for listing shared summaries`);
|
||||
return {
|
||||
data: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { rows, count } = await SharedSummary.findAndCountAll({
|
||||
where: { sharedWith: internalUserId },
|
||||
include: [
|
||||
{
|
||||
model: RequestSummary,
|
||||
as: 'summary',
|
||||
include: [
|
||||
{
|
||||
model: WorkflowRequest,
|
||||
as: 'request',
|
||||
attributes: ['requestId', 'requestNumber', 'title', 'status', 'closureDate']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'initiator',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'sharedByUser',
|
||||
attributes: ['userId', 'email', 'displayName', 'designation']
|
||||
}
|
||||
],
|
||||
order: [['sharedAt', 'DESC']],
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
const summaries = rows.map((shared: any) => {
|
||||
const summary = (shared as any).summary;
|
||||
const request = summary?.request;
|
||||
const initiator = summary?.initiator;
|
||||
const sharedBy = (shared as any).sharedByUser;
|
||||
|
||||
return {
|
||||
sharedSummaryId: (shared as any).sharedSummaryId,
|
||||
summaryId: (shared as any).summaryId,
|
||||
requestId: request?.requestId,
|
||||
requestNumber: request?.requestNumber || 'N/A',
|
||||
title: summary?.title || request?.title || 'N/A',
|
||||
initiatorName: initiator?.displayName || 'Unknown',
|
||||
sharedByName: sharedBy?.displayName || 'Unknown',
|
||||
sharedAt: (shared as any).sharedAt,
|
||||
viewedAt: (shared as any).viewedAt,
|
||||
isRead: (shared as any).isRead,
|
||||
closureDate: request?.closureDate
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: summaries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
totalPages: Math.ceil(count / limit) || 1
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to list shared summaries for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark shared summary as viewed
|
||||
*/
|
||||
async markAsViewed(sharedSummaryId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const shared = await SharedSummary.findByPk(sharedSummaryId);
|
||||
if (!shared) {
|
||||
throw new Error('Shared summary not found');
|
||||
}
|
||||
|
||||
if ((shared as any).sharedWith !== userId) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
|
||||
await shared.update({
|
||||
viewedAt: new Date(),
|
||||
isRead: true
|
||||
});
|
||||
|
||||
logger.info(`[Summary] Marked shared summary ${sharedSummaryId} as viewed by user ${userId}`);
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to mark shared summary as viewed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List summaries created by user
|
||||
*/
|
||||
async listMySummaries(userId: string, page: number = 1, limit: number = 20): Promise<any> {
|
||||
try {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { rows, count } = await RequestSummary.findAndCountAll({
|
||||
where: { initiatorId: userId },
|
||||
include: [
|
||||
{
|
||||
model: WorkflowRequest,
|
||||
as: 'request',
|
||||
attributes: ['requestId', 'requestNumber', 'title', 'status', 'closureDate']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
const summaries = rows.map((summary: any) => {
|
||||
const request = (summary as any).request;
|
||||
return {
|
||||
summaryId: (summary as any).summaryId,
|
||||
requestId: request?.requestId,
|
||||
requestNumber: request?.requestNumber || 'N/A',
|
||||
title: (summary as any).title || request?.title || 'N/A',
|
||||
createdAt: (summary as any).createdAt,
|
||||
closureDate: request?.closureDate
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: summaries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
totalPages: Math.ceil(count / limit) || 1
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[Summary] Failed to list summaries for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format status for display
|
||||
*/
|
||||
private formatStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'APPROVED': 'Approved',
|
||||
'REJECTED': 'Rejected',
|
||||
'PENDING': 'Pending',
|
||||
'IN_PROGRESS': 'In Progress',
|
||||
'SKIPPED': 'Skipped'
|
||||
};
|
||||
return statusMap[status.toUpperCase()] || status;
|
||||
}
|
||||
}
|
||||
|
||||
export const summaryService = new SummaryService();
|
||||
|
||||
@ -454,26 +454,41 @@ export class WorkflowService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) {
|
||||
/**
|
||||
* List all workflows for ADMIN/MANAGEMENT users (organization-level)
|
||||
* Shows ALL requests in the organization, including where admin is initiator
|
||||
* Used by: "All Requests" page for admin users
|
||||
*/
|
||||
async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause with filters
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Exclude drafts
|
||||
// Exclude drafts only
|
||||
whereConditions.push({ isDraft: false });
|
||||
|
||||
// Apply status filter
|
||||
// NOTE: NO initiator exclusion here - admin sees ALL requests
|
||||
|
||||
// Apply status filter (pending, approved, rejected, closed)
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
const statusUpper = filters.status.toUpperCase();
|
||||
if (statusUpper === 'PENDING') {
|
||||
// Pending includes both PENDING and IN_PROGRESS
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ status: 'PENDING' },
|
||||
{ status: 'IN_PROGRESS' }
|
||||
]
|
||||
});
|
||||
} else if (statusUpper === 'CLOSED') {
|
||||
whereConditions.push({ status: 'CLOSED' });
|
||||
} else if (statusUpper === 'REJECTED') {
|
||||
whereConditions.push({ status: 'REJECTED' });
|
||||
} else if (statusUpper === 'APPROVED') {
|
||||
whereConditions.push({ status: 'APPROVED' });
|
||||
} else {
|
||||
// Fallback: use the uppercase value as-is
|
||||
whereConditions.push({ status: statusUpper });
|
||||
}
|
||||
}
|
||||
@ -506,6 +521,57 @@ export class WorkflowService {
|
||||
whereConditions.push({ initiatorId: filters.initiator });
|
||||
}
|
||||
|
||||
// Apply approver filter (with current vs any logic)
|
||||
if (filters?.approver && filters.approver !== 'all') {
|
||||
const approverId = filters.approver;
|
||||
const approverType = filters.approverType || 'current'; // Default to 'current'
|
||||
|
||||
if (approverType === 'current') {
|
||||
// Filter by current active approver only
|
||||
// Find request IDs where this approver is the current active approver
|
||||
const currentApproverLevels = await ApprovalLevel.findAll({
|
||||
where: {
|
||||
approverId: approverId,
|
||||
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
||||
},
|
||||
attributes: ['requestId', 'levelNumber'],
|
||||
});
|
||||
|
||||
// Get the current level for each request to match only if this approver is at the current level
|
||||
const requestIds: string[] = [];
|
||||
for (const level of currentApproverLevels) {
|
||||
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
||||
attributes: ['requestId', 'currentLevel'],
|
||||
});
|
||||
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
||||
requestIds.push((level as any).requestId);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
} else {
|
||||
// Filter by any approver (past or current)
|
||||
// Find all request IDs where this user is an approver at any level
|
||||
const allApproverLevels = await ApprovalLevel.findAll({
|
||||
where: { approverId: approverId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
||||
|
||||
if (approverRequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply date range filter
|
||||
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
||||
let dateStart: Date | null = null;
|
||||
@ -553,6 +619,75 @@ export class WorkflowService {
|
||||
|
||||
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
||||
|
||||
// If SLA compliance filter is active, we need to:
|
||||
// 1. Fetch all matching records (or a larger batch)
|
||||
// 2. Enrich them (which calculates SLA)
|
||||
// 3. Filter by SLA compliance
|
||||
// 4. Then paginate
|
||||
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
||||
// Fetch a larger batch to filter by SLA (up to 1000 records)
|
||||
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
|
||||
// Enrich all records (calculates SLA)
|
||||
const enrichedData = await this.enrichForCards(allRows);
|
||||
|
||||
// Filter by SLA compliance
|
||||
const slaFilteredData = enrichedData.filter((req: any) => {
|
||||
const slaCompliance = filters.slaCompliance || '';
|
||||
|
||||
// Get SLA status from various possible locations
|
||||
const slaStatus = req.currentLevelSLA?.status ||
|
||||
req.currentApprover?.sla?.status ||
|
||||
req.sla?.status ||
|
||||
req.summary?.sla?.status;
|
||||
|
||||
if (slaCompliance.toLowerCase() === 'compliant') {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
||||
if (!isCompleted) return false;
|
||||
if (!slaStatus) return true;
|
||||
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
||||
}
|
||||
|
||||
if (!slaStatus) {
|
||||
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
'on-track': 'on_track',
|
||||
'on_track': 'on_track',
|
||||
'approaching': 'approaching',
|
||||
'critical': 'critical',
|
||||
'breached': 'breached'
|
||||
};
|
||||
|
||||
const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase();
|
||||
return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus;
|
||||
});
|
||||
|
||||
// Apply pagination to filtered results
|
||||
const totalFiltered = slaFilteredData.length;
|
||||
const paginatedData = slaFilteredData.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalFiltered,
|
||||
totalPages: Math.ceil(totalFiltered / limit) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Normal pagination (no SLA filter)
|
||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
offset,
|
||||
@ -700,15 +835,98 @@ export class WorkflowService {
|
||||
return data;
|
||||
}
|
||||
|
||||
async listMyRequests(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }) {
|
||||
/**
|
||||
* List requests where user is a PARTICIPANT (not initiator) for REGULAR USERS
|
||||
* Shows only requests where user is approver or spectator, EXCLUDES initiator requests
|
||||
* Used by: "All Requests" page for regular users
|
||||
* NOTE: This is SEPARATE from listWorkflows (admin) - they don't interfere with each other
|
||||
* @deprecated Use listParticipantRequests instead for clarity
|
||||
*/
|
||||
async listMyRequests(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
department?: string;
|
||||
initiator?: string;
|
||||
approver?: string;
|
||||
approverType?: 'current' | 'any';
|
||||
slaCompliance?: string;
|
||||
dateRange?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause with filters
|
||||
const whereConditions: any[] = [{ initiatorId: userId }];
|
||||
// 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)
|
||||
|
||||
// Apply status filter
|
||||
// Get requests where user is an approver (in any approval level)
|
||||
const approverLevels = await ApprovalLevel.findAll({
|
||||
where: { approverId: userId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const approverRequestIds = approverLevels.map((l: any) => l.requestId);
|
||||
|
||||
// Get requests where user is a participant/spectator
|
||||
const participants = await Participant.findAll({
|
||||
where: { userId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const participantRequestIds = participants.map((p: any) => p.requestId);
|
||||
|
||||
// Combine request IDs where user is participant (approver or spectator)
|
||||
const allRequestIds = Array.from(new Set([
|
||||
...approverRequestIds,
|
||||
...participantRequestIds
|
||||
]));
|
||||
|
||||
// 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)
|
||||
if (allRequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: allRequestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
|
||||
// Exclude drafts
|
||||
whereConditions.push({ isDraft: false });
|
||||
|
||||
// Apply status filter (pending, approved, rejected, closed)
|
||||
// Same logic as listWorkflows but applied to participant requests only
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
whereConditions.push({ status: filters.status.toUpperCase() });
|
||||
const statusUpper = filters.status.toUpperCase();
|
||||
if (statusUpper === 'PENDING') {
|
||||
// Pending includes both PENDING and IN_PROGRESS
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ status: 'PENDING' },
|
||||
{ status: 'IN_PROGRESS' }
|
||||
]
|
||||
});
|
||||
} else if (statusUpper === 'CLOSED') {
|
||||
whereConditions.push({ status: 'CLOSED' });
|
||||
} else if (statusUpper === 'REJECTED') {
|
||||
whereConditions.push({ status: 'REJECTED' });
|
||||
} else if (statusUpper === 'APPROVED') {
|
||||
whereConditions.push({ status: 'APPROVED' });
|
||||
} else {
|
||||
// Fallback: use the uppercase value as-is
|
||||
whereConditions.push({ status: statusUpper });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
@ -727,6 +945,589 @@ export class WorkflowService {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply department filter (through initiator)
|
||||
if (filters?.department && filters.department !== 'all') {
|
||||
whereConditions.push({
|
||||
'$initiator.department$': filters.department
|
||||
});
|
||||
}
|
||||
|
||||
// Apply initiator filter
|
||||
if (filters?.initiator && filters.initiator !== 'all') {
|
||||
whereConditions.push({ initiatorId: filters.initiator });
|
||||
}
|
||||
|
||||
// Apply approver filter (with current vs any logic) - for listParticipantRequests
|
||||
if (filters?.approver && filters.approver !== 'all') {
|
||||
const approverId = filters.approver;
|
||||
const approverType = filters.approverType || 'current'; // Default to 'current'
|
||||
|
||||
if (approverType === 'current') {
|
||||
// Filter by current active approver only
|
||||
// Find request IDs where this approver is the current active approver
|
||||
const currentApproverLevels = await ApprovalLevel.findAll({
|
||||
where: {
|
||||
approverId: approverId,
|
||||
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
||||
},
|
||||
attributes: ['requestId', 'levelNumber'],
|
||||
});
|
||||
|
||||
// Get the current level for each request to match only if this approver is at the current level
|
||||
const requestIds: string[] = [];
|
||||
for (const level of currentApproverLevels) {
|
||||
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
||||
attributes: ['requestId', 'currentLevel'],
|
||||
});
|
||||
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
||||
requestIds.push((level as any).requestId);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
} else {
|
||||
// Filter by any approver (past or current)
|
||||
// Find all request IDs where this user is an approver at any level
|
||||
const allApproverLevels = await ApprovalLevel.findAll({
|
||||
where: { approverId: approverId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
||||
|
||||
if (approverRequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply date range filter (same logic as listWorkflows)
|
||||
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
||||
let dateStart: Date | null = null;
|
||||
let dateEnd: Date | null = null;
|
||||
|
||||
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.dateRange) {
|
||||
const now = dayjs();
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
dateStart = now.startOf('day').toDate();
|
||||
dateEnd = now.endOf('day').toDate();
|
||||
break;
|
||||
case 'week':
|
||||
dateStart = now.startOf('week').toDate();
|
||||
dateEnd = now.endOf('week').toDate();
|
||||
break;
|
||||
case 'month':
|
||||
dateStart = now.startOf('month').toDate();
|
||||
dateEnd = now.endOf('month').toDate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dateStart && dateEnd) {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
||||
// Fallback to createdAt if submissionDate is null
|
||||
{
|
||||
[Op.and]: [
|
||||
{ submissionDate: null },
|
||||
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
||||
|
||||
// If SLA compliance filter is active, fetch all, enrich, filter, then paginate
|
||||
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
||||
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
|
||||
const enrichedData = await this.enrichForCards(allRows);
|
||||
|
||||
// Filter by SLA compliance
|
||||
const slaFilteredData = enrichedData.filter((req: any) => {
|
||||
const slaCompliance = filters.slaCompliance || '';
|
||||
const slaStatus = req.currentLevelSLA?.status ||
|
||||
req.currentApprover?.sla?.status ||
|
||||
req.sla?.status ||
|
||||
req.summary?.sla?.status;
|
||||
|
||||
if (slaCompliance.toLowerCase() === 'compliant') {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
||||
if (!isCompleted) return false;
|
||||
if (!slaStatus) return true;
|
||||
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
||||
}
|
||||
|
||||
if (!slaStatus) {
|
||||
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
'on-track': 'on_track',
|
||||
'on_track': 'on_track',
|
||||
'approaching': 'approaching',
|
||||
'critical': 'critical',
|
||||
'breached': 'breached'
|
||||
};
|
||||
|
||||
const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase();
|
||||
return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus;
|
||||
});
|
||||
|
||||
const totalFiltered = slaFilteredData.length;
|
||||
const paginatedData = slaFilteredData.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalFiltered,
|
||||
totalPages: Math.ceil(totalFiltered / limit) || 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Normal pagination (no SLA filter)
|
||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
offset,
|
||||
limit,
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
const data = await this.enrichForCards(rows);
|
||||
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* List requests where user is a PARTICIPANT (not initiator) 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
|
||||
* Completely separate from listWorkflows (admin) to avoid interference
|
||||
*/
|
||||
async listParticipantRequests(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
department?: string;
|
||||
initiator?: string;
|
||||
approver?: string;
|
||||
approverType?: 'current' | 'any';
|
||||
slaCompliance?: string;
|
||||
dateRange?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
) {
|
||||
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)
|
||||
|
||||
// Get requests where user is an approver (in any approval level)
|
||||
const approverLevels = await ApprovalLevel.findAll({
|
||||
where: { approverId: userId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const approverRequestIds = approverLevels.map((l: any) => l.requestId);
|
||||
|
||||
// Get requests where user is a participant/spectator
|
||||
const participants = await Participant.findAll({
|
||||
where: { userId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const participantRequestIds = participants.map((p: any) => p.requestId);
|
||||
|
||||
// Combine request IDs where user is participant (approver or spectator)
|
||||
const allRequestIds = Array.from(new Set([
|
||||
...approverRequestIds,
|
||||
...participantRequestIds
|
||||
]));
|
||||
|
||||
// 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)
|
||||
if (allRequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: allRequestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
|
||||
// Exclude drafts
|
||||
whereConditions.push({ isDraft: false });
|
||||
|
||||
// Apply status filter (pending, approved, rejected, closed)
|
||||
// Same logic as listWorkflows but applied to participant requests only
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
const statusUpper = filters.status.toUpperCase();
|
||||
if (statusUpper === 'PENDING') {
|
||||
// Pending includes both PENDING and IN_PROGRESS
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ status: 'PENDING' },
|
||||
{ status: 'IN_PROGRESS' }
|
||||
]
|
||||
});
|
||||
} else if (statusUpper === 'CLOSED') {
|
||||
whereConditions.push({ status: 'CLOSED' });
|
||||
} else if (statusUpper === 'REJECTED') {
|
||||
whereConditions.push({ status: 'REJECTED' });
|
||||
} else if (statusUpper === 'APPROVED') {
|
||||
whereConditions.push({ status: 'APPROVED' });
|
||||
} else {
|
||||
// Fallback: use the uppercase value as-is
|
||||
whereConditions.push({ status: statusUpper });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
if (filters?.priority && filters.priority !== 'all') {
|
||||
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
||||
}
|
||||
|
||||
// Apply search filter (title, description, or requestNumber)
|
||||
if (filters?.search && filters.search.trim()) {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
||||
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
||||
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Apply department filter (through initiator)
|
||||
if (filters?.department && filters.department !== 'all') {
|
||||
whereConditions.push({
|
||||
'$initiator.department$': filters.department
|
||||
});
|
||||
}
|
||||
|
||||
// Apply initiator filter
|
||||
if (filters?.initiator && filters.initiator !== 'all') {
|
||||
whereConditions.push({ initiatorId: filters.initiator });
|
||||
}
|
||||
|
||||
// Apply approver filter (with current vs any logic) - for listParticipantRequests
|
||||
if (filters?.approver && filters.approver !== 'all') {
|
||||
const approverId = filters.approver;
|
||||
const approverType = filters.approverType || 'current'; // Default to 'current'
|
||||
|
||||
if (approverType === 'current') {
|
||||
// Filter by current active approver only
|
||||
// Find request IDs where this approver is the current active approver
|
||||
const currentApproverLevels = await ApprovalLevel.findAll({
|
||||
where: {
|
||||
approverId: approverId,
|
||||
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
||||
},
|
||||
attributes: ['requestId', 'levelNumber'],
|
||||
});
|
||||
|
||||
// Get the current level for each request to match only if this approver is at the current level
|
||||
const requestIds: string[] = [];
|
||||
for (const level of currentApproverLevels) {
|
||||
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
||||
attributes: ['requestId', 'currentLevel'],
|
||||
});
|
||||
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
||||
requestIds.push((level as any).requestId);
|
||||
}
|
||||
}
|
||||
|
||||
if (requestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
} else {
|
||||
// Filter by any approver (past or current)
|
||||
// Find all request IDs where this user is an approver at any level
|
||||
const allApproverLevels = await ApprovalLevel.findAll({
|
||||
where: { approverId: approverId },
|
||||
attributes: ['requestId'],
|
||||
});
|
||||
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
||||
|
||||
if (approverRequestIds.length > 0) {
|
||||
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
||||
} else {
|
||||
// No matching requests - return empty result
|
||||
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply date range filter (same logic as listWorkflows)
|
||||
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
||||
let dateStart: Date | null = null;
|
||||
let dateEnd: Date | null = null;
|
||||
|
||||
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.dateRange) {
|
||||
const now = dayjs();
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
dateStart = now.startOf('day').toDate();
|
||||
dateEnd = now.endOf('day').toDate();
|
||||
break;
|
||||
case 'week':
|
||||
dateStart = now.startOf('week').toDate();
|
||||
dateEnd = now.endOf('week').toDate();
|
||||
break;
|
||||
case 'month':
|
||||
dateStart = now.startOf('month').toDate();
|
||||
dateEnd = now.endOf('month').toDate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dateStart && dateEnd) {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
||||
// Fallback to createdAt if submissionDate is null
|
||||
{
|
||||
[Op.and]: [
|
||||
{ submissionDate: null },
|
||||
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
||||
|
||||
// If SLA compliance filter is active, fetch all, enrich, filter, then paginate
|
||||
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
||||
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
|
||||
const enrichedData = await this.enrichForCards(allRows);
|
||||
|
||||
// Filter by SLA compliance
|
||||
const slaFilteredData = enrichedData.filter((req: any) => {
|
||||
const slaCompliance = filters.slaCompliance || '';
|
||||
const slaStatus = req.currentLevelSLA?.status ||
|
||||
req.currentApprover?.sla?.status ||
|
||||
req.sla?.status ||
|
||||
req.summary?.sla?.status;
|
||||
|
||||
if (slaCompliance.toLowerCase() === 'compliant') {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
||||
if (!isCompleted) return false;
|
||||
if (!slaStatus) return true;
|
||||
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
||||
}
|
||||
|
||||
if (!slaStatus) {
|
||||
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
'on-track': 'on_track',
|
||||
'on_track': 'on_track',
|
||||
'approaching': 'approaching',
|
||||
'critical': 'critical',
|
||||
'breached': 'breached'
|
||||
};
|
||||
|
||||
const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase();
|
||||
return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus;
|
||||
});
|
||||
|
||||
const totalFiltered = slaFilteredData.length;
|
||||
const paginatedData = slaFilteredData.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalFiltered,
|
||||
totalPages: Math.ceil(totalFiltered / limit) || 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Normal pagination (no SLA filter)
|
||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
offset,
|
||||
limit,
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
const data = await this.enrichForCards(rows);
|
||||
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* List requests where user is the initiator (for "My Requests" page)
|
||||
*/
|
||||
async listMyInitiatedRequests(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
filters?: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
department?: string;
|
||||
dateRange?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause with filters - only requests where user is initiator
|
||||
const whereConditions: any[] = [{ initiatorId: userId }];
|
||||
|
||||
// Exclude drafts
|
||||
whereConditions.push({ isDraft: false });
|
||||
|
||||
// Apply status filter
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
const statusUpper = filters.status.toUpperCase();
|
||||
if (statusUpper === 'PENDING') {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ status: 'PENDING' },
|
||||
{ status: 'IN_PROGRESS' }
|
||||
]
|
||||
});
|
||||
} else {
|
||||
whereConditions.push({ status: statusUpper });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
if (filters?.priority && filters.priority !== 'all') {
|
||||
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
||||
}
|
||||
|
||||
// Apply search filter (title, description, or requestNumber)
|
||||
if (filters?.search && filters.search.trim()) {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
||||
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
||||
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Apply department filter (through initiator)
|
||||
if (filters?.department && filters.department !== 'all') {
|
||||
whereConditions.push({
|
||||
'$initiator.department$': filters.department
|
||||
});
|
||||
}
|
||||
|
||||
// Apply date range filter (same logic as listWorkflows)
|
||||
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
||||
let dateStart: Date | null = null;
|
||||
let dateEnd: Date | null = null;
|
||||
|
||||
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.startDate && filters.endDate) {
|
||||
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
||||
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
||||
} else if (filters.dateRange) {
|
||||
const now = dayjs();
|
||||
switch (filters.dateRange) {
|
||||
case 'today':
|
||||
dateStart = now.startOf('day').toDate();
|
||||
dateEnd = now.endOf('day').toDate();
|
||||
break;
|
||||
case 'week':
|
||||
dateStart = now.startOf('week').toDate();
|
||||
dateEnd = now.endOf('week').toDate();
|
||||
break;
|
||||
case 'month':
|
||||
dateStart = now.startOf('month').toDate();
|
||||
dateEnd = now.endOf('month').toDate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dateStart && dateEnd) {
|
||||
whereConditions.push({
|
||||
[Op.or]: [
|
||||
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
||||
// Fallback to createdAt if submissionDate is null
|
||||
{
|
||||
[Op.and]: [
|
||||
{ submissionDate: null },
|
||||
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
||||
|
||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||
@ -1145,8 +1946,36 @@ export class WorkflowService {
|
||||
}
|
||||
|
||||
// Create participants if provided
|
||||
// Deduplicate participants by userId (database has unique constraint on request_id + user_id)
|
||||
// Priority: INITIATOR > APPROVER > SPECTATOR (keep the highest privilege role)
|
||||
if (workflowData.participants) {
|
||||
const participantMap = new Map<string, typeof workflowData.participants[0]>();
|
||||
const rolePriority: Record<string, number> = {
|
||||
'INITIATOR': 3,
|
||||
'APPROVER': 2,
|
||||
'SPECTATOR': 1
|
||||
};
|
||||
|
||||
for (const participantData of workflowData.participants) {
|
||||
const existing = participantMap.get(participantData.userId);
|
||||
|
||||
if (existing) {
|
||||
// User already exists, check if we should replace with higher priority role
|
||||
const existingPriority = rolePriority[existing.participantType] || 0;
|
||||
const newPriority = rolePriority[participantData.participantType] || 0;
|
||||
|
||||
if (newPriority > existingPriority) {
|
||||
logger.info(`[Workflow] User ${participantData.userId} (${participantData.userEmail}) has multiple roles. Keeping ${participantData.participantType} over ${existing.participantType}`);
|
||||
participantMap.set(participantData.userId, participantData);
|
||||
} else {
|
||||
logger.info(`[Workflow] User ${participantData.userId} (${participantData.userEmail}) has multiple roles. Keeping ${existing.participantType} over ${participantData.participantType}`);
|
||||
}
|
||||
} else {
|
||||
participantMap.set(participantData.userId, participantData);
|
||||
}
|
||||
}
|
||||
|
||||
for (const participantData of participantMap.values()) {
|
||||
await Participant.create({
|
||||
requestId: workflow.requestId,
|
||||
userId: participantData.userId,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user