shard summary implemented

This commit is contained in:
laxmanhalaki 2025-11-24 19:15:37 +05:30
parent 29f7421cd3
commit 8c602fef24
29 changed files with 2567 additions and 76 deletions

View File

@ -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

View File

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,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">

View 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

View File

@ -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",

View File

@ -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({

View 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();

View File

@ -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;

View 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');
}

View 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');
}

View 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
View 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;

View File

@ -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

View File

@ -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);

View 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;

View File

@ -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))

View File

@ -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();

View File

@ -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 },
];
/**

View File

@ -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

View 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();

View File

@ -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,