new attribute added for request status as worflow state , and filter and summary related isus fixed which came after migartion

This commit is contained in:
laxmanhalaki 2026-02-04 20:27:17 +05:30
parent c9a0305d44
commit 47fabeb15e
17 changed files with 1512 additions and 181 deletions

43
debug-finalize.ts Normal file
View File

@ -0,0 +1,43 @@
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import dns from 'dns';
import { WorkflowRequestModel } from './src/models/mongoose/WorkflowRequest.schema';
dotenv.config();
async function check() {
try {
const mongoUri = process.env.MONGO_URI || process.env.MONGODB_URL;
if (!mongoUri) {
console.error('MONGO_URI not found in .env');
process.exit(1);
}
if (mongoUri.startsWith('mongodb+srv://')) {
dns.setServers(['8.8.8.8', '8.8.4.4', '1.1.1.1', '1.0.0.1']);
}
await mongoose.connect(mongoUri);
console.log('✅ Connected to MongoDB');
const requests = await WorkflowRequestModel.find({
$or: [
{ conclusionRemark: { $exists: true, $ne: null } },
{ workflowState: 'CLOSED' }
]
}).sort({ updatedAt: -1 }).limit(10);
console.log('Results (Last 10 finalized/closed):');
requests.forEach(r => {
console.log(`- REQ: ${r.requestNumber}, Status: ${r.status}, State: ${r.workflowState}, HasRemark: ${!!r.conclusionRemark}`);
});
process.exit(0);
} catch (error) {
console.error('Check failed:', error);
process.exit(1);
}
}
check();

View File

@ -0,0 +1,61 @@
# Implementation Plan: Status Ambiguity Refinement
This document outlines the specific code changes required to implement the **Dual-Key Status Architecture**.
## 1. Goal
Decouple the business outcome (Approved/Rejected) from the lifecycle state (Open/Closed/Draft) to ensure transparency in finalized requests.
## 2. Schema Changes
### `WorkflowRequest.schema.ts`
- **Update `status` Enum**: Remove `CLOSED` and `CANCELLED`.
- **Add `workflowState`**:
- Type: `String`
- Enum: `['DRAFT', 'OPEN', 'CLOSED']`
- Default: `'DRAFT'`
- Index: `true`
## 3. Logic Updates
### A. Workflow Creation (`WorkflowService.createWorkflow`)
- Initialize `status: 'DRAFT'`.
- Initialize `workflowState: 'DRAFT'`.
- Set `isDraft: true`.
### B. Workflow Submission (`WorkflowService.submitRequest`)
- Update `status: 'PENDING'`.
- Update `workflowState: 'OPEN'`.
- Set `isDraft: false`.
### C. Approval/Rejection (`WorkflowService`)
- When approved at a level: Keep `status` as `IN_PROGRESS` or set to `APPROVED` if final.
- When rejected: Set `status` to `REJECTED`.
- **Crucial**: The `workflowState` remains `OPEN` during these actions.
### D. Finalization (`ConclusionController.finalizeConclusion`)
- **Current Behavior**: Sets `status = 'CLOSED'`.
- **New Behavior**:
- Sets `workflowState = 'CLOSED'`.
- **Does NOT** change `status`. The `status` will remain `APPROVED` or `REJECTED`.
- Sets `closureDate = new Date()`.
### E. Pause Logic (`PauseMongoService`)
- Set `status = 'PAUSED'`.
- Set `isPaused = true`.
- Keep `workflowState = 'OPEN'`.
## 4. Dashboard & KPI Updates (`DashboardMongoService`)
### `getRequestStats`
- Update the aggregation pipeline to group by `workflowState`.
- `OPEN` category will now include all requests where `workflowState == 'OPEN'`.
- `CLOSED` category will now include all requests where `workflowState == 'CLOSED'`.
- This ensures that a "Closed" count on the dashboard includes both Approved and Rejected requests that have been finalized.
### `getTATEfficiency`
- Update match criteria to `workflowState: 'CLOSED'` instead of `status: 'CLOSED'`.
## 5. Filter Alignment (`listWorkflowsInternal`)
- Update the status filter to handle the new field mapping.
- If user filters by `status: 'CLOSED'`, the query will target `workflowState: 'CLOSED'`.
- If user filters by `status: 'APPROVED'`, the query will target `status: 'APPROVED'`.

View File

