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};
|
||||
//# sourceMappingURL=conclusionApi-CLFPR2m0.js.map
|
||||
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-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
@ -52,7 +52,7 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</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/radix-vendor-C2EbRL2a.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
||||
@ -60,7 +60,7 @@
|
||||
<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/router-vendor-1fSSvDCY.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DD2tGQ-m.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BP7LmC3Z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -219,8 +219,10 @@ export class DashboardController {
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
const page = Number(req.query.page || 1);
|
||||
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({
|
||||
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
|
||||
*/
|
||||
|
||||
@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
||||
import { Notification } from '@models/Notification';
|
||||
import { Op } from 'sequelize';
|
||||
import logger from '@utils/logger';
|
||||
import { notificationService } from '@services/notification.service';
|
||||
|
||||
export class NotificationController {
|
||||
/**
|
||||
@ -172,5 +173,32 @@ export class NotificationController {
|
||||
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;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
// Notification Preferences
|
||||
emailNotificationsEnabled: boolean;
|
||||
pushNotificationsEnabled: boolean;
|
||||
inAppNotificationsEnabled: boolean;
|
||||
|
||||
isActive: boolean;
|
||||
role: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||
lastLogin?: Date;
|
||||
@ -46,7 +52,7 @@ interface UserAttributes {
|
||||
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 {
|
||||
public userId!: string;
|
||||
@ -77,6 +83,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
|
||||
office?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
// Notification Preferences
|
||||
public emailNotificationsEnabled!: boolean;
|
||||
public pushNotificationsEnabled!: boolean;
|
||||
public inAppNotificationsEnabled!: boolean;
|
||||
|
||||
public isActive!: boolean;
|
||||
public role!: UserRole; // RBAC: USER, MANAGEMENT, ADMIN
|
||||
public lastLogin?: Date;
|
||||
@ -222,6 +234,30 @@ User.init(
|
||||
allowNull: true,
|
||||
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: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
|
||||
@ -114,6 +114,12 @@ router.get('/metadata/departments',
|
||||
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)
|
||||
router.get('/requests/by-approver',
|
||||
authenticateToken,
|
||||
|
||||
@ -3,6 +3,7 @@ import authRoutes from './auth.routes';
|
||||
import workflowRoutes from './workflow.routes';
|
||||
import summaryRoutes from './summary.routes';
|
||||
import userRoutes from './user.routes';
|
||||
import userPreferenceRoutes from './userPreference.routes';
|
||||
import documentRoutes from './document.routes';
|
||||
import tatRoutes from './tat.routes';
|
||||
import adminRoutes from './admin.routes';
|
||||
@ -29,6 +30,7 @@ router.use('/auth', authRoutes);
|
||||
router.use('/config', configRoutes); // System configuration (public)
|
||||
router.use('/workflows', workflowRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
|
||||
router.use('/documents', documentRoutes);
|
||||
router.use('/tat', tatRoutes);
|
||||
router.use('/admin', adminRoutes);
|
||||
|
||||
@ -42,5 +42,11 @@ router.delete('/:notificationId',
|
||||
asyncHandler(notificationController.deleteNotification.bind(notificationController))
|
||||
);
|
||||
|
||||
// Get user's push subscriptions
|
||||
router.get('/subscriptions',
|
||||
authenticateToken,
|
||||
asyncHandler(notificationController.getUserSubscriptions.bind(notificationController))
|
||||
);
|
||||
|
||||
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 m26 = require('../migrations/20250126-add-pause-fields-to-approval-levels');
|
||||
const m27 = require('../migrations/20250127-migrate-in-progress-to-pending');
|
||||
const m28 = require('../migrations/20251203-add-user-notification-preferences');
|
||||
|
||||
const migrations = [
|
||||
{ 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-approval-levels', module: m26 },
|
||||
{ name: '20250127-migrate-in-progress-to-pending', module: m27 },
|
||||
{ name: '20251203-add-user-notification-preferences', module: m28 },
|
||||
];
|
||||
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
|
||||
@ -379,35 +379,14 @@ class AIService {
|
||||
// Use provider's generateText method
|
||||
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 maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000');
|
||||
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) {
|
||||
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...`);
|
||||
|
||||
// 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`);
|
||||
logger.warn(`[AI Service] ⚠️ AI exceeded suggested limit (${remarkText.length} > ${maxLength}). Content preserved to avoid incomplete information.`);
|
||||
}
|
||||
|
||||
// Extract key points (look for bullet points or numbered items)
|
||||
|
||||
@ -282,27 +282,6 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
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(),
|
||||
'AI_PROVIDER',
|
||||
@ -471,6 +450,27 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
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
|
||||
(
|
||||
gen_random_uuid(),
|
||||
@ -486,7 +486,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
'{}'::jsonb,
|
||||
'toggle',
|
||||
NULL,
|
||||
30,
|
||||
31,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
@ -507,7 +507,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
'{}'::jsonb,
|
||||
'toggle',
|
||||
NULL,
|
||||
31,
|
||||
32,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
@ -528,7 +528,7 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
||||
'{"min": 1000, "max": 30000}'::jsonb,
|
||||
'number',
|
||||
NULL,
|
||||
32,
|
||||
33,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
|
||||
@ -907,8 +907,18 @@ export class DashboardService {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Check if user is admin or management (has broader access)
|
||||
@ -929,22 +939,48 @@ export class DashboardService {
|
||||
// Calculate offset
|
||||
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
|
||||
// IMPORTANT: WHERE conditions must match the main query to avoid pagination mismatch
|
||||
const countResult = await sequelize.query(`
|
||||
SELECT COUNT(*) as total
|
||||
FROM (
|
||||
SELECT DISTINCT al.approver_id
|
||||
FROM approval_levels al
|
||||
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||
WHERE al.action_date BETWEEN :start AND :end
|
||||
AND al.status IN ('APPROVED', 'REJECTED')
|
||||
AND al.action_date IS NOT NULL
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.tat_hours > 0
|
||||
AND al.approver_id IS NOT NULL
|
||||
AND al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours >= 0
|
||||
${priorityFilter}
|
||||
${slaFilter}
|
||||
GROUP BY al.approver_id
|
||||
HAVING COUNT(DISTINCT al.level_id) > 0
|
||||
) AS distinct_approvers
|
||||
`, {
|
||||
replacements: { start: range.start, end: range.end },
|
||||
replacements,
|
||||
type: QueryTypes.SELECT
|
||||
});
|
||||
|
||||
@ -962,57 +998,37 @@ export class DashboardService {
|
||||
al.approver_name,
|
||||
COUNT(DISTINCT al.level_id)::int AS total_approved,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours > 0
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.action_date IS NOT NULL
|
||||
AND (
|
||||
al.elapsed_hours < al.tat_hours
|
||||
OR (al.elapsed_hours <= al.tat_hours AND (al.tat_breached IS NULL OR al.tat_breached = false))
|
||||
OR (al.tat_breached IS NOT NULL AND al.tat_breached = false)
|
||||
)
|
||||
WHEN al.status = 'APPROVED'
|
||||
THEN al.level_id
|
||||
END)::int AS approved_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN al.status = 'REJECTED'
|
||||
THEN al.level_id
|
||||
END)::int AS rejected_count,
|
||||
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
|
||||
END)::int AS within_tat_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours > 0
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.action_date IS NOT NULL
|
||||
AND (
|
||||
al.elapsed_hours > al.tat_hours
|
||||
OR (al.tat_breached IS NOT NULL AND al.tat_breached = true)
|
||||
)
|
||||
WHEN al.tat_breached = true
|
||||
THEN al.level_id
|
||||
END)::int AS breached_count,
|
||||
ROUND(
|
||||
((COUNT(DISTINCT CASE
|
||||
WHEN al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours > 0
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.action_date IS NOT NULL
|
||||
AND (
|
||||
al.elapsed_hours < al.tat_hours
|
||||
OR (al.elapsed_hours <= al.tat_hours AND (al.tat_breached IS NULL OR al.tat_breached = false))
|
||||
OR (al.tat_breached IS NOT NULL AND al.tat_breached = false)
|
||||
)
|
||||
WHEN al.tat_breached = false
|
||||
OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours)
|
||||
THEN al.level_id
|
||||
END)::numeric / NULLIF(COUNT(DISTINCT CASE
|
||||
WHEN al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours > 0
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.action_date IS NOT NULL
|
||||
THEN al.level_id
|
||||
END), 0)) * 100)::numeric,
|
||||
END)::numeric / NULLIF(COUNT(DISTINCT al.level_id), 0)) * 100)::numeric,
|
||||
0
|
||||
)::int AS tat_compliance_percent,
|
||||
ROUND(AVG(CASE
|
||||
WHEN al.elapsed_hours IS NOT NULL
|
||||
AND al.elapsed_hours > 0
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.action_date IS NOT NULL
|
||||
THEN al.elapsed_hours
|
||||
END)::numeric, 1) AS avg_response_hours
|
||||
ROUND(AVG(COALESCE(al.elapsed_hours, 0))::numeric, 1) AS avg_response_hours
|
||||
FROM approval_levels al
|
||||
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||
WHERE al.action_date BETWEEN :start AND :end
|
||||
AND al.status IN ('APPROVED', 'REJECTED')
|
||||
AND al.action_date IS NOT NULL
|
||||
@ -1020,7 +1036,9 @@ export class DashboardService {
|
||||
AND al.tat_hours > 0
|
||||
AND al.approver_id 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
|
||||
HAVING COUNT(DISTINCT al.level_id) > 0
|
||||
ORDER BY
|
||||
@ -1029,7 +1047,7 @@ export class DashboardService {
|
||||
total_approved DESC -- More approvals as tie-breaker
|
||||
LIMIT :limit OFFSET :offset
|
||||
`, {
|
||||
replacements: { start: range.start, end: range.end, limit, offset },
|
||||
replacements: { ...replacements, limit, offset },
|
||||
type: QueryTypes.SELECT
|
||||
});
|
||||
|
||||
@ -1050,7 +1068,8 @@ export class DashboardService {
|
||||
al.level_number,
|
||||
al.level_start_time,
|
||||
al.tat_hours,
|
||||
wf.priority
|
||||
wf.priority,
|
||||
wf.initiator_id
|
||||
FROM approval_levels al
|
||||
JOIN workflow_requests wf ON al.request_id = wf.request_id
|
||||
WHERE al.status IN ('PENDING', 'IN_PROGRESS')
|
||||
@ -1058,6 +1077,7 @@ export class DashboardService {
|
||||
AND wf.is_draft = false
|
||||
AND al.level_start_time IS NOT NULL
|
||||
AND al.tat_hours > 0
|
||||
AND wf.initiator_id != al.approver_id
|
||||
ORDER BY al.request_id, al.level_number ASC
|
||||
)
|
||||
SELECT
|
||||
@ -1155,9 +1175,14 @@ export class DashboardService {
|
||||
approverId: a.approver_id,
|
||||
approverName: a.approver_name,
|
||||
totalApproved: a.total_approved,
|
||||
approvedCount: a.approved_count,
|
||||
rejectedCount: a.rejected_count,
|
||||
closedCount: a.closed_count,
|
||||
tatCompliancePercent,
|
||||
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,
|
||||
@ -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
|
||||
*/
|
||||
@ -2331,12 +2503,23 @@ export class DashboardService {
|
||||
replacements.dateEnd = dateFilterObj.end;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
// Status filter - Filter by the approver's action status, not overall workflow status
|
||||
let statusFilter = '';
|
||||
if (status && status !== 'all') {
|
||||
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 {
|
||||
// For other statuses, filter by workflow status
|
||||
statusFilter = `AND wf.status = :statusFilter`;
|
||||
replacements.statusFilter = status.toUpperCase();
|
||||
}
|
||||
@ -2400,6 +2583,10 @@ export class DashboardService {
|
||||
INNER JOIN approval_levels al ON wf.request_id = al.request_id
|
||||
WHERE al.approver_id = :approverId
|
||||
AND wf.is_draft = false
|
||||
AND (
|
||||
al.status IN ('APPROVED', 'REJECTED')
|
||||
OR al.level_number <= wf.current_level
|
||||
)
|
||||
${dateFilter}
|
||||
${statusFilter}
|
||||
${priorityFilter}
|
||||
@ -2452,6 +2639,10 @@ export class DashboardService {
|
||||
LEFT JOIN users u ON wf.initiator_id = u.user_id
|
||||
WHERE al.approver_id = :approverId
|
||||
AND wf.is_draft = false
|
||||
AND (
|
||||
al.status IN ('APPROVED', 'REJECTED')
|
||||
OR al.level_number <= wf.current_level
|
||||
)
|
||||
${dateFilter}
|
||||
${statusFilter}
|
||||
${priorityFilter}
|
||||
|
||||
@ -57,6 +57,22 @@ class NotificationService {
|
||||
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
|
||||
*/
|
||||
@ -92,88 +108,123 @@ class NotificationService {
|
||||
|
||||
/**
|
||||
* Send notification to users - saves to DB and sends via push/socket
|
||||
* Respects user notification preferences
|
||||
*/
|
||||
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
||||
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) {
|
||||
try {
|
||||
// 1. Save notification to database for in-app display
|
||||
const notification = await Notification.create({
|
||||
userId,
|
||||
requestId: payload.requestId,
|
||||
notificationType: payload.type || 'general',
|
||||
title: payload.title,
|
||||
message: payload.body,
|
||||
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);
|
||||
// Fetch user preferences
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: [
|
||||
'userId',
|
||||
'emailNotificationsEnabled',
|
||||
'pushNotificationsEnabled',
|
||||
'inAppNotificationsEnabled'
|
||||
]
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
logger.info(`[Notification] Emitted socket event to user ${userId}`);
|
||||
}
|
||||
} catch (socketError) {
|
||||
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
|
||||
if (!user) {
|
||||
logger.warn(`[Notification] User ${userId} not found, skipping notification`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Send push notification (if user has subscriptions)
|
||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||
// Load from DB if memory empty
|
||||
if (subs.length === 0) {
|
||||
const sentVia: string[] = [];
|
||||
|
||||
// 1. Save notification to database for in-app display (if enabled)
|
||||
if (user.inAppNotificationsEnabled) {
|
||||
const notification = await Notification.create({
|
||||
userId,
|
||||
requestId: payload.requestId,
|
||||
notificationType: payload.type || 'general',
|
||||
title: payload.title,
|
||||
message: payload.body,
|
||||
isRead: false,
|
||||
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 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 });
|
||||
logNotificationEvent('sent', {
|
||||
userId,
|
||||
channel: 'push',
|
||||
type: payload.type,
|
||||
requestId: payload.requestId,
|
||||
const { emitToUser } = require('../realtime/socket');
|
||||
if (emitToUser) {
|
||||
emitToUser(userId, 'notification:new', {
|
||||
notification: notification.toJSON(),
|
||||
...payload
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
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 enabled and user has subscriptions)
|
||||
if (user.pushNotificationsEnabled) {
|
||||
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) {
|
||||
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) {
|
||||
logger.error(`[Notification] Failed to create notification for user ${userId}:`, error);
|
||||
// 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