dashboard enhanced created new apis for approver performance and added api for breacg reason

This commit is contained in:
laxmanhalaki 2025-11-18 20:42:26 +05:30
parent 336df2023c
commit dcb53a89ed
16 changed files with 1188 additions and 352 deletions

View File

@ -1,222 +0,0 @@
# In-App Notification System - Setup Guide
## 🎯 Overview
Complete real-time in-app notification system for Royal Enfield Workflow Management System.
## ✅ Features Implemented
### Backend:
1. **Notification Model** (`models/Notification.ts`)
- Stores all in-app notifications
- Tracks read/unread status
- Supports priority levels (LOW, MEDIUM, HIGH, URGENT)
- Metadata for request context
2. **Notification Controller** (`controllers/notification.controller.ts`)
- GET `/api/v1/notifications` - List user's notifications with pagination
- GET `/api/v1/notifications/unread-count` - Get unread count
- PATCH `/api/v1/notifications/:notificationId/read` - Mark as read
- POST `/api/v1/notifications/mark-all-read` - Mark all as read
- DELETE `/api/v1/notifications/:notificationId` - Delete notification
3. **Enhanced Notification Service** (`services/notification.service.ts`)
- Saves notifications to database (for in-app display)
- Emits real-time socket.io events
- Sends push notifications (if subscribed)
- All in one call: `notificationService.sendToUsers()`
4. **Socket.io Enhancement** (`realtime/socket.ts`)
- Added `join:user` event for personal notification room
- Added `emitToUser()` function for targeted notifications
- Real-time delivery without page refresh
### Frontend:
1. **Notification API Service** (`services/notificationApi.ts`)
- Complete API client for all notification endpoints
2. **PageLayout Integration** (`components/layout/PageLayout/PageLayout.tsx`)
- Real-time notification bell with unread count badge
- Dropdown showing latest 10 notifications
- Click to mark as read and navigate to request
- "Mark all as read" functionality
- Auto-refreshes when new notifications arrive
- Works even if browser push notifications disabled
3. **Data Freshness** (MyRequests, OpenRequests, ClosedRequests)
- Fixed stale data after DB deletion
- Always shows fresh data from API
## 📦 Database Setup
### Step 1: Run Migration
Execute this SQL in your PostgreSQL database:
```bash
psql -U postgres -d re_workflow_db -f migrations/create_notifications_table.sql
```
OR run manually in pgAdmin/SQL tool:
```sql
-- See: migrations/create_notifications_table.sql
```
### Step 2: Verify Table Created
```sql
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'notifications';
```
## 🚀 How It Works
### 1. When an Event Occurs (e.g., Request Assigned):
**Backend:**
```typescript
await notificationService.sendToUsers(
[approverId],
{
title: 'New request assigned',
body: 'Marketing Campaign Approval - REQ-2025-12345',
requestId: workflowId,
requestNumber: 'REQ-2025-12345',
url: `/request/REQ-2025-12345`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
}
);
```
This automatically:
- ✅ Saves notification to `notifications` table
- ✅ Emits `notification:new` socket event to user
- ✅ Sends browser push notification (if enabled)
### 2. Frontend Receives Notification:
**PageLayout** automatically:
- ✅ Receives socket event in real-time
- ✅ Updates notification count badge
- ✅ Adds to notification dropdown
- ✅ Shows blue dot for unread
- ✅ User clicks → marks as read → navigates to request
## 📌 Notification Events (Major)
Based on your requirement, here are the key events that trigger notifications:
| Event | Type | Sent To | Priority |
|-------|------|---------|----------|
| Request Created | `created` | Initiator | MEDIUM |
| Request Assigned | `assignment` | Approver | HIGH |
| Approval Given | `approved` | Initiator | HIGH |
| Request Rejected | `rejected` | Initiator | URGENT |
| TAT Alert (50%) | `tat_alert` | Approver | MEDIUM |
| TAT Alert (75%) | `tat_alert` | Approver | HIGH |
| TAT Breached | `tat_breach` | Approver + Initiator | URGENT |
| Work Note Mention | `mention` | Tagged Users | MEDIUM |
| New Comment | `comment` | Participants | LOW |
## 🔧 Configuration
### Backend (.env):
```env
# Already configured - no changes needed
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
```
### Frontend (.env):
```env
# Already configured
VITE_API_BASE_URL=http://localhost:5000/api/v1
```
## ✅ Testing
### 1. Test Basic Notification:
```bash
# Create a workflow and assign to an approver
# Check approver's notification bell - should show count
```
### 2. Test Real-Time Delivery:
```bash
# Have 2 users logged in (different browsers)
# User A creates request, assigns to User B
# User B should see notification appear immediately (no refresh needed)
```
### 3. Test TAT Notifications:
```bash
# Create request with 1-hour TAT
# Wait for threshold notifications (50%, 75%, 100%)
# Approver should receive in-app notifications
```
### 4. Test Work Note Mentions:
```bash
# Add work note with @mention
# Tagged user should receive notification
```
## 🎨 UI Features
- **Unread Badge**: Shows count (1-9, or "9+" for 10+)
- **Blue Dot**: Indicates unread notifications
- **Blue Background**: Highlights unread items
- **Time Ago**: "5 minutes ago", "2 hours ago", etc.
- **Click to Navigate**: Automatically opens the related request
- **Mark All Read**: Single click to clear all unread
- **Scrollable**: Shows latest 10, with "View all" link
## 📱 Fallback for Disabled Push Notifications
Even if user denies browser push notifications:
- ✅ In-app notifications ALWAYS work
- ✅ Notifications saved to database
- ✅ Real-time delivery via socket.io
- ✅ No permission required
- ✅ Works on all browsers
## 🔍 Debug Endpoints
```bash
# Get notifications for current user
GET /api/v1/notifications?page=1&limit=10
# Get only unread
GET /api/v1/notifications?unreadOnly=true
# Get unread count
GET /api/v1/notifications/unread-count
```
## 🎉 Benefits
1. **No Browser Permission Needed** - Always works, unlike push notifications
2. **Real-Time Updates** - Instant delivery via socket.io
3. **Persistent** - Saved in database, available after login
4. **Actionable** - Click to navigate to related request
5. **User-Friendly** - Clean UI integrated into header
6. **Complete Tracking** - Know what was sent via which channel
## 🔥 Next Steps (Optional)
1. **Email Integration**: Send email for URGENT priority notifications
2. **SMS Integration**: Critical alerts via SMS
3. **Notification Preferences**: Let users choose which events to receive
4. **Notification History Page**: Full-page view with filters
5. **Sound Alerts**: Play sound when new notification arrives
6. **Desktop Notifications**: Browser native notifications (if permitted)
---
**✅ In-App Notifications are now fully operational!**
Users will receive instant notifications for all major workflow events, even without browser push permissions enabled.

