fixed bugs

This commit is contained in:
laxmanhalaki 2025-12-03 19:59:44 +05:30
parent a6bafa8764
commit 134b7d547d
25 changed files with 1053 additions and 260 deletions

View 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! 🎉**

View File

@ -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

View File

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,7 +52,7 @@
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,7 +60,7 @@
<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>

View File

@ -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
*/ */

View File

@ -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 });
}
}
} }

View 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
});
}
};

View 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');
}
};

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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;

View 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;

View File

@ -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();

View File

@ -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)

View File

@ -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,

View File

@ -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}

View File

@ -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,14 +108,33 @@ 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 user = await User.findByPk(userId, {
attributes: [
'userId',
'emailNotificationsEnabled',
'pushNotificationsEnabled',
'inAppNotificationsEnabled'
]
});
if (!user) {
logger.warn(`[Notification] User ${userId} not found, skipping notification`);
continue;
}
const sentVia: string[] = [];
// 1. Save notification to database for in-app display (if enabled)
if (user.inAppNotificationsEnabled) {
const notification = await Notification.create({ const notification = await Notification.create({
userId, userId,
requestId: payload.requestId, requestId: payload.requestId,
@ -114,12 +149,13 @@ class NotificationService {
requestNumber: payload.requestNumber, requestNumber: payload.requestNumber,
...payload.metadata ...payload.metadata
}, },
sentVia, sentVia: ['IN_APP'],
emailSent: false, emailSent: false,
smsSent: false, smsSent: false,
pushSent: false pushSent: false
} as any); } as any);
sentVia.push('IN_APP');
logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`); logger.info(`[Notification] Created in-app notification for user ${userId}: ${payload.title}`);
// 2. Emit real-time socket event for immediate delivery // 2. Emit real-time socket event for immediate delivery
@ -136,7 +172,8 @@ class NotificationService {
logger.warn(`[Notification] Socket emit failed (not critical):`, socketError); logger.warn(`[Notification] Socket emit failed (not critical):`, socketError);
} }
// 3. Send push notification (if user has subscriptions) // 3. Send push notification (if enabled and user has subscriptions)
if (user.pushNotificationsEnabled) {
let subs = this.userIdToSubscriptions.get(userId) || []; let subs = this.userIdToSubscriptions.get(userId) || [];
// Load from DB if memory empty // Load from DB if memory empty
if (subs.length === 0) { if (subs.length === 0) {
@ -151,6 +188,7 @@ class NotificationService {
try { try {
await webpush.sendNotification(sub, message); await webpush.sendNotification(sub, message);
await notification.update({ pushSent: true }); await notification.update({ pushSent: true });
sentVia.push('PUSH');
logNotificationEvent('sent', { logNotificationEvent('sent', {
userId, userId,
channel: 'push', channel: 'push',
@ -174,6 +212,19 @@ class NotificationService {
} }
} }
} }
} 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

View 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>;