fixed bugs
This commit is contained in:
parent
a6bafa8764
commit
134b7d547d
239
USER_NOTIFICATION_PREFERENCES.md
Normal file
239
USER_NOTIFICATION_PREFERENCES.md
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
# User Notification Preferences
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Individual users can now control their notification preferences across three channels: **Email**, **Push**, and **In-App** notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### ✅ Backend
|
||||||
|
|
||||||
|
1. **Database Schema**
|
||||||
|
- Added three boolean fields to `users` table:
|
||||||
|
- `email_notifications_enabled` (default: true)
|
||||||
|
- `push_notifications_enabled` (default: true)
|
||||||
|
- `in_app_notifications_enabled` (default: true)
|
||||||
|
- Migration file: `20251203-add-user-notification-preferences.ts`
|
||||||
|
|
||||||
|
2. **User Model Updates**
|
||||||
|
- `Re_Backend/src/models/User.ts`
|
||||||
|
- Added fields: `emailNotificationsEnabled`, `pushNotificationsEnabled`, `inAppNotificationsEnabled`
|
||||||
|
|
||||||
|
3. **API Endpoints**
|
||||||
|
- **GET** `/api/v1/user/preferences/notifications` - Get current user's preferences
|
||||||
|
- **PUT** `/api/v1/user/preferences/notifications` - Update preferences
|
||||||
|
- Controller: `Re_Backend/src/controllers/userPreference.controller.ts`
|
||||||
|
- Routes: `Re_Backend/src/routes/userPreference.routes.ts`
|
||||||
|
- Validator: `Re_Backend/src/validators/userPreference.validator.ts`
|
||||||
|
|
||||||
|
4. **Notification Service Enhancement**
|
||||||
|
- `Re_Backend/src/services/notification.service.ts`
|
||||||
|
- Now checks user preferences before sending notifications
|
||||||
|
- Respects individual channel settings (email, push, in-app)
|
||||||
|
|
||||||
|
### ✅ Frontend
|
||||||
|
|
||||||
|
1. **API Service**
|
||||||
|
- `Re_Figma_Code/src/services/userPreferenceApi.ts`
|
||||||
|
- Functions: `getNotificationPreferences()`, `updateNotificationPreferences()`
|
||||||
|
|
||||||
|
2. **UI Component**
|
||||||
|
- `Re_Figma_Code/src/components/settings/NotificationPreferences.tsx`
|
||||||
|
- Beautiful card-based UI with toggle switches
|
||||||
|
- Real-time updates with loading states
|
||||||
|
- Success/error feedback
|
||||||
|
|
||||||
|
3. **Settings Page Integration**
|
||||||
|
- `Re_Figma_Code/src/pages/Settings/Settings.tsx`
|
||||||
|
- Full-width notification preferences card
|
||||||
|
- Separate browser push registration button
|
||||||
|
- Available for both admin and regular users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
|
||||||
|
1. **Navigate to Settings**
|
||||||
|
- User clicks on Settings in the navigation
|
||||||
|
|
||||||
|
2. **View Notification Preferences**
|
||||||
|
- Top card shows three toggle switches:
|
||||||
|
- 📧 **Email Notifications** - Receive notifications via email
|
||||||
|
- 🔔 **Push Notifications** - Receive browser push notifications
|
||||||
|
- 💬 **In-App Notifications** - Show notifications within the application
|
||||||
|
|
||||||
|
3. **Toggle Preferences**
|
||||||
|
- Click any switch to enable/disable that channel
|
||||||
|
- Changes are saved immediately
|
||||||
|
- Success message confirms the update
|
||||||
|
|
||||||
|
4. **Register Browser for Push** (separate card)
|
||||||
|
- One-time setup per browser/device
|
||||||
|
- Requests browser permission
|
||||||
|
- Registers the browser endpoint for push notifications
|
||||||
|
|
||||||
|
### System Behavior
|
||||||
|
|
||||||
|
**When sending notifications:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// System checks user preferences
|
||||||
|
if (user.inAppNotificationsEnabled) {
|
||||||
|
// Create in-app notification in database
|
||||||
|
// Emit socket event for real-time delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.pushNotificationsEnabled) {
|
||||||
|
// Send browser push notification (if browser is registered)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailNotificationsEnabled) {
|
||||||
|
// Send email notification (when implemented)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Users have full control over notification channels
|
||||||
|
- ✅ Reduces notification fatigue
|
||||||
|
- ✅ Improves user experience
|
||||||
|
- ✅ Respects user preferences while ensuring critical alerts are delivered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Get Notification Preferences
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
GET /api/v1/user/preferences/notifications
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"emailNotificationsEnabled": true,
|
||||||
|
"pushNotificationsEnabled": true,
|
||||||
|
"inAppNotificationsEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Notification Preferences
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
PUT /api/v1/user/preferences/notifications
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"emailNotificationsEnabled": false,
|
||||||
|
"pushNotificationsEnabled": true,
|
||||||
|
"inAppNotificationsEnabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Notification preferences updated successfully",
|
||||||
|
"data": {
|
||||||
|
"emailNotificationsEnabled": false,
|
||||||
|
"pushNotificationsEnabled": true,
|
||||||
|
"inAppNotificationsEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
To apply the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Re_Backend
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
This will add the three notification preference columns to the `users` table with default value `true` for all existing users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Configuration vs User Preferences
|
||||||
|
|
||||||
|
### Two Levels of Control:
|
||||||
|
|
||||||
|
1. **System-Wide (Admin Only)**
|
||||||
|
- Settings → Configuration → Notification Rules
|
||||||
|
- `ENABLE_EMAIL_NOTIFICATIONS` - Master switch for email
|
||||||
|
- `ENABLE_PUSH_NOTIFICATIONS` - Master switch for push
|
||||||
|
- If admin disables a channel, it's disabled for ALL users
|
||||||
|
|
||||||
|
2. **User-Level (Individual Users)**
|
||||||
|
- Settings → User Settings → Notification Preferences
|
||||||
|
- Users can disable channels for themselves
|
||||||
|
- User preferences are respected only if admin has enabled the channel
|
||||||
|
|
||||||
|
### Logic:
|
||||||
|
```
|
||||||
|
Notification Sent = Admin Enabled AND User Enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Email notification implementation (currently just a preference toggle)
|
||||||
|
- [ ] SMS notifications support
|
||||||
|
- [ ] Granular notification types (e.g., only approval requests, only TAT alerts)
|
||||||
|
- [ ] Quiet hours / Do Not Disturb schedules
|
||||||
|
- [ ] Notification digest/batching preferences
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] User can view their notification preferences
|
||||||
|
- [x] User can toggle email notifications on/off
|
||||||
|
- [x] User can toggle push notifications on/off
|
||||||
|
- [x] User can toggle in-app notifications on/off
|
||||||
|
- [x] Notification service respects user preferences
|
||||||
|
- [x] In-app notifications are not created if disabled
|
||||||
|
- [x] Push notifications are not sent if disabled
|
||||||
|
- [x] UI shows loading states during updates
|
||||||
|
- [x] UI shows success/error messages
|
||||||
|
- [x] Migration adds columns with correct defaults
|
||||||
|
- [x] API endpoints require authentication
|
||||||
|
- [x] Changes persist after logout/login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ✅ `src/models/User.ts` - Added notification preference fields
|
||||||
|
- ✅ `src/migrations/20251203-add-user-notification-preferences.ts` - Migration
|
||||||
|
- ✅ `src/controllers/userPreference.controller.ts` - New controller
|
||||||
|
- ✅ `src/routes/userPreference.routes.ts` - New routes
|
||||||
|
- ✅ `src/validators/userPreference.validator.ts` - New validator
|
||||||
|
- ✅ `src/routes/index.ts` - Registered new routes
|
||||||
|
- ✅ `src/services/notification.service.ts` - Updated to respect preferences
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- ✅ `src/services/userPreferenceApi.ts` - New API service
|
||||||
|
- ✅ `src/components/settings/NotificationPreferences.tsx` - New component
|
||||||
|
- ✅ `src/pages/Settings/Settings.tsx` - Integrated new component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Complete! 🎉**
|
||||||
|
|
||||||
@ -1,2 +1,2 @@
|
|||||||
import{a as t}from"./index-BJNKp6s1.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-biwEPLZp.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-1fSSvDCY.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
|
import{a as t}from"./index-CPLcj4mB.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-biwEPLZp.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-1fSSvDCY.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
|
||||||
//# sourceMappingURL=conclusionApi-CLFPR2m0.js.map
|
//# sourceMappingURL=conclusionApi-BgpqHPwu.js.map
|
||||||
@ -1 +1 @@
|
|||||||
{"version":3,"file":"conclusionApi-CLFPR2m0.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
|
{"version":3,"file":"conclusionApi-BgpqHPwu.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/assets/index-BP7LmC3Z.css
Normal file
1
build/assets/index-BP7LmC3Z.css
Normal file
File diff suppressed because one or more lines are too long
64
build/assets/index-CPLcj4mB.js
Normal file
64
build/assets/index-CPLcj4mB.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-CPLcj4mB.js.map
Normal file
1
build/assets/index-CPLcj4mB.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,20 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
<meta name="theme-color" content="#2d4a3e" />
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<!-- Preload critical fonts and icons -->
|
<!-- Preload critical fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|
||||||
<!-- Ensure proper icon rendering and layout -->
|
<!-- Ensure proper icon rendering and layout -->
|
||||||
<style>
|
<style>
|
||||||
/* Ensure Lucide icons render properly */
|
/* Ensure Lucide icons render properly */
|
||||||
svg {
|
svg {
|
||||||
@ -51,8 +51,8 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-BJNKp6s1.js"></script>
|
<script type="module" crossorigin src="/assets/index-CPLcj4mB.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-C2EbRL2a.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-C2EbRL2a.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
||||||
@ -60,10 +60,10 @@
|
|||||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-1fSSvDCY.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-vendor-1fSSvDCY.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DD2tGQ-m.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BP7LmC3Z.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -219,8 +219,10 @@ export class DashboardController {
|
|||||||
const endDate = req.query.endDate as string | undefined;
|
const endDate = req.query.endDate as string | undefined;
|
||||||
const page = Number(req.query.page || 1);
|
const page = Number(req.query.page || 1);
|
||||||
const limit = Number(req.query.limit || 10);
|
const limit = Number(req.query.limit || 10);
|
||||||
|
const priority = req.query.priority as string | undefined;
|
||||||
|
const slaCompliance = req.query.slaCompliance as string | undefined;
|
||||||
|
|
||||||
const result = await this.dashboardService.getApproverPerformance(userId, dateRange, page, limit, startDate, endDate);
|
const result = await this.dashboardService.getApproverPerformance(userId, dateRange, page, limit, startDate, endDate, priority, slaCompliance);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -540,6 +542,50 @@ export class DashboardController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single approver stats only (separate API for performance)
|
||||||
|
*/
|
||||||
|
async getSingleApproverStats(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).user?.userId;
|
||||||
|
const approverId = req.query.approverId as string;
|
||||||
|
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 priority = req.query.priority as string | undefined;
|
||||||
|
const slaCompliance = req.query.slaCompliance as string | undefined;
|
||||||
|
|
||||||
|
if (!approverId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Approver ID is required'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await this.dashboardService.getSingleApproverStats(
|
||||||
|
userId,
|
||||||
|
approverId,
|
||||||
|
dateRange,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
priority,
|
||||||
|
slaCompliance
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Dashboard] Error fetching single approver stats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch approver stats'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get requests filtered by approver ID for detailed performance analysis
|
* Get requests filtered by approver ID for detailed performance analysis
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
|||||||
import { Notification } from '@models/Notification';
|
import { Notification } from '@models/Notification';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
import { notificationService } from '@services/notification.service';
|
||||||
|
|
||||||
export class NotificationController {
|
export class NotificationController {
|
||||||
/**
|
/**
|
||||||
@ -172,5 +173,32 @@ export class NotificationController {
|
|||||||
res.status(500).json({ success: false, message: error.message });
|
res.status(500).json({ success: false, message: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's push notification subscriptions
|
||||||
|
*/
|
||||||
|
async getUserSubscriptions(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await notificationService.getUserSubscriptions(userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
subscriptions,
|
||||||
|
count: subscriptions.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[Notification Controller] Error fetching subscriptions:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
src/controllers/userPreference.controller.ts
Normal file
113
src/controllers/userPreference.controller.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { User } from '@models/User';
|
||||||
|
import { updateNotificationPreferencesSchema } from '@validators/userPreference.validator';
|
||||||
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's notification preferences
|
||||||
|
*/
|
||||||
|
export const getNotificationPreferences = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId, {
|
||||||
|
attributes: [
|
||||||
|
'userId',
|
||||||
|
'emailNotificationsEnabled',
|
||||||
|
'pushNotificationsEnabled',
|
||||||
|
'inAppNotificationsEnabled'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[UserPreference] Retrieved notification preferences for user ${userId}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
emailNotificationsEnabled: user.emailNotificationsEnabled,
|
||||||
|
pushNotificationsEnabled: user.pushNotificationsEnabled,
|
||||||
|
inAppNotificationsEnabled: user.inAppNotificationsEnabled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[UserPreference] Failed to get notification preferences:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to retrieve notification preferences',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current user's notification preferences
|
||||||
|
*/
|
||||||
|
export const updateNotificationPreferences = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const validated = updateNotificationPreferencesSchema.parse(req.body);
|
||||||
|
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only provided fields
|
||||||
|
const updateData: any = {};
|
||||||
|
if (validated.emailNotificationsEnabled !== undefined) {
|
||||||
|
updateData.emailNotificationsEnabled = validated.emailNotificationsEnabled;
|
||||||
|
}
|
||||||
|
if (validated.pushNotificationsEnabled !== undefined) {
|
||||||
|
updateData.pushNotificationsEnabled = validated.pushNotificationsEnabled;
|
||||||
|
}
|
||||||
|
if (validated.inAppNotificationsEnabled !== undefined) {
|
||||||
|
updateData.inAppNotificationsEnabled = validated.inAppNotificationsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.update(updateData);
|
||||||
|
|
||||||
|
logger.info(`[UserPreference] Updated notification preferences for user ${userId}:`, updateData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Notification preferences updated successfully',
|
||||||
|
data: {
|
||||||
|
emailNotificationsEnabled: user.emailNotificationsEnabled,
|
||||||
|
pushNotificationsEnabled: user.pushNotificationsEnabled,
|
||||||
|
inAppNotificationsEnabled: user.inAppNotificationsEnabled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
error: error.errors.map((e: any) => e.message).join(', ')
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('[UserPreference] Failed to update notification preferences:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to update notification preferences',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
53
src/migrations/20251203-add-user-notification-preferences.ts
Normal file
53
src/migrations/20251203-add-user-notification-preferences.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { QueryInterface, DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Add notification preference columns to users table
|
||||||
|
await queryInterface.addColumn('users', 'email_notifications_enabled', {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
comment: 'User preference for receiving email notifications'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('users', 'push_notifications_enabled', {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
comment: 'User preference for receiving push notifications'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('users', 'in_app_notifications_enabled', {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
comment: 'User preference for receiving in-app notifications'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add indexes for faster queries
|
||||||
|
await queryInterface.addIndex('users', ['email_notifications_enabled'], {
|
||||||
|
name: 'idx_users_email_notifications_enabled'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('users', ['push_notifications_enabled'], {
|
||||||
|
name: 'idx_users_push_notifications_enabled'
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('users', ['in_app_notifications_enabled'], {
|
||||||
|
name: 'idx_users_in_app_notifications_enabled'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface: QueryInterface): Promise<void> {
|
||||||
|
// Remove indexes first
|
||||||
|
await queryInterface.removeIndex('users', 'idx_users_in_app_notifications_enabled');
|
||||||
|
await queryInterface.removeIndex('users', 'idx_users_push_notifications_enabled');
|
||||||
|
await queryInterface.removeIndex('users', 'idx_users_email_notifications_enabled');
|
||||||
|
|
||||||
|
// Remove columns
|
||||||
|
await queryInterface.removeColumn('users', 'in_app_notifications_enabled');
|
||||||
|
await queryInterface.removeColumn('users', 'push_notifications_enabled');
|
||||||
|
await queryInterface.removeColumn('users', 'email_notifications_enabled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@ -39,6 +39,12 @@ interface UserAttributes {
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notification Preferences
|
||||||
|
emailNotificationsEnabled: boolean;
|
||||||
|
pushNotificationsEnabled: boolean;
|
||||||
|
inAppNotificationsEnabled: boolean;
|
||||||
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -46,7 +52,7 @@ interface UserAttributes {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
|
interface UserCreationAttributes extends Optional<UserAttributes, 'userId' | 'employeeId' | 'department' | 'designation' | 'phone' | 'manager' | 'secondEmail' | 'jobTitle' | 'employeeNumber' | 'postalAddress' | 'mobilePhone' | 'adGroups' | 'emailNotificationsEnabled' | 'pushNotificationsEnabled' | 'inAppNotificationsEnabled' | 'role' | 'lastLogin' | 'createdAt' | 'updatedAt'> {}
|
||||||
|
|
||||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||||
public userId!: string;
|
public userId!: string;
|
||||||
@ -77,6 +83,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
|||||||
office?: string;
|
office?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notification Preferences
|
||||||
|
public emailNotificationsEnabled!: boolean;
|
||||||
|
public pushNotificationsEnabled!: boolean;
|
||||||
|
public inAppNotificationsEnabled!: boolean;
|
||||||
|
|
||||||
public isActive!: boolean;
|
public isActive!: boolean;
|
||||||
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||||
public lastLogin?: Date;
|
public lastLogin?: Date;
|
||||||
@ -222,6 +234,30 @@ User.init(
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
comment: 'JSON object containing location details (city, state, country, office, timezone)'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Notification Preferences
|
||||||
|
emailNotificationsEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
field: 'email_notifications_enabled',
|
||||||
|
comment: 'User preference for receiving email notifications'
|
||||||
|
},
|
||||||
|
pushNotificationsEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
field: 'push_notifications_enabled',
|
||||||
|
comment: 'User preference for receiving push notifications'
|
||||||
|
},
|
||||||
|
inAppNotificationsEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
field: 'in_app_notifications_enabled',
|
||||||
|
comment: 'User preference for receiving in-app notifications'
|
||||||
|
},
|
||||||
|
|
||||||
isActive: {
|
isActive: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
|||||||
@ -114,6 +114,12 @@ router.get('/metadata/departments',
|
|||||||
asyncHandler(dashboardController.getDepartments.bind(dashboardController))
|
asyncHandler(dashboardController.getDepartments.bind(dashboardController))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get single approver stats only (for performance - separate from requests)
|
||||||
|
router.get('/stats/single-approver',
|
||||||
|
authenticateToken,
|
||||||
|
asyncHandler(dashboardController.getSingleApproverStats.bind(dashboardController))
|
||||||
|
);
|
||||||
|
|
||||||
// Get requests filtered by approver ID (for detailed performance analysis)
|
// Get requests filtered by approver ID (for detailed performance analysis)
|
||||||
router.get('/requests/by-approver',
|
router.get('/requests/by-approver',
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import authRoutes from './auth.routes';
|
|||||||
import workflowRoutes from './workflow.routes';
|
import workflowRoutes from './workflow.routes';
|
||||||
import summaryRoutes from './summary.routes';
|
import summaryRoutes from './summary.routes';
|
||||||
import userRoutes from './user.routes';
|
import userRoutes from './user.routes';
|
||||||
|
import userPreferenceRoutes from './userPreference.routes';
|
||||||
import documentRoutes from './document.routes';
|
import documentRoutes from './document.routes';
|
||||||
import tatRoutes from './tat.routes';
|
import tatRoutes from './tat.routes';
|
||||||
import adminRoutes from './admin.routes';
|
import adminRoutes from './admin.routes';
|
||||||
@ -29,6 +30,7 @@ router.use('/auth', authRoutes);
|
|||||||
router.use('/config', configRoutes); // System configuration (public)
|
router.use('/config', configRoutes); // System configuration (public)
|
||||||
router.use('/workflows', workflowRoutes);
|
router.use('/workflows', workflowRoutes);
|
||||||
router.use('/users', userRoutes);
|
router.use('/users', userRoutes);
|
||||||
|
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
|
||||||
router.use('/documents', documentRoutes);
|
router.use('/documents', documentRoutes);
|
||||||
router.use('/tat', tatRoutes);
|
router.use('/tat', tatRoutes);
|
||||||
router.use('/admin', adminRoutes);
|
router.use('/admin', adminRoutes);
|
||||||
|
|||||||
@ -42,5 +42,11 @@ router.delete('/:notificationId',
|
|||||||
asyncHandler(notificationController.deleteNotification.bind(notificationController))
|
asyncHandler(notificationController.deleteNotification.bind(notificationController))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get user's push subscriptions
|
||||||
|
router.get('/subscriptions',
|
||||||
|
authenticateToken,
|
||||||
|
asyncHandler(notificationController.getUserSubscriptions.bind(notificationController))
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
28
src/routes/userPreference.routes.ts
Normal file
28
src/routes/userPreference.routes.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticateToken } from '@middlewares/auth.middleware';
|
||||||
|
import {
|
||||||
|
getNotificationPreferences,
|
||||||
|
updateNotificationPreferences
|
||||||
|
} from '@controllers/userPreference.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/v1/user/preferences/notifications
|
||||||
|
* @desc Get current user's notification preferences
|
||||||
|
* @access Private (Authenticated users)
|
||||||
|
*/
|
||||||
|
router.get('/notifications', getNotificationPreferences);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/v1/user/preferences/notifications
|
||||||
|
* @desc Update current user's notification preferences
|
||||||
|
* @access Private (Authenticated users)
|
||||||
|
*/
|
||||||
|
router.put('/notifications', updateNotificationPreferences);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@ -118,6 +118,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests');
|
const m25 = require('../migrations/20250126-add-pause-fields-to-workflow-requests');
|
||||||
const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
|
const m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
|
||||||
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
|
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
|
||||||
|
const m28 = require('../migrations/20251203-add-user-notification-preferences');
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ name: '2025103000-create-users', module: m0 },
|
||||||
@ -148,6 +149,7 @@ async function runMigrations(): Promise<void> {
|
|||||||
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
|
{ name: '20250126-add-pause-fields-to-workflow-requests', module: m25 },
|
||||||
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
|
{ name: '20250126-add-pause-fields-to-approval-levels', module: m26 },
|
||||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||||
|
{ name: '20251203-add-user-notification-preferences', module: m28 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const queryInterface = sequelize.getQueryInterface();
|
const queryInterface = sequelize.getQueryInterface();
|
||||||
|
|||||||
@ -379,35 +379,14 @@ class AIService {
|
|||||||
// Use provider's generateText method
|
// Use provider's generateText method
|
||||||
let remarkText = await this.provider.generateText(prompt);
|
let remarkText = await this.provider.generateText(prompt);
|
||||||
|
|
||||||
// Get max length from config for validation
|
// Get max length from config for logging
|
||||||
const { getConfigValue } = require('./configReader.service');
|
const { getConfigValue } = require('./configReader.service');
|
||||||
const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000');
|
const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000');
|
||||||
const maxLength = parseInt(maxLengthStr || '2000', 10);
|
const maxLength = parseInt(maxLengthStr || '2000', 10);
|
||||||
|
|
||||||
// Validate length - AI should already be within limit, but trim as safety net
|
// Log length (no trimming - preserve complete AI-generated content)
|
||||||
if (remarkText.length > maxLength) {
|
if (remarkText.length > maxLength) {
|
||||||
logger.warn(`[AI Service] ⚠️ AI exceeded character limit (${remarkText.length} > ${maxLength}). This should be rare - AI was instructed to prioritize and condense. Applying safety trim...`);
|
logger.warn(`[AI Service] ⚠️ AI exceeded suggested limit (${remarkText.length} > ${maxLength}). Content preserved to avoid incomplete information.`);
|
||||||
|
|
||||||
// Try to find a natural break point (sentence end) near the limit
|
|
||||||
const safeLimit = maxLength - 3;
|
|
||||||
let trimPoint = safeLimit;
|
|
||||||
|
|
||||||
// Look for last sentence end (. ! ?) within the safe limit
|
|
||||||
const lastPeriod = remarkText.lastIndexOf('.', safeLimit);
|
|
||||||
const lastExclaim = remarkText.lastIndexOf('!', safeLimit);
|
|
||||||
const lastQuestion = remarkText.lastIndexOf('?', safeLimit);
|
|
||||||
const bestBreak = Math.max(lastPeriod, lastExclaim, lastQuestion);
|
|
||||||
|
|
||||||
// Use sentence break if it's reasonably close to the limit (within 80% of max)
|
|
||||||
if (bestBreak > maxLength * 0.8) {
|
|
||||||
trimPoint = bestBreak + 1; // Include the punctuation
|
|
||||||
remarkText = remarkText.substring(0, trimPoint).trim();
|
|
||||||
} else {
|
|
||||||
// Fall back to hard trim with ellipsis
|
|
||||||
remarkText = remarkText.substring(0, safeLimit).trim() + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[AI Service] Trimmed to ${remarkText.length} characters`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract key points (look for bullet points or numbered items)
|
// Extract key points (look for bullet points or numbered items)
|
||||||
|
|||||||
@ -282,27 +282,6 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
),
|
),
|
||||||
(
|
|
||||||
gen_random_uuid(),
|
|
||||||
'AI_REMARK_MAX_CHARACTERS',
|
|
||||||
'AI_CONFIGURATION',
|
|
||||||
'500',
|
|
||||||
'NUMBER',
|
|
||||||
'AI Remark Maximum Characters',
|
|
||||||
'Maximum character limit for AI-generated conclusion remarks',
|
|
||||||
'500',
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
'{"min": 100, "max": 2000}'::jsonb,
|
|
||||||
'number',
|
|
||||||
NULL,
|
|
||||||
21,
|
|
||||||
false,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
'AI_PROVIDER',
|
'AI_PROVIDER',
|
||||||
@ -471,6 +450,27 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
gen_random_uuid(),
|
||||||
|
'AI_MAX_REMARK_LENGTH',
|
||||||
|
'AI_CONFIGURATION',
|
||||||
|
'2000',
|
||||||
|
'NUMBER',
|
||||||
|
'AI Max Remark Length',
|
||||||
|
'Maximum character length for AI-generated conclusion remarks',
|
||||||
|
'2000',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
'{"min": 500, "max": 5000}'::jsonb,
|
||||||
|
'number',
|
||||||
|
NULL,
|
||||||
|
30,
|
||||||
|
false,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
-- Notification Rules
|
-- Notification Rules
|
||||||
(
|
(
|
||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
@ -486,7 +486,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
'{}'::jsonb,
|
'{}'::jsonb,
|
||||||
'toggle',
|
'toggle',
|
||||||
NULL,
|
NULL,
|
||||||
30,
|
31,
|
||||||
false,
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
@ -507,7 +507,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
'{}'::jsonb,
|
'{}'::jsonb,
|
||||||
'toggle',
|
'toggle',
|
||||||
NULL,
|
NULL,
|
||||||
31,
|
32,
|
||||||
false,
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
@ -528,7 +528,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
'{"min": 1000, "max": 30000}'::jsonb,
|
'{"min": 1000, "max": 30000}'::jsonb,
|
||||||
'number',
|
'number',
|
||||||
NULL,
|
NULL,
|
||||||
32,
|
33,
|
||||||
false,
|
false,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
|
|||||||
@ -907,8 +907,18 @@ export class DashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Approver Performance metrics with pagination
|
* Get Approver Performance metrics with pagination
|
||||||
|
* Supports priority and SLA filters for stats calculation
|
||||||
*/
|
*/
|
||||||
async getApproverPerformance(userId: string, dateRange?: string, page: number = 1, limit: number = 10, startDate?: string, endDate?: string) {
|
async getApproverPerformance(
|
||||||
|
userId: string,
|
||||||
|
dateRange?: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
priority?: string,
|
||||||
|
slaCompliance?: string
|
||||||
|
) {
|
||||||
const range = this.parseDateRange(dateRange, startDate, endDate);
|
const range = this.parseDateRange(dateRange, startDate, endDate);
|
||||||
|
|
||||||
// Check if user is admin or management (has broader access)
|
// Check if user is admin or management (has broader access)
|
||||||
@ -929,22 +939,48 @@ export class DashboardService {
|
|||||||
// Calculate offset
|
// Calculate offset
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
const replacements: any = { start: range.start, end: range.end };
|
||||||
|
let priorityFilter = '';
|
||||||
|
let slaFilter = '';
|
||||||
|
|
||||||
|
if (priority && priority !== 'all') {
|
||||||
|
priorityFilter = `AND wf.priority = :priority`;
|
||||||
|
replacements.priority = priority.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLA filter logic - will be applied in main query
|
||||||
|
if (slaCompliance && slaCompliance !== 'all') {
|
||||||
|
if (slaCompliance === 'breached') {
|
||||||
|
slaFilter = `AND al.tat_breached = true`;
|
||||||
|
} else if (slaCompliance === 'compliant') {
|
||||||
|
slaFilter = `AND (al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours))`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get total count - only count distinct approvers who have completed approvals
|
// Get total count - only count distinct approvers who have completed approvals
|
||||||
|
// IMPORTANT: WHERE conditions must match the main query to avoid pagination mismatch
|
||||||
const countResult = await sequelize.query(`
|
const countResult = await sequelize.query(`
|
||||||
SELECT COUNT(*) as total
|
SELECT COUNT(*) as total
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT al.approver_id
|
SELECT DISTINCT al.approver_id
|
||||||
FROM approval_levels al
|
FROM approval_levels al
|
||||||
|
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||||
WHERE al.action_date BETWEEN :start AND :end
|
WHERE al.action_date BETWEEN :start AND :end
|
||||||
AND al.status IN ('APPROVED', 'REJECTED')
|
AND al.status IN ('APPROVED', 'REJECTED')
|
||||||
AND al.action_date IS NOT NULL
|
AND al.action_date IS NOT NULL
|
||||||
|
AND al.level_start_time IS NOT NULL
|
||||||
AND al.tat_hours > 0
|
AND al.tat_hours > 0
|
||||||
AND al.approver_id IS NOT NULL
|
AND al.approver_id IS NOT NULL
|
||||||
|
AND al.elapsed_hours IS NOT NULL
|
||||||
|
AND al.elapsed_hours >= 0
|
||||||
|
${priorityFilter}
|
||||||
|
${slaFilter}
|
||||||
GROUP BY al.approver_id
|
GROUP BY al.approver_id
|
||||||
HAVING COUNT(DISTINCT al.level_id) > 0
|
HAVING COUNT(DISTINCT al.level_id) > 0
|
||||||
) AS distinct_approvers
|
) AS distinct_approvers
|
||||||
`, {
|
`, {
|
||||||
replacements: { start: range.start, end: range.end },
|
replacements,
|
||||||
type: QueryTypes.SELECT
|
type: QueryTypes.SELECT
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -962,57 +998,37 @@ export class DashboardService {
|
|||||||
al.approver_name,
|
al.approver_name,
|
||||||
COUNT(DISTINCT al.level_id)::int AS total_approved,
|
COUNT(DISTINCT al.level_id)::int AS total_approved,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN al.elapsed_hours IS NOT NULL
|
WHEN al.status = 'APPROVED'
|
||||||
AND al.elapsed_hours > 0
|
THEN al.level_id
|
||||||
AND al.level_start_time IS NOT NULL
|
END)::int AS approved_count,
|
||||||
AND al.action_date IS NOT NULL
|
COUNT(DISTINCT CASE
|
||||||
AND (
|
WHEN al.status = 'REJECTED'
|
||||||
al.elapsed_hours < al.tat_hours
|
THEN al.level_id
|
||||||
OR (al.elapsed_hours <= al.tat_hours AND (al.tat_breached IS NULL OR al.tat_breached = false))
|
END)::int AS rejected_count,
|
||||||
OR (al.tat_breached IS NOT NULL AND al.tat_breached = false)
|
COUNT(DISTINCT CASE
|
||||||
)
|
WHEN wf.status = 'CLOSED'
|
||||||
|
THEN al.level_id
|
||||||
|
END)::int AS closed_count,
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN al.tat_breached = false
|
||||||
|
OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours)
|
||||||
THEN al.level_id
|
THEN al.level_id
|
||||||
END)::int AS within_tat_count,
|
END)::int AS within_tat_count,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN al.elapsed_hours IS NOT NULL
|
WHEN al.tat_breached = true
|
||||||
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
|
THEN al.level_id
|
||||||
END)::int AS breached_count,
|
END)::int AS breached_count,
|
||||||
ROUND(
|
ROUND(
|
||||||
((COUNT(DISTINCT CASE
|
((COUNT(DISTINCT CASE
|
||||||
WHEN al.elapsed_hours IS NOT NULL
|
WHEN al.tat_breached = false
|
||||||
AND al.elapsed_hours > 0
|
OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours)
|
||||||
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
|
THEN al.level_id
|
||||||
END)::numeric / NULLIF(COUNT(DISTINCT CASE
|
END)::numeric / NULLIF(COUNT(DISTINCT al.level_id), 0)) * 100)::numeric,
|
||||||
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
|
0
|
||||||
)::int AS tat_compliance_percent,
|
)::int AS tat_compliance_percent,
|
||||||
ROUND(AVG(CASE
|
ROUND(AVG(COALESCE(al.elapsed_hours, 0))::numeric, 1) AS avg_response_hours
|
||||||
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
|
FROM approval_levels al
|
||||||
|
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||||
WHERE al.action_date BETWEEN :start AND :end
|
WHERE al.action_date BETWEEN :start AND :end
|
||||||
AND al.status IN ('APPROVED', 'REJECTED')
|
AND al.status IN ('APPROVED', 'REJECTED')
|
||||||
AND al.action_date IS NOT NULL
|
AND al.action_date IS NOT NULL
|
||||||
@ -1020,7 +1036,9 @@ export class DashboardService {
|
|||||||
AND al.tat_hours > 0
|
AND al.tat_hours > 0
|
||||||
AND al.approver_id IS NOT NULL
|
AND al.approver_id IS NOT NULL
|
||||||
AND al.elapsed_hours IS NOT NULL
|
AND al.elapsed_hours IS NOT NULL
|
||||||
AND al.elapsed_hours > 0
|
AND al.elapsed_hours >= 0
|
||||||
|
${priorityFilter}
|
||||||
|
${slaFilter}
|
||||||
GROUP BY al.approver_id, al.approver_name
|
GROUP BY al.approver_id, al.approver_name
|
||||||
HAVING COUNT(DISTINCT al.level_id) > 0
|
HAVING COUNT(DISTINCT al.level_id) > 0
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@ -1029,7 +1047,7 @@ export class DashboardService {
|
|||||||
total_approved DESC -- More approvals as tie-breaker
|
total_approved DESC -- More approvals as tie-breaker
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
`, {
|
`, {
|
||||||
replacements: { start: range.start, end: range.end, limit, offset },
|
replacements: { ...replacements, limit, offset },
|
||||||
type: QueryTypes.SELECT
|
type: QueryTypes.SELECT
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1050,7 +1068,8 @@ export class DashboardService {
|
|||||||
al.level_number,
|
al.level_number,
|
||||||
al.level_start_time,
|
al.level_start_time,
|
||||||
al.tat_hours,
|
al.tat_hours,
|
||||||
wf.priority
|
wf.priority,
|
||||||
|
wf.initiator_id
|
||||||
FROM approval_levels al
|
FROM approval_levels al
|
||||||
JOIN workflow_requests wf ON al.request_id = wf.request_id
|
JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||||
WHERE al.status IN ('PENDING', 'IN_PROGRESS')
|
WHERE al.status IN ('PENDING', 'IN_PROGRESS')
|
||||||
@ -1058,6 +1077,7 @@ export class DashboardService {
|
|||||||
AND wf.is_draft = false
|
AND wf.is_draft = false
|
||||||
AND al.level_start_time IS NOT NULL
|
AND al.level_start_time IS NOT NULL
|
||||||
AND al.tat_hours > 0
|
AND al.tat_hours > 0
|
||||||
|
AND wf.initiator_id != al.approver_id
|
||||||
ORDER BY al.request_id, al.level_number ASC
|
ORDER BY al.request_id, al.level_number ASC
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@ -1155,9 +1175,14 @@ export class DashboardService {
|
|||||||
approverId: a.approver_id,
|
approverId: a.approver_id,
|
||||||
approverName: a.approver_name,
|
approverName: a.approver_name,
|
||||||
totalApproved: a.total_approved,
|
totalApproved: a.total_approved,
|
||||||
|
approvedCount: a.approved_count,
|
||||||
|
rejectedCount: a.rejected_count,
|
||||||
|
closedCount: a.closed_count,
|
||||||
tatCompliancePercent,
|
tatCompliancePercent,
|
||||||
avgResponseHours: parseFloat(a.avg_response_hours || 0),
|
avgResponseHours: parseFloat(a.avg_response_hours || 0),
|
||||||
pendingCount: pendingCountMap.get(a.approver_id) || 0
|
pendingCount: pendingCountMap.get(a.approver_id) || 0,
|
||||||
|
withinTatCount: a.within_tat_count,
|
||||||
|
breachedCount: a.breached_count
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
@ -2281,6 +2306,153 @@ export class DashboardService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single approver stats only (dedicated API for performance)
|
||||||
|
* Only respects date, priority, and SLA filters
|
||||||
|
*/
|
||||||
|
async getSingleApproverStats(
|
||||||
|
userId: string,
|
||||||
|
approverId: string,
|
||||||
|
dateRange?: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
priority?: string,
|
||||||
|
slaCompliance?: string
|
||||||
|
) {
|
||||||
|
const user = await User.findByPk(userId);
|
||||||
|
const isAdmin = user?.hasManagementAccess() || false;
|
||||||
|
|
||||||
|
// Allow users to view their own performance, or admins to view any approver's performance
|
||||||
|
if (!isAdmin && approverId !== userId) {
|
||||||
|
throw new Error('Unauthorized: You can only view your own performance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date range if provided
|
||||||
|
let dateFilter = '';
|
||||||
|
const replacements: any = { approverId };
|
||||||
|
|
||||||
|
logger.info(`[Dashboard] Single approver stats - Received filters:`, {
|
||||||
|
dateRange,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
priority,
|
||||||
|
slaCompliance
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate);
|
||||||
|
dateFilter = `
|
||||||
|
AND (
|
||||||
|
(wf.submission_date IS NOT NULL AND wf.submission_date >= :dateStart AND wf.submission_date <= :dateEnd)
|
||||||
|
OR (al.action_date IS NOT NULL AND al.action_date >= :dateStart AND al.action_date <= :dateEnd)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
replacements.dateStart = dateFilterObj.start;
|
||||||
|
replacements.dateEnd = dateFilterObj.end;
|
||||||
|
logger.info(`[Dashboard] Date filter applied:`, {
|
||||||
|
start: dateFilterObj.start,
|
||||||
|
end: dateFilterObj.end
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info(`[Dashboard] No date filter applied - showing all data`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority filter
|
||||||
|
let priorityFilter = '';
|
||||||
|
if (priority && priority !== 'all') {
|
||||||
|
priorityFilter = `AND wf.priority = :priorityFilter`;
|
||||||
|
replacements.priorityFilter = priority.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLA Compliance filter
|
||||||
|
let slaFilter = '';
|
||||||
|
if (slaCompliance && slaCompliance !== 'all') {
|
||||||
|
if (slaCompliance === 'breached') {
|
||||||
|
slaFilter = `AND al.tat_breached = true`;
|
||||||
|
} else if (slaCompliance === 'compliant') {
|
||||||
|
slaFilter = `AND (al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours))`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate aggregated stats using approval_levels directly
|
||||||
|
// Count ALL approval levels assigned to this approver (like the All Requests pattern)
|
||||||
|
// TAT Compliance includes: completed + pending breached + levels from closed workflows
|
||||||
|
const statsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT al.level_id) as totalApproved,
|
||||||
|
SUM(CASE WHEN al.status = 'APPROVED' THEN 1 ELSE 0 END) as approvedCount,
|
||||||
|
SUM(CASE WHEN al.status = 'REJECTED' THEN 1 ELSE 0 END) as rejectedCount,
|
||||||
|
SUM(CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') THEN 1 ELSE 0 END) as pendingCount,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED')
|
||||||
|
AND (al.tat_breached = false
|
||||||
|
OR (al.tat_breached IS NULL AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours < al.tat_hours))
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END) as withinTatCount,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN ((al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED') AND al.tat_breached = true)
|
||||||
|
OR (al.status IN ('PENDING', 'IN_PROGRESS') AND al.tat_breached = true)
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END) as breachedCount,
|
||||||
|
SUM(CASE
|
||||||
|
WHEN al.status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND al.tat_breached = true
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END) as pendingBreachedCount,
|
||||||
|
AVG(CASE
|
||||||
|
WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED')
|
||||||
|
AND al.elapsed_hours IS NOT NULL
|
||||||
|
AND al.elapsed_hours >= 0
|
||||||
|
THEN al.elapsed_hours
|
||||||
|
ELSE NULL
|
||||||
|
END) as avgResponseHours,
|
||||||
|
SUM(CASE WHEN wf.status = 'CLOSED' THEN 1 ELSE 0 END) as closedCount
|
||||||
|
FROM approval_levels al
|
||||||
|
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||||
|
WHERE al.approver_id = :approverId
|
||||||
|
AND wf.is_draft = false
|
||||||
|
${dateFilter}
|
||||||
|
${priorityFilter}
|
||||||
|
${slaFilter}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [statsResult] = await sequelize.query(statsQuery, {
|
||||||
|
replacements,
|
||||||
|
type: QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = statsResult as any;
|
||||||
|
|
||||||
|
// Database returns lowercase column names
|
||||||
|
// TAT Compliance calculation includes pending breached requests
|
||||||
|
// Total for compliance = completed + pending breached
|
||||||
|
const totalCompleted = (parseInt(stats.approvedcount) || 0) + (parseInt(stats.rejectedcount) || 0);
|
||||||
|
const pendingBreached = parseInt(stats.pendingbreachedcount) || 0;
|
||||||
|
const totalForCompliance = totalCompleted + pendingBreached;
|
||||||
|
const tatCompliancePercent = totalForCompliance > 0
|
||||||
|
? Math.round(((parseInt(stats.withintatcount) || 0) / totalForCompliance) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Get approver name
|
||||||
|
const approver = await User.findByPk(approverId);
|
||||||
|
|
||||||
|
const approverStats = {
|
||||||
|
approverId,
|
||||||
|
approverName: approver ? `${approver.firstName} ${approver.lastName}` : 'Unknown',
|
||||||
|
totalApproved: parseInt(stats.totalapproved) || 0,
|
||||||
|
approvedCount: parseInt(stats.approvedcount) || 0,
|
||||||
|
rejectedCount: parseInt(stats.rejectedcount) || 0,
|
||||||
|
closedCount: parseInt(stats.closedcount) || 0,
|
||||||
|
pendingCount: parseInt(stats.pendingcount) || 0,
|
||||||
|
withinTatCount: parseInt(stats.withintatcount) || 0,
|
||||||
|
breachedCount: parseInt(stats.breachedcount) || 0,
|
||||||
|
tatCompliancePercent,
|
||||||
|
avgResponseHours: parseFloat(stats.avgresponsehours) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return approverStats;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get requests filtered by approver ID with detailed filtering support
|
* Get requests filtered by approver ID with detailed filtering support
|
||||||
*/
|
*/
|
||||||
@ -2331,12 +2503,23 @@ export class DashboardService {
|
|||||||
replacements.dateEnd = dateFilterObj.end;
|
replacements.dateEnd = dateFilterObj.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filter
|
// Status filter - Filter by the approver's action status, not overall workflow status
|
||||||
let statusFilter = '';
|
let statusFilter = '';
|
||||||
if (status && status !== 'all') {
|
if (status && status !== 'all') {
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
statusFilter = `AND wf.status IN ('PENDING', 'IN_PROGRESS')`; // IN_PROGRESS legacy support
|
// Show requests where this approver is the current approver AND their level is pending
|
||||||
|
statusFilter = `AND al.status IN ('PENDING', 'IN_PROGRESS')`;
|
||||||
|
} else if (status === 'approved') {
|
||||||
|
// Show requests this approver has approved (regardless of overall workflow status)
|
||||||
|
statusFilter = `AND al.status = 'APPROVED'`;
|
||||||
|
} else if (status === 'rejected') {
|
||||||
|
// Show requests this approver has rejected
|
||||||
|
statusFilter = `AND al.status = 'REJECTED'`;
|
||||||
|
} else if (status === 'closed') {
|
||||||
|
// Show requests that are fully closed
|
||||||
|
statusFilter = `AND wf.status = 'CLOSED'`;
|
||||||
} else {
|
} else {
|
||||||
|
// For other statuses, filter by workflow status
|
||||||
statusFilter = `AND wf.status = :statusFilter`;
|
statusFilter = `AND wf.status = :statusFilter`;
|
||||||
replacements.statusFilter = status.toUpperCase();
|
replacements.statusFilter = status.toUpperCase();
|
||||||
}
|
}
|
||||||
@ -2400,6 +2583,10 @@ export class DashboardService {
|
|||||||
INNER JOIN approval_levels al ON wf.request_id = al.request_id
|
INNER JOIN approval_levels al ON wf.request_id = al.request_id
|
||||||
WHERE al.approver_id = :approverId
|
WHERE al.approver_id = :approverId
|
||||||
AND wf.is_draft = false
|
AND wf.is_draft = false
|
||||||
|
AND (
|
||||||
|
al.status IN ('APPROVED', 'REJECTED')
|
||||||
|
OR al.level_number <= wf.current_level
|
||||||
|
)
|
||||||
${dateFilter}
|
${dateFilter}
|
||||||
${statusFilter}
|
${statusFilter}
|
||||||
${priorityFilter}
|
${priorityFilter}
|
||||||
@ -2452,6 +2639,10 @@ export class DashboardService {
|
|||||||
LEFT JOIN users u ON wf.initiator_id = u.user_id
|
LEFT JOIN users u ON wf.initiator_id = u.user_id
|
||||||
WHERE al.approver_id = :approverId
|
WHERE al.approver_id = :approverId
|
||||||
AND wf.is_draft = false
|
AND wf.is_draft = false
|
||||||
|
AND (
|
||||||
|
al.status IN ('APPROVED', 'REJECTED')
|
||||||
|
OR al.level_number <= wf.current_level
|
||||||
|
)
|
||||||
${dateFilter}
|
${dateFilter}
|
||||||
${statusFilter}
|
${statusFilter}
|
||||||
${priorityFilter}
|
${priorityFilter}
|
||||||
|
|||||||
@ -57,6 +57,22 @@ class NotificationService {
|
|||||||
logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`);
|
logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all subscriptions for a user
|
||||||
|
*/
|
||||||
|
async getUserSubscriptions(userId: string) {
|
||||||
|
try {
|
||||||
|
const subscriptions = await Subscription.findAll({
|
||||||
|
where: { userId },
|
||||||
|
attributes: ['subscriptionId', 'endpoint', 'userAgent', 'createdAt']
|
||||||
|
});
|
||||||
|
return subscriptions;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Notification] Failed to get subscriptions for user ${userId}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove expired/invalid subscription from database and memory cache
|
* Remove expired/invalid subscription from database and memory cache
|
||||||
*/
|
*/
|
||||||
@ -92,88 +108,123 @@ class NotificationService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification to users - saves to DB and sends via push/socket
|
* Send notification to users - saves to DB and sends via push/socket
|
||||||
|
* Respects user notification preferences
|
||||||
*/
|
*/
|
||||||
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
||||||
const message = JSON.stringify(payload);
|
const message = JSON.stringify(payload);
|
||||||
const sentVia: string[] = ['IN_APP']; // Always save to DB for in-app display
|
const { User } = require('@models/User');
|
||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
// 1. Save notification to database for in-app display
|
// Fetch user preferences
|
||||||
const notification = await Notification.create({
|
const user = await User.findByPk(userId, {
|
||||||
userId,
|
attributes: [
|
||||||
requestId: payload.requestId,
|
'userId',
|
||||||
notificationType: payload.type || 'general',
|
'emailNotificationsEnabled',
|
||||||
title: payload.title,
|
'pushNotificationsEnabled',
|
||||||
message: payload.body,
|
'inAppNotificationsEnabled'
|
||||||
isRead: false,
|
]
|
||||||
priority: payload.priority || 'MEDIUM',
|
});
|
||||||
actionUrl: payload.url,
|
|
||||||
actionRequired: payload.actionRequired || false,
|
|
||||||
metadata: {
|
|
||||||
requestNumber: payload.requestNumber,
|
|
||||||
...payload.metadata
|
|
||||||
},
|
|
||||||
sentVia,
|
|
||||||
emailSent: false,
|
|
||||||
smsSent: false,
|
|
||||||
pushSent: false
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`);
|
if (!user) {
|
||||||
|
logger.warn(`[Notification] User ${userId} not found, skipping notification`);
|
||||||
// 2. Emit real-time socket event for immediate delivery
|
continue;
|
||||||
try {
|
|
||||||
const { emitToUser } = require('../realtime/socket');
|
|
||||||
if (emitToUser) {
|
|
||||||
emitToUser(userId, 'notification:new', {
|
|
||||||
notification: notification.toJSON(),
|
|
||||||
...payload
|
|
||||||
});
|
|
||||||
logger.info(`[Notification] Emitted socket event to user ${userId}`);
|
|
||||||
}
|
|
||||||
} catch (socketError) {
|
|
||||||
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send push notification (if user has subscriptions)
|
const sentVia: string[] = [];
|
||||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
|
||||||
// Load from DB if memory empty
|
|
||||||
if (subs.length === 0) {
|
|
||||||
try {
|
|
||||||
const rows = await Subscription.findAll({ where: { userId } });
|
|
||||||
subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subs.length > 0) {
|
// 1. Save notification to database for in-app display (if enabled)
|
||||||
for (const sub of subs) {
|
if (user.inAppNotificationsEnabled) {
|
||||||
try {
|
const notification = await Notification.create({
|
||||||
await webpush.sendNotification(sub, message);
|
userId,
|
||||||
await notification.update({ pushSent: true });
|
requestId: payload.requestId,
|
||||||
logNotificationEvent('sent', {
|
notificationType: payload.type || 'general',
|
||||||
userId,
|
title: payload.title,
|
||||||
channel: 'push',
|
message: payload.body,
|
||||||
type: payload.type,
|
isRead: false,
|
||||||
requestId: payload.requestId,
|
priority: payload.priority || 'MEDIUM',
|
||||||
|
actionUrl: payload.url,
|
||||||
|
actionRequired: payload.actionRequired || false,
|
||||||
|
metadata: {
|
||||||
|
requestNumber: payload.requestNumber,
|
||||||
|
...payload.metadata
|
||||||
|
},
|
||||||
|
sentVia: ['IN_APP'],
|
||||||
|
emailSent: false,
|
||||||
|
smsSent: false,
|
||||||
|
pushSent: false
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
sentVia.push('IN_APP');
|
||||||
|
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`);
|
||||||
|
|
||||||
|
// 2. Emit real-time socket event for immediate delivery
|
||||||
|
try {
|
||||||
|
const { emitToUser } = require('../realtime/socket');
|
||||||
|
if (emitToUser) {
|
||||||
|
emitToUser(userId, 'notification:new', {
|
||||||
|
notification: notification.toJSON(),
|
||||||
|
...payload
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
logger.info(`[Notification] Emitted socket event to user ${userId}`);
|
||||||
// Check if subscription is expired/invalid
|
}
|
||||||
if (this.isExpiredSubscriptionError(err)) {
|
} catch (socketError) {
|
||||||
logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`);
|
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
|
||||||
await this.removeExpiredSubscription(userId, sub.endpoint);
|
}
|
||||||
} else {
|
|
||||||
logNotificationEvent('failed', {
|
// 3. Send push notification (if enabled and user has subscriptions)
|
||||||
userId,
|
if (user.pushNotificationsEnabled) {
|
||||||
channel: 'push',
|
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||||
type: payload.type,
|
// Load from DB if memory empty
|
||||||
requestId: payload.requestId,
|
if (subs.length === 0) {
|
||||||
error: err,
|
try {
|
||||||
});
|
const rows = await Subscription.findAll({ where: { userId } });
|
||||||
|
subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length > 0) {
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(sub, message);
|
||||||
|
await notification.update({ pushSent: true });
|
||||||
|
sentVia.push('PUSH');
|
||||||
|
logNotificationEvent('sent', {
|
||||||
|
userId,
|
||||||
|
channel: 'push',
|
||||||
|
type: payload.type,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
// Check if subscription is expired/invalid
|
||||||
|
if (this.isExpiredSubscriptionError(err)) {
|
||||||
|
logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`);
|
||||||
|
await this.removeExpiredSubscription(userId, sub.endpoint);
|
||||||
|
} else {
|
||||||
|
logNotificationEvent('failed', {
|
||||||
|
userId,
|
||||||
|
channel: 'push',
|
||||||
|
type: payload.type,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`[Notification] In-app notifications disabled for user ${userId}, skipping notification`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Email notifications (when implemented)
|
||||||
|
// if (user.emailNotificationsEnabled) {
|
||||||
|
// // Send email notification
|
||||||
|
// sentVia.push('EMAIL');
|
||||||
|
// }
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[Notification] Failed to create notification for user ${userId}:`, error);
|
logger.error(`[Notification] Failed to create notification for user ${userId}:`, error);
|
||||||
// Continue to next user even if one fails
|
// Continue to next user even if one fails
|
||||||
|
|||||||
13
src/validators/userPreference.validator.ts
Normal file
13
src/validators/userPreference.validator.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const updateNotificationPreferencesSchema = z.object({
|
||||||
|
emailNotificationsEnabled: z.boolean().optional(),
|
||||||
|
pushNotificationsEnabled: z.boolean().optional(),
|
||||||
|
inAppNotificationsEnabled: z.boolean().optional()
|
||||||
|
}).refine(
|
||||||
|
(data) => Object.keys(data).length > 0,
|
||||||
|
{ message: 'At least one notification preference must be provided' }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UpdateNotificationPreferencesRequest = z.infer<typeof updateNotificationPreferencesSchema>;
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user