@ -0,0 +1,55 @@
# Dual-Key Status Architecture
This document defines the status management system for the Royal Enfield Workflow application. It uses a "Dual-Key" approach to resolve ambiguity between request lifecycles and business outcomes.
## 1. Core Concepts
| Key | Purpose | Possible Values |
| :--- | :--- | :--- |
| **`status`** | **Business Outcome**. Tells you *what* happened or the current granular action. | `DRAFT`, `PENDING`, `IN_PROGRESS`, `APPROVED`, `REJECTED`, `PAUSED` |
| **`workflowState`** | **Lifecycle State**. Tells you *where* the request is in its journey. | `DRAFT`, `OPEN`, `CLOSED` |
---
## 2. Status Mapping Table
The `workflowState` is automatically derived from the `status` and the finalization event (Conclusion Remark).
| Primary Status | Finalized? | workflowState | Description |
| :--- | :--- | :--- | :--- |
| `DRAFT` | No | `DRAFT` | Request is being prepared by the initiator. |
| `PENDING` | No | `OPEN` | Waiting for first level activation or system processing. |
| `IN_PROGRESS` | No | `OPEN` | Actively moving through approval levels. |
| `PAUSED` | No | `OPEN` | Temporarily frozen; `isPaused` flag is `true`. |
| `APPROVED` | No | `OPEN` | All levels approved, but initiator hasn't written the final conclusion. |
| `REJECTED` | No | `OPEN` | Rejected by an approver, but initiator hasn't acknowledged/finalized. |
| **`APPROVED`** | **Yes** | **`CLOSED`** | **Final state: Approved and Archived.** |
| **`REJECTED`** | **Yes** | **`CLOSED`** | **Final state: Rejected and Archived.** |
---
## 3. Ambiguity Resolution (The "Why")
Previously, the system changed `status` to `CLOSED` after finalization, which destroyed the information about whether the request was Approved or Rejected.
**Corrected Behavior:**
- **Outcome remains visible**: A finalized request will now keep its `status` as `APPROVED` or `REJECTED`.
- **Filtering made easy**: Dashboard charts use `workflowState: 'CLOSED'` to count all finished work, while list filters use `status: 'APPROVED'` to find specific results.
---
## 4. Technical Implementation Notes
### Schema Changes
- **`WorkflowRequest`**: Added `workflowState` (String, Indexed).
- **`status` Enum**: Removed `CLOSED` (deprecated) and `CANCELLED`.
### Transition Logic
1. **Approval/Rejection**: Updates `status` to `APPROVED` or `REJECTED`. `workflowState` remains `OPEN`.
2. **Finalization (Conclusion)**: Triggered by initiator. Updates `workflowState` to `CLOSED`. **Does NOT change `status`.**
3. **Pause**: Set `status` to `PAUSED` and `isPaused: true`. `workflowState` stays `OPEN`.
### Impacted Services
- `DashboardMongoService`: Uses `workflowState` for Facet/KPI counts.
- `WorkflowService`: Filter logic updated to respect both keys.
- `ConclusionController`: `finalizeConclusion` logic updated to toggle `workflowState`.

View File

@ -25,7 +25,7 @@ export class ConclusionController {
}
// Check if user is the initiator (compare userId strings)
if ((request as any).initiatorId !== userId) {
if ((request as any).initiator.userId !== userId) {
return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' });
}
@ -98,7 +98,7 @@ export class ConclusionController {
: (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0);
return {
levelNumber: level.levelNumber,
approverName: level.approverName,
approverName: level.approver?.name || level.approverName || 'Unknown',
status: level.status,
comments: level.comments,
actionDate: level.actionDate,
@ -232,7 +232,7 @@ export class ConclusionController {
}
// Check if user is the initiator
if ((request as any).initiatorId !== userId) {
if ((request as any).initiator.userId !== userId) {
return res.status(403).json({ error: 'Only the initiator can update conclusion remarks' });
}
@ -287,10 +287,10 @@ export class ConclusionController {
}
// Fetch initiator manually
const initiator = await User.findOne({ userId: (request as any).initiatorId });
const initiator = await User.findOne({ userId: (request as any).initiator.userId });
// Check if user is the initiator
if ((request as any).initiatorId !== userId) {
if ((request as any).initiator.userId !== userId) {
return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' });
}
@ -333,8 +333,8 @@ export class ConclusionController {
await conclusion.save();
}
// Update request status to CLOSED
request.status = 'CLOSED';
// Update request workflowState to CLOSED (keep granular status as APPROVED/REJECTED)
request.workflowState = 'CLOSED';
(request as any).conclusionRemark = finalRemark;
(request as any).closureDate = new Date();
await request.save();

View File

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

View File

@ -485,6 +485,7 @@ export class WorkflowController {
dateRange: req.query.dateRange as string | undefined,
startDate: req.query.startDate as string | undefined,
endDate: req.query.endDate as string | undefined,
lifecycle: req.query.lifecycle as string | undefined,
};
// USE MONGODB SERVICE FOR LISTING
@ -514,8 +515,9 @@ export class WorkflowController {
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 lifecycle = req.query.lifecycle as string | undefined;
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const filters = { search, status, priority, department, initiator, approverName: approver, approverType, slaCompliance, dateRange, startDate, endDate, lifecycle };
const result = await workflowServiceMongo.listMyRequests(userId, page, limit, filters);
ResponseHandler.success(res, result, 'My requests fetched');
@ -548,8 +550,9 @@ export class WorkflowController {
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 lifecycle = req.query.lifecycle as string | undefined;
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const filters = { search, status, priority, templateType, department, initiator, approverName: approver, approverType, slaCompliance, dateRange, startDate, endDate, lifecycle };
const result = await workflowServiceMongo.listParticipantRequests(userId, page, limit, filters);
ResponseHandler.success(res, result, 'Participant requests fetched');
@ -578,8 +581,9 @@ export class WorkflowController {
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 lifecycle = req.query.lifecycle as string | undefined;
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate };
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate, lifecycle };
const result = await workflowServiceMongo.listMyInitiatedRequests(userId, page, limit, filters);
ResponseHandler.success(res, result, 'My initiated requests fetched');

View File

@ -17,7 +17,8 @@ export interface IWorkflowRequest extends Document {
title: string;
description: string;
priority: 'STANDARD' | 'EXPRESS';
status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'APPROVED' | 'REJECTED' | 'CLOSED' | 'PAUSED' | 'CANCELLED';
status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'APPROVED' | 'REJECTED' | 'PAUSED';
workflowState: 'DRAFT' | 'OPEN' | 'CLOSED';
// Flattened/Cached Fields for KPIs
currentLevel: number; // Display purposes - can become stale when levels shift
@ -68,7 +69,13 @@ const WorkflowRequestSchema = new Schema<IWorkflowRequest>({
priority: { type: String, enum: ['STANDARD', 'EXPRESS'], default: 'STANDARD' },
status: {
type: String,
enum: ['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'CLOSED', 'PAUSED', 'CANCELLED'],
enum: ['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'PAUSED'],
default: 'DRAFT',
index: true
},
workflowState: {
type: String,
enum: ['DRAFT', 'OPEN', 'CLOSED'],
default: 'DRAFT',
index: true
},

View File

@ -264,6 +264,14 @@ class AIService {
// Use Vertex AI to generate text
let remarkText = await this.generateText(prompt);
// STRIP MARKDOWN CODE BLOCKS (Clean up AI response)
// Sometimes AI wraps HTML in ```html ... ``` even when asked not to
remarkText = remarkText
.replace(/^```html\s*/i, '') // Remove opening ```html
.replace(/^```\s*/i, '') // Remove opening ```
.replace(/\s*```$/, '') // Remove closing ```
.trim(); // Trim whitespace
// Get max length from config for logging
const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000');
const maxLength = parseInt(maxLengthStr || '2000', 10);
@ -442,6 +450,7 @@ ${isRejected
<p><strong>Outcome:</strong> [Final outcome]</p>
- Keep HTML clean and minimal - no inline styles, no divs, no classes
- The HTML should render nicely in a rich text editor
- IMPORTANT: DO NOT wrap the output in markdown code blocks (e.g., \`\`\`html ... \`\`\`). Return ONLY the raw HTML string.
Write the conclusion now in HTML format. STRICT LIMIT: ${maxLength} characters maximum (including HTML tags). Prioritize and condense if needed:`;

File diff suppressed because it is too large Load Diff

View File

@ -609,7 +609,8 @@ export class DealerClaimMongoService {
if (action === 'CANCEL') {
// Update workflow status
workflow.status = WorkflowStatus.CANCELLED; // Make sure WorkflowStatus.CANCELLED exists or use 'CANCELLED'
workflow.status = 'REJECTED';
workflow.workflowState = 'CLOSED';
workflow.isDeleted = true; // Soft delete or just mark cancelled? Usually cancelled.
// Let's stick to status update.
await workflow.save();

View File

@ -118,6 +118,7 @@ export class PauseMongoService {
workflow.pauseReason = reason;
workflow.pauseResumeDate = resumeDate;
workflow.status = 'PAUSED';
workflow.workflowState = 'OPEN';
await workflow.save();
// Cancel jobs
@ -265,6 +266,7 @@ export class PauseMongoService {
workflow.pauseReason = undefined;
workflow.pauseResumeDate = undefined;
workflow.status = 'IN_PROGRESS'; // Assuming previous status was IN_PROGRESS or PENDING
workflow.workflowState = 'OPEN';
await workflow.save();
// Cancel Resume Job

View File

@ -4,6 +4,7 @@ import {
import logger from '../utils/logger';
import { v4 as uuidv4 } from 'uuid';
import mongoose from 'mongoose';
import { UserService } from './user.service';
/**
* Summary Service
@ -36,7 +37,7 @@ export class SummaryService {
throw new Error('Request must be closed (APPROVED, REJECTED, or CLOSED) before creating summary');
}
const initiatorId = (workflow as any).initiatorId;
const initiatorId = (workflow as any).initiator?.userId || (workflow as any).initiatorId;
const isInitiator = initiatorId === userId;
const isAdmin = userRole && ['admin', 'super_admin', 'management'].includes(userRole.toLowerCase());
@ -248,6 +249,8 @@ export class SummaryService {
const summary = await RequestSummary.findOne({ summaryId });
if (!summary) throw new Error('Summary not found');
const userService = new UserService();
if (summary.initiatorId !== sharedBy) {
throw new Error('Only the initiator can share this summary');
}
@ -265,6 +268,14 @@ export class SummaryService {
// Try email
const userByEmail = await User.findOne({ email: uid });
if (userByEmail) internalUserIds.push(userByEmail.userId);
else {
// Try fetching from Okta and creating (Auto-onboarding)
// uid might be Okta ID
const neUser = await userService.ensureOktaUserExists(uid);
if (neUser) {
internalUserIds.push(neUser.userId);
}
}
}
}

View File

@ -468,4 +468,24 @@ export class UserService {
return user;
}
/**
* Ensure user exists by fetching from Okta ID (Auto-onboarding)
*/
async ensureOktaUserExists(oktaId: string): Promise<IUser | null> {
try {
// 1. Fetch from Okta
const oktaUser = await this.fetchUserFromOktaById(oktaId);
if (!oktaUser) return null;
// 2. Extract Data
const ssoData = extractOktaUserData(oktaUser);
if (!ssoData) return null;
// 3. Create or Update in DB
return await this.createOrUpdateUser(ssoData);
} catch (error) {
logger.error(`[UserService] Failed to ensure Okta user exists for ID ${oktaId}`, error);
return null;
}
}
}

View File

@ -8,7 +8,7 @@ import logger from '../utils/logger';
import { notificationMongoService } from './notification.service';
import { activityMongoService } from './activity.service';
import { tatSchedulerMongoService } from './tatScheduler.service';
import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus } from '../utils/tatTimeUtils';
import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus, formatTime } from '../utils/tatTimeUtils';
const tatScheduler = tatSchedulerMongoService;
@ -132,6 +132,7 @@ export class WorkflowServiceMongo {
description: workflowData.description,
priority: workflowData.priority,
status: 'DRAFT',
workflowState: 'DRAFT',
currentLevel: 1,
totalLevels: workflowData.approvalLevels.length,
totalTatHours,
@ -157,7 +158,7 @@ export class WorkflowServiceMongo {
},
tat: {
assignedHours: level.tatHours,
assignedDays: Math.ceil(level.tatHours / 24),
assignedDays: Math.ceil(level.tatHours / 8),
elapsedHours: 0,
remainingHours: level.tatHours,
percentageUsed: 0,
@ -922,6 +923,47 @@ export class WorkflowServiceMongo {
throw error;
}
}
/**
* Parse date range string to Date objects
*/
private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): { start: Date; end: Date } | null {
if (dateRange === 'custom' && startDate && endDate) {
return {
start: dayjs(startDate).startOf('day').toDate(),
end: dayjs(endDate).endOf('day').toDate()
};
}
if (!dateRange || dateRange === 'all') return null;
const now = dayjs();
switch (dateRange) {
case 'today':
return { start: now.startOf('day').toDate(), end: now.endOf('day').toDate() };
case 'week':
return { start: now.startOf('week').toDate(), end: now.endOf('week').toDate() };
case 'month':
return { start: now.startOf('month').toDate(), end: now.endOf('month').toDate() };
case 'quarter':
const quarterStartMonth = Math.floor(now.month() / 3) * 3;
return {
start: now.month(quarterStartMonth).startOf('month').toDate(),
end: now.month(quarterStartMonth + 2).endOf('month').toDate()
};
case 'year':
return { start: now.startOf('year').toDate(), end: now.endOf('year').toDate() };
default:
// If it's not a known keyword, try parsing it as a number of days
const days = parseInt(dateRange, 10);
if (!isNaN(days)) {
return {
start: now.subtract(days, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate()
};
}
return null;
}
}
async listWorkflows(page: number, limit: number, filters: any) {
return this.listWorkflowsInternal(page, limit, filters, undefined, 'all');
@ -952,21 +994,68 @@ export class WorkflowServiceMongo {
const now = new Date();
// 1. Build Base Match Stage
const matchStage: any = { isDraft: false };
const matchStage: any = { isDeleted: false };
// Handle Draft Visibility:
// - Allow drafts if specifically requested via status='DRAFT'
// - Allow drafts if viewing user's own 'initiated' requests
// - Otherwise, exclude drafts by default
if (filters.status && filters.status.toUpperCase() === 'DRAFT') {
matchStage.isDraft = true;
} else if (listType === 'initiated') {
// Initiated view shows both drafts and active requests
// Unless a specific status filter is already applied
if (!filters.status || filters.status === 'all') {
matchStage.isDraft = { $in: [true, false] };
} else {
matchStage.isDraft = false;
}
} else {
matchStage.isDraft = false;
}
if (filters.search) matchStage.$text = { $search: filters.search };
// 1.1 Handle Lifecycle Filter (Open vs Closed)
if (filters.lifecycle && filters.lifecycle !== 'all') {
const lifecycle = filters.lifecycle.toLowerCase();
if (lifecycle === 'open') {
matchStage.workflowState = 'OPEN';
} else if (lifecycle === 'closed') {
matchStage.workflowState = 'CLOSED';
}
}
// 1.2 Handle Outcome Status Filter
if (filters.status && filters.status !== 'all') {
const status = filters.status.toUpperCase();
if (status === 'PENDING') {
// Pending outcome usually means in-progress in the OPEN state
matchStage.status = { $in: ['PENDING', 'IN_PROGRESS'] };
} else if (status === 'DRAFT') {
matchStage.isDraft = true;
} else if (status === 'CLOSED') {
// "CLOSED" as a status is now deprecated in favor of Lifecycle Filter
// But if legacy code still sends it, we map it to CLOSED state
matchStage.workflowState = 'CLOSED';
} else {
matchStage.status = status;
}
}
if (filters.priority && filters.priority !== 'all') matchStage.priority = filters.priority.toUpperCase();
if (filters.templateType && filters.templateType !== 'all') matchStage.templateType = filters.templateType.toUpperCase();
if (filters.initiator) matchStage['initiator.userId'] = filters.initiator;
if (filters.department && filters.department !== 'all') matchStage['initiator.department'] = filters.department;
if (filters.startDate && filters.endDate) {
matchStage['dates.created'] = {
// Date Range Logic
const range = this.parseDateRange(filters.dateRange, filters.startDate, filters.endDate);
if (range) {
matchStage.createdAt = {
$gte: range.start,
$lte: range.end
};
} else if (filters.startDate && filters.endDate) {
matchStage.createdAt = {
$gte: new Date(filters.startDate),
$lte: new Date(filters.endDate)
};
@ -1044,11 +1133,12 @@ export class WorkflowServiceMongo {
}
});
matchStage.$or = [
{ 'active_step.0.approver.userId': userId }, // Check first element of array
{ 'active_step.0.approver.userId': userId },
{ $and: [{ 'initiator.userId': userId }, { status: 'APPROVED' }] },
{ $and: [{ 'membership.userId': userId }, { 'membership.participantType': 'SPECTATOR' }] }
];
// Only show non-closed/non-rejected for "open for me" (except approved for initiator)
// Only show non-closed for "open for me"
matchStage.workflowState = { $ne: 'CLOSED' };
matchStage.status = { $in: ['PENDING', 'IN_PROGRESS', 'PAUSED', 'APPROVED'] };
} else if (listType === 'closed_by_me' && userId) {
// Past approver or spectator AND status is CLOSED or REJECTED
@ -1061,7 +1151,10 @@ export class WorkflowServiceMongo {
}
});
matchStage['membership.userId'] = userId;
matchStage.status = { $in: ['CLOSED', 'REJECTED'] };
matchStage.$or = [
{ workflowState: 'CLOSED' },
{ workflowState: { $exists: false }, status: { $in: ['CLOSED', 'REJECTED'] } }
];
}
// CRITICAL: Add match stage AFTER lookups so active_step and membership arrays exist
@ -1069,6 +1162,47 @@ export class WorkflowServiceMongo {
// 3. Deep Filters (Approver Name, Level Status)
if (filters.approverName) {
const approverRegex = { $regex: filters.approverName, $options: 'i' };
const approverMatch = {
$or: [
{ 'approver.name': approverRegex },
{ 'approver.userId': filters.approverName }
]
};
if (filters.approverType === 'current') {
// Filter by CURRENT level approver name or ID
pipeline.push(
{
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLvl: "$currentLevel" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$requestId", "$$reqId"] },
{ $eq: ["$levelNumber", "$$currLvl"] }
]
}
}
}
],
as: 'current_step_filter'
}
},
{
$match: {
$or: [
{ 'current_step_filter.approver.name': approverRegex },
{ 'current_step_filter.approver.userId': filters.approverName }
]
}
}
);
} else {
// Search in ANY level (approverType === 'any' or undefined)
pipeline.push(
{
$lookup: {
@ -1078,9 +1212,17 @@ export class WorkflowServiceMongo {
as: 'matches_approvers'
}
},
{ $match: { 'matches_approvers.approver.name': { $regex: filters.approverName, $options: 'i' } } }
{
$match: {
$or: [
{ 'matches_approvers.approver.name': approverRegex },
{ 'matches_approvers.approver.userId': filters.approverName }
]
}
}
);
}
}
if (filters.levelStatus && filters.levelNumber) {
pipeline.push(
@ -1096,8 +1238,46 @@ export class WorkflowServiceMongo {
);
}
if (filters.slaCompliance && filters.slaCompliance !== 'all') {
pipeline.push({
$lookup: {
from: 'approval_levels',
let: { reqId: "$requestId", currLevelId: "$currentLevelId", currLvl: "$currentLevel" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$requestId", "$$reqId"] },
{
$or: [
{ $eq: ["$levelId", "$$currLevelId"] },
{
$and: [
{ $eq: [{ $type: "$$currLevelId" }, "missing"] },
{ $eq: ["$levelNumber", "$$currLvl"] }
]
}
]
}
]
}
}
}
],
as: 'active_sla_step'
}
});
if (filters.slaCompliance === 'breached') {
pipeline.push({ $match: { 'active_sla_step.tat.isBreached': true } });
} else if (filters.slaCompliance === 'on_track') {
pipeline.push({ $match: { 'active_sla_step.tat.isBreached': false } });
}
}
// 4. Sort & Pagination
const sortField = sortBy || 'dates.created';
const sortField = sortBy || 'createdAt';
const sortDir = sortOrder?.toLowerCase() === 'asc' ? 1 : -1;
pipeline.push(
@ -1127,6 +1307,7 @@ export class WorkflowServiceMongo {
title: 1,
description: 1,
status: 1,
workflowState: 1,
priority: 1,
workflowType: 1,
templateType: 1,
@ -1217,10 +1398,8 @@ export class WorkflowServiceMongo {
deadline: deadline || null,
isPaused: !!pauseInfo,
status: currentStep.tat?.isBreached ? 'breached' : 'on_track',
remainingText: `${Math.floor(Math.max(0, assignedHours - elapsedHours))}h ${Math.round((Math.max(0, assignedHours - elapsedHours) % 1) * 60)}m`,
elapsedText: elapsedHours >= 24
? `${Math.floor(elapsedHours / 24)}d ${Math.floor(elapsedHours % 24)}h ${Math.round((elapsedHours % 1) * 60)}m`
: `${Math.floor(elapsedHours)}h ${Math.round((elapsedHours % 1) * 60)}m`
remainingText: formatTime(Math.max(0, assignedHours - elapsedHours)),
elapsedText: formatTime(elapsedHours)
};
} catch (error) {
logger.error('[listWorkflows] TAT calculation error:', error);
@ -1259,10 +1438,8 @@ export class WorkflowServiceMongo {
deadline: workflowDeadline,
isPaused: result.isPaused || false,
status: requestPercentageUsed >= 100 ? 'breached' : 'on_track',
remainingText: `${Math.floor(requestRemainingHours)}h ${Math.round((requestRemainingHours % 1) * 60)}m`,
elapsedText: requestElapsedHours >= 24
? `${Math.floor(requestElapsedHours / 24)}d ${Math.floor(requestElapsedHours % 24)}h ${Math.round((requestElapsedHours % 1) * 60)}m`
: `${Math.floor(requestElapsedHours)}h ${Math.round((requestElapsedHours % 1) * 60)}m`
remainingText: formatTime(requestRemainingHours),
elapsedText: formatTime(requestElapsedHours)
};
// Add currentApprover info (from currentStep if available)
@ -1296,7 +1473,7 @@ export class WorkflowServiceMongo {
// 7. Total Count (Optimized)
let total = 0;
const needsAggCount = !!(filters.approverName || (filters.levelStatus) || listType === 'my_requests' || listType === 'participant' || listType === 'open_for_me' || listType === 'closed_by_me');
const needsAggCount = !!(filters.approverName || filters.levelStatus || filters.slaCompliance || listType === 'my_requests' || listType === 'participant' || listType === 'open_for_me' || listType === 'closed_by_me');
if (needsAggCount) {
const countPipeline = [...pipeline].filter(s => !s.$sort && !s.$skip && !s.$limit && !s.$project && !s.$lookup || (s.$lookup && (s.$lookup.from === 'participants' || s.$lookup.from === 'approval_levels')));
@ -1446,6 +1623,7 @@ export class WorkflowServiceMongo {
description: requestObj.description,
priority: requestObj.priority,
status: requestObj.status,
workflowState: requestObj.workflowState || 'OPEN',
currentLevel: requestObj.currentLevel,
totalLevels: requestObj.totalLevels,
totalTatHours: requestObj.totalTatHours?.toString() || '0.00',
@ -1575,10 +1753,8 @@ export class WorkflowServiceMongo {
deadline: levelObj.tat?.endTime || null,
isPaused: levelObj.paused?.isPaused || false,
status: levelObj.tat?.isBreached ? 'breached' : 'on_track',
remainingText: `${Math.floor(remainingHours)}h ${Math.round((remainingHours % 1) * 60)}m`,
elapsedText: elapsedHours >= 24
? `${Math.floor(elapsedHours / 24)}d ${Math.floor(elapsedHours % 24)}h ${Math.round((elapsedHours % 1) * 60)}m`
: `${Math.floor(elapsedHours)}h ${Math.round((elapsedHours % 1) * 60)}m`
remainingText: formatTime(remainingHours),
elapsedText: formatTime(elapsedHours)
} : null
};
}));
@ -1618,10 +1794,8 @@ export class WorkflowServiceMongo {
status: requestPercentageUsed >= 100 ? 'breached' : 'on_track',
isPaused: requestObj.isPaused || false, // Use requestObj.isPaused for workflow level
deadline: workflowDeadline,
elapsedText: requestElapsedHours >= 24
? `${Math.floor(requestElapsedHours / 24)}d ${Math.floor(requestElapsedHours % 24)}h ${Math.round((requestElapsedHours % 1) * 60)}m`
: `${Math.floor(requestElapsedHours)}h ${Math.round((requestElapsedHours % 1) * 60)}m`,
remainingText: `${Math.floor(requestRemainingHours)}h ${Math.round((requestRemainingHours % 1) * 60)}m`
elapsedText: formatTime(requestElapsedHours),
remainingText: formatTime(requestRemainingHours)
};
} catch (error) {
console.error('[getWorkflowDetails] Request-level TAT calculation error:', error);
@ -1649,8 +1823,8 @@ export class WorkflowServiceMongo {
status: currentApprovalData.tatBreached ? 'breached' : 'on_track', // Corrected to 'on_track'
isPaused: currentApprovalData.isPaused,
deadline: currentApprovalData.levelEndTime || null,
elapsedText: `${Math.floor(currentApprovalData.elapsedHours)}h ${Math.round((currentApprovalData.elapsedHours % 1) * 60)}m`,
remainingText: `${Math.floor(currentApprovalData.remainingHours)}h ${Math.round((currentApprovalData.remainingHours % 1) * 60)}m`
elapsedText: formatTime(currentApprovalData.elapsedHours),
remainingText: formatTime(currentApprovalData.remainingHours)
} : null)
};
@ -1712,6 +1886,7 @@ export class WorkflowServiceMongo {
workflow.isDraft = false;
workflow.status = 'PENDING';
workflow.workflowState = 'OPEN';
workflow.submissionDate = new Date();
await workflow.save();

View File

@ -9,8 +9,7 @@ export enum WorkflowStatus {
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
CLOSED = 'CLOSED',
PAUSED = 'PAUSED',
CANCELLED = 'CANCELLED'
PAUSED = 'PAUSED'
}
export enum ApprovalStatus {

View File

@ -540,21 +540,6 @@ export async function calculateSLAStatus(
status = 'approaching';
}
// Format remaining time
const formatTime = (hours: number) => {
if (hours <= 0) return '0h';
const days = Math.floor(hours / 8); // 8 working hours per day
const remainingHrs = Math.floor(hours % 8);
const minutes = Math.round((hours % 1) * 60);
if (days > 0) {
return minutes > 0
? `${days}d ${remainingHrs}h ${minutes}m`
: `${days}d ${remainingHrs}h`;
}
return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`;
};
return {
elapsedHours: Math.round(elapsedHours * 100) / 100,
remainingHours: Math.round(remainingHours * 100) / 100,
@ -567,6 +552,27 @@ export async function calculateSLAStatus(
};
}
/**
* Format hours into "X day(s) Y h Z min" string
* @param hours - Number of hours
* @returns Formatted string
*/
export const formatTime = (hours: number): string => {
if (hours <= 0) return '0h';
const days = Math.floor(hours / 8); // 8 working hours per day
const remainingHrs = Math.floor(hours % 8);
const minutes = Math.round((hours % 1) * 60);
const dayLabel = days === 1 ? 'day' : 'days';
if (days > 0) {
return minutes > 0
? `${days} ${dayLabel} ${remainingHrs}h ${minutes}min`
: `${days} ${dayLabel} ${remainingHrs}h`;
}
return minutes > 0 ? `${remainingHrs}h ${minutes}min` : `${remainingHrs}h`;
};
/**
* Calculate elapsed working hours between two dates
* Uses minute-by-minute precision to accurately count only working time

View File

@ -0,0 +1,85 @@
import mongoose from 'mongoose';
import * as dotenv from 'dotenv';
import { dashboardMongoService } from './src/services/dashboard.service';
import { UserModel } from './src/models/mongoose/User.schema';
import { WorkflowRequestModel } from './src/models/mongoose/WorkflowRequest.schema';
dotenv.config();
async function verify() {
try {
await mongoose.connect(process.env.MONGO_URI!);
console.log('Connected to MongoDB');
// 1. Find an Admin and a Regular User
const adminUser = await UserModel.findOne({ role: 'ADMIN', isActive: true });
const regularUser = await UserModel.findOne({ role: 'USER', isActive: true });
if (!adminUser || !regularUser) {
console.error('Could not find both Admin and Regular User for testing');
// List some users to help debug
const users = await UserModel.find({ isActive: true }).limit(5);
console.log('Available users:', users.map(u => ({ email: u.email, role: u.role })));
process.exit(1);
}
console.log(`\nTesting with:`);
console.log(`Admin User: ${adminUser.email} (${adminUser.userId})`);
console.log(`Regular User: ${regularUser.email} (${regularUser.userId})`);
// 2. Test getUpcomingDeadlines
console.log('\n--- Testing Upcoming Deadlines ---');
const adminDeadlines = await dashboardMongoService.getUpcomingDeadlines(adminUser.userId, 1, 10, false);
const userDeadlines = await dashboardMongoService.getUpcomingDeadlines(regularUser.userId, 1, 10, true);
console.log(`Admin count: ${adminDeadlines.totalRecords}`);
console.log(`User count: ${userDeadlines.totalRecords}`);
if (userDeadlines.deadlines.length > 0) {
const first = userDeadlines.deadlines[0];
console.log(`First User Deadline Approver: ${first.approverEmail} (User Email: ${regularUser.email})`);
if (first.approverEmail !== regularUser.email) {
console.warn('WARNING: Regular user sees a deadline they are not the approver for! (Wait, are they just a participant?)');
// Check if they are actually the current approver
console.log('Actual Approver in data:', first.approverEmail);
} else {
console.log('SUCCESS: User only sees their own deadlines.');
}
} else {
console.log('No deadlines found for regular user.');
}
// 3. Test getCriticalRequests
console.log('\n--- Testing Critical Requests ---');
const adminCritical = await dashboardMongoService.getCriticalRequests(adminUser.userId, 1, 10, false);
const userCritical = await dashboardMongoService.getCriticalRequests(regularUser.userId, 1, 10, true);
console.log(`Admin count: ${adminCritical.totalRecords}`);
console.log(`User count: ${userCritical.totalRecords}`);
// 4. Test getRecentActivity
console.log('\n--- Testing Recent Activity ---');
const adminActivity = await dashboardMongoService.getRecentActivity(adminUser.userId, 1, 10, false);
const userActivity = await dashboardMongoService.getRecentActivity(regularUser.userId, 1, 10, true);
console.log(`Admin count: ${adminActivity.totalRecords}`);
console.log(`User count: ${userActivity.totalRecords}`);
// 5. Test getRequestStats
console.log('\n--- Testing Request Stats ---');
// userId, dateRange, startDate, endDate, status, priority, templateType, department, initiator, approver, approverType, search, slaCompliance, viewAsUser
const adminStats = await dashboardMongoService.getRequestStats(adminUser.userId, 'all', undefined, undefined, 'all', 'all', 'all', 'all', 'all', 'all', 'any', undefined, 'all', false);
const userStats = await dashboardMongoService.getRequestStats(regularUser.userId, 'all', undefined, undefined, 'all', 'all', 'all', 'all', 'all', 'all', 'any', undefined, 'all', true);
console.log(`Admin Total Requests: ${adminStats.totalRequests}`);
console.log(`User Total (Involved): ${userStats.totalRequests}`);
console.log('\nVerification Complete');
} catch (error) {
console.error('Verification failed:', error);
} finally {
await mongoose.disconnect();
}
}
verify();