157
package-lock.json generated
View File

@ -65,6 +65,7 @@
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
},
@ -2672,6 +2673,16 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/arrify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
@ -3812,6 +3823,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -4989,6 +5013,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -5061,6 +5098,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/google-auth-library": {
"version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
@ -6955,6 +7023,20 @@
"node": ">= 6.0.0"
}
},
"node_modules/mylas": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
"integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -7467,6 +7549,16 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
@ -7672,6 +7764,19 @@
"node": ">=8"
}
},
"node_modules/plimit-lit": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
"integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"queue-lit": "^1.5.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -7860,6 +7965,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-lit": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
"integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -8024,6 +8139,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve.exports": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
@ -9279,6 +9404,38 @@
"node": ">=10"
}
},
"node_modules/tsc-alias": {
"version": "1.8.16",
"resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz",
"integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"commander": "^9.0.0",
"get-tsconfig": "^4.10.0",
"globby": "^11.0.4",
"mylas": "^2.1.9",
"normalize-path": "^3.0.0",
"plimit-lit": "^1.2.6"
},
"bin": {
"tsc-alias": "dist/bin/index.js"
},
"engines": {
"node": ">=16.20.2"
}
},
"node_modules/tsc-alias/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/tsconfig": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",

View File

@ -7,20 +7,13 @@
"start": "node dist/server.js",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"build": "tsc",
"build": "tsc && tsc-alias",
"build:watch": "tsc --watch",
"start:prod": "NODE_ENV=production node dist/server.js",
"test": "jest --coverage",
"test:unit": "jest --testPathPattern=tests/unit",
"test:integration": "jest --testPathPattern=tests/integration",
"test:watch": "jest --watch",
"start:prod": "node dist/server.js",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"type-check": "tsc --noEmit",
"db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:seed": "sequelize-cli db:seed:all",
"clean": "rm -rf dist",
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
@ -84,6 +77,7 @@
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
},

View File

@ -365,8 +365,11 @@ export class DashboardController {
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 startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined;
const result = await this.dashboardService.getLifecycleReport(userId, page, limit);
const result = await this.dashboardService.getLifecycleReport(userId, page, limit, dateRange, startDate, endDate);
res.json({
success: true,
@ -396,6 +399,8 @@ export class DashboardController {
const page = Number(req.query.page || 1);
const limit = Number(req.query.limit || 50);
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 filterUserId = req.query.filterUserId as string | undefined;
const filterType = req.query.filterType as string | undefined;
const filterCategory = req.query.filterCategory as string | undefined;
@ -409,7 +414,9 @@ export class DashboardController {
filterUserId,
filterType,
filterCategory,
filterSeverity
filterSeverity,
startDate,
endDate
);
res.json({
@ -432,8 +439,41 @@ export class DashboardController {
}
/**
* Get Workflow Aging Report
* Get list of departments (metadata for filtering)
* GET /api/v1/dashboard/metadata/departments
*/
async getDepartments(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: 'Unauthorized',
timestamp: new Date()
});
return;
}
const departments = await this.dashboardService.getDepartments(userId);
res.status(200).json({
success: true,
message: 'Departments retrieved successfully',
data: {
departments
},
timestamp: new Date()
});
} catch (error) {
logger.error('[Dashboard] Get Departments failed:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
timestamp: new Date()
});
}
}
async getWorkflowAgingReport(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.userId;
@ -441,13 +481,17 @@ export class DashboardController {
const page = Number(req.query.page || 1);
const limit = Number(req.query.limit || 50);
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 result = await this.dashboardService.getWorkflowAgingReport(
userId,
threshold,
page,
limit,
dateRange
dateRange,
startDate,
endDate
);
res.json({
@ -468,5 +512,63 @@ export class DashboardController {
});
}
}
/**
* Get requests filtered by approver ID for detailed performance analysis
*/
async getRequestsByApprover(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.userId;
const approverId = req.query.approverId as string;
const page = Number(req.query.page || 1);
const limit = Number(req.query.limit || 50);
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 status = req.query.status as string | undefined;
const priority = req.query.priority as string | undefined;
const slaCompliance = req.query.slaCompliance as string | undefined;
const search = req.query.search as string | undefined;
if (!approverId) {
res.status(400).json({
success: false,
error: 'Approver ID is required'
});
return;
}
const result = await this.dashboardService.getRequestsByApprover(
userId,
approverId,
page,
limit,
dateRange,
startDate,
endDate,
status,
priority,
slaCompliance,
search
);
res.json({
success: true,
data: result.requests,
pagination: {
currentPage: result.currentPage,
totalPages: result.totalPages,
totalRecords: result.totalRecords,
limit: result.limit
}
});
} catch (error) {
logger.error('[Dashboard] Error fetching requests by approver:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch requests by approver'
});
}
}
}

View File

@ -2,9 +2,13 @@ import { Request, Response } from 'express';
import { TatAlert } from '@models/TatAlert';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { User } from '@models/User';
import { WorkflowRequest } from '@models/WorkflowRequest';
import logger from '@utils/logger';
import { sequelize } from '@config/database';
import { QueryTypes } from 'sequelize';
import { activityService } from '@services/activity.service';
import { getRequestMetadata } from '@utils/requestUtils';
import type { AuthenticatedRequest } from '../types/express';
/**
* Get TAT alerts for a specific request
@ -155,6 +159,121 @@ export const getTatBreachReport = async (req: Request, res: Response) => {
}
};
/**
* Update breach reason for a TAT alert
*/
export const updateBreachReason = async (req: Request, res: Response) => {
try {
const { levelId } = req.params;
const { breachReason } = req.body;
const userId = (req as AuthenticatedRequest).user?.userId;
const requestMeta = getRequestMetadata(req);
if (!userId) {
return res.status(401).json({
success: false,
error: 'Unauthorized'
});
}
if (!breachReason || typeof breachReason !== 'string' || breachReason.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Breach reason is required'
});
}
// Get the approval level to verify permissions
const level = await ApprovalLevel.findByPk(levelId);
if (!level) {
return res.status(404).json({
success: false,
error: 'Approval level not found'
});
}
// Get user to check role
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
const userRole = (user as any).role;
const approverId = (level as any).approverId;
// Check permissions: ADMIN, MANAGEMENT, or the approver
const hasPermission =
userRole === 'ADMIN' ||
userRole === 'MANAGEMENT' ||
approverId === userId;
if (!hasPermission) {
return res.status(403).json({
success: false,
error: 'You do not have permission to update breach reason'
});
}
// Get user details for activity logging
const userDisplayName = (user as any).displayName || (user as any).email || 'Unknown User';
const isUpdate = !!(level as any).breachReason; // Check if this is an update or first time
const levelNumber = (level as any).levelNumber;
const approverName = (level as any).approverName || 'Unknown Approver';
// Update breach reason directly in approval_levels table
await level.update({
breachReason: breachReason.trim()
});
// Reload to get updated data
await level.reload();
// Log activity for the request
const userRoleLabel = userRole === 'ADMIN' ? 'Admin' : userRole === 'MANAGEMENT' ? 'Management' : 'Approver';
await activityService.log({
requestId: level.requestId,
type: 'comment', // Using comment type for breach reason entry
user: {
userId: userId,
name: userDisplayName,
email: (user as any).email
},
timestamp: new Date().toISOString(),
action: isUpdate ? 'Updated TAT breach reason' : 'Added TAT breach reason',
details: `${userDisplayName} (${userRoleLabel}) ${isUpdate ? 'updated' : 'added'} TAT breach reason for ${approverName} (Level ${levelNumber}): "${breachReason.trim()}"`,
metadata: {
levelId: level.levelId,
levelNumber: levelNumber,
approverName: approverName,
breachReason: breachReason.trim(),
updatedByRole: userRole
},
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
});
logger.info(`[TAT Controller] Breach reason ${isUpdate ? 'updated' : 'added'} for level ${levelId} by user ${userId} (${userRole})`);
return res.json({
success: true,
message: `Breach reason ${isUpdate ? 'updated' : 'added'} successfully`,
data: {
levelId: level.levelId,
breachReason: breachReason.trim()
}
});
} catch (error) {
logger.error('[TAT Controller] Error updating breach reason:', error);
return res.status(500).json({
success: false,
error: 'Failed to update breach reason'
});
}
};
/**
* Get approver TAT performance
*/

View File

@ -0,0 +1,49 @@
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Migration: Add breach_reason column to approval_levels table
* Purpose: Store TAT breach reason directly in approval_levels table
* Date: 2025-11-18
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Check if table exists first
const tables = await queryInterface.showAllTables();
if (!tables.includes('approval_levels')) {
// Table doesn't exist yet, skipping
return;
}
// Get existing columns
const tableDescription = await queryInterface.describeTable('approval_levels');
// Add breach_reason column only if it doesn't exist
if (!tableDescription.breach_reason) {
await queryInterface.addColumn('approval_levels', 'breach_reason', {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Reason for TAT breach - can contain paragraph-length text'
});
console.log('✅ Added breach_reason column to approval_levels table');
} else {
console.log(' breach_reason column already exists, skipping');
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Check if table exists
const tables = await queryInterface.showAllTables();
if (!tables.includes('approval_levels')) {
return;
}
// Get existing columns
const tableDescription = await queryInterface.describeTable('approval_levels');
// Remove column only if it exists
if (tableDescription.breach_reason) {
await queryInterface.removeColumn('approval_levels', 'breach_reason');
console.log('✅ Removed breach_reason column from approval_levels table');
}
}

View File

@ -20,6 +20,7 @@ interface ApprovalLevelAttributes {
actionDate?: Date;
comments?: string;
rejectionReason?: string;
breachReason?: string;
isFinalApprover: boolean;
elapsedHours: number;
remainingHours: number;
@ -32,7 +33,7 @@ interface ApprovalLevelAttributes {
updatedAt: Date;
}
interface ApprovalLevelCreationAttributes extends Optional<ApprovalLevelAttributes, 'levelId' | 'levelName' | 'levelStartTime' | 'levelEndTime' | 'actionDate' | 'comments' | 'rejectionReason' | 'tat50AlertSent' | 'tat75AlertSent' | 'tatBreached' | 'tatStartTime' | 'tatDays' | 'createdAt' | 'updatedAt'> {}
interface ApprovalLevelCreationAttributes extends Optional<ApprovalLevelAttributes, 'levelId' | 'levelName' | 'levelStartTime' | 'levelEndTime' | 'actionDate' | 'comments' | 'rejectionReason' | 'breachReason' | 'tat50AlertSent' | 'tat75AlertSent' | 'tatBreached' | 'tatStartTime' | 'tatDays' | 'createdAt' | 'updatedAt'> {}
class ApprovalLevel extends Model<ApprovalLevelAttributes, ApprovalLevelCreationAttributes> implements ApprovalLevelAttributes {
public levelId!: string;
@ -50,6 +51,7 @@ class ApprovalLevel extends Model<ApprovalLevelAttributes, ApprovalLevelCreation
public actionDate?: Date;
public comments?: string;
public rejectionReason?: string;
public breachReason?: string;
public isFinalApprover!: boolean;
public elapsedHours!: number;
public remainingHours!: number;
@ -152,6 +154,12 @@ ApprovalLevel.init(
allowNull: true,
field: 'rejection_reason'
},
breachReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'breach_reason',
comment: 'Reason for TAT breach - can contain paragraph-length text'
},
isFinalApprover: {
type: DataTypes.BOOLEAN,
defaultValue: false,

View File

@ -56,7 +56,6 @@ export function initSocket(httpServer: any) {
const userId = typeof data === 'string' ? data : data.userId;
socket.join(`user:${userId}`);
currentUserId = userId;
console.log(`[Socket] User ${userId} joined personal notification room`);
});
socket.on('join:request', (data: { requestId: string; userId?: string }) => {
@ -132,7 +131,6 @@ export function emitToRequestRoom(requestId: string, event: string, payload: any
export function emitToUser(userId: string, event: string, payload: any) {
if (!io) return;
io.to(`user:${userId}`).emit(event, payload);
console.log(`[Socket] Emitted '${event}' to user ${userId}`);
}

View File

@ -108,5 +108,17 @@ router.get('/reports/workflow-aging',
asyncHandler(dashboardController.getWorkflowAgingReport.bind(dashboardController))
);
// Get departments metadata (for filtering)
router.get('/metadata/departments',
authenticateToken,
asyncHandler(dashboardController.getDepartments.bind(dashboardController))
);
// Get requests filtered by approver ID (for detailed performance analysis)
router.get('/requests/by-approver',
authenticateToken,
asyncHandler(dashboardController.getRequestsByApprover.bind(dashboardController))
);
export default router;

View File

@ -5,7 +5,8 @@ import {
getTatAlertsByLevel,
getTatComplianceSummary,
getTatBreachReport,
getApproverTatPerformance
getApproverTatPerformance,
updateBreachReason
} from '@controllers/tat.controller';
const router = Router();
@ -49,5 +50,12 @@ router.get('/breaches', getTatBreachReport);
*/
router.get('/performance/:approverId', getApproverTatPerformance);
/**
* @route PUT /api/tat/breach-reason/:levelId
* @desc Update breach reason for a TAT alert
* @access Private (ADMIN, MANAGEMENT, or approver)
*/
router.put('/breach-reason/:levelId', updateBreachReason);
export default router;

View File

@ -18,6 +18,7 @@ import * as m14 from '../migrations/20251105-add-skip-fields-to-approval-levels'
import * as m15 from '../migrations/2025110501-alter-tat-days-to-generated';
import * as m16 from '../migrations/20251111-create-notifications';
import * as m17 from '../migrations/20251111-create-conclusion-remarks';
import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-levels';
interface Migration {
name: string;
@ -50,6 +51,7 @@ const migrations: Migration[] = [
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
];
/**

View File

@ -226,9 +226,21 @@ export class ApprovalService {
logger.error(`[Approval] Unhandled error in background AI generation:`, err);
});
// Notify initiator about approval and pending conclusion step
// Notify initiator and all participants (including spectators) about approval
// Spectators are CC'd for transparency, similar to email CC
if (wf) {
await notificationService.sendToUsers([ (wf as any).initiatorId ], {
const participants = await Participant.findAll({
where: { requestId: level.requestId }
});
const targetUserIds = new Set<string>();
targetUserIds.add((wf as any).initiatorId);
for (const p of participants as any[]) {
targetUserIds.add(p.userId); // Includes spectators
}
// Send notification to initiator (with action required)
const initiatorId = (wf as any).initiatorId;
await notificationService.sendToUsers([initiatorId], {
title: `Request Approved - Closure Pending`,
body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`,
requestNumber: (wf as any).requestNumber,
@ -239,7 +251,22 @@ export class ApprovalService {
actionRequired: true
});
logger.info(`[Approval] ✅ Final approval complete for ${level.requestId}. Initiator notified to finalize conclusion.`);
// Send notification to all participants/spectators (for transparency, no action required)
const participantUserIds = Array.from(targetUserIds).filter(id => id !== initiatorId);
if (participantUserIds.length > 0) {
await notificationService.sendToUsers(participantUserIds, {
title: `Request Approved`,
body: `Request "${(wf as any).title}" has been fully approved. The initiator will finalize the conclusion remark to close the request.`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'approval_pending_closure',
priority: 'MEDIUM',
actionRequired: false
});
}
logger.info(`[Approval] ✅ Final approval complete for ${level.requestId}. Initiator and ${participants.length} participant(s) notified.`);
}
} else {
// Not final - move to next level

File diff suppressed because it is too large Load Diff

View File

@ -118,8 +118,6 @@ export class UserService {
const oktaApiToken = process.env.OKTA_API_TOKEN;
if (!oktaDomain || !oktaApiToken) {
console.error('❌ Okta credentials not configured');
// Fallback to local DB search
return await this.searchUsersLocal(q, limit, excludeUserId);
}
@ -161,8 +159,6 @@ export class UserService {
isActive: true
}));
} catch (error: any) {
console.error('❌ Okta user search failed:', error.message);
// Fallback to local DB search
return await this.searchUsersLocal(q, limit, excludeUserId);
}
}

View File

@ -1,6 +1,5 @@
import { JwtPayload } from 'jsonwebtoken';
export type UserRole = 'USER' | 'MANAGEMENT' | 'ADMIN';
import { UserRole } from './user.types';
declare global {
namespace Express {
@ -8,7 +7,7 @@ declare global {
user?: {
userId: string;
email: string;
employeeId?: string | null; // Optional - schema not finalized
employeeId?: string | null;
role?: UserRole;
};
cookies?: {

View File

@ -38,19 +38,13 @@ async function loadWorkingHoursCache(): Promise<void> {
endDay: endDay
};
workingHoursCacheExpiry = dayjs().add(5, 'minute').toDate();
console.log(`[TAT Utils] ✅ Working hours loaded from admin config: ${hours.startHour}:00 - ${hours.endHour}:00 (Days: ${startDay}-${endDay})`);
} catch (error) {
console.error('[TAT] Error loading working hours:', error);
// Fallback to default values from TAT_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
};
console.log(`[TAT Utils] ⚠️ Using fallback working hours from system config: ${TAT_CONFIG.WORK_START_HOUR}:00 - ${TAT_CONFIG.WORK_END_HOUR}:00`);
}
}
@ -174,7 +168,6 @@ export async function addWorkingHours(start: Date | string, hoursToAdd: number):
// If start time was outside working hours, reset to clean work start time (no minutes)
if (wasOutsideWorkingHours) {
current = current.minute(0).second(0).millisecond(0);
console.log(`[TAT Utils] Start time ${originalStart} was outside working hours, advanced to ${current.format('YYYY-MM-DD HH:mm:ss')}`);
}
// Split into whole hours and fractional part
@ -244,13 +237,9 @@ export async function addWorkingHoursExpress(start: Date | string, hoursToAdd: n
const originalStart = current.format('YYYY-MM-DD HH:mm:ss');
const currentHour = current.hour();
if (currentHour < config.startHour) {
// Before working hours - reset to clean work start
current = current.hour(config.startHour).minute(0).second(0).millisecond(0);
console.log(`[TAT Utils Express] Start time ${originalStart} was before working hours, advanced to ${current.format('YYYY-MM-DD HH:mm:ss')}`);
} else if (currentHour >= config.endHour) {
// After working hours - reset to clean start of next day
current = current.add(1, 'day').hour(config.startHour).minute(0).second(0).millisecond(0);
console.log(`[TAT Utils Express] Start time ${originalStart} was after working hours, advanced to ${current.format('YYYY-MM-DD HH:mm:ss')}`);
}
// Split into whole hours and fractional part
@ -381,7 +370,6 @@ export async function initializeHolidaysCache(): Promise<void> {
export async function clearWorkingHoursCache(): Promise<void> {
workingHoursCache = null;
workingHoursCacheExpiry = null;
console.log('[TAT Utils] Working hours cache cleared - reloading from database...');
// Immediately reload the cache with new values
await loadWorkingHoursCache();
@ -607,14 +595,7 @@ export async function calculateElapsedWorkingHours(
}
}
// Log if we advanced the start time for elapsed calculation
if (start.format('YYYY-MM-DD HH:mm:ss') !== originalStart) {
console.log(`[TAT Utils] Elapsed time calculation: Start ${originalStart} was outside working hours, advanced to ${start.format('YYYY-MM-DD HH:mm:ss')}`);
}
// If end time is before adjusted start time, return 0 (TAT hasn't started yet)
if (end.isBefore(start)) {
console.log(`[TAT Utils] Current time is before TAT start time - elapsed hours: 0`);
return 0;
}
@ -682,18 +663,6 @@ export async function calculateElapsedWorkingHours(
const hours = totalWorkingMinutes / 60;
// Warning log for unusually high values
if (hours > 16) { // More than 2 working days
console.warn('[TAT] High elapsed hours detected:', {
startDate: start.format('YYYY-MM-DD HH:mm'),
endDate: end.format('YYYY-MM-DD HH:mm'),
priority,
elapsedHours: hours,
workingHoursConfig: config,
calendarHours: end.diff(start, 'hour')
});
}
return hours;
}