diff --git a/Business_Days_Calculation_Recommendations.md b/Business_Days_Calculation_Recommendations.md new file mode 100644 index 0000000..ecc7501 --- /dev/null +++ b/Business_Days_Calculation_Recommendations.md @@ -0,0 +1,241 @@ +# Business Days Calculation - Current Issues & Recommendations + +## ๐Ÿ”ด **CRITICAL ISSUE: TAT Processor Using Wrong Calculation** + +### Current Problem: +In `Re_Backend/src/queues/tatProcessor.ts` (lines 64-65), the TAT calculation uses **simple calendar hours**: + +```typescript +const elapsedMs = now.getTime() - new Date(levelStartTime).getTime(); +const elapsedHours = elapsedMs / (1000 * 60 * 60); +``` + +**This is WRONG because:** +- โŒ Counts ALL hours (24/7), including nights, weekends, holidays +- โŒ Doesn't respect working hours (9 AM - 6 PM) +- โŒ Doesn't exclude weekends for STANDARD priority +- โŒ Doesn't exclude holidays +- โŒ Causes incorrect TAT breach alerts + +### โœ… **Solution Available:** +You already have a proper function `calculateElapsedWorkingHours()` in `tatTimeUtils.ts` that: +- โœ… Respects working hours (9 AM - 6 PM) +- โœ… Excludes weekends for STANDARD priority +- โœ… Excludes holidays +- โœ… Handles EXPRESS vs STANDARD differently +- โœ… Uses minute-by-minute precision + +### ๐Ÿ”ง **Fix Required:** + +**Update `tatProcessor.ts` to use proper working hours calculation:** + +```typescript +// BEFORE (WRONG): +const elapsedMs = now.getTime() - new Date(levelStartTime).getTime(); +const elapsedHours = elapsedMs / (1000 * 60 * 60); + +// AFTER (CORRECT): +import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils'; +const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); +const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority); +``` + +--- + +## ๐Ÿ“Š **Business Days Calculation for Workflow Aging Report** + +### Current Situation: +- โœ… You have `calculateElapsedWorkingHours()` - calculates hours +- โŒ You DON'T have `calculateBusinessDays()` - calculates days + +### Need: +For the **Workflow Aging Report**, you need to show "Days Open" as **business days** (excluding weekends and holidays), not calendar days. + +### ๐Ÿ”ง **Solution: Add Business Days Function** + +Add this function to `Re_Backend/src/utils/tatTimeUtils.ts`: + +```typescript +/** + * Calculate business days between two dates + * Excludes weekends and holidays + * @param startDate - Start date + * @param endDate - End date (defaults to now) + * @param priority - 'express' or 'standard' (express includes weekends, standard excludes) + * @returns Number of business days + */ +export async function calculateBusinessDays( + startDate: Date | string, + endDate: Date | string | null = null, + priority: string = 'standard' +): Promise { + await loadWorkingHoursCache(); + await loadHolidaysCache(); + + let start = dayjs(startDate).startOf('day'); + const end = dayjs(endDate || new Date()).startOf('day'); + + // In test mode, use calendar days + if (isTestMode()) { + return end.diff(start, 'day') + 1; + } + + const config = workingHoursCache || { + startHour: TAT_CONFIG.WORK_START_HOUR, + endHour: TAT_CONFIG.WORK_END_HOUR, + startDay: TAT_CONFIG.WORK_START_DAY, + endDay: TAT_CONFIG.WORK_END_DAY + }; + + let businessDays = 0; + let current = start; + + // Count each day from start to end (inclusive) + while (current.isBefore(end) || current.isSame(end, 'day')) { + const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday + const dateStr = current.format('YYYY-MM-DD'); + + // For express priority: count all days (including weekends) but exclude holidays + // For standard priority: count only working days (Mon-Fri) and exclude holidays + const isWorkingDay = priority === 'express' + ? true // Express includes weekends + : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); + + const isNotHoliday = !holidaysCache.has(dateStr); + + if (isWorkingDay && isNotHoliday) { + businessDays++; + } + + current = current.add(1, 'day'); + + // Safety check to prevent infinite loops + if (current.diff(start, 'day') > 730) { // 2 years + console.error('[TAT] Safety break - exceeded 2 years in business days calculation'); + break; + } + } + + return businessDays; +} +``` + +--- + +## ๐Ÿ“‹ **Summary of Issues & Fixes** + +### Issue 1: TAT Processor Using Calendar Hours โœ… **FIXED** +- **File:** `Re_Backend/src/queues/tatProcessor.ts` +- **Line:** 64-65 (now 66-77) +- **Problem:** Uses simple calendar hours instead of working hours +- **Impact:** Incorrect TAT breach calculations +- **Fix:** โœ… Replaced with `calculateElapsedWorkingHours()` and `addWorkingHours()`/`addWorkingHoursExpress()` +- **Status:** โœ… **COMPLETED** - Now uses proper working hours calculation + +### Issue 2: Missing Business Days Function โœ… **FIXED** +- **File:** `Re_Backend/src/utils/tatTimeUtils.ts` +- **Problem:** No function to calculate business days count +- **Impact:** Workflow Aging Report shows calendar days instead of business days +- **Fix:** โœ… Added `calculateBusinessDays()` function (lines 697-758) +- **Status:** โœ… **COMPLETED** - Function implemented and exported + +### Issue 3: Workflow Aging Report Using Calendar Days โœ… **FIXED** +- **File:** `Re_Backend/src/services/dashboard.service.ts` +- **Problem:** Will use calendar days if not fixed +- **Impact:** Incorrect "Days Open" calculation +- **Fix:** โœ… Uses `calculateBusinessDays()` in report endpoint (getWorkflowAgingReport method) +- **Status:** โœ… **COMPLETED** - Report now uses business days calculation + +--- + +## ๐Ÿ› ๏ธ **Implementation Steps** โœ… **ALL COMPLETED** + +### Step 1: Fix TAT Processor (CRITICAL) โœ… **DONE** +1. โœ… Opened `Re_Backend/src/queues/tatProcessor.ts` +2. โœ… Imported `calculateElapsedWorkingHours`, `addWorkingHours`, `addWorkingHoursExpress` from `@utils/tatTimeUtils` +3. โœ… Replaced lines 64-65 with proper working hours calculation (now lines 66-77) +4. โœ… Gets priority from workflow +5. โณ **TODO:** Test TAT breach alerts + +### Step 2: Add Business Days Function โœ… **DONE** +1. โœ… Opened `Re_Backend/src/utils/tatTimeUtils.ts` +2. โœ… Added `calculateBusinessDays()` function (lines 697-758) +3. โœ… Exported the function +4. โณ **TODO:** Test with various date ranges + +### Step 3: Update Workflow Aging Report โœ… **DONE** +1. โœ… Built report endpoint using `calculateBusinessDays()` +2. โœ… Filters requests where `businessDays > threshold` +3. โœ… Displays business days instead of calendar days + +--- + +## โœ… **What's Already Working** + +- โœ… `calculateElapsedWorkingHours()` - Properly calculates working hours +- โœ… `calculateSLAStatus()` - Comprehensive SLA calculation +- โœ… Working hours configuration (from admin settings) +- โœ… Holiday support (from database) +- โœ… Priority-based calculation (express vs standard) +- โœ… Used correctly in `approval.service.ts` and `dashboard.service.ts` + +--- + +## ๐ŸŽฏ **Priority Order** + +1. **๐Ÿ”ด CRITICAL:** Fix TAT Processor (affects all TAT calculations) +2. **๐ŸŸก HIGH:** Add Business Days Function (needed for reports) +3. **๐ŸŸก HIGH:** Update Workflow Aging Report to use business days + +--- + +## ๐Ÿ“ **Code Example: Fixed TAT Processor** + +```typescript +// In tatProcessor.ts, around line 60-70 +import { calculateElapsedWorkingHours } from '@utils/tatTimeUtils'; + +// ... existing code ... + +const tatHours = Number((approvalLevel as any).tatHours || 0); +const levelStartTime = (approvalLevel as any).levelStartTime || (approvalLevel as any).createdAt; +const now = new Date(); + +// FIXED: Use proper working hours calculation +const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); +const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority); +const remainingHours = Math.max(0, tatHours - elapsedHours); +const expectedCompletionTime = dayjs(levelStartTime).add(tatHours, 'hour').toDate(); + +// ... rest of code ... +``` + +--- + +## ๐Ÿงช **Testing Recommendations** + +1. **Test TAT Breach Calculation:** + - Create a request with 8-hour TAT + - Submit on Friday 5 PM + - Should NOT breach until Monday 1 PM (next working hour) + - Currently will breach on Saturday 1 AM (wrong!) + +2. **Test Business Days:** + - Start: Monday, Jan 1 + - End: Friday, Jan 5 + - Should return: 5 business days (not 5 calendar days if there are holidays) + +3. **Test Express vs Standard:** + - Express: Should count weekends + - Standard: Should exclude weekends + +--- + +## ๐Ÿ“š **Related Files** + +- `Re_Backend/src/queues/tatProcessor.ts` - โœ… **FIXED** - Now uses `calculateElapsedWorkingHours()` and proper deadline calculation +- `Re_Backend/src/utils/tatTimeUtils.ts` - โœ… **FIXED** - Added `calculateBusinessDays()` function +- `Re_Backend/src/services/approval.service.ts` - โœ… Already using correct calculation +- `Re_Backend/src/services/dashboard.service.ts` - โœ… **FIXED** - Uses `calculateBusinessDays()` in Workflow Aging Report +- `Re_Backend/src/services/workflow.service.ts` - โœ… Already using correct calculation + diff --git a/Data_Collection_Analysis.md b/Data_Collection_Analysis.md new file mode 100644 index 0000000..8544e93 --- /dev/null +++ b/Data_Collection_Analysis.md @@ -0,0 +1,535 @@ +# Data Collection Analysis - What We Have vs What We're Collecting + +## Overview +This document compares the database structure with what we're currently collecting and recommends what we should start collecting for the Detailed Reports. + +--- + +## 1. ACTIVITIES TABLE + +### โœ… **Database Fields Available:** +```sql +- activity_id (PK) +- request_id (FK) โœ… COLLECTING +- user_id (FK) โœ… COLLECTING +- user_name โœ… COLLECTING +- activity_type โœ… COLLECTING +- activity_description โœ… COLLECTING +- activity_category โŒ NOT COLLECTING (set to NULL) +- severity โŒ NOT COLLECTING (set to NULL) +- metadata โœ… COLLECTING (partially) +- is_system_event โœ… COLLECTING +- ip_address โŒ NOT COLLECTING (set to NULL) +- user_agent โŒ NOT COLLECTING (set to NULL) +- created_at โœ… COLLECTING +``` + +### ๐Ÿ”ด **Currently NOT Collecting (But Should):** + +1. **IP Address** (`ip_address`) + - **Status:** Field exists, but always set to `null` + - **Impact:** Cannot show IP in User Activity Log Report + - **Fix:** Extract from `req.ip` or `req.headers['x-forwarded-for']` in controllers + - **Priority:** HIGH (needed for security/audit) + +2. **User Agent** (`user_agent`) + - **Status:** Field exists, but always set to `null` + - **Impact:** Cannot show device/browser info in reports + - **Fix:** Extract from `req.headers['user-agent']` in controllers + - **Priority:** MEDIUM (nice to have for analytics) + +3. **Activity Category** (`activity_category`) + - **Status:** Field exists, but always set to `null` + - **Impact:** Cannot categorize activities (e.g., "AUTHENTICATION", "WORKFLOW", "DOCUMENT") + - **Fix:** Map `activity_type` to category: + - `created`, `approval`, `rejection`, `status_change` โ†’ "WORKFLOW" + - `comment` โ†’ "COLLABORATION" + - `document_added` โ†’ "DOCUMENT" + - `sla_warning` โ†’ "SYSTEM" + - **Priority:** MEDIUM (helps with filtering/reporting) + +4. **Severity** (`severity`) + - **Status:** Field exists, but always set to `null` + - **Impact:** Cannot prioritize critical activities + - **Fix:** Map based on activity type: + - `rejection`, `sla_warning` โ†’ "WARNING" + - `approval`, `closed` โ†’ "INFO" + - `status_change` โ†’ "INFO" + - **Priority:** LOW (optional enhancement) + +### ๐Ÿ“ **Recommendation:** +**Update `activity.service.ts` to accept and store:** +```typescript +async log(entry: ActivityEntry & { + ipAddress?: string; + userAgent?: string; + category?: string; + severity?: string; +}) { + // ... existing code ... + const activityData = { + // ... existing fields ... + ipAddress: entry.ipAddress || null, + userAgent: entry.userAgent || null, + activityCategory: entry.category || this.inferCategory(entry.type), + severity: entry.severity || this.inferSeverity(entry.type), + }; +} +``` + +**Update all controller calls to pass IP and User Agent:** +```typescript +activityService.log({ + // ... existing fields ... + ipAddress: req.ip || req.headers['x-forwarded-for'] || null, + userAgent: req.headers['user-agent'] || null, +}); +``` + +--- + +## 2. APPROVAL_LEVELS TABLE + +### โœ… **Database Fields Available:** +```sql +- level_id (PK) +- request_id (FK) โœ… COLLECTING +- level_number โœ… COLLECTING +- level_name โŒ OPTIONAL (may not be set) +- approver_id (FK) โœ… COLLECTING +- approver_email โœ… COLLECTING +- approver_name โœ… COLLECTING +- tat_hours โœ… COLLECTING +- tat_days โœ… COLLECTING (auto-calculated) +- status โœ… COLLECTING +- level_start_time โœ… COLLECTING +- level_end_time โœ… COLLECTING +- action_date โœ… COLLECTING +- comments โœ… COLLECTING +- rejection_reason โœ… COLLECTING +- is_final_approver โœ… COLLECTING +- elapsed_hours โœ… COLLECTING +- remaining_hours โœ… COLLECTING +- tat_percentage_used โœ… COLLECTING +- tat50_alert_sent โœ… COLLECTING +- tat75_alert_sent โœ… COLLECTING +- tat_breached โœ… COLLECTING +- tat_start_time โœ… COLLECTING +- created_at โœ… COLLECTING +- updated_at โœ… COLLECTING +``` + +### ๐Ÿ”ด **Currently NOT Collecting (But Should):** + +1. **Level Name** (`level_name`) + - **Status:** Field exists, but may be NULL + - **Impact:** Cannot show stage name in reports (only level number) + - **Fix:** When creating approval levels, prompt for or auto-generate level names: + - "Department Head Review" + - "Finance Approval" + - "Final Approval" + - **Priority:** MEDIUM (improves report readability) + +### ๐Ÿ“ **Recommendation:** +**Ensure level_name is set when creating approval levels:** +```typescript +await ApprovalLevel.create({ + // ... existing fields ... + levelName: levelData.levelName || `Level ${levelNumber}`, +}); +``` + +--- + +## 3. USER_SESSIONS TABLE + +### โœ… **Database Fields Available:** +```sql +- session_id (PK) +- user_id (FK) +- session_token โœ… COLLECTING +- refresh_token โœ… COLLECTING +- ip_address โ“ CHECK IF COLLECTING +- user_agent โ“ CHECK IF COLLECTING +- device_type โ“ CHECK IF COLLECTING +- browser โ“ CHECK IF COLLECTING +- os โ“ CHECK IF COLLECTING +- login_at โœ… COLLECTING +- last_activity_at โœ… COLLECTING +- logout_at โ“ CHECK IF COLLECTING +- expires_at โœ… COLLECTING +- is_active โœ… COLLECTING +- logout_reason โ“ CHECK IF COLLECTING +``` + +### ๐Ÿ”ด **Missing for Login Activity Tracking:** + +1. **Login Activities in Activities Table** + - **Status:** Login events are NOT logged in `activities` table + - **Impact:** Cannot show login activities in User Activity Log Report + - **Fix:** Add login activity logging in auth middleware/controller: + ```typescript + // After successful login + await activityService.log({ + requestId: 'SYSTEM_LOGIN', // Special request ID for system events + type: 'login', + user: { userId, name: user.displayName }, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + category: 'AUTHENTICATION', + severity: 'INFO', + timestamp: new Date().toISOString(), + action: 'User Login', + details: `User logged in from ${req.ip}` + }); + ``` + - **Priority:** HIGH (needed for security audit) + +2. **Device/Browser Parsing** + - **Status:** Fields exist but may not be populated + - **Impact:** Cannot show device type in reports + - **Fix:** Parse user agent to extract: + - `device_type`: "WEB", "MOBILE" + - `browser`: "Chrome", "Firefox", "Safari" + - `os`: "Windows", "macOS", "iOS", "Android" + - **Priority:** MEDIUM (nice to have) + +--- + +## 4. WORKFLOW_REQUESTS TABLE + +### โœ… **All Fields Are Being Collected:** +- All fields in `workflow_requests` are properly collected +- No missing data here + +### ๐Ÿ“ **Note:** +- `submission_date` vs `created_at`: Use `submission_date` for "days open" calculation +- `closure_date`: Available for completed requests + +--- + +## 5. TAT_TRACKING TABLE + +### โœ… **Database Fields Available:** +```sql +- tracking_id (PK) +- request_id (FK) +- level_id (FK) +- tracking_type โœ… COLLECTING +- tat_status โœ… COLLECTING +- total_tat_hours โœ… COLLECTING +- elapsed_hours โœ… COLLECTING +- remaining_hours โœ… COLLECTING +- percentage_used โœ… COLLECTING +- threshold_50_breached โœ… COLLECTING +- threshold_50_alerted_at โœ… COLLECTING +- threshold_80_breached โœ… COLLECTING +- threshold_80_alerted_at โœ… COLLECTING +- threshold_100_breached โœ… COLLECTING +- threshold_100_alerted_at โœ… COLLECTING +- alert_count โœ… COLLECTING +- last_calculated_at โœ… COLLECTING +``` + +### โœ… **All Fields Are Being Collected:** +- TAT tracking appears to be fully implemented + +--- + +## 6. AUDIT_LOGS TABLE + +### โœ… **Database Fields Available:** +```sql +- audit_id (PK) +- user_id (FK) +- entity_type +- entity_id +- action +- action_category +- old_values (JSONB) +- new_values (JSONB) +- changes_summary +- ip_address +- user_agent +- session_id +- request_method +- request_url +- response_status +- execution_time_ms +- created_at +``` + +### ๐Ÿ”ด **Status:** +- **Audit logging may not be fully implemented** +- **Impact:** Cannot track all system changes for audit purposes +- **Priority:** MEDIUM (for compliance/security) + +--- + +## SUMMARY: What to Start Collecting + +### ๐Ÿ”ด **HIGH PRIORITY (Must Have for Reports):** + +1. **IP Address in Activities** โœ… Field exists, just need to populate + - Extract from `req.ip` or `req.headers['x-forwarded-for']` + - Update `activity.service.ts` to accept IP + - Update all controller calls + +2. **User Agent in Activities** โœ… Field exists, just need to populate + - Extract from `req.headers['user-agent']` + - Update `activity.service.ts` to accept user agent + - Update all controller calls + +3. **Login Activities** โŒ Not currently logged + - Add login activity logging in auth controller + - Use special `requestId: 'SYSTEM_LOGIN'` for system events + - Include IP and user agent + +### ๐ŸŸก **MEDIUM PRIORITY (Nice to Have):** + +4. **Activity Category** โœ… Field exists, just need to populate + - Auto-infer from `activity_type` + - Helps with filtering and reporting + +5. **Level Names** โœ… Field exists, ensure it's set + - Improve readability in reports + - Auto-generate if not provided + +6. **Severity** โœ… Field exists, just need to populate + - Auto-infer from `activity_type` + - Helps prioritize critical activities + +### ๐ŸŸข **LOW PRIORITY (Future Enhancement):** + +7. **Device/Browser Parsing** + - Parse user agent to extract device type, browser, OS + - Store in `user_sessions` table + +8. **Audit Logging** + - Implement comprehensive audit logging + - Track all system changes + +--- + +## 7. BUSINESS DAYS CALCULATION FOR WORKFLOW AGING + +### โœ… **Available:** +- `calculateElapsedWorkingHours()` - Calculates working hours (excludes weekends/holidays) +- Working hours configuration (9 AM - 6 PM, Mon-Fri) +- Holiday support (from database) +- Priority-based calculation (express vs standard) + +### โŒ **Missing:** +1. **Business Days Count Function** + - Need a function to calculate business days (not hours) + - For Workflow Aging Report: "Days Open" should be business days + - Currently only have working hours calculation + +2. **TAT Processor Using Wrong Calculation** + - `tatProcessor.ts` uses simple calendar hours: + ```typescript + const elapsedMs = now.getTime() - new Date(levelStartTime).getTime(); + const elapsedHours = elapsedMs / (1000 * 60 * 60); + ``` + - Should use `calculateElapsedWorkingHours()` instead + - This causes incorrect TAT breach calculations + +### ๐Ÿ”ง **What Needs to be Built:** + +1. **Add Business Days Calculation Function:** + ```typescript + // In tatTimeUtils.ts + export async function calculateBusinessDays( + startDate: Date | string, + endDate: Date | string = new Date(), + priority: string = 'standard' + ): Promise { + await loadWorkingHoursCache(); + await loadHolidaysCache(); + + let start = dayjs(startDate); + const end = dayjs(endDate); + const config = workingHoursCache || { /* defaults */ }; + + let businessDays = 0; + let current = start.startOf('day'); + + while (current.isBefore(end) || current.isSame(end, 'day')) { + const dayOfWeek = current.day(); + const dateStr = current.format('YYYY-MM-DD'); + + const isWorkingDay = priority === 'express' + ? true + : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); + const isNotHoliday = !holidaysCache.has(dateStr); + + if (isWorkingDay && isNotHoliday) { + businessDays++; + } + + current = current.add(1, 'day'); + } + + return businessDays; + } + ``` + +2. **Fix TAT Processor:** + - Replace calendar hours calculation with `calculateElapsedWorkingHours()` + - This will fix TAT breach alerts to use proper working hours + +3. **Update Workflow Aging Report:** + - Use `calculateBusinessDays()` instead of calendar days + - Filter by business days threshold + +--- + +## IMPLEMENTATION CHECKLIST + +### Phase 1: Quick Wins (Fields Exist, Just Need to Populate) +- [ ] Update `activity.service.ts` to accept `ipAddress` and `userAgent` +- [ ] Update all controller calls to pass IP and user agent +- [ ] Add activity category inference +- [ ] Add severity inference + +### Phase 2: Fix TAT Calculations (CRITICAL) +- [x] Fix `tatProcessor.ts` to use `calculateElapsedWorkingHours()` instead of calendar hours โœ… +- [x] Add `calculateBusinessDays()` function to `tatTimeUtils.ts` โœ… +- [ ] Test TAT breach calculations with working hours + +### Phase 3: New Functionality +- [x] Add login activity logging โœ… (Implemented in auth.controller.ts for SSO and token exchange) +- [x] Ensure level names are set when creating approval levels โœ… (levelName set in workflow.service.ts) +- [x] Add device/browser parsing for user sessions โœ… (userAgentParser.ts utility created - can be used for parsing user agent strings) + +### Phase 4: Enhanced Reporting +- [x] Build report endpoints using collected data โœ… (getLifecycleReport, getActivityLogReport, getWorkflowAgingReport) +- [x] Add filtering by category, severity โœ… (Filtering by category and severity added to getActivityLogReport, frontend UI added) +- [x] Add IP/user agent to activity log reports โœ… (IP and user agent captured and displayed) +- [x] Use business days in Workflow Aging Report โœ… (calculateBusinessDays implemented and used) + +--- + +## CODE CHANGES NEEDED + +### 1. Update Activity Service (`activity.service.ts`) + +```typescript +export type ActivityEntry = { + requestId: string; + type: 'created' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning' | 'ai_conclusion_generated' | 'closed' | 'login'; + user?: { userId: string; name?: string; email?: string }; + timestamp: string; + action: string; + details: string; + metadata?: any; + ipAddress?: string; // NEW + userAgent?: string; // NEW + category?: string; // NEW + severity?: string; // NEW +}; + +class ActivityService { + private inferCategory(type: string): string { + const categoryMap: Record = { + 'created': 'WORKFLOW', + 'approval': 'WORKFLOW', + 'rejection': 'WORKFLOW', + 'status_change': 'WORKFLOW', + 'assignment': 'WORKFLOW', + 'comment': 'COLLABORATION', + 'document_added': 'DOCUMENT', + 'sla_warning': 'SYSTEM', + 'reminder': 'SYSTEM', + 'ai_conclusion_generated': 'SYSTEM', + 'closed': 'WORKFLOW', + 'login': 'AUTHENTICATION' + }; + return categoryMap[type] || 'OTHER'; + } + + private inferSeverity(type: string): string { + const severityMap: Record = { + 'rejection': 'WARNING', + 'sla_warning': 'WARNING', + 'approval': 'INFO', + 'closed': 'INFO', + 'status_change': 'INFO', + 'login': 'INFO', + 'created': 'INFO', + 'comment': 'INFO', + 'document_added': 'INFO' + }; + return severityMap[type] || 'INFO'; + } + + async log(entry: ActivityEntry) { + // ... existing code ... + const activityData = { + requestId: entry.requestId, + userId: entry.user?.userId || null, + userName: entry.user?.name || entry.user?.email || null, + activityType: entry.type, + activityDescription: entry.details, + activityCategory: entry.category || this.inferCategory(entry.type), + severity: entry.severity || this.inferSeverity(entry.type), + metadata: entry.metadata || null, + isSystemEvent: !entry.user, + ipAddress: entry.ipAddress || null, // NEW + userAgent: entry.userAgent || null, // NEW + }; + // ... rest of code ... + } +} +``` + +### 2. Update Controller Calls (Example) + +```typescript +// In workflow.controller.ts, approval.controller.ts, etc. +activityService.log({ + requestId: workflow.requestId, + type: 'created', + user: { userId, name: user.displayName }, + timestamp: new Date().toISOString(), + action: 'Request Created', + details: `Request ${workflow.requestNumber} created`, + ipAddress: req.ip || req.headers['x-forwarded-for'] || null, // NEW + userAgent: req.headers['user-agent'] || null, // NEW +}); +``` + +### 3. Add Login Activity Logging + +```typescript +// In auth.controller.ts after successful login +await activityService.log({ + requestId: 'SYSTEM_LOGIN', // Special ID for system events + type: 'login', + user: { userId: user.userId, name: user.displayName }, + timestamp: new Date().toISOString(), + action: 'User Login', + details: `User logged in successfully`, + ipAddress: req.ip || req.headers['x-forwarded-for'] || null, + userAgent: req.headers['user-agent'] || null, + category: 'AUTHENTICATION', + severity: 'INFO' +}); +``` + +--- + +## CONCLUSION + +**Good News:** Most fields already exist in the database! We just need to: +1. Populate existing fields (IP, user agent, category, severity) +2. Add login activity logging +3. Ensure level names are set + +**Estimated Effort:** +- Phase 1 (Quick Wins): 2-4 hours +- Phase 2 (New Functionality): 4-6 hours +- Phase 3 (Enhanced Reporting): 8-12 hours + +**Total: ~14-22 hours of development work** + diff --git a/Royal_Enfield_API_Collection.postman_collection.json b/Royal_Enfield_API_Collection.postman_collection.json new file mode 100644 index 0000000..38e501d --- /dev/null +++ b/Royal_Enfield_API_Collection.postman_collection.json @@ -0,0 +1,1846 @@ +{ + "info": { + "_postman_id": "royal-enfield-workflow-api", + "name": "Royal Enfield Workflow API", + "description": "Complete API collection for Royal Enfield Workflow Management System with prefilled payloads and detailed comments.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "royal-enfield-api-v1" + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api/v1", + "type": "string" + }, + { + "key": "accessToken", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "Health Check", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/health", + "host": ["{{baseUrl}}"], + "path": ["health"] + }, + "description": "Check if the API service is running and healthy" + } + } + ] + }, + { + "name": "Authentication", + "item": [ + { + "name": "Token Exchange (Development)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Email of the user for development token exchange\n \"email\": \"user@example.com\",\n \n // User's full name\n \"name\": \"John Doe\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/token-exchange", + "host": ["{{baseUrl}}"], + "path": ["auth", "token-exchange"] + }, + "description": "Development endpoint to exchange user credentials for JWT token (localhost only)" + } + }, + { + "name": "SSO Callback", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Okta authorization code received from SSO redirect\n \"code\": \"authorization_code_from_okta\",\n \n // State parameter for CSRF protection\n \"state\": \"random_state_string\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/sso-callback", + "host": ["{{baseUrl}}"], + "path": ["auth", "sso-callback"] + }, + "description": "Handle SSO callback from Okta and exchange authorization code for tokens" + } + }, + { + "name": "Refresh Token", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Refresh token received during login\n \"refreshToken\": \"your_refresh_token_here\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/refresh", + "host": ["{{baseUrl}}"], + "path": ["auth", "refresh"] + }, + "description": "Get a new access token using refresh token" + } + }, + { + "name": "Get Current User", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/auth/me", + "host": ["{{baseUrl}}"], + "path": ["auth", "me"] + }, + "description": "Get current authenticated user details" + } + }, + { + "name": "Validate Token", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/auth/validate", + "host": ["{{baseUrl}}"], + "path": ["auth", "validate"] + }, + "description": "Validate current access token" + } + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/auth/logout", + "host": ["{{baseUrl}}"], + "path": ["auth", "logout"] + }, + "description": "Logout current user and clear tokens" + } + } + ] + }, + { + "name": "Configuration", + "item": [ + { + "name": "Get Public Config", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/config", + "host": ["{{baseUrl}}"], + "path": ["config"] + }, + "description": "Get public system configuration (no auth required)" + } + } + ] + }, + { + "name": "Users", + "item": [ + { + "name": "Search Users", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/users/search?q=john", + "host": ["{{baseUrl}}"], + "path": ["users", "search"], + "query": [ + { + "key": "q", + "value": "john", + "description": "Search query - can search by email or name" + } + ] + }, + "description": "Search for users by email or name" + } + }, + { + "name": "Ensure User Exists", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // User's email address\n \"email\": \"user@example.com\",\n \n // User's full name\n \"name\": \"John Doe\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/users/ensure", + "host": ["{{baseUrl}}"], + "path": ["users", "ensure"] + }, + "description": "Ensure user exists in database - creates if not exists" + } + } + ] + }, + { + "name": "Workflows", + "item": [ + { + "name": "List All Workflows", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows", + "host": ["{{baseUrl}}"], + "path": ["workflows"] + }, + "description": "Get list of all workflows accessible to current user" + } + }, + { + "name": "List My Requests", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/my", + "host": ["{{baseUrl}}"], + "path": ["workflows", "my"] + }, + "description": "Get workflows initiated by current user" + } + }, + { + "name": "List Open For Me", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/open-for-me", + "host": ["{{baseUrl}}"], + "path": ["workflows", "open-for-me"] + }, + "description": "Get workflows that are open and require action from current user" + } + }, + { + "name": "List Closed By Me", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/closed-by-me", + "host": ["{{baseUrl}}"], + "path": ["workflows", "closed-by-me"] + }, + "description": "Get workflows closed by current user" + } + }, + { + "name": "Create Workflow (JSON)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Request title - brief description\n \"requestTitle\": \"Purchase Order Approval for Office Equipment\",\n \n // Detailed description of the request\n \"requestDescription\": \"Approval needed for purchasing new office equipment including laptops, monitors, and office furniture. Total budget: $50,000\",\n \n // Priority: STANDARD | EXPRESS\n \"priority\": \"STANDARD\",\n \n // Department requesting approval\n \"requestingDepartment\": \"IT\",\n \n // Category of request\n \"requestCategory\": \"PURCHASE_ORDER\",\n \n // Approvers list - array of approval levels\n \"approvers\": [\n {\n // Approver's email\n \"email\": \"manager@example.com\",\n \n // TAT (Turn Around Time) in hours\n \"tatHours\": 24,\n \n // Level number (sequential)\n \"level\": 1\n },\n {\n \"email\": \"director@example.com\",\n \"tatHours\": 48,\n \"level\": 2\n },\n {\n \"email\": \"cfo@example.com\",\n \"tatHours\": 72,\n \"level\": 3\n }\n ],\n \n // Spectators (optional) - users who can view but not approve\n \"spectators\": [\n {\n \"email\": \"hr@example.com\"\n },\n {\n \"email\": \"finance@example.com\"\n }\n ],\n \n // Document IDs (if documents uploaded separately)\n \"documentIds\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows", + "host": ["{{baseUrl}}"], + "path": ["workflows"] + }, + "description": "Create new workflow request with JSON payload" + } + }, + { + "name": "Create Workflow (Multipart with Files)", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "requestTitle", + "value": "Purchase Order Approval for Office Equipment", + "type": "text", + "description": "Request title" + }, + { + "key": "requestDescription", + "value": "Approval needed for purchasing new office equipment", + "type": "text", + "description": "Detailed description" + }, + { + "key": "priority", + "value": "STANDARD", + "type": "text", + "description": "STANDARD or EXPRESS" + }, + { + "key": "requestingDepartment", + "value": "IT", + "type": "text", + "description": "Department name" + }, + { + "key": "requestCategory", + "value": "PURCHASE_ORDER", + "type": "text", + "description": "Category of request" + }, + { + "key": "approvers", + "value": "[{\"email\":\"manager@example.com\",\"tatHours\":24,\"level\":1},{\"email\":\"director@example.com\",\"tatHours\":48,\"level\":2}]", + "type": "text", + "description": "JSON array of approvers" + }, + { + "key": "spectators", + "value": "[{\"email\":\"hr@example.com\"}]", + "type": "text", + "description": "JSON array of spectators (optional)" + }, + { + "key": "files", + "type": "file", + "src": [], + "description": "Upload files (multiple files supported)" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/workflows/multipart", + "host": ["{{baseUrl}}"], + "path": ["workflows", "multipart"] + }, + "description": "Create workflow with file uploads using multipart/form-data" + } + }, + { + "name": "Get Workflow Details", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001", + "description": "Workflow ID or Request Number" + } + ] + }, + "description": "Get basic workflow details by ID or request number" + } + }, + { + "name": "Get Workflow Full Details", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/details", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "details"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001", + "description": "Workflow ID or Request Number" + } + ] + }, + "description": "Get full workflow details with all related data (approvals, documents, participants)" + } + }, + { + "name": "Update Workflow (JSON)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Updated request title (optional)\n \"requestTitle\": \"Updated Purchase Order for Office Equipment\",\n \n // Updated description (optional)\n \"requestDescription\": \"Modified requirements for office equipment purchase\",\n \n // Updated priority (optional)\n \"priority\": \"EXPRESS\",\n \n // Updated department (optional)\n \"requestingDepartment\": \"IT\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Update workflow details (only allowed in DRAFT status)" + } + }, + { + "name": "Update Workflow (Multipart)", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "requestTitle", + "value": "Updated Title", + "type": "text" + }, + { + "key": "requestDescription", + "value": "Updated Description", + "type": "text" + }, + { + "key": "files", + "type": "file", + "src": [] + } + ] + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/multipart", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "multipart"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Update workflow with file uploads" + } + }, + { + "name": "Submit Workflow", + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/submit", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "submit"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Submit workflow for approval (changes status from DRAFT to OPEN)" + } + }, + { + "name": "Get Workflow Activity", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/activity", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "activity"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Get activity log/history for a workflow" + } + } + ] + }, + { + "name": "Approvals", + "item": [ + { + "name": "Get Approval Levels", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvals", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvals"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Get all approval levels for a workflow" + } + }, + { + "name": "Get Current Approval Level", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvals/current", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvals", "current"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Get current active approval level" + } + }, + { + "name": "Approve Level", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Approval remark/comment\n \"remark\": \"Approved. Budget allocation confirmed and all requirements are justified.\",\n \n // Whether to use AI-generated remark (optional)\n \"useAiRemark\": false\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvals/:levelId/approve", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvals", ":levelId", "approve"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + }, + { + "key": "levelId", + "value": "level-uuid-here" + } + ] + }, + "description": "Approve an approval level" + } + }, + { + "name": "Reject Level", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Rejection reason/remark (required)\n \"remark\": \"Rejected due to insufficient budget justification. Please provide more details on cost breakdown.\",\n \n // Action: REJECT to reject\n \"action\": \"REJECT\",\n \n // Whether to use AI-generated remark (optional)\n \"useAiRemark\": false\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvals/:levelId/reject", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvals", ":levelId", "reject"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + }, + { + "key": "levelId", + "value": "level-uuid-here" + } + ] + }, + "description": "Reject an approval level with reason" + } + }, + { + "name": "Skip Approver", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Reason for skipping approver\n \"reason\": \"Approver is on leave. Moving to next level.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvals/:levelId/skip", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvals", ":levelId", "skip"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + }, + { + "key": "levelId", + "value": "level-uuid-here" + } + ] + }, + "description": "Skip an approver level (only initiator or other approvers)" + } + } + ] + }, + { + "name": "Participants", + "item": [ + { + "name": "Add Approver", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Email of new approver to add\n \"email\": \"new.approver@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/participants/approver", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "participants", "approver"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Add an approver to the workflow" + } + }, + { + "name": "Add Spectator", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Email of spectator to add\n \"email\": \"spectator@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/participants/spectator", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "participants", "spectator"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Add a spectator (view-only participant) to the workflow" + } + }, + { + "name": "Add Approver At Specific Level", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Email of approver\n \"email\": \"new.approver@example.com\",\n \n // TAT hours for this approver\n \"tatHours\": 24,\n \n // Level number where to insert (existing levels will shift)\n \"level\": 2\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/approvers/at-level", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "approvers", "at-level"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Add approver at specific level with level shifting" + } + } + ] + }, + { + "name": "Documents", + "item": [ + { + "name": "Upload Document", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": [], + "description": "File to upload" + }, + { + "key": "requestId", + "value": "request-uuid-here", + "type": "text", + "description": "Request/Workflow ID" + }, + { + "key": "category", + "value": "SUPPORTING_DOCUMENT", + "type": "text", + "description": "Document category (optional)" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/documents", + "host": ["{{baseUrl}}"], + "path": ["documents"] + }, + "description": "Upload a document/file" + } + }, + { + "name": "Preview Document", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/documents/:documentId/preview", + "host": ["{{baseUrl}}"], + "path": ["workflows", "documents", ":documentId", "preview"], + "variable": [ + { + "key": "documentId", + "value": "document-uuid-here" + } + ] + }, + "description": "Preview document (inline viewing for images/PDFs)" + } + }, + { + "name": "Download Document", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/documents/:documentId/download", + "host": ["{{baseUrl}}"], + "path": ["workflows", "documents", ":documentId", "download"], + "variable": [ + { + "key": "documentId", + "value": "document-uuid-here" + } + ] + }, + "description": "Download document as attachment" + } + } + ] + }, + { + "name": "Work Notes", + "item": [ + { + "name": "List Work Notes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/:id/work-notes", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "work-notes"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Get all work notes for a workflow" + } + }, + { + "name": "Create Work Note", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "This is a work note discussing the current status and next steps for approval.", + "type": "text", + "description": "Work note content/text" + }, + { + "key": "files", + "type": "file", + "src": [], + "description": "Attachments (optional, multiple files)" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/workflows/:id/work-notes", + "host": ["{{baseUrl}}"], + "path": ["workflows", ":id", "work-notes"], + "variable": [ + { + "key": "id", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Create a work note with optional file attachments" + } + }, + { + "name": "Preview Work Note Attachment", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/work-notes/attachments/:attachmentId/preview", + "host": ["{{baseUrl}}"], + "path": ["workflows", "work-notes", "attachments", ":attachmentId", "preview"], + "variable": [ + { + "key": "attachmentId", + "value": "attachment-uuid-here" + } + ] + }, + "description": "Preview work note attachment" + } + }, + { + "name": "Download Work Note Attachment", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/work-notes/attachments/:attachmentId/download", + "host": ["{{baseUrl}}"], + "path": ["workflows", "work-notes", "attachments", ":attachmentId", "download"], + "variable": [ + { + "key": "attachmentId", + "value": "attachment-uuid-here" + } + ] + }, + "description": "Download work note attachment" + } + } + ] + }, + { + "name": "Conclusions", + "item": [ + { + "name": "Generate Conclusion", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/conclusions/:requestId/generate", + "host": ["{{baseUrl}}"], + "path": ["conclusions", ":requestId", "generate"], + "variable": [ + { + "key": "requestId", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Generate AI-powered conclusion remark for workflow (initiator only)" + } + }, + { + "name": "Update Conclusion", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Updated conclusion text\n \"conclusionRemark\": \"Project has been approved with all requirements met. Implementation will begin next quarter.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/conclusions/:requestId", + "host": ["{{baseUrl}}"], + "path": ["conclusions", ":requestId"], + "variable": [ + { + "key": "requestId", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Update conclusion remark (edit by initiator)" + } + }, + { + "name": "Finalize Conclusion", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/conclusions/:requestId/finalize", + "host": ["{{baseUrl}}"], + "path": ["conclusions", ":requestId", "finalize"], + "variable": [ + { + "key": "requestId", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Finalize conclusion and close the workflow (initiator only)" + } + }, + { + "name": "Get Conclusion", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/conclusions/:requestId", + "host": ["{{baseUrl}}"], + "path": ["conclusions", ":requestId"], + "variable": [ + { + "key": "requestId", + "value": "REQ-2024-0001" + } + ] + }, + "description": "Get conclusion for a workflow" + } + } + ] + }, + { + "name": "Notifications", + "item": [ + { + "name": "Get User Notifications", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/notifications?page=1&limit=20&unreadOnly=false", + "host": ["{{baseUrl}}"], + "path": ["notifications"], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (default: 1)" + }, + { + "key": "limit", + "value": "20", + "description": "Items per page (default: 20)" + }, + { + "key": "unreadOnly", + "value": "false", + "description": "Show only unread notifications" + } + ] + }, + "description": "Get user's notifications with pagination" + } + }, + { + "name": "Get Unread Count", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/notifications/unread-count", + "host": ["{{baseUrl}}"], + "path": ["notifications", "unread-count"] + }, + "description": "Get count of unread notifications" + } + }, + { + "name": "Mark Notification as Read", + "request": { + "method": "PATCH", + "header": [], + "url": { + "raw": "{{baseUrl}}/notifications/:notificationId/read", + "host": ["{{baseUrl}}"], + "path": ["notifications", ":notificationId", "read"], + "variable": [ + { + "key": "notificationId", + "value": "notification-uuid-here" + } + ] + }, + "description": "Mark a specific notification as read" + } + }, + { + "name": "Mark All as Read", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/notifications/mark-all-read", + "host": ["{{baseUrl}}"], + "path": ["notifications", "mark-all-read"] + }, + "description": "Mark all user notifications as read" + } + }, + { + "name": "Delete Notification", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/notifications/:notificationId", + "host": ["{{baseUrl}}"], + "path": ["notifications", ":notificationId"], + "variable": [ + { + "key": "notificationId", + "value": "notification-uuid-here" + } + ] + }, + "description": "Delete a notification" + } + }, + { + "name": "Subscribe to Push Notifications", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Push subscription object from browser\n \"subscription\": {\n \"endpoint\": \"https://fcm.googleapis.com/fcm/send/...\",\n \"keys\": {\n \"p256dh\": \"key-data-here\",\n \"auth\": \"auth-data-here\"\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/workflows/notifications/subscribe", + "host": ["{{baseUrl}}"], + "path": ["workflows", "notifications", "subscribe"] + }, + "description": "Subscribe to push notifications" + } + }, + { + "name": "Test Push Notification", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/workflows/notifications/test", + "host": ["{{baseUrl}}"], + "path": ["workflows", "notifications", "test"] + }, + "description": "Test push notification for current user" + } + } + ] + }, + { + "name": "Dashboard", + "item": [ + { + "name": "Get KPIs", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/kpis", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "kpis"] + }, + "description": "Get KPI summary (all KPI cards)" + } + }, + { + "name": "Get Request Statistics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/requests", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "requests"] + }, + "description": "Get detailed request statistics" + } + }, + { + "name": "Get TAT Efficiency", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/tat-efficiency", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "tat-efficiency"] + }, + "description": "Get TAT (Turn Around Time) efficiency metrics" + } + }, + { + "name": "Get Approver Load", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/approver-load", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "approver-load"] + }, + "description": "Get approver load/workload statistics" + } + }, + { + "name": "Get Engagement Statistics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/engagement", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "engagement"] + }, + "description": "Get engagement and quality metrics" + } + }, + { + "name": "Get AI Insights", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/ai-insights", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "ai-insights"] + }, + "description": "Get AI and closure insights" + } + }, + { + "name": "Get AI Remark Utilization", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/ai-remark-utilization", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "ai-remark-utilization"] + }, + "description": "Get AI remark utilization with monthly trends" + } + }, + { + "name": "Get Approver Performance", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/approver-performance", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "approver-performance"] + }, + "description": "Get approver performance metrics" + } + }, + { + "name": "Get Recent Activity", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/activity/recent", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "activity", "recent"] + }, + "description": "Get recent activity feed" + } + }, + { + "name": "Get Critical Requests", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/requests/critical", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "requests", "critical"] + }, + "description": "Get high priority/critical requests" + } + }, + { + "name": "Get Upcoming Deadlines", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/deadlines/upcoming", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "deadlines", "upcoming"] + }, + "description": "Get upcoming deadlines" + } + }, + { + "name": "Get Department Statistics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/by-department", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "by-department"] + }, + "description": "Get department-wise summary" + } + }, + { + "name": "Get Priority Distribution", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/dashboard/stats/priority-distribution", + "host": ["{{baseUrl}}"], + "path": ["dashboard", "stats", "priority-distribution"] + }, + "description": "Get priority distribution statistics" + } + } + ] + }, + { + "name": "TAT (Turn Around Time)", + "item": [ + { + "name": "Get TAT Alerts by Request", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/tat/alerts/request/:requestId", + "host": ["{{baseUrl}}"], + "path": ["tat", "alerts", "request", ":requestId"], + "variable": [ + { + "key": "requestId", + "value": "request-uuid-here" + } + ] + }, + "description": "Get all TAT alerts for a specific request" + } + }, + { + "name": "Get TAT Alerts by Level", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/tat/alerts/level/:levelId", + "host": ["{{baseUrl}}"], + "path": ["tat", "alerts", "level", ":levelId"], + "variable": [ + { + "key": "levelId", + "value": "level-uuid-here" + } + ] + }, + "description": "Get TAT alerts for a specific approval level" + } + }, + { + "name": "Get TAT Compliance Summary", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/tat/compliance/summary?startDate=2024-01-01&endDate=2024-12-31", + "host": ["{{baseUrl}}"], + "path": ["tat", "compliance", "summary"], + "query": [ + { + "key": "startDate", + "value": "2024-01-01", + "description": "Start date (optional)" + }, + { + "key": "endDate", + "value": "2024-12-31", + "description": "End date (optional)" + } + ] + }, + "description": "Get TAT compliance summary with optional date range" + } + }, + { + "name": "Get TAT Breach Report", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/tat/breaches", + "host": ["{{baseUrl}}"], + "path": ["tat", "breaches"] + }, + "description": "Get TAT breach report (all breached requests)" + } + }, + { + "name": "Get Approver TAT Performance", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/tat/performance/:approverId", + "host": ["{{baseUrl}}"], + "path": ["tat", "performance", ":approverId"], + "variable": [ + { + "key": "approverId", + "value": "approver-uuid-here" + } + ] + }, + "description": "Get TAT performance metrics for a specific approver" + } + } + ] + }, + { + "name": "Admin", + "item": [ + { + "name": "Holiday Management", + "item": [ + { + "name": "Get All Holidays", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/holidays?year=2024", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays"], + "query": [ + { + "key": "year", + "value": "2024", + "description": "Filter by year (optional)" + } + ] + }, + "description": "Get all holidays with optional year filter (Admin only)" + } + }, + { + "name": "Get Holiday Calendar", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/holidays/calendar/:year", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays", "calendar", ":year"], + "variable": [ + { + "key": "year", + "value": "2024" + } + ] + }, + "description": "Get holiday calendar for a specific year (Admin only)" + } + }, + { + "name": "Create Holiday", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Holiday date in ISO format (YYYY-MM-DD)\n \"holidayDate\": \"2024-12-25\",\n \n // Holiday name\n \"holidayName\": \"Christmas Day\",\n \n // Description (optional)\n \"description\": \"Christmas celebration - office closed\",\n \n // Holiday type: NATIONAL | REGIONAL | COMPANY_SPECIFIC\n \"holidayType\": \"NATIONAL\",\n \n // Is recurring every year\n \"isRecurring\": true,\n \n // Applicable departments (optional) - empty array means all departments\n \"applicableDepartments\": [],\n \n // Applicable regions (optional)\n \"applicableRegions\": [\"ALL\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/holidays", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays"] + }, + "description": "Create a new holiday (Admin only)" + } + }, + { + "name": "Update Holiday", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Updated holiday name (optional)\n \"holidayName\": \"Christmas Day - Updated\",\n \n // Updated description (optional)\n \"description\": \"Updated description\",\n \n // Updated holiday type (optional)\n \"holidayType\": \"NATIONAL\",\n \n // Updated recurring flag (optional)\n \"isRecurring\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/holidays/:holidayId", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays", ":holidayId"], + "variable": [ + { + "key": "holidayId", + "value": "holiday-uuid-here" + } + ] + }, + "description": "Update a holiday (Admin only)" + } + }, + { + "name": "Delete Holiday", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/holidays/:holidayId", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays", ":holidayId"], + "variable": [ + { + "key": "holidayId", + "value": "holiday-uuid-here" + } + ] + }, + "description": "Delete (deactivate) a holiday (Admin only)" + } + }, + { + "name": "Bulk Import Holidays", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Array of holidays to import\n \"holidays\": [\n {\n \"holidayDate\": \"2024-01-01\",\n \"holidayName\": \"New Year's Day\",\n \"description\": \"New Year celebration\",\n \"holidayType\": \"NATIONAL\",\n \"isRecurring\": true\n },\n {\n \"holidayDate\": \"2024-01-26\",\n \"holidayName\": \"Republic Day\",\n \"description\": \"Indian Republic Day\",\n \"holidayType\": \"NATIONAL\",\n \"isRecurring\": true\n },\n {\n \"holidayDate\": \"2024-08-15\",\n \"holidayName\": \"Independence Day\",\n \"description\": \"Indian Independence Day\",\n \"holidayType\": \"NATIONAL\",\n \"isRecurring\": true\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/holidays/bulk-import", + "host": ["{{baseUrl}}"], + "path": ["admin", "holidays", "bulk-import"] + }, + "description": "Bulk import holidays from JSON array (Admin only)" + } + } + ] + }, + { + "name": "Configuration Management", + "item": [ + { + "name": "Get All Configurations", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/configurations?category=TAT", + "host": ["{{baseUrl}}"], + "path": ["admin", "configurations"], + "query": [ + { + "key": "category", + "value": "TAT", + "description": "Filter by category (optional): TAT, AI, NOTIFICATION, SYSTEM" + } + ] + }, + "description": "Get all admin configurations with optional category filter (Admin only)" + } + }, + { + "name": "Update Configuration", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // New configuration value (type depends on config key)\n \"configValue\": \"50\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/configurations/:configKey", + "host": ["{{baseUrl}}"], + "path": ["admin", "configurations", ":configKey"], + "variable": [ + { + "key": "configKey", + "value": "TAT_THRESHOLD_1", + "description": "Config key to update" + } + ] + }, + "description": "Update a configuration value (Admin only)" + } + }, + { + "name": "Reset Configuration", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/configurations/:configKey/reset", + "host": ["{{baseUrl}}"], + "path": ["admin", "configurations", ":configKey", "reset"], + "variable": [ + { + "key": "configKey", + "value": "TAT_THRESHOLD_1" + } + ] + }, + "description": "Reset configuration to default value (Admin only)" + } + } + ] + }, + { + "name": "User Role Management (RBAC)", + "item": [ + { + "name": "Assign Role by Email", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // User's email address\n \"email\": \"user@example.com\",\n \n // Role to assign: USER | MANAGEMENT | ADMIN\n \"role\": \"MANAGEMENT\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/users/assign-role", + "host": ["{{baseUrl}}"], + "path": ["admin", "users", "assign-role"] + }, + "description": "Assign role to user by email - creates user from Okta if doesn't exist (Admin only)" + } + }, + { + "name": "Update User Role", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // New role: USER | MANAGEMENT | ADMIN\n \"role\": \"ADMIN\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/admin/users/:userId/role", + "host": ["{{baseUrl}}"], + "path": ["admin", "users", ":userId", "role"], + "variable": [ + { + "key": "userId", + "value": "user-uuid-here" + } + ] + }, + "description": "Update user's role (Admin only)" + } + }, + { + "name": "Get Users By Role", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/users/by-role?role=ADMIN", + "host": ["{{baseUrl}}"], + "path": ["admin", "users", "by-role"], + "query": [ + { + "key": "role", + "value": "ADMIN", + "description": "Filter by role: ADMIN | MANAGEMENT | USER" + } + ] + }, + "description": "Get all users filtered by role (Admin only)" + } + }, + { + "name": "Get Role Statistics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/users/role-statistics", + "host": ["{{baseUrl}}"], + "path": ["admin", "users", "role-statistics"] + }, + "description": "Get count of users in each role (Admin only)" + } + } + ] + } + ] + }, + { + "name": "AI Service", + "item": [ + { + "name": "Get AI Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/ai/status", + "host": ["{{baseUrl}}"], + "path": ["ai", "status"] + }, + "description": "Get AI service status (availability and provider)" + } + }, + { + "name": "Reinitialize AI Service", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/ai/reinitialize", + "host": ["{{baseUrl}}"], + "path": ["ai", "reinitialize"] + }, + "description": "Reinitialize AI service after configuration change (Admin only)" + } + } + ] + }, + { + "name": "Debug & Testing", + "item": [ + { + "name": "Get TAT Jobs for Request", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/debug/tat-jobs/:requestId", + "host": ["{{baseUrl}}"], + "path": ["debug", "tat-jobs", ":requestId"], + "variable": [ + { + "key": "requestId", + "value": "request-uuid-here" + } + ] + }, + "description": "Debug endpoint to check scheduled TAT jobs for a request" + } + }, + { + "name": "Get All TAT Jobs", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/debug/tat-jobs", + "host": ["{{baseUrl}}"], + "path": ["debug", "tat-jobs"] + }, + "description": "Debug endpoint to check all queued TAT jobs" + } + }, + { + "name": "Calculate TAT Times", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Start time in ISO format (optional, defaults to now)\n \"startTime\": \"2024-01-15T09:00:00Z\",\n \n // TAT hours\n \"tatHours\": 24,\n \n // Priority: STANDARD | EXPRESS\n \"priority\": \"STANDARD\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/debug/tat-calculate", + "host": ["{{baseUrl}}"], + "path": ["debug", "tat-calculate"] + }, + "description": "Debug endpoint to test TAT time calculations" + } + }, + { + "name": "Get Queue Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/debug/queue-status", + "host": ["{{baseUrl}}"], + "path": ["debug", "queue-status"] + }, + "description": "Debug endpoint to check queue and worker status" + } + }, + { + "name": "Trigger Test TAT", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n // Test request ID\n \"requestId\": \"test-request-123\",\n \n // Test level ID\n \"levelId\": \"test-level-456\",\n \n // Test approver ID\n \"approverId\": \"test-approver-789\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/debug/trigger-test-tat", + "host": ["{{baseUrl}}"], + "path": ["debug", "trigger-test-tat"] + }, + "description": "Debug endpoint to manually trigger a test TAT job (fires in 5 seconds)" + } + } + ] + } + ] +} + diff --git a/src/app.ts b/src/app.ts index 0d2edb4..07a439e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,16 @@ const initializeDatabase = async () => { // Initialize database initializeDatabase(); +// Trust proxy - Enable this when behind a reverse proxy (nginx, load balancer, etc.) +// This allows Express to read X-Forwarded-* headers correctly +// Set to true in production, false in development +if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production') { + app.set('trust proxy', true); +} else { + // In development, trust first proxy (useful for local testing with nginx) + app.set('trust proxy', 1); +} + // CORS middleware - MUST be before other middleware app.use(corsMiddleware); diff --git a/src/controllers/approval.controller.ts b/src/controllers/approval.controller.ts index 73aee19..350b642 100644 --- a/src/controllers/approval.controller.ts +++ b/src/controllers/approval.controller.ts @@ -3,6 +3,7 @@ import { ApprovalService } from '@services/approval.service'; import { validateApprovalAction } from '@validators/approval.validator'; import { ResponseHandler } from '@utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; +import { getRequestMetadata } from '@utils/requestUtils'; const approvalService = new ApprovalService(); @@ -12,7 +13,11 @@ export class ApprovalController { const { levelId } = req.params; const validatedData = validateApprovalAction(req.body); - const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId); + const requestMeta = getRequestMetadata(req); + const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, { + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent + }); if (!level) { ResponseHandler.notFound(res, 'Approval level not found'); diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 577d0ce..219daf5 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -4,6 +4,8 @@ import { validateSSOCallback, validateRefreshToken, validateTokenExchange } from import { ResponseHandler } from '../utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; import logger from '../utils/logger'; +import { activityService, SYSTEM_EVENT_REQUEST_ID } from '../services/activity.service'; +import { getRequestMetadata } from '../utils/requestUtils'; export class AuthController { private authService: AuthService; @@ -23,6 +25,31 @@ export class AuthController { const result = await this.authService.handleSSOCallback(validatedData as any); + // Log login activity + const requestMeta = getRequestMetadata(req); + await activityService.log({ + requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events + type: 'login', + user: { + userId: result.user.userId, + name: result.user.displayName || result.user.email, + email: result.user.email + }, + timestamp: new Date().toISOString(), + action: 'User Login', + details: `User logged in via SSO from ${requestMeta.ipAddress || 'unknown IP'}`, + metadata: { + loginMethod: 'SSO', + employeeId: result.user.employeeId, + department: result.user.department, + role: result.user.role + }, + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent, + category: 'AUTHENTICATION', + severity: 'INFO' + }); + ResponseHandler.success(res, { user: result.user, accessToken: result.accessToken, @@ -274,6 +301,31 @@ export class AuthController { const result = await this.authService.exchangeCodeForTokens(code, redirectUri); + // Log login activity + const requestMeta = getRequestMetadata(req); + await activityService.log({ + requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events + type: 'login', + user: { + userId: result.user.userId, + name: result.user.displayName || result.user.email, + email: result.user.email + }, + timestamp: new Date().toISOString(), + action: 'User Login', + details: `User logged in via token exchange from ${requestMeta.ipAddress || 'unknown IP'}`, + metadata: { + loginMethod: 'TOKEN_EXCHANGE', + employeeId: result.user.employeeId, + department: result.user.department, + role: result.user.role + }, + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent, + category: 'AUTHENTICATION', + severity: 'INFO' + }); + // Set cookies with httpOnly flag for security const isProduction = process.env.NODE_ENV === 'production'; const cookieOptions = { diff --git a/src/controllers/conclusion.controller.ts b/src/controllers/conclusion.controller.ts index 20bc281..4c4214c 100644 --- a/src/controllers/conclusion.controller.ts +++ b/src/controllers/conclusion.controller.ts @@ -3,6 +3,7 @@ import { WorkflowRequest, ApprovalLevel, WorkNote, Document, Activity, Conclusio import { aiService } from '@services/ai.service'; import { activityService } from '@services/activity.service'; import logger from '@utils/logger'; +import { getRequestMetadata } from '@utils/requestUtils'; export class ConclusionController { /** @@ -170,13 +171,16 @@ export class ConclusionController { } // Log activity + const requestMeta = getRequestMetadata(req); await activityService.log({ requestId, type: 'ai_conclusion_generated', user: { userId, name: (request as any).initiator?.displayName || 'Initiator' }, timestamp: new Date().toISOString(), action: 'AI Conclusion Generated', - details: 'AI-powered conclusion remark generated for review' + details: 'AI-powered conclusion remark generated for review', + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent }); return res.status(200).json({ @@ -338,13 +342,16 @@ export class ConclusionController { logger.info(`[Conclusion] โœ… Request ${requestId} finalized and closed`); // Log activity + const requestMeta = getRequestMetadata(req); await activityService.log({ requestId, type: 'closed', user: { userId, name: (request as any).initiator?.displayName || 'Initiator' }, timestamp: new Date().toISOString(), action: 'Request Closed', - details: `Request closed with conclusion remark by ${(request as any).initiator?.displayName}` + details: `Request closed with conclusion remark by ${(request as any).initiator?.displayName}`, + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent }); return res.status(200).json({ diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts index 3e3a713..ebf1f37 100644 --- a/src/controllers/dashboard.controller.ts +++ b/src/controllers/dashboard.controller.ts @@ -16,8 +16,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 kpis = await this.dashboardService.getKPIs(userId, dateRange); + const kpis = await this.dashboardService.getKPIs(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -39,8 +41,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 stats = await this.dashboardService.getRequestStats(userId, dateRange); + const stats = await this.dashboardService.getRequestStats(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -62,8 +66,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 efficiency = await this.dashboardService.getTATEfficiency(userId, dateRange); + const efficiency = await this.dashboardService.getTATEfficiency(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -85,8 +91,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 load = await this.dashboardService.getApproverLoad(userId, dateRange); + const load = await this.dashboardService.getApproverLoad(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -108,8 +116,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 engagement = await this.dashboardService.getEngagementStats(userId, dateRange); + const engagement = await this.dashboardService.getEngagementStats(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -131,8 +141,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 insights = await this.dashboardService.getAIInsights(userId, dateRange); + const insights = await this.dashboardService.getAIInsights(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -154,8 +166,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 utilization = await this.dashboardService.getAIRemarkUtilization(userId, dateRange); + const utilization = await this.dashboardService.getAIRemarkUtilization(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -177,10 +191,12 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 page = Number(req.query.page || 1); const limit = Number(req.query.limit || 10); - const result = await this.dashboardService.getApproverPerformance(userId, dateRange, page, limit); + const result = await this.dashboardService.getApproverPerformance(userId, dateRange, page, limit, startDate, endDate); res.json({ success: true, @@ -298,8 +314,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 stats = await this.dashboardService.getDepartmentStats(userId, dateRange); + const stats = await this.dashboardService.getDepartmentStats(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -321,8 +339,10 @@ export class DashboardController { try { const userId = (req as any).user?.userId; 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 distribution = await this.dashboardService.getPriorityDistribution(userId, dateRange); + const distribution = await this.dashboardService.getPriorityDistribution(userId, dateRange, startDate, endDate); res.json({ success: true, @@ -336,5 +356,117 @@ export class DashboardController { }); } } + + /** + * Get Request Lifecycle Report + */ + async getLifecycleReport(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const page = Number(req.query.page || 1); + const limit = Number(req.query.limit || 50); + + const result = await this.dashboardService.getLifecycleReport(userId, page, limit); + + res.json({ + success: true, + data: result.lifecycleData, + pagination: { + currentPage: result.currentPage, + totalPages: result.totalPages, + totalRecords: result.totalRecords, + limit: result.limit + } + }); + } catch (error) { + logger.error('[Dashboard] Error fetching lifecycle report:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch lifecycle report' + }); + } + } + + /** + * Get enhanced User Activity Log Report + */ + async getActivityLogReport(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const page = Number(req.query.page || 1); + const limit = Number(req.query.limit || 50); + const dateRange = req.query.dateRange as string | undefined; + const filterUserId = req.query.filterUserId as string | undefined; + const filterType = req.query.filterType as string | undefined; + const filterCategory = req.query.filterCategory as string | undefined; + const filterSeverity = req.query.filterSeverity as string | undefined; + + const result = await this.dashboardService.getActivityLogReport( + userId, + page, + limit, + dateRange, + filterUserId, + filterType, + filterCategory, + filterSeverity + ); + + res.json({ + success: true, + data: result.activities, + pagination: { + currentPage: result.currentPage, + totalPages: result.totalPages, + totalRecords: result.totalRecords, + limit: result.limit + } + }); + } catch (error) { + logger.error('[Dashboard] Error fetching activity log report:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch activity log report' + }); + } + } + + /** + * Get Workflow Aging Report + */ + async getWorkflowAgingReport(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const threshold = Number(req.query.threshold || 7); + const page = Number(req.query.page || 1); + const limit = Number(req.query.limit || 50); + const dateRange = req.query.dateRange as string | undefined; + + const result = await this.dashboardService.getWorkflowAgingReport( + userId, + threshold, + page, + limit, + dateRange + ); + + res.json({ + success: true, + data: result.agingData, + pagination: { + currentPage: result.currentPage, + totalPages: result.totalPages, + totalRecords: result.totalRecords, + limit: result.limit + } + }); + } catch (error) { + logger.error('[Dashboard] Error fetching workflow aging report:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch workflow aging report' + }); + } + } } diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 59591e6..fef69a6 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -6,6 +6,7 @@ import { User } from '@models/User'; import { ResponseHandler } from '@utils/responseHandler'; import { activityService } from '@services/activity.service'; import type { AuthenticatedRequest } from '../types/express'; +import { getRequestMetadata } from '@utils/requestUtils'; export class DocumentController { async upload(req: AuthenticatedRequest, res: Response): Promise { @@ -58,6 +59,7 @@ export class DocumentController { const uploaderName = (user as any)?.displayName || (user as any)?.email || 'User'; // Log activity for document upload + const requestMeta = getRequestMetadata(req); await activityService.log({ requestId, type: 'document_added', @@ -70,7 +72,9 @@ export class DocumentController { fileSize: file.size, fileType: extension, category - } + }, + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent }); ResponseHandler.success(res, doc, 'File uploaded', 201); diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 067bea9..3e8f10d 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -10,6 +10,7 @@ import { User } from '@models/User'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; +import { getRequestMetadata } from '@utils/requestUtils'; const workflowService = new WorkflowService(); @@ -22,7 +23,11 @@ export class WorkflowController { ...validatedData, priority: validatedData.priority as Priority }; - const workflow = await workflowService.createWorkflow(req.user.userId, workflowData); + const requestMeta = getRequestMetadata(req); + const workflow = await workflowService.createWorkflow(req.user.userId, workflowData, { + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent + }); ResponseHandler.success(res, workflow, 'Workflow created successfully', 201); } catch (error) { @@ -49,7 +54,11 @@ export class WorkflowController { const validated = validateCreateWorkflow(parsed); const workflowData = { ...validated, priority: validated.priority as Priority } as any; - const workflow = await workflowService.createWorkflow(userId, workflowData); + const requestMeta = getRequestMetadata(req); + const workflow = await workflowService.createWorkflow(userId, workflowData, { + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent + }); // Attach files as documents (category defaults to SUPPORTING) const files = (req as any).files as Express.Multer.File[] | undefined; @@ -87,6 +96,7 @@ export class WorkflowController { docs.push(doc); // Log document upload activity + const requestMeta = getRequestMetadata(req); activityService.log({ requestId: workflow.requestId, type: 'document_added', @@ -94,7 +104,9 @@ export class WorkflowController { timestamp: new Date().toISOString(), action: 'Document Added', details: `Added ${file.originalname} as supporting document by ${uploaderName}`, - metadata: { fileName: file.originalname, fileSize: file.size, fileType: extension } + metadata: { fileName: file.originalname, fileSize: file.size, fileType: extension }, + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent }); } } @@ -155,7 +167,15 @@ export class WorkflowController { 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 workflowService.listMyRequests(userId, page, limit); + + // 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 + }; + + const result = await workflowService.listMyRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My requests fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -168,7 +188,19 @@ export class WorkflowController { 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 workflowService.listOpenForMe(userId, page, limit); + + // 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 sorting parameters + const sortBy = req.query.sortBy as string | undefined; + const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; + + const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Open requests for user fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -181,7 +213,19 @@ export class WorkflowController { 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 workflowService.listClosedByMe(userId, page, limit); + + // 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 sorting parameters + const sortBy = req.query.sortBy as string | undefined; + const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; + + const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Closed requests by user fetched'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/src/controllers/worknote.controller.ts b/src/controllers/worknote.controller.ts index 68dae8e..b05a421 100644 --- a/src/controllers/worknote.controller.ts +++ b/src/controllers/worknote.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import { workNoteService } from '../services/worknote.service'; import { WorkflowService } from '../services/workflow.service'; +import { getRequestMetadata } from '@utils/requestUtils'; export class WorkNoteController { private workflowService = new WorkflowService(); @@ -50,7 +51,11 @@ export class WorkNoteController { mentionedUsers: mentions // Pass mentioned user IDs to service }; - const note = await workNoteService.create(requestId, user, workNotePayload, files); + const requestMeta = getRequestMetadata(req); + const note = await workNoteService.create(requestId, user, workNotePayload, files, { + ipAddress: requestMeta.ipAddress, + userAgent: requestMeta.userAgent + }); res.status(201).json({ success: true, data: note }); } } diff --git a/src/queues/tatProcessor.ts b/src/queues/tatProcessor.ts index 3f7b890..e247cb1 100644 --- a/src/queues/tatProcessor.ts +++ b/src/queues/tatProcessor.ts @@ -6,6 +6,7 @@ import { TatAlert, TatAlertType } from '@models/TatAlert'; import { activityService } from '@services/activity.service'; import logger from '@utils/logger'; import dayjs from 'dayjs'; +import { calculateElapsedWorkingHours, addWorkingHours, addWorkingHoursExpress } from '@utils/tatTimeUtils'; interface TatJobData { type: 'threshold1' | 'threshold2' | 'breach'; @@ -61,10 +62,19 @@ export async function handleTatJob(job: Job) { const tatHours = Number((approvalLevel as any).tatHours || 0); const levelStartTime = (approvalLevel as any).levelStartTime || (approvalLevel as any).createdAt; const now = new Date(); - const elapsedMs = now.getTime() - new Date(levelStartTime).getTime(); - const elapsedHours = elapsedMs / (1000 * 60 * 60); + + // FIXED: Use proper working hours calculation instead of calendar hours + // This respects working hours (9 AM - 6 PM), excludes weekends for STANDARD priority, and excludes holidays + const priority = ((workflow as any).priority || 'STANDARD').toString().toLowerCase(); + const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now, priority); const remainingHours = Math.max(0, tatHours - elapsedHours); - const expectedCompletionTime = dayjs(levelStartTime).add(tatHours, 'hour').toDate(); + + // Calculate expected completion time using proper working hours calculation + // EXPRESS: includes weekends but only during working hours + // STANDARD: excludes weekends and only during working hours + const expectedCompletionTime = priority === 'express' + ? (await addWorkingHoursExpress(levelStartTime, tatHours)).toDate() + : (await addWorkingHours(levelStartTime, tatHours)).toDate(); switch (type) { case 'threshold1': diff --git a/src/routes/dashboard.routes.ts b/src/routes/dashboard.routes.ts index 6a50b83..1c89bab 100644 --- a/src/routes/dashboard.routes.ts +++ b/src/routes/dashboard.routes.ts @@ -90,5 +90,23 @@ router.get('/stats/priority-distribution', asyncHandler(dashboardController.getPriorityDistribution.bind(dashboardController)) ); +// Get Request Lifecycle Report +router.get('/reports/lifecycle', + authenticateToken, + asyncHandler(dashboardController.getLifecycleReport.bind(dashboardController)) +); + +// Get enhanced User Activity Log Report +router.get('/reports/activity-log', + authenticateToken, + asyncHandler(dashboardController.getActivityLogReport.bind(dashboardController)) +); + +// Get Workflow Aging Report +router.get('/reports/workflow-aging', + authenticateToken, + asyncHandler(dashboardController.getWorkflowAgingReport.bind(dashboardController)) +); + export default router; diff --git a/src/services/activity.service.ts b/src/services/activity.service.ts index 170ae5d..58cfcb0 100644 --- a/src/services/activity.service.ts +++ b/src/services/activity.service.ts @@ -1,18 +1,61 @@ import logger from '@utils/logger'; +// Special UUID for system events (login, etc.) - well-known UUID: 00000000-0000-0000-0000-000000000001 +export const SYSTEM_EVENT_REQUEST_ID = '00000000-0000-0000-0000-000000000001'; + export type ActivityEntry = { requestId: string; - type: 'created' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning' | 'ai_conclusion_generated' | 'closed'; + type: 'created' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning' | 'ai_conclusion_generated' | 'closed' | 'login'; user?: { userId: string; name?: string; email?: string }; timestamp: string; action: string; details: string; metadata?: any; + ipAddress?: string; + userAgent?: string; + category?: string; + severity?: string; }; class ActivityService { private byRequest: Map = new Map(); + private inferCategory(type: string): string { + const categoryMap: Record = { + 'created': 'WORKFLOW', + 'approval': 'WORKFLOW', + 'rejection': 'WORKFLOW', + 'status_change': 'WORKFLOW', + 'assignment': 'WORKFLOW', + 'comment': 'COLLABORATION', + 'document_added': 'DOCUMENT', + 'sla_warning': 'SYSTEM', + 'reminder': 'SYSTEM', + 'ai_conclusion_generated': 'SYSTEM', + 'closed': 'WORKFLOW', + 'login': 'AUTHENTICATION' + }; + return categoryMap[type] || 'OTHER'; + } + + private inferSeverity(type: string): string { + const severityMap: Record = { + 'rejection': 'WARNING', + 'sla_warning': 'WARNING', + 'approval': 'INFO', + 'closed': 'INFO', + 'status_change': 'INFO', + 'login': 'INFO', + 'created': 'INFO', + 'comment': 'INFO', + 'document_added': 'INFO', + 'assignment': 'INFO', + 'reminder': 'INFO', + 'ai_conclusion_generated': 'INFO' + }; + return severityMap[type] || 'INFO'; + } + async log(entry: ActivityEntry) { const list = this.byRequest.get(entry.requestId) || []; list.push(entry); @@ -29,19 +72,20 @@ class ActivityService { userName: userName, activityType: entry.type, activityDescription: entry.details, - activityCategory: null, - severity: null, + activityCategory: entry.category || this.inferCategory(entry.type), + severity: entry.severity || this.inferSeverity(entry.type), metadata: entry.metadata || null, isSystemEvent: !entry.user, - ipAddress: null, - userAgent: null, + ipAddress: entry.ipAddress || null, // Database accepts null + userAgent: entry.userAgent || null, // Database accepts null }; logger.info(`[Activity] Creating activity:`, { requestId: entry.requestId, userName, userId: entry.user?.userId, - type: entry.type + type: entry.type, + ipAddress: entry.ipAddress ? '***' : null }); await Activity.create(activityData); diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 2326c93..1c03fc4 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -13,7 +13,7 @@ import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.service'; export class ApprovalService { - async approveLevel(levelId: string, action: ApprovalAction, _userId: string): Promise { + async approveLevel(levelId: string, action: ApprovalAction, _userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { try { const level = await ApprovalLevel.findByPk(levelId); if (!level) return null; @@ -87,7 +87,9 @@ export class ApprovalService { user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', - details: `Request approved and finalized by ${level.approverName || level.approverEmail}. Awaiting conclusion remark from initiator.` + details: `Request approved and finalized by ${level.approverName || level.approverEmail}. Awaiting conclusion remark from initiator.`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); // Generate AI conclusion remark ASYNCHRONOUSLY (don't wait) @@ -201,7 +203,9 @@ export class ApprovalService { user: { userId: 'system', name: 'System' }, timestamp: new Date().toISOString(), action: 'AI Conclusion Generated', - details: 'AI-powered conclusion remark generated for review by initiator' + details: 'AI-powered conclusion remark generated for review by initiator', + ipAddress: undefined, // System-generated, no IP + userAgent: undefined // System-generated, no user agent }); } else { // Log why AI generation was skipped @@ -295,7 +299,9 @@ export class ApprovalService { user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', - details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}` + details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); } } else { @@ -322,7 +328,9 @@ export class ApprovalService { user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Approved', - details: `Request approved and finalized by ${level.approverName || level.approverEmail}` + details: `Request approved and finalized by ${level.approverName || level.approverEmail}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); } } @@ -373,7 +381,9 @@ export class ApprovalService { user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), action: 'Rejected', - details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}` + details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); } } diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts index a8691f4..a419fe9 100644 --- a/src/services/dashboard.service.ts +++ b/src/services/dashboard.service.ts @@ -21,7 +21,17 @@ export class DashboardService { /** * Parse date range string to Date objects */ - private parseDateRange(dateRange?: string): DateRangeFilter { + private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter { + // If custom date range is provided, use those dates + if (dateRange === 'custom' && startDate && endDate) { + const start = dayjs(startDate).startOf('day').toDate(); + const end = dayjs(endDate).endOf('day').toDate(); + // Ensure end date is not in the future + const now = dayjs(); + const actualEnd = end > now.toDate() ? now.endOf('day').toDate() : end; + return { start, end: actualEnd }; + } + const now = dayjs(); switch (dateRange) { @@ -54,10 +64,10 @@ export class DashboardService { end: now.endOf('year').toDate() }; default: - // Default to last 30 days + // Default to last 30 days (inclusive of today) return { - start: now.subtract(30, 'day').toDate(), - end: now.toDate() + start: now.subtract(30, 'day').startOf('day').toDate(), + end: now.endOf('day').toDate() // Include full current day }; } } @@ -65,8 +75,8 @@ export class DashboardService { /** * Get all KPIs for dashboard */ - async getKPIs(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getKPIs(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Run all KPI queries in parallel for performance const [ @@ -76,11 +86,11 @@ export class DashboardService { engagement, aiInsights ] = await Promise.all([ - this.getRequestStats(userId, dateRange), - this.getTATEfficiency(userId, dateRange), - this.getApproverLoad(userId, dateRange), - this.getEngagementStats(userId, dateRange), - this.getAIInsights(userId, dateRange) + this.getRequestStats(userId, dateRange, startDate, endDate), + this.getTATEfficiency(userId, dateRange, startDate, endDate), + this.getApproverLoad(userId, dateRange, startDate, endDate), + this.getEngagementStats(userId, dateRange, startDate, endDate), + this.getAIInsights(userId, dateRange, startDate, endDate) ]); return { @@ -100,34 +110,57 @@ export class DashboardService { /** * Get request volume and status statistics */ - async getRequestStats(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getRequestStats(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; - // For regular users: show only requests they INITIATED (not participated in) - // For admin: show all requests - let whereClause = ` - WHERE wf.created_at BETWEEN :start AND :end + // 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 + // For approved/rejected, count only those submitted in date range (use submission_date, not created_at) + let whereClauseForDateRange = ` + WHERE wf.submission_date BETWEEN :start AND :end AND wf.is_draft = false + AND (wf.is_deleted IS NULL OR wf.is_deleted = false) + AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; + let whereClauseForAllPending = ` + WHERE wf.is_draft = false + AND (wf.is_deleted IS NULL OR wf.is_deleted = false) + AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS') + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + // Get total, approved, and rejected requests created in date range const result = await sequelize.query(` SELECT COUNT(*)::int AS total_requests, - COUNT(CASE WHEN wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS' THEN 1 END)::int AS open_requests, COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_requests, COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected_requests FROM workflow_requests wf - ${whereClause} + ${whereClauseForDateRange} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); + // Get ALL pending/open requests (regardless of creation date) + // Organization Level (Admin): All pending requests across organization + // Personal Level (Regular User): Only pending requests they initiated + const pendingResult = await sequelize.query(` + SELECT COUNT(*)::int AS open_requests + FROM workflow_requests wf + ${whereClauseForAllPending} + `, { + replacements: { userId }, + type: QueryTypes.SELECT + }); + // Get draft count separately const draftResult = await sequelize.query(` SELECT COUNT(*)::int AS draft_count @@ -140,11 +173,12 @@ export class DashboardService { }); const stats = result[0] as any; + const pending = (pendingResult[0] as any); const drafts = (draftResult[0] as any); return { totalRequests: stats.total_requests || 0, - openRequests: stats.open_requests || 0, + openRequests: pending.open_requests || 0, // All pending requests regardless of creation date approvedRequests: stats.approved_requests || 0, rejectedRequests: stats.rejected_requests || 0, draftRequests: drafts.draft_count || 0, @@ -160,33 +194,36 @@ export class DashboardService { /** * Get TAT efficiency metrics */ - async getTATEfficiency(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getTATEfficiency(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests // For admin: all requests + // Include requests that were COMPLETED (closure_date or updated_at) within the date range + // This ensures we capture all requests that finished during the period, regardless of when they started let whereClause = ` - WHERE wf.created_at BETWEEN :start AND :end - AND wf.status IN ('APPROVED', 'REJECTED') + WHERE wf.status IN ('APPROVED', 'REJECTED') AND wf.is_draft = false + AND wf.submission_date IS NOT NULL + AND ( + (wf.closure_date IS NOT NULL AND wf.closure_date BETWEEN :start AND :end) + OR (wf.closure_date IS NULL AND wf.updated_at BETWEEN :start AND :end) + ) ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; - const result = await sequelize.query(` + // Get completed requests with their submission and closure dates + const completedRequests = await sequelize.query(` SELECT - COUNT(*)::int AS total_completed, - COUNT(CASE WHEN EXISTS ( - SELECT 1 FROM tat_alerts ta - WHERE ta.request_id = wf.request_id - AND ta.is_breached = true - ) THEN 1 END)::int AS breached_count, - AVG( - EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600 - )::numeric AS avg_cycle_time_hours + wf.request_id, + wf.submission_date, + wf.closure_date, + wf.updated_at, + wf.priority FROM workflow_requests wf ${whereClause} `, { @@ -194,16 +231,68 @@ export class DashboardService { type: QueryTypes.SELECT }); - const stats = result[0] as any; - const totalCompleted = stats.total_completed || 0; - const breachedCount = stats.breached_count || 0; + // Calculate cycle time using working hours for each request + const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); + const cycleTimes: number[] = []; + let breachedCount = 0; + + logger.info(`[Dashboard] Calculating cycle time for ${completedRequests.length} completed requests`); + + for (const req of completedRequests as any) { + const submissionDate = req.submission_date; + // Use closure_date if available, otherwise use updated_at + const completionDate = req.closure_date || req.updated_at; + const priority = (req.priority || 'STANDARD').toLowerCase(); + + if (submissionDate && completionDate) { + try { + // Calculate elapsed working hours (respects working hours, weekends, holidays) + const elapsedHours = await calculateElapsedWorkingHours( + submissionDate, + completionDate, + priority + ); + cycleTimes.push(elapsedHours); + logger.info(`[Dashboard] Request ${req.request_id} (${priority}): ${elapsedHours.toFixed(2)}h (submission: ${submissionDate}, completion: ${completionDate})`); + } catch (error) { + logger.error(`[Dashboard] Error calculating cycle time for request ${req.request_id}:`, error); + } + } else { + logger.warn(`[Dashboard] Skipping request ${req.request_id} - missing dates (submission: ${submissionDate}, completion: ${completionDate})`); + } + + // Check for breaches + const breachCheck = await sequelize.query(` + SELECT COUNT(*)::int AS breach_count + FROM tat_alerts ta + WHERE ta.request_id = :requestId + AND ta.is_breached = true + `, { + replacements: { requestId: req.request_id }, + type: QueryTypes.SELECT + }); + + if ((breachCheck[0] as any)?.breach_count > 0) { + breachedCount++; + } + } + + const totalCompleted = completedRequests.length; const compliantCount = totalCompleted - breachedCount; const compliancePercent = totalCompleted > 0 ? Math.round((compliantCount / totalCompleted) * 100) : 0; + // Calculate average cycle time + const sum = cycleTimes.reduce((sum, hours) => sum + hours, 0); + const avgCycleTimeHours = cycleTimes.length > 0 + ? Math.round((sum / cycleTimes.length) * 10) / 10 + : 0; + + logger.info(`[Dashboard] Cycle time calculation: ${cycleTimes.length} requests included, sum: ${sum.toFixed(2)}h, average: ${avgCycleTimeHours.toFixed(2)}h`); + return { avgTATCompliance: compliancePercent, - avgCycleTimeHours: Math.round(parseFloat(stats.avg_cycle_time_hours || 0) * 10) / 10, - avgCycleTimeDays: Math.round((parseFloat(stats.avg_cycle_time_hours || 0) / 24) * 10) / 10, + avgCycleTimeHours, + avgCycleTimeDays: Math.round((avgCycleTimeHours / 8) * 10) / 10, // 8 working hours per day delayedWorkflows: breachedCount, totalCompleted, compliantWorkflows: compliantCount, @@ -217,8 +306,8 @@ export class DashboardService { /** * Get approver load statistics */ - async getApproverLoad(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getApproverLoad(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Get pending actions where user is the CURRENT active approver // This means: the request is at this user's level AND it's the current level @@ -272,12 +361,12 @@ export class DashboardService { /** * Get engagement and quality metrics */ - async getEngagementStats(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getEngagementStats(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // Get work notes count - uses created_at // For regular users: only from requests they initiated @@ -337,19 +426,21 @@ export class DashboardService { /** * Get AI insights and closure metrics */ - async getAIInsights(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getAIInsights(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests + // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` - WHERE wf.created_at BETWEEN :start AND :end + WHERE wf.submission_date BETWEEN :start AND :end AND wf.status = 'APPROVED' AND wf.conclusion_remark IS NOT NULL AND wf.is_draft = false + AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; @@ -387,12 +478,12 @@ export class DashboardService { /** * Get AI Remark Utilization with monthly trends */ - async getAIRemarkUtilization(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getAIRemarkUtilization(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests const userFilter = !isAdmin ? `AND cr.edited_by = :userId` : ''; @@ -448,12 +539,12 @@ export class DashboardService { /** * Get Approver Performance metrics with pagination */ - async getApproverPerformance(userId: string, dateRange?: string, page: number = 1, limit: number = 10) { - const range = this.parseDateRange(dateRange); + async getApproverPerformance(userId: string, dateRange?: string, page: number = 1, limit: number = 10, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: return empty (only admins should see this) if (!isAdmin) { @@ -469,13 +560,20 @@ export class DashboardService { // Calculate offset const offset = (page - 1) * limit; - // Get total count + // Get total count - only count distinct approvers who have completed approvals const countResult = await sequelize.query(` - SELECT COUNT(DISTINCT al.approver_id) as total - FROM approval_levels al - WHERE al.action_date BETWEEN :start AND :end - AND al.status IN ('APPROVED', 'REJECTED') - HAVING COUNT(*) > 0 + SELECT COUNT(*) as total + FROM ( + SELECT DISTINCT al.approver_id + FROM approval_levels al + WHERE al.action_date BETWEEN :start AND :end + AND al.status IN ('APPROVED', 'REJECTED') + AND al.action_date IS NOT NULL + AND al.tat_hours > 0 + AND al.approver_id IS NOT NULL + GROUP BY al.approver_id + HAVING COUNT(DISTINCT al.level_id) > 0 + ) AS distinct_approvers `, { replacements: { start: range.start, end: range.end }, type: QueryTypes.SELECT @@ -484,34 +582,127 @@ export class DashboardService { const totalRecords = Number((countResult[0] as any)?.total || 0); const totalPages = Math.ceil(totalRecords / limit); - // Get approver performance metrics + // Get approver performance metrics (approved/rejected in date range) + // IMPORTANT: This must only count approvals where the user acted as APPROVER, not as INITIATOR + // TAT % = (Requests approved within TAT / Total requests approved) * 100 + // Check if elapsed_hours < tat_hours to determine if within TAT (exact match = within but not ideal) + // Exclude records with NULL or 0 elapsed_hours (invalid data) const approverMetrics = await sequelize.query(` SELECT al.approver_id, al.approver_name, - COUNT(*)::int AS total_approved, + COUNT(DISTINCT al.level_id)::int AS total_approved, + COUNT(DISTINCT CASE + WHEN al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 + AND al.level_start_time IS NOT NULL + AND al.action_date IS NOT NULL + AND ( + al.elapsed_hours < al.tat_hours + OR (al.elapsed_hours <= al.tat_hours AND (al.tat_breached IS NULL OR al.tat_breached = false)) + OR (al.tat_breached IS NOT NULL AND al.tat_breached = false) + ) + THEN al.level_id + END)::int AS within_tat_count, + COUNT(DISTINCT CASE + WHEN al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 + AND al.level_start_time IS NOT NULL + AND al.action_date IS NOT NULL + AND ( + al.elapsed_hours > al.tat_hours + OR (al.tat_breached IS NOT NULL AND al.tat_breached = true) + ) + THEN al.level_id + END)::int AS breached_count, ROUND( - AVG( - CASE - WHEN al.tat_breached = false THEN 100 - ELSE 0 - END - ), 0 + ((COUNT(DISTINCT CASE + WHEN al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 + AND al.level_start_time IS NOT NULL + AND al.action_date IS NOT NULL + AND ( + al.elapsed_hours < al.tat_hours + OR (al.elapsed_hours <= al.tat_hours AND (al.tat_breached IS NULL OR al.tat_breached = false)) + OR (al.tat_breached IS NOT NULL AND al.tat_breached = false) + ) + THEN al.level_id + END)::numeric / NULLIF(COUNT(DISTINCT CASE + WHEN al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 + AND al.level_start_time IS NOT NULL + AND al.action_date IS NOT NULL + THEN al.level_id + END), 0)) * 100)::numeric, + 0 )::int AS tat_compliance_percent, - ROUND(AVG(al.elapsed_hours)::numeric, 1) AS avg_response_hours, - COUNT(CASE WHEN al.status = 'PENDING' THEN 1 END)::int AS pending_count + ROUND(AVG(CASE + WHEN al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 + AND al.level_start_time IS NOT NULL + AND al.action_date IS NOT NULL + THEN al.elapsed_hours + END)::numeric, 1) AS avg_response_hours FROM approval_levels al WHERE al.action_date BETWEEN :start AND :end AND al.status IN ('APPROVED', 'REJECTED') + AND al.action_date IS NOT NULL + AND al.level_start_time IS NOT NULL + AND al.tat_hours > 0 + AND al.approver_id IS NOT NULL + AND al.elapsed_hours IS NOT NULL + AND al.elapsed_hours > 0 GROUP BY al.approver_id, al.approver_name - HAVING COUNT(*) > 0 - ORDER BY total_approved DESC + HAVING COUNT(DISTINCT al.level_id) > 0 + ORDER BY + tat_compliance_percent DESC, -- Higher TAT compliance first (100% > 90% > 80%) + avg_response_hours ASC, -- Faster response time next (5h < 10h < 20h) + total_approved DESC -- More approvals as tie-breaker LIMIT :limit OFFSET :offset `, { replacements: { start: range.start, end: range.end, limit, offset }, type: QueryTypes.SELECT }); + // Get current pending counts for each approver (separate query for current pending requests) + const approverIds = approverMetrics.map((a: any) => a.approver_id); + let pendingCounts: any[] = []; + + if (approverIds.length > 0) { + // Find all pending/in-progress approval levels and get the first (current) level for each request + // This should match the logic from listOpenForMe to ensure consistency + pendingCounts = await sequelize.query(` + WITH pending_levels AS ( + SELECT DISTINCT ON (al.request_id) + al.request_id, + al.approver_id, + al.level_id, + al.level_number + FROM approval_levels al + JOIN workflow_requests wf ON al.request_id = wf.request_id + WHERE al.status IN ('PENDING', 'IN_PROGRESS') + AND wf.status IN ('PENDING', 'IN_PROGRESS') + AND wf.is_draft = false + ORDER BY al.request_id, al.level_number ASC + ) + SELECT + approver_id, + COUNT(DISTINCT level_id)::int AS pending_count + FROM pending_levels + WHERE approver_id IN (:approverIds) + GROUP BY approver_id + `, { + replacements: { approverIds }, + type: QueryTypes.SELECT + }); + } + + // Create a map for quick lookup of pending counts + const pendingCountMap = new Map(); + pendingCounts.forEach((pc: any) => { + pendingCountMap.set(pc.approver_id, pc.pending_count || 0); + }); + return { performance: approverMetrics.map((a: any) => ({ approverId: a.approver_id, @@ -519,7 +710,7 @@ export class DashboardService { totalApproved: a.total_approved, tatCompliancePercent: a.tat_compliance_percent, avgResponseHours: parseFloat(a.avg_response_hours || 0), - pendingCount: a.pending_count + pendingCount: pendingCountMap.get(a.approver_id) || 0 })), currentPage: page, totalPages, @@ -534,7 +725,7 @@ export class DashboardService { async getRecentActivity(userId: string, page: number = 1, limit: number = 10) { // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only activities from their initiated requests OR where they're a participant let whereClause = isAdmin ? '' : ` @@ -618,34 +809,33 @@ export class DashboardService { async getCriticalRequests(userId: string, page: number = 1, limit: number = 10) { // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; - // For regular users: show only their initiated requests OR where they are current approver + // For regular users: show only requests where they are current approver (awaiting their approval) + // For admins: show all critical requests organization-wide let whereClause = ` WHERE wf.status IN ('PENDING', 'IN_PROGRESS') AND wf.is_draft = false - ${!isAdmin ? `AND ( - wf.initiator_id = :userId - OR EXISTS ( - SELECT 1 FROM approval_levels al - WHERE al.request_id = wf.request_id - AND al.approver_id = :userId - AND al.level_number = wf.current_level - AND al.status = 'IN_PROGRESS' - ) + ${!isAdmin ? `AND EXISTS ( + SELECT 1 FROM approval_levels al + WHERE al.request_id = wf.request_id + AND al.approver_id = :userId + AND al.level_number = wf.current_level + AND al.status = 'IN_PROGRESS' )` : ''} `; + // For TAT Breach Report, only show requests where the CURRENT level has breached + // This ensures we don't show requests where a previous level breached but current level is fine const criticalCondition = ` - AND ( - -- Has TAT breaches - EXISTS ( - SELECT 1 FROM tat_alerts ta - WHERE ta.request_id = wf.request_id - AND (ta.is_breached = true OR ta.threshold_percentage >= 75) - ) - -- Or is express priority - OR wf.priority = 'EXPRESS' + AND EXISTS ( + SELECT 1 + FROM tat_alerts ta + INNER JOIN approval_levels al_current ON ta.level_id = al_current.level_id + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + AND al_current.level_number = wf.current_level + AND al_current.status = 'IN_PROGRESS' ) `; @@ -677,12 +867,37 @@ export class DashboardService { wf.total_levels, wf.submission_date, wf.total_tat_hours, + COALESCE(u.department, 'Unknown') AS department, + al.approver_name AS current_approver_name, + al.approver_email AS current_approver_email, ( SELECT COUNT(*)::int FROM tat_alerts ta + INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true + AND al_breach.level_number = wf.current_level ) AS breach_count, + ( + SELECT ta.alert_sent_at + FROM tat_alerts ta + INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + AND al_breach.level_number = wf.current_level + ORDER BY ta.alert_sent_at DESC + LIMIT 1 + ) AS first_breach_time, + ( + SELECT ta.tat_hours_elapsed - ta.tat_hours_allocated + FROM tat_alerts ta + INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + AND al_breach.level_number = wf.current_level + ORDER BY ta.alert_sent_at DESC + LIMIT 1 + ) AS breach_hours, ( SELECT al.tat_hours FROM approval_levels al @@ -698,6 +913,10 @@ export class DashboardService { LIMIT 1 ) AS current_level_start_time FROM workflow_requests wf + LEFT JOIN users u ON wf.initiator_id = u.user_id + LEFT JOIN approval_levels al ON al.request_id = wf.request_id + AND al.level_number = wf.current_level + AND al.status = 'IN_PROGRESS' ${whereClause} ${criticalCondition} ORDER BY @@ -711,23 +930,61 @@ export class DashboardService { }); // Calculate working hours TAT for each critical request's current level + // Filter out requests where current level hasn't actually breached (TAT < 100%) const criticalWithSLA = await Promise.all(criticalRequests.map(async (req: any) => { const priority = (req.priority || 'standard').toLowerCase(); const currentLevelTatHours = parseFloat(req.current_level_tat_hours) || 0; const currentLevelStartTime = req.current_level_start_time; let currentLevelRemainingHours = currentLevelTatHours; + let currentLevelElapsedHours = 0; + let tatPercentageUsed = 0; if (currentLevelStartTime && currentLevelTatHours > 0) { try { // Use working hours calculation for current level const slaData = await calculateSLAStatus(currentLevelStartTime, currentLevelTatHours, priority); currentLevelRemainingHours = slaData.remainingHours; + currentLevelElapsedHours = slaData.elapsedHours; + tatPercentageUsed = slaData.percentageUsed; } catch (error) { logger.error(`[Dashboard] Error calculating SLA for critical request ${req.request_id}:`, error); } } + // Only include if current level has actually breached (TAT >= 100%) + // This filters out false positives where is_breached flag might be set incorrectly + // Check if elapsed hours >= allocated TAT hours to ensure actual breach + // (percentageUsed is capped at 100, so we check elapsed vs allocated directly) + if (currentLevelTatHours > 0 && currentLevelElapsedHours < currentLevelTatHours) { + return null; // Skip this request - not actually breached + } + + // Calculate breach time (hours since first breach) + let breachTime = 0; + if (req.first_breach_time) { + const breachDate = dayjs(req.first_breach_time); + const now = dayjs(); + breachTime = now.diff(breachDate, 'hour', true); + } else if (req.breach_hours && req.breach_hours > 0) { + breachTime = req.breach_hours; + } else if (currentLevelElapsedHours > currentLevelTatHours) { + // Calculate breach time from elapsed hours + breachTime = currentLevelElapsedHours - currentLevelTatHours; + } + + // Determine breach reason + let breachReason = 'TAT Exceeded'; + if (req.breach_count > 0) { + if (priority === 'express') { + breachReason = 'Express Priority - TAT Exceeded'; + } else { + breachReason = 'Standard TAT Breach'; + } + } else if (req.priority === 'EXPRESS') { + breachReason = 'Express Priority - High Risk'; + } + return { requestId: req.request_id, requestNumber: req.request_number, @@ -740,15 +997,26 @@ export class DashboardService { totalTATHours: currentLevelRemainingHours, // Current level remaining hours originalTATHours: currentLevelTatHours, // Original TAT hours allocated for current level breachCount: req.breach_count || 0, - isCritical: req.breach_count > 0 || req.priority === 'EXPRESS' + isCritical: true, // Only true breaches reach here + department: req.department || 'Unknown', + approver: req.current_approver_name || req.current_approver_email || 'N/A', + breachTime: breachTime, + breachReason: breachReason }; })); + // Filter out null values (requests that didn't actually breach) + const filteredCritical = criticalWithSLA.filter(req => req !== null); + + // Recalculate total records after filtering + const actualTotalRecords = filteredCritical.length; + const actualTotalPages = Math.ceil(actualTotalRecords / limit); + return { - criticalRequests: criticalWithSLA, + criticalRequests: filteredCritical, currentPage: page, - totalPages, - totalRecords, + totalPages: actualTotalPages, + totalRecords: actualTotalRecords, limit }; } @@ -759,7 +1027,7 @@ export class DashboardService { async getUpcomingDeadlines(userId: string, page: number = 1, limit: number = 10) { // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only show CURRENT LEVEL where they are the approver // For admins: show all current active levels @@ -868,17 +1136,19 @@ export class DashboardService { /** * Get department-wise statistics */ - async getDepartmentStats(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getDepartmentStats(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests + // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` - WHERE wf.created_at BETWEEN :start AND :end + WHERE wf.submission_date BETWEEN :start AND :end AND wf.is_draft = false + AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; @@ -913,49 +1183,501 @@ export class DashboardService { /** * Get priority distribution statistics */ - async getPriorityDistribution(userId: string, dateRange?: string) { - const range = this.parseDateRange(dateRange); + async getPriorityDistribution(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); - const isAdmin = user?.hasAdminAccess() || false; + const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests + // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` - WHERE wf.created_at BETWEEN :start AND :end + WHERE wf.submission_date BETWEEN :start AND :end AND wf.is_draft = false + AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; - const priorityStats = await sequelize.query(` + // Get all requests for counting (total, approved, breached) + const allRequests = await sequelize.query(` SELECT + wf.request_id, wf.priority, - COUNT(*)::int AS total_count, - AVG( - EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600 - )::numeric AS avg_cycle_time_hours, - COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_count, - COUNT(CASE WHEN EXISTS ( + wf.status, + CASE WHEN EXISTS ( SELECT 1 FROM tat_alerts ta WHERE ta.request_id = wf.request_id AND ta.is_breached = true - ) THEN 1 END)::int AS breached_count + ) THEN 1 ELSE 0 END AS is_breached FROM workflow_requests wf ${whereClause} - GROUP BY wf.priority `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); - return priorityStats.map((p: any) => ({ - priority: (p.priority || 'STANDARD').toLowerCase(), - totalCount: p.total_count, - avgCycleTimeHours: Math.round(parseFloat(p.avg_cycle_time_hours || 0) * 10) / 10, - approvedCount: p.approved_count, - breachedCount: p.breached_count, - complianceRate: p.total_count > 0 ? Math.round(((p.total_count - p.breached_count) / p.total_count) * 100) : 0 + // Get only COMPLETED requests for cycle time calculation + let whereClauseCompleted = ` + WHERE wf.submission_date BETWEEN :start AND :end + AND wf.status IN ('APPROVED', 'REJECTED') + AND wf.is_draft = false + AND wf.submission_date IS NOT NULL + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const completedRequests = await sequelize.query(` + SELECT + wf.request_id, + wf.priority, + wf.submission_date, + wf.closure_date, + wf.updated_at + FROM workflow_requests wf + ${whereClauseCompleted} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + // Group by priority and calculate working hours for each + const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); + const priorityMap = new Map(); + + // First, count all requests by priority + for (const req of allRequests as any) { + const priority = (req.priority || 'STANDARD').toLowerCase(); + + if (!priorityMap.has(priority)) { + priorityMap.set(priority, { + totalCount: 0, + cycleTimes: [], + approvedCount: 0, + breachedCount: 0 + }); + } + + const stats = priorityMap.get(priority)!; + stats.totalCount++; + + if (req.status === 'APPROVED') { + stats.approvedCount++; + } + + if (req.is_breached === 1) { + stats.breachedCount++; + } + } + + // Then, calculate cycle time only for completed requests + for (const req of completedRequests as any) { + const priority = (req.priority || 'STANDARD').toLowerCase(); + + if (!priorityMap.has(priority)) { + // This shouldn't happen, but handle it gracefully + priorityMap.set(priority, { + totalCount: 0, + cycleTimes: [], + approvedCount: 0, + breachedCount: 0 + }); + } + + const stats = priorityMap.get(priority)!; + + // Calculate cycle time using working hours + const submissionDate = req.submission_date; + const completionDate = req.closure_date || req.updated_at; + + if (submissionDate && completionDate) { + try { + const elapsedHours = await calculateElapsedWorkingHours( + submissionDate, + completionDate, + priority + ); + stats.cycleTimes.push(elapsedHours); + } catch (error) { + logger.error(`[Dashboard] Error calculating cycle time for request ${req.request_id}:`, error); + } + } + } + + // Calculate averages per priority + return Array.from(priorityMap.entries()).map(([priority, stats]) => { + const avgCycleTimeHours = stats.cycleTimes.length > 0 + ? Math.round((stats.cycleTimes.reduce((sum, hours) => sum + hours, 0) / stats.cycleTimes.length) * 10) / 10 + : 0; + + return { + priority, + totalCount: stats.totalCount, + avgCycleTimeHours, + approvedCount: stats.approvedCount, + breachedCount: stats.breachedCount, + complianceRate: stats.totalCount > 0 ? Math.round(((stats.totalCount - stats.breachedCount) / stats.totalCount) * 100) : 0 + }; + }); + } + + /** + * Get Request Lifecycle Report with full timeline and TAT compliance + */ + async getLifecycleReport(userId: string, page: number = 1, limit: number = 50) { + const user = await User.findByPk(userId); + const isAdmin = user?.hasManagementAccess() || false; + + const offset = (page - 1) * limit; + + // For regular users: only their initiated requests or where they're participants + let whereClause = isAdmin ? '' : ` + AND ( + wf.initiator_id = :userId + OR EXISTS ( + SELECT 1 FROM participants p + WHERE p.request_id = wf.request_id + AND p.user_id = :userId + ) + ) + `; + + // Get total count + const countResult = await sequelize.query(` + SELECT COUNT(*) as total + FROM workflow_requests wf + WHERE wf.is_draft = false + ${whereClause} + `, { + replacements: { userId }, + type: QueryTypes.SELECT + }); + + const totalRecords = Number((countResult[0] as any).total); + const totalPages = Math.ceil(totalRecords / limit); + + // Get requests with initiator name and current level name + const requests = await sequelize.query(` + SELECT + wf.request_id, + wf.request_number, + wf.title, + wf.priority, + wf.status, + wf.submission_date, + wf.closure_date, + wf.current_level, + wf.total_levels, + wf.total_tat_hours, + wf.created_at, + wf.updated_at, + u.display_name AS initiator_name, + u.email AS initiator_email, + al.level_name AS current_stage_name, + al.approver_name AS current_approver_name, + ( + SELECT COUNT(*) + FROM tat_alerts ta + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + ) AS breach_count + FROM workflow_requests wf + LEFT JOIN users u ON wf.initiator_id = u.user_id + LEFT JOIN approval_levels al ON al.request_id = wf.request_id + AND al.level_number = wf.current_level + WHERE wf.is_draft = false + ${whereClause} + ORDER BY wf.updated_at DESC + LIMIT :limit OFFSET :offset + `, { + replacements: { userId, limit, offset }, + type: QueryTypes.SELECT + }); + + // Calculate overall TAT and compliance for each request + const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); + const lifecycleData = await Promise.all(requests.map(async (req: any) => { + const submissionDate = req.submission_date; + const endDate = req.closure_date || new Date(); + const priority = (req.priority || 'STANDARD').toLowerCase(); + + // Calculate elapsed working hours + const elapsedHours = submissionDate + ? await calculateElapsedWorkingHours(submissionDate, endDate, priority) + : 0; + + // Determine TAT compliance + const isBreached = req.breach_count > 0; + const status = isBreached ? 'Delayed' : 'On Time'; + + return { + requestId: req.request_id, + requestNumber: req.request_number, + title: req.title, + priority: (req.priority || 'STANDARD').toLowerCase(), + status, + initiatorName: req.initiator_name || req.initiator_email || 'Unknown', + initiatorEmail: req.initiator_email, + submissionDate: req.submission_date, + closureDate: req.closure_date, + currentLevel: req.current_level, + totalLevels: req.total_levels, + currentStageName: req.current_stage_name || `Level ${req.current_level}`, + currentApproverName: req.current_approver_name, + overallTATHours: elapsedHours, + totalTATHours: parseFloat(req.total_tat_hours || 0), + breachCount: parseInt(req.breach_count || 0), + createdAt: req.created_at, + updatedAt: req.updated_at + }; })); + + return { + lifecycleData, + currentPage: page, + totalPages, + totalRecords, + limit + }; + } + + /** + * Get enhanced User Activity Log Report with IP and user agent + */ + async getActivityLogReport( + userId: string, + page: number = 1, + limit: number = 50, + dateRange?: string, + filterUserId?: string, + filterType?: string, + filterCategory?: string, + filterSeverity?: string + ) { + const user = await User.findByPk(userId); + const isAdmin = user?.hasManagementAccess() || false; + + const range = this.parseDateRange(dateRange); + const offset = (page - 1) * limit; + + // For admins: no restrictions - can see ALL activities from ALL users (including login activities) + // For regular users: only activities from their initiated requests OR where they're a participant + // Also include system events (like login) where the user_id matches + let whereClause = isAdmin ? '' : ` + AND ( + a.user_id = :userId + OR wf.initiator_id = :userId + OR EXISTS ( + SELECT 1 FROM participants p + WHERE p.request_id = a.request_id + AND p.user_id = :userId + ) + ) + `; + + // Add filters + if (filterUserId) { + whereClause += ` AND a.user_id = :filterUserId`; + } + if (filterType) { + whereClause += ` AND a.activity_type = :filterType`; + } + if (filterCategory) { + whereClause += ` AND a.activity_category = :filterCategory`; + } + if (filterSeverity) { + whereClause += ` AND a.severity = :filterSeverity`; + } + + // Get total count + const countResult = await sequelize.query(` + SELECT COUNT(*) as total + FROM activities a + LEFT JOIN workflow_requests wf ON a.request_id = wf.request_id + WHERE a.created_at BETWEEN :start AND :end + ${whereClause} + `, { + replacements: { + userId, + start: range.start, + end: range.end, + filterUserId: filterUserId || null, + filterType: filterType || null, + filterCategory: filterCategory || null, + filterSeverity: filterSeverity || null + }, + type: QueryTypes.SELECT + }); + + const totalRecords = Number((countResult[0] as any).total); + const totalPages = Math.ceil(totalRecords / limit); + + // Get paginated activities with IP and user agent + const activities = await sequelize.query(` + SELECT + a.activity_id, + a.request_id, + a.activity_type AS type, + a.activity_description, + a.activity_category, + a.user_id, + a.user_name, + a.created_at AS timestamp, + a.ip_address, + a.user_agent, + wf.request_number, + wf.title AS request_title, + wf.priority + FROM activities a + LEFT JOIN workflow_requests wf ON a.request_id = wf.request_id + WHERE a.created_at BETWEEN :start AND :end + ${whereClause} + ORDER BY a.created_at DESC + LIMIT :limit OFFSET :offset + `, { + replacements: { + userId, + start: range.start, + end: range.end, + limit, + offset, + filterUserId: filterUserId || null, + filterType: filterType || null, + filterCategory: filterCategory || null, + filterSeverity: filterSeverity || null + }, + type: QueryTypes.SELECT + }); + + return { + activities: activities.map((a: any) => ({ + activityId: a.activity_id, + requestId: a.request_id, + requestNumber: a.request_number || null, + requestTitle: a.request_title || null, + type: a.type, + action: a.activity_description || a.type, + details: a.activity_description || a.activity_category || a.type, // Use activity_description for login details + userId: a.user_id, + userName: a.user_name, + timestamp: a.timestamp, + ipAddress: a.ip_address, + userAgent: a.user_agent, + priority: (a.priority || '').toLowerCase() + })), + currentPage: page, + totalPages, + totalRecords, + limit + }; + } + + /** + * Get Workflow Aging Report with business days calculation + */ + async getWorkflowAgingReport( + userId: string, + threshold: number = 7, + page: number = 1, + limit: number = 50, + dateRange?: string + ) { + const user = await User.findByPk(userId); + const isAdmin = user?.hasManagementAccess() || false; + + const range = this.parseDateRange(dateRange); + const offset = (page - 1) * limit; + + // For regular users: only their initiated requests or where they're participants + let whereClause = isAdmin ? '' : ` + AND ( + wf.initiator_id = :userId + OR EXISTS ( + SELECT 1 FROM participants p + WHERE p.request_id = wf.request_id + AND p.user_id = :userId + ) + ) + `; + + // Get all active requests (not closed) + const allRequests = await sequelize.query(` + SELECT + wf.request_id, + wf.request_number, + wf.title, + wf.priority, + wf.status, + wf.submission_date, + wf.current_level, + wf.total_levels, + u.display_name AS initiator_name, + u.email AS initiator_email, + al.level_name AS current_stage_name, + al.approver_name AS current_approver_name + FROM workflow_requests wf + LEFT JOIN users u ON wf.initiator_id = u.user_id + LEFT JOIN approval_levels al ON al.request_id = wf.request_id + AND al.level_number = wf.current_level + WHERE wf.is_draft = false + AND wf.status NOT IN ('CLOSED', 'APPROVED', 'REJECTED') + AND wf.submission_date IS NOT NULL + AND wf.submission_date BETWEEN :start AND :end + ${whereClause} + `, { + replacements: { userId, start: range.start, end: range.end }, + type: QueryTypes.SELECT + }); + + // Calculate business days for each request and filter by threshold + const { calculateBusinessDays } = await import('@utils/tatTimeUtils'); + const agingData = []; + + for (const req of allRequests) { + const priority = ((req as any).priority || 'STANDARD').toLowerCase(); + const businessDays = await calculateBusinessDays( + (req as any).submission_date, + null, // current date + priority + ); + + if (businessDays > threshold) { + agingData.push({ + requestId: (req as any).request_id, + requestNumber: (req as any).request_number, + title: (req as any).title, + priority: priority, + status: ((req as any).status || 'PENDING').toLowerCase(), + initiatorName: (req as any).initiator_name || (req as any).initiator_email || 'Unknown', + initiatorEmail: (req as any).initiator_email, + submissionDate: (req as any).submission_date, + daysOpen: businessDays, + currentLevel: (req as any).current_level, + totalLevels: (req as any).total_levels, + currentStageName: (req as any).current_stage_name || `Level ${(req as any).current_level}`, + currentApproverName: (req as any).current_approver_name + }); + } + } + + // Sort by days open (descending) and paginate + agingData.sort((a, b) => b.daysOpen - a.daysOpen); + + const totalRecords = agingData.length; + const totalPages = Math.ceil(totalRecords / limit); + const paginatedData = agingData.slice(offset, offset + limit); + + return { + agingData: paginatedData, + currentPage: page, + totalPages, + totalRecords, + limit + }; } } diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 214b187..e2fd08e 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -507,11 +507,15 @@ export class WorkflowService { // Calculate OVERALL request SLA (from submission to total deadline) const { calculateSLAStatus } = require('@utils/tatTimeUtils'); const submissionDate = (wf as any).submissionDate; + const closureDate = (wf as any).closureDate; + // For completed requests, use closure_date; for active requests, use current time + const overallEndDate = closureDate || null; + let overallSLA = null; if (submissionDate && totalTatHours > 0) { try { - overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority); + overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority, overallEndDate); } catch (error) { logger.error('[Workflow] Error calculating overall SLA:', error); } @@ -522,10 +526,13 @@ export class WorkflowService { if (currentLevel) { const levelStartTime = (currentLevel as any).levelStartTime || (currentLevel as any).tatStartTime; const levelTatHours = Number((currentLevel as any).tatHours || 0); + // For completed levels, use the level's completion time (if available) + // Otherwise, if request is completed, use closure_date + const levelEndDate = (currentLevel as any).completedAt || closureDate || null; if (levelStartTime && levelTatHours > 0) { try { - currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority); + currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate); } catch (error) { logger.error('[Workflow] Error calculating current level SLA:', error); } @@ -594,10 +601,37 @@ export class WorkflowService { return data; } - async listMyRequests(userId: string, page: number, limit: number) { + async listMyRequests(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }) { const offset = (page - 1) * limit; + + // Build where clause with filters + const whereConditions: any[] = [{ initiatorId: userId }]; + + // Apply status filter + if (filters?.status && filters.status !== 'all') { + whereConditions.push({ status: filters.status.toUpperCase() }); + } + + // 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()}%` } } + ] + }); + } + + const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; + const { rows, count } = await WorkflowRequest.findAndCountAll({ - where: { initiatorId: userId }, + where, offset, limit, order: [['createdAt', 'DESC']], @@ -609,7 +643,7 @@ export class WorkflowService { return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } - async listOpenForMe(userId: string, page: number, limit: number) { + async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) { const offset = (page - 1) * limit; // Find all pending/in-progress approval levels across requests ordered by levelNumber const pendingLevels = await ApprovalLevel.findAll({ @@ -664,30 +698,149 @@ export class WorkflowService { // Combine all request IDs (approver, spectator, and approved as initiator) const allOpenRequestIds = Array.from(new Set([...allRequestIds, ...approvedInitiatorRequestIds])); - const { rows, count } = await WorkflowRequest.findAndCountAll({ - where: { - requestId: { [Op.in]: allOpenRequestIds.length ? allOpenRequestIds : ['00000000-0000-0000-0000-000000000000'] }, - status: { [Op.in]: [ - WorkflowStatus.PENDING as any, - (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', - WorkflowStatus.APPROVED as any, // Include APPROVED for initiators awaiting closure - 'PENDING', - 'IN_PROGRESS', - 'APPROVED' - ] as any }, - }, - offset, - limit, - order: [['createdAt', 'DESC']], - include: [ - { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, - ], + // Build base where conditions + const baseConditions: any[] = []; + + // Add the main OR condition for request IDs + if (allOpenRequestIds.length > 0) { + baseConditions.push({ + requestId: { [Op.in]: allOpenRequestIds } + }); + } else { + // No matching requests + baseConditions.push({ + requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } + }); + } + + // Add status condition + baseConditions.push({ + status: { [Op.in]: [ + WorkflowStatus.PENDING as any, + (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', + WorkflowStatus.APPROVED as any, + 'PENDING', + 'IN_PROGRESS', + 'APPROVED' + ] as any } }); - const data = await this.enrichForCards(rows); + + // Apply status filter if provided (overrides default status filter) + if (filters?.status && filters.status !== 'all') { + baseConditions.pop(); // Remove default status + baseConditions.push({ status: filters.status.toUpperCase() }); + } + + // Apply priority filter + if (filters?.priority && filters.priority !== 'all') { + baseConditions.push({ priority: filters.priority.toUpperCase() }); + } + + // Apply search filter (title, description, or requestNumber) + if (filters?.search && filters.search.trim()) { + baseConditions.push({ + [Op.or]: [ + { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } + ] + }); + } + + const where = baseConditions.length > 0 ? { [Op.and]: baseConditions } : {}; + + // Build order clause based on sortBy parameter + // For computed fields (due, sla), we'll sort after enrichment + let order: any[] = [['createdAt', 'DESC']]; // Default order + const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC'); + + if (sortBy) { + switch (sortBy.toLowerCase()) { + case 'created': + order = [['createdAt', validSortOrder]]; + break; + case 'priority': + // Map priority values: EXPRESS = 1, STANDARD = 2 for ascending (standard first), or reverse for descending + // For simplicity, we'll sort alphabetically: EXPRESS < STANDARD + order = [['priority', validSortOrder], ['createdAt', 'DESC']]; // Secondary sort by createdAt + break; + // For 'due' and 'sla', we need to sort after enrichment (handled below) + case 'due': + case 'sla': + // Keep default order - will sort after enrichment + break; + default: + // Unknown sortBy, use default + break; + } + } + + // For computed field sorting (due, sla), we need to fetch all matching records first, + // enrich them, sort, then paginate. For DB fields, we can use SQL pagination. + const needsPostEnrichmentSort = sortBy && ['due', 'sla'].includes(sortBy.toLowerCase()); + + let rows: any[]; + let count: number; + + if (needsPostEnrichmentSort) { + // Fetch all matching records (no pagination yet) + const result = await WorkflowRequest.findAndCountAll({ + where, + include: [ + { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, + ], + }); + + // Enrich all records + const allEnriched = await this.enrichForCards(result.rows); + + // Sort enriched data + allEnriched.sort((a: any, b: any) => { + let aValue: any, bValue: any; + + if (sortBy.toLowerCase() === 'due') { + aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; + bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; + } else if (sortBy.toLowerCase() === 'sla') { + aValue = a.currentLevelSLA?.percentageUsed || 0; + bValue = b.currentLevelSLA?.percentageUsed || 0; + } else { + return 0; + } + + if (validSortOrder === 'ASC') { + return aValue > bValue ? 1 : -1; + } else { + return aValue < bValue ? 1 : -1; + } + }); + + count = result.count; + + // Apply pagination after sorting + const startIndex = offset; + const endIndex = startIndex + limit; + rows = allEnriched.slice(startIndex, endIndex); + } else { + // Use database sorting for simple fields (created, priority) + const result = await WorkflowRequest.findAndCountAll({ + where, + offset, + limit, + order, + include: [ + { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, + ], + }); + rows = result.rows; + count = result.count; + } + + const data = needsPostEnrichmentSort ? rows : await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } - async listClosedByMe(userId: string, page: number, limit: number) { + async listClosedByMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) { const offset = (page - 1) * limit; // Get requests where user participated as approver @@ -722,40 +875,130 @@ export class WorkflowService { const whereConditions: any[] = []; // 1. Requests where user was approver/spectator (show APPROVED, REJECTED, CLOSED) + const approverSpectatorStatuses = [ + WorkflowStatus.APPROVED as any, + WorkflowStatus.REJECTED as any, + (WorkflowStatus as any).CLOSED ?? 'CLOSED', + 'APPROVED', + 'REJECTED', + 'CLOSED' + ] as any; + if (allRequestIds.length > 0) { - whereConditions.push({ - requestId: { [Op.in]: allRequestIds }, - status: { [Op.in]: [ - WorkflowStatus.APPROVED as any, - WorkflowStatus.REJECTED as any, - (WorkflowStatus as any).CLOSED ?? 'CLOSED', - 'APPROVED', - 'REJECTED', - 'CLOSED' - ] as any } - }); + const approverConditionParts: any[] = [ + { requestId: { [Op.in]: allRequestIds } } + ]; + + // Apply status filter + if (filters?.status && filters.status !== 'all') { + approverConditionParts.push({ status: filters.status.toUpperCase() }); + } else { + approverConditionParts.push({ status: { [Op.in]: approverSpectatorStatuses } }); + } + + // Apply priority filter + if (filters?.priority && filters.priority !== 'all') { + approverConditionParts.push({ priority: filters.priority.toUpperCase() }); + } + + // Apply search filter (title, description, or requestNumber) + if (filters?.search && filters.search.trim()) { + approverConditionParts.push({ + [Op.or]: [ + { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } + ] + }); + } + + const approverCondition = approverConditionParts.length > 0 + ? { [Op.and]: approverConditionParts } + : { requestId: { [Op.in]: allRequestIds } }; + + whereConditions.push(approverCondition); } // 2. Requests where user is initiator (show ONLY REJECTED or CLOSED, NOT APPROVED) // APPROVED means initiator still needs to finalize conclusion - whereConditions.push({ - initiatorId: userId, - status: { [Op.in]: [ - WorkflowStatus.REJECTED as any, - (WorkflowStatus as any).CLOSED ?? 'CLOSED', - 'REJECTED', - 'CLOSED' - ] as any } - }); + const initiatorStatuses = [ + WorkflowStatus.REJECTED as any, + (WorkflowStatus as any).CLOSED ?? 'CLOSED', + 'REJECTED', + 'CLOSED' + ] as any; + + const initiatorConditionParts: any[] = [ + { initiatorId: userId } + ]; + + // Apply status filter + if (filters?.status && filters.status !== 'all') { + const filterStatus = filters.status.toUpperCase(); + // Only apply if status is REJECTED or CLOSED (not APPROVED for initiator) + if (filterStatus === 'REJECTED' || filterStatus === 'CLOSED') { + initiatorConditionParts.push({ status: filterStatus }); + } else { + // If filtering for APPROVED, don't include initiator requests + initiatorConditionParts.push({ status: { [Op.in]: [] } }); // Empty set - no results + } + } else { + initiatorConditionParts.push({ status: { [Op.in]: initiatorStatuses } }); + } + + // Apply priority filter + if (filters?.priority && filters.priority !== 'all') { + initiatorConditionParts.push({ priority: filters.priority.toUpperCase() }); + } + + // Apply search filter (title, description, or requestNumber) + if (filters?.search && filters.search.trim()) { + initiatorConditionParts.push({ + [Op.or]: [ + { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, + { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } + ] + }); + } + + const initiatorCondition = initiatorConditionParts.length > 0 + ? { [Op.and]: initiatorConditionParts } + : { initiatorId: userId }; + + whereConditions.push(initiatorCondition); + + // Build where clause with OR conditions + const where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {}; + + // Build order clause based on sortBy parameter + let order: any[] = [['createdAt', 'DESC']]; // Default order + const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC'); + + if (sortBy) { + switch (sortBy.toLowerCase()) { + case 'created': + order = [['createdAt', validSortOrder]]; + break; + case 'due': + // Sort by closureDate or updatedAt (closed date) + order = [['updatedAt', validSortOrder], ['createdAt', 'DESC']]; + break; + case 'priority': + order = [['priority', validSortOrder], ['createdAt', 'DESC']]; + break; + default: + // Unknown sortBy, use default + break; + } + } // Fetch closed/rejected/approved requests (including finalized ones) const { rows, count } = await WorkflowRequest.findAndCountAll({ - where: { - [Op.or]: whereConditions - }, + where, offset, limit, - order: [['createdAt', 'DESC']], + order, include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], @@ -763,7 +1006,7 @@ export class WorkflowService { const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } - async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest): Promise { + async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { try { const requestNumber = generateRequestNumber(); const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0); @@ -834,7 +1077,9 @@ export class WorkflowService { user: { userId: initiatorId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Initial request submitted', - details: `Initial request submitted for ${workflowData.title} by ${initiatorName}` + details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); // Send notification to INITIATOR confirming submission diff --git a/src/services/worknote.service.ts b/src/services/worknote.service.ts index 81f7217..ac190a1 100644 --- a/src/services/worknote.service.ts +++ b/src/services/worknote.service.ts @@ -71,7 +71,7 @@ export class WorkNoteService { } } - async create(requestId: string, user: { userId: string; name?: string; role?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path: string; originalname: string; mimetype: string; size: number }>): Promise { + async create(requestId: string, user: { userId: string; name?: string; role?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path: string; originalname: string; mimetype: string; size: number }>, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { logger.info('[WorkNote] Creating note:', { requestId, user, messageLength: payload.message?.length }); const note = await WorkNote.create({ @@ -123,7 +123,9 @@ export class WorkNoteService { user: { userId: user.userId, name: user.name || 'User' }, timestamp: new Date().toISOString(), action: 'Work Note Added', - details: `${user.name || 'User'} added a work note: ${payload.message.substring(0, 100)}${payload.message.length > 100 ? '...' : ''}` + details: `${user.name || 'User'} added a work note: ${payload.message.substring(0, 100)}${payload.message.length > 100 ? '...' : ''}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined }); try { diff --git a/src/utils/requestUtils.ts b/src/utils/requestUtils.ts new file mode 100644 index 0000000..a390ca8 --- /dev/null +++ b/src/utils/requestUtils.ts @@ -0,0 +1,80 @@ +import { Request } from 'express'; + +/** + * Extract client IP address from request + * Handles proxies and load balancers via x-forwarded-for header + * Normalizes IPv6 loopback (::1) to IPv4 loopback (127.0.0.1) + */ +export function getClientIp(req: Request | any): string | null { + let ip: string | null = null; + + // Priority 1: Check x-forwarded-for header (for proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + // x-forwarded-for can contain multiple IPs, take the first one + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + ip = ips.split(',')[0].trim(); + } + + // Priority 2: Check x-real-ip header (some proxies use this) + if (!ip) { + const realIp = req.headers['x-real-ip']; + if (realIp) { + ip = Array.isArray(realIp) ? realIp[0] : realIp; + } + } + + // Priority 3: Check cf-connecting-ip (Cloudflare) + if (!ip) { + const cfIp = req.headers['cf-connecting-ip']; + if (cfIp) { + ip = Array.isArray(cfIp) ? cfIp[0] : cfIp; + } + } + + // Priority 4: Fallback to req.ip (requires trust proxy to be set) + if (!ip && req.ip) { + ip = req.ip; + } + + // Priority 5: Check connection remote address + if (!ip && req.socket?.remoteAddress) { + ip = req.socket.remoteAddress; + } + + // Normalize IPv6 loopback to IPv4 loopback for consistency + if (ip === '::1' || ip === '::ffff:127.0.0.1') { + ip = '127.0.0.1'; + } + + // Remove IPv6 prefix if present (::ffff:) + if (ip && ip.startsWith('::ffff:')) { + ip = ip.substring(7); + } + + return ip; +} + +/** + * Extract user agent from request + */ +export function getUserAgent(req: Request | any): string | null { + return req.headers['user-agent'] || null; +} + +/** + * Extract both IP and user agent from request + * Returns undefined instead of null to match TypeScript optional property types + */ +export function getRequestMetadata(req: Request | any): { + ipAddress: string | undefined; + userAgent: string | undefined; +} { + const ip = getClientIp(req); + const ua = getUserAgent(req); + return { + ipAddress: ip || undefined, + userAgent: ua || undefined + }; +} + diff --git a/src/utils/tatTimeUtils.ts b/src/utils/tatTimeUtils.ts index 637b75f..0fd99ae 100644 --- a/src/utils/tatTimeUtils.ts +++ b/src/utils/tatTimeUtils.ts @@ -476,16 +476,18 @@ export async function isCurrentlyWorkingTime(priority: string = 'standard'): Pro export async function calculateSLAStatus( levelStartTime: Date | string, tatHours: number, - priority: string = 'standard' + priority: string = 'standard', + endDate?: Date | string | null ) { await loadWorkingHoursCache(); await loadHolidaysCache(); const startDate = dayjs(levelStartTime); - const now = dayjs(); + // Use provided endDate if available (for completed requests), otherwise use current time + const endTime = endDate ? dayjs(endDate) : dayjs(); // Calculate elapsed working hours - const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, now.toDate(), priority); + const elapsedHours = await calculateElapsedWorkingHours(levelStartTime, endTime.toDate(), priority); const remainingHours = Math.max(0, tatHours - elapsedHours); const percentageUsed = tatHours > 0 ? Math.min(100, Math.round((elapsedHours / tatHours) * 100)) : 0; @@ -497,7 +499,8 @@ export async function calculateSLAStatus( : (await addWorkingHours(levelStartTime, tatHours)).toDate(); // Check if currently paused (outside working hours) - const isPaused = !(await isCurrentlyWorkingTime(priority)); + // For completed requests (with endDate), it's not paused + const isPaused = endDate ? false : !(await isCurrentlyWorkingTime(priority)); // Determine status let status: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track'; @@ -694,3 +697,66 @@ export async function calculateElapsedWorkingHours( return hours; } +/** + * Calculate business days between two dates + * Excludes weekends and holidays + * @param startDate - Start date + * @param endDate - End date (defaults to now) + * @param priority - 'express' or 'standard' (express includes weekends, standard excludes) + * @returns Number of business days + */ +export async function calculateBusinessDays( + startDate: Date | string, + endDate: Date | string | null = null, + priority: string = 'standard' +): Promise { + await loadWorkingHoursCache(); + await loadHolidaysCache(); + + let start = dayjs(startDate).startOf('day'); + const end = dayjs(endDate || new Date()).startOf('day'); + + // In test mode, use calendar days + if (isTestMode()) { + return end.diff(start, 'day') + 1; + } + + const config = workingHoursCache || { + startHour: TAT_CONFIG.WORK_START_HOUR, + endHour: TAT_CONFIG.WORK_END_HOUR, + startDay: TAT_CONFIG.WORK_START_DAY, + endDay: TAT_CONFIG.WORK_END_DAY + }; + + let businessDays = 0; + let current = start; + + // Count each day from start to end (inclusive) + while (current.isBefore(end) || current.isSame(end, 'day')) { + const dayOfWeek = current.day(); // 0 = Sunday, 6 = Saturday + const dateStr = current.format('YYYY-MM-DD'); + + // For express priority: count all days (including weekends) but exclude holidays + // For standard priority: count only working days (Mon-Fri) and exclude holidays + const isWorkingDay = priority === 'express' + ? true // Express includes weekends + : (dayOfWeek >= config.startDay && dayOfWeek <= config.endDay); + + const isNotHoliday = !holidaysCache.has(dateStr); + + if (isWorkingDay && isNotHoliday) { + businessDays++; + } + + current = current.add(1, 'day'); + + // Safety check to prevent infinite loops + if (current.diff(start, 'day') > 730) { // 2 years + console.error('[TAT] Safety break - exceeded 2 years in business days calculation'); + break; + } + } + + return businessDays; +} + diff --git a/src/utils/userAgentParser.ts b/src/utils/userAgentParser.ts new file mode 100644 index 0000000..b256297 --- /dev/null +++ b/src/utils/userAgentParser.ts @@ -0,0 +1,85 @@ +/** + * Parse user agent string to extract device, browser, and OS information + * Simple parser without external dependencies + */ + +export interface ParsedUserAgent { + deviceType: 'WEB' | 'MOBILE' | 'TABLET' | 'UNKNOWN'; + browser: string; + os: string; + raw: string; +} + +/** + * Parse user agent string to extract device, browser, and OS + */ +export function parseUserAgent(userAgent: string | null | undefined): ParsedUserAgent { + if (!userAgent) { + return { + deviceType: 'UNKNOWN', + browser: 'Unknown', + os: 'Unknown', + raw: '' + }; + } + + const ua = userAgent.toLowerCase(); + + // Detect device type + let deviceType: 'WEB' | 'MOBILE' | 'TABLET' | 'UNKNOWN' = 'WEB'; + if (/tablet|ipad|playbook|silk/i.test(userAgent)) { + deviceType = 'TABLET'; + } else if (/mobile|iphone|ipod|android|blackberry|opera|mini|windows\s+phone|palm|iemobile/i.test(userAgent)) { + deviceType = 'MOBILE'; + } + + // Detect browser + let browser = 'Unknown'; + if (ua.includes('edg/')) { + browser = 'Edge'; + } else if (ua.includes('chrome/') && !ua.includes('edg/')) { + browser = 'Chrome'; + } else if (ua.includes('firefox/')) { + browser = 'Firefox'; + } else if (ua.includes('safari/') && !ua.includes('chrome/')) { + browser = 'Safari'; + } else if (ua.includes('opera/') || ua.includes('opr/')) { + browser = 'Opera'; + } else if (ua.includes('msie') || ua.includes('trident/')) { + browser = 'Internet Explorer'; + } + + // Detect OS + let os = 'Unknown'; + if (ua.includes('windows nt')) { + if (ua.includes('windows nt 10.0')) { + os = 'Windows 10/11'; + } else if (ua.includes('windows nt 6.3')) { + os = 'Windows 8.1'; + } else if (ua.includes('windows nt 6.2')) { + os = 'Windows 8'; + } else if (ua.includes('windows nt 6.1')) { + os = 'Windows 7'; + } else { + os = 'Windows'; + } + } else if (ua.includes('mac os x') || ua.includes('macintosh')) { + os = 'macOS'; + } else if (ua.includes('linux')) { + os = 'Linux'; + } else if (ua.includes('android')) { + os = 'Android'; + } else if (ua.includes('iphone') || ua.includes('ipad')) { + os = 'iOS'; + } else if (ua.includes('ipod')) { + os = 'iOS'; + } + + return { + deviceType, + browser, + os, + raw: userAgent + }; +} +