diff --git a/docs/REDIS_SETUP_WINDOWS.md b/docs/REDIS_SETUP_WINDOWS.md index 814403c..4840b40 100644 --- a/docs/REDIS_SETUP_WINDOWS.md +++ b/docs/REDIS_SETUP_WINDOWS.md @@ -1,45 +1,103 @@ # Redis Setup for Windows -## Method 1: Using Memurai (Redis-compatible for Windows) +## ⚠️ IMPORTANT: Redis Version Requirements -Memurai is a Redis-compatible server for Windows. +**BullMQ requires Redis version 5.0.0 or higher.** + +❌ **DO NOT USE**: Microsoft Archive Redis (https://github.com/microsoftarchive/redis/releases) +- This is **outdated** and only provides Redis 3.x +- **Version 3.0.504 is NOT compatible** with BullMQ +- You will get errors: `Redis version needs to be greater or equal than 5.0.0` + +✅ **USE ONE OF THESE METHODS INSTEAD**: + +--- + +## Method 1: Using Memurai (Recommended for Windows) ⭐ + +Memurai is a **Redis-compatible** server built specifically for Windows with full Redis 6.x+ compatibility. + +### Why Memurai? +- ✅ **Native Windows support** - Runs as a Windows service +- ✅ **Redis 6.x+ compatible** - Full feature support +- ✅ **Easy installation** - Just install and run +- ✅ **Free for development** - Free tier available +- ✅ **Production-ready** - Used in enterprise environments + +### Installation Steps: 1. **Download Memurai**: - Visit: https://www.memurai.com/get-memurai - - Download the installer + - Download the **Developer Edition** (free) 2. **Install**: - - Run the installer + - Run the installer (`Memurai-*.exe`) - Choose default options - - It will automatically start as a Windows service + - Memurai will install as a Windows service and start automatically -3. **Verify**: +3. **Verify Installation**: ```powershell # Check if service is running Get-Service Memurai + # Should show: Running - # Or connect with redis-cli + # Test connection memurai-cli ping # Should return: PONG + + # Check version (should be 6.x or 7.x) + memurai-cli --version ``` -4. **Configure** (if needed): - - Default port: 6379 - - Service runs automatically on startup +4. **Configuration**: + - Default port: **6379** + - Connection string: `redis://localhost:6379` + - Service runs automatically on Windows startup + - No additional configuration needed for development -## Method 2: Using Docker Desktop +## Method 2: Using Docker Desktop (Alternative) 🐳 -1. **Install Docker Desktop**: +If you have Docker Desktop installed, this is the easiest method to get Redis 7.x. + +### Installation Steps: + +1. **Install Docker Desktop** (if not already installed): - Download from: https://www.docker.com/products/docker-desktop + - Install and start Docker Desktop 2. **Start Redis Container**: ```powershell - docker run -d --name redis -p 6379:6379 redis:7-alpine + # Run Redis 7.x in a container + docker run -d --name redis-tat -p 6379:6379 redis:7-alpine + + # Or if you want it to restart automatically: + docker run -d --name redis-tat -p 6379:6379 --restart unless-stopped redis:7-alpine ``` 3. **Verify**: ```powershell + # Check if container is running docker ps | Select-String redis + + # Check Redis version + docker exec redis-tat redis-server --version + # Should show: Redis server v=7.x.x + + # Test connection + docker exec redis-tat redis-cli ping + # Should return: PONG + ``` + +4. **Stop/Start Redis**: + ```powershell + # Stop Redis + docker stop redis-tat + + # Start Redis + docker start redis-tat + + # Remove container (if needed) + docker rm -f redis-tat ``` ## Method 3: Using WSL2 (Windows Subsystem for Linux) @@ -76,38 +134,191 @@ Test-NetConnection -ComputerName localhost -Port 6379 ## Troubleshooting +### ❌ Error: "Redis version needs to be greater or equal than 5.0.0 Current: 3.0.504" + +**Problem**: You're using Microsoft Archive Redis (version 3.x) which is **too old** for BullMQ. + +**Solution**: +1. **Stop the old Redis**: + ```powershell + # Find and stop the old Redis process + Get-Process redis-server -ErrorAction SilentlyStop | Stop-Process -Force + ``` + +2. **Uninstall/Remove old Redis** (if installed as service): + ```powershell + # Check if running as service + Get-Service | Where-Object {$_.Name -like "*redis*"} + ``` + +3. **Install one of the recommended methods**: + - **Option A**: Install Memurai (Recommended) - See Method 1 above + - **Option B**: Use Docker - See Method 2 above + - **Option C**: Use WSL2 - See Method 3 above + +4. **Verify new Redis version**: + ```powershell + # For Memurai + memurai-cli --version + # Should show: 6.x or 7.x + + # For Docker + docker exec redis-tat redis-server --version + # Should show: Redis server v=7.x.x + ``` + +5. **Restart your backend server**: + ```powershell + # The TAT worker will now detect the correct Redis version + npm run dev + ``` + ### Port Already in Use ```powershell # Check what's using port 6379 netstat -ano | findstr :6379 -# Kill the process if needed +# Kill the process if needed (replace with actual process ID) taskkill /PID /F + +# Or if using old Redis, stop it: +Get-Process redis-server -ErrorAction SilentlyStop | Stop-Process -Force ``` -### Service Not Starting +### Service Not Starting (Memurai) ```powershell -# For Memurai +# Start Memurai service net start Memurai +# Check service status +Get-Service Memurai + # Check logs Get-EventLog -LogName Application -Source Memurai -Newest 10 + +# Restart service +Restart-Service Memurai +``` + +### Docker Container Not Starting +```powershell +# Check Docker is running +docker ps + +# Check Redis container logs +docker logs redis-tat + +# Restart container +docker restart redis-tat + +# Remove and recreate if needed +docker rm -f redis-tat +docker run -d --name redis-tat -p 6379:6379 redis:7-alpine +``` + +### Cannot Connect to Redis +```powershell +# Test connection +Test-NetConnection -ComputerName localhost -Port 6379 + +# For Memurai +memurai-cli ping + +# For Docker +docker exec redis-tat redis-cli ping ``` ## Configuration -Default Redis/Memurai configuration works out of the box. No changes needed for development. +### Environment Variable -**Connection String**: `redis://localhost:6379` +Add to your `.env` file: +```env +REDIS_URL=redis://localhost:6379 +``` + +### Default Settings +- **Port**: `6379` +- **Host**: `localhost` +- **Connection String**: `redis://localhost:6379` +- No authentication required for local development +- Default configuration works out of the box + +## Verification After Setup + +After installing Redis, verify it's working: + +```powershell +# 1. Check Redis version (must be 5.0+) +# For Memurai: +memurai-cli --version + +# For Docker: +docker exec redis-tat redis-server --version + +# 2. Test connection +# For Memurai: +memurai-cli ping +# Expected: PONG + +# For Docker: +docker exec redis-tat redis-cli ping +# Expected: PONG + +# 3. Check if backend can connect +# Start your backend server and check logs: +npm run dev + +# Look for: +# [TAT Queue] Connected to Redis +# [TAT Worker] Connected to Redis at redis://127.0.0.1:6379 +# [TAT Worker] Redis version: 7.x.x (or 6.x.x) +# [TAT Worker] Worker is ready and listening for jobs +``` + +## Quick Fix: Migrating from Old Redis + +If you already installed Microsoft Archive Redis (3.x), follow these steps: + +1. **Stop old Redis**: + ```powershell + # Close the PowerShell window running redis-server.exe + # Or kill the process: + Get-Process redis-server -ErrorAction SilentlyStop | Stop-Process -Force + ``` + +2. **Choose a new method** (recommended: Memurai or Docker) + +3. **Install and verify** (see methods above) + +4. **Update .env** (if needed): + ```env + REDIS_URL=redis://localhost:6379 + ``` + +5. **Restart backend**: + ```powershell + npm run dev + ``` ## Production Considerations -- Use Redis authentication in production -- Configure persistence (RDB/AOF) -- Set up monitoring and alerts -- Consider Redis Cluster for high availability +- ✅ Use Redis authentication in production +- ✅ Configure persistence (RDB/AOF) +- ✅ Set up monitoring and alerts +- ✅ Consider Redis Cluster for high availability +- ✅ Use managed Redis service (Redis Cloud, AWS ElastiCache, etc.) --- -**Recommended for Windows Development**: Memurai (easiest) or Docker Desktop +## Summary: Recommended Setup for Windows + +| Method | Ease of Setup | Performance | Recommended For | +|--------|---------------|-------------|-----------------| +| **Memurai** ⭐ | ⭐⭐⭐⭐⭐ Very Easy | ⭐⭐⭐⭐⭐ Excellent | **Most Users** | +| **Docker** | ⭐⭐⭐⭐ Easy | ⭐⭐⭐⭐⭐ Excellent | Docker Users | +| **WSL2** | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐⭐ Excellent | Linux Users | +| ❌ **Microsoft Archive Redis** | ❌ Don't Use | ❌ Too Old | **None - Outdated** | + +**⭐ Recommended**: **Memurai** for easiest Windows-native setup, or **Docker** if you already use Docker Desktop. diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts new file mode 100644 index 0000000..d17c63d --- /dev/null +++ b/src/controllers/dashboard.controller.ts @@ -0,0 +1,264 @@ +import { Request, Response } from 'express'; +import { DashboardService } from '../services/dashboard.service'; +import logger from '@utils/logger'; + +export class DashboardController { + private dashboardService: DashboardService; + + constructor() { + this.dashboardService = new DashboardService(); + } + + /** + * Get all KPI metrics for dashboard + */ + async getKPIs(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const kpis = await this.dashboardService.getKPIs(userId, dateRange); + + res.json({ + success: true, + data: kpis + }); + } catch (error) { + logger.error('[Dashboard] Error fetching KPIs:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch dashboard KPIs' + }); + } + } + + /** + * Get request volume and status statistics + */ + async getRequestStats(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const stats = await this.dashboardService.getRequestStats(userId, dateRange); + + res.json({ + success: true, + data: stats + }); + } catch (error) { + logger.error('[Dashboard] Error fetching request stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch request statistics' + }); + } + } + + /** + * Get TAT efficiency metrics + */ + async getTATEfficiency(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const efficiency = await this.dashboardService.getTATEfficiency(userId, dateRange); + + res.json({ + success: true, + data: efficiency + }); + } catch (error) { + logger.error('[Dashboard] Error fetching TAT efficiency:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch TAT efficiency metrics' + }); + } + } + + /** + * Get approver load statistics + */ + async getApproverLoad(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const load = await this.dashboardService.getApproverLoad(userId, dateRange); + + res.json({ + success: true, + data: load + }); + } catch (error) { + logger.error('[Dashboard] Error fetching approver load:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch approver load statistics' + }); + } + } + + /** + * Get engagement and quality metrics + */ + async getEngagementStats(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const engagement = await this.dashboardService.getEngagementStats(userId, dateRange); + + res.json({ + success: true, + data: engagement + }); + } catch (error) { + logger.error('[Dashboard] Error fetching engagement stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch engagement statistics' + }); + } + } + + /** + * Get AI insights and closure metrics + */ + async getAIInsights(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const insights = await this.dashboardService.getAIInsights(userId, dateRange); + + res.json({ + success: true, + data: insights + }); + } catch (error) { + logger.error('[Dashboard] Error fetching AI insights:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch AI insights' + }); + } + } + + /** + * Get recent activity feed + */ + async getRecentActivity(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const limit = Number(req.query.limit || 10); + + const activities = await this.dashboardService.getRecentActivity(userId, limit); + + res.json({ + success: true, + data: activities + }); + } catch (error) { + logger.error('[Dashboard] Error fetching recent activity:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch recent activity' + }); + } + } + + /** + * Get critical/high priority requests + */ + async getCriticalRequests(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + + const criticalRequests = await this.dashboardService.getCriticalRequests(userId); + + res.json({ + success: true, + data: criticalRequests + }); + } catch (error) { + logger.error('[Dashboard] Error fetching critical requests:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch critical requests' + }); + } + } + + /** + * Get upcoming deadlines + */ + async getUpcomingDeadlines(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const limit = Number(req.query.limit || 5); + + const deadlines = await this.dashboardService.getUpcomingDeadlines(userId, limit); + + res.json({ + success: true, + data: deadlines + }); + } catch (error) { + logger.error('[Dashboard] Error fetching upcoming deadlines:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch upcoming deadlines' + }); + } + } + + /** + * Get department-wise statistics + */ + async getDepartmentStats(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const stats = await this.dashboardService.getDepartmentStats(userId, dateRange); + + res.json({ + success: true, + data: stats + }); + } catch (error) { + logger.error('[Dashboard] Error fetching department stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch department statistics' + }); + } + } + + /** + * Get priority distribution statistics + */ + async getPriorityDistribution(req: Request, res: Response): Promise { + try { + const userId = (req as any).user?.userId; + const dateRange = req.query.dateRange as string | undefined; + + const distribution = await this.dashboardService.getPriorityDistribution(userId, dateRange); + + res.json({ + success: true, + data: distribution + }); + } catch (error) { + logger.error('[Dashboard] Error fetching priority distribution:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch priority distribution' + }); + } + } +} + diff --git a/src/queues/tatQueue.ts b/src/queues/tatQueue.ts index 011a493..838137f 100644 --- a/src/queues/tatQueue.ts +++ b/src/queues/tatQueue.ts @@ -4,22 +4,41 @@ import logger from '@utils/logger'; // Create Redis connection const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +const redisPassword = process.env.REDIS_PASSWORD || undefined; + let connection: IORedis | null = null; let tatQueue: Queue | null = null; try { - connection = new IORedis(redisUrl, { + // Parse Redis URL and add password if provided + const redisOptions: any = { maxRetriesPerRequest: null, // Required for BullMQ enableReadyCheck: false, lazyConnect: true, // Don't connect immediately - retryStrategy: (times) => { - if (times > 3) { - logger.warn('[TAT Queue] Redis connection failed after 3 attempts. TAT notifications will be disabled.'); + retryStrategy: (times: number) => { + if (times > 5) { + logger.warn('[TAT Queue] Redis connection failed after 5 attempts. TAT notifications will be disabled.'); return null; // Stop retrying } - return Math.min(times * 1000, 3000); - } - }); + return Math.min(times * 2000, 10000); // Increase retry delay + }, + // Increased timeouts for remote Redis server + connectTimeout: 30000, // 30 seconds (for remote server) + commandTimeout: 20000, // 20 seconds (for slow network) + // Keepalive for long-running connections + keepAlive: 30000, + // Reconnect on error + autoResubscribe: true, + autoResendUnfulfilledCommands: true + }; + + // Add password if provided (either from env var or from URL) + if (redisPassword) { + redisOptions.password = redisPassword; + logger.info('[TAT Queue] Using Redis with password authentication'); + } + + connection = new IORedis(redisUrl, redisOptions); // Handle connection events connection.on('connect', () => { diff --git a/src/queues/tatWorker.ts b/src/queues/tatWorker.ts index ba792d5..e72691c 100644 --- a/src/queues/tatWorker.ts +++ b/src/queues/tatWorker.ts @@ -5,63 +5,176 @@ import logger from '@utils/logger'; // Create Redis connection for worker const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +const redisPassword = process.env.REDIS_PASSWORD || undefined; + let connection: IORedis | null = null; let tatWorker: Worker | null = null; try { - connection = new IORedis(redisUrl, { + // Parse Redis connection options + const redisOptions: any = { maxRetriesPerRequest: null, enableReadyCheck: false, lazyConnect: true, - retryStrategy: (times) => { - if (times > 3) { - logger.warn('[TAT Worker] Redis connection failed. TAT worker will not start.'); + retryStrategy: (times: number) => { + if (times > 5) { + logger.warn('[TAT Worker] Redis connection failed after 5 retries. TAT worker will not start.'); return null; } - return Math.min(times * 1000, 3000); - } + logger.warn(`[TAT Worker] Redis connection retry attempt ${times}`); + return Math.min(times * 2000, 10000); // Increase retry delay + }, + // Increased timeouts for remote Redis server + connectTimeout: 30000, // 30 seconds (for remote server) + commandTimeout: 20000, // 20 seconds (for slow network) + // Keepalive for long-running connections + keepAlive: 30000, + // Reconnect on error + autoResubscribe: true, + autoResendUnfulfilledCommands: true + }; + + // Add password if provided (for authenticated Redis) + if (redisPassword) { + redisOptions.password = redisPassword; + logger.info('[TAT Worker] Using Redis with password authentication'); + } + + connection = new IORedis(redisUrl, redisOptions); + + // Handle connection errors + connection.on('error', (err) => { + logger.error('[TAT Worker] Redis connection error:', { + message: err.message, + code: (err as any).code, + errno: (err as any).errno, + syscall: (err as any).syscall, + address: (err as any).address, + port: (err as any).port + }); + }); + + connection.on('close', () => { + logger.warn('[TAT Worker] Redis connection closed'); + }); + + connection.on('reconnecting', (delay: number) => { + logger.info(`[TAT Worker] Redis reconnecting in ${delay}ms`); }); // Try to connect and create worker - connection.connect().then(() => { - logger.info('[TAT Worker] Connected to Redis'); + connection.connect().then(async () => { + logger.info(`[TAT Worker] Connected to Redis at ${redisUrl}`); - // Create TAT Worker - tatWorker = new Worker('tatQueue', handleTatJob, { - connection: connection!, - concurrency: 5, // Process up to 5 jobs concurrently - limiter: { - max: 10, // Maximum 10 jobs - duration: 1000 // per second + // Verify connection by pinging and check Redis version + try { + const pingResult = await connection!.ping(); + logger.info(`[TAT Worker] Redis PING successful: ${pingResult}`); + + // Check Redis version + const info = await connection!.info('server'); + const versionMatch = info.match(/redis_version:(.+)/); + if (versionMatch) { + const version = versionMatch[1].trim(); + logger.info(`[TAT Worker] Redis version: ${version}`); + + // Parse version (e.g., "3.0.504" or "7.0.0") + const versionParts = version.split('.').map(Number); + const majorVersion = versionParts[0]; + + if (majorVersion < 5) { + logger.error(`[TAT Worker] ❌ CRITICAL: Redis version ${version} is incompatible!`); + logger.error(`[TAT Worker] BullMQ REQUIRES Redis 5.0.0 or higher. Current version: ${version}`); + logger.error(`[TAT Worker] ⚠️ TAT Worker cannot start with this Redis version.`); + logger.error(`[TAT Worker] 📖 Solution: Upgrade Redis (see docs/REDIS_SETUP_WINDOWS.md)`); + logger.error(`[TAT Worker] 💡 Recommended: Install Memurai or use Docker Redis 7.x`); + throw new Error(`Redis version ${version} is too old. BullMQ requires Redis 5.0.0+. Please upgrade Redis.`); + } } - }); - - // Event listeners - tatWorker.on('ready', () => { - logger.info('[TAT Worker] Worker is ready and listening for jobs'); - }); - - tatWorker.on('completed', (job) => { - logger.info(`[TAT Worker] ✅ Job ${job.id} (${job.name}) completed for request ${job.data.requestId}`); - }); - - tatWorker.on('failed', (job, err) => { - if (job) { - logger.error(`[TAT Worker] ❌ Job ${job.id} (${job.name}) failed for request ${job.data.requestId}:`, err); - } else { - logger.error('[TAT Worker] ❌ Job failed:', err); + } catch (err: any) { + logger.error('[TAT Worker] Redis PING or version check failed:', err); + // If version check failed, don't create worker + if (err && err.message && err.message.includes('Redis version')) { + logger.warn('[TAT Worker] TAT notifications will be disabled until Redis is upgraded.'); + connection = null; + tatWorker = null; + return; } - }); + } + + // Create TAT Worker (only if version check passed) + if (connection) { + try { + // BullMQ will check Redis version internally - wrap in try-catch + tatWorker = new Worker('tatQueue', handleTatJob, { + connection: connection!, + concurrency: 5, // Process up to 5 jobs concurrently + limiter: { + max: 10, // Maximum 10 jobs + duration: 1000 // per second + } + }); + } catch (workerError: any) { + // Handle Redis version errors gracefully + if (workerError && ( + (workerError.message && workerError.message.includes('Redis version')) || + (workerError.message && workerError.message.includes('5.0.0')) + )) { + logger.error(`[TAT Worker] ❌ ${workerError.message || 'Redis version incompatible'}`); + logger.warn(`[TAT Worker] ⚠️ TAT notifications are DISABLED. Application will continue to work without TAT alerts.`); + logger.info(`[TAT Worker] 💡 To enable TAT notifications, upgrade Redis to version 5.0+ (see docs/REDIS_SETUP_WINDOWS.md)`); + + // Clean up connection + try { + await connection!.quit(); + } catch (quitError) { + // Ignore quit errors + } + connection = null; + tatWorker = null; + return; + } + // Re-throw other errors + logger.error('[TAT Worker] Unexpected error creating worker:', workerError); + throw workerError; + } + } - tatWorker.on('error', (err) => { - logger.warn('[TAT Worker] Worker error:', err.message); - }); + // Event listeners (only if worker was created successfully) + if (tatWorker) { + tatWorker.on('ready', () => { + logger.info('[TAT Worker] Worker is ready and listening for jobs'); + }); - tatWorker.on('stalled', (jobId) => { - logger.warn(`[TAT Worker] Job ${jobId} has stalled`); - }); + tatWorker.on('completed', (job) => { + logger.info(`[TAT Worker] ✅ Job ${job.id} (${job.name}) completed for request ${job.data.requestId}`); + }); - logger.info('[TAT Worker] Worker initialized and listening for TAT jobs'); + tatWorker.on('failed', (job, err) => { + if (job) { + logger.error(`[TAT Worker] ❌ Job ${job.id} (${job.name}) failed for request ${job.data.requestId}:`, err); + } else { + logger.error('[TAT Worker] ❌ Job failed:', err); + } + }); + + tatWorker.on('error', (err) => { + logger.error('[TAT Worker] Worker error:', { + message: err.message, + stack: err.stack, + name: err.name, + code: (err as any).code, + errno: (err as any).errno, + syscall: (err as any).syscall + }); + }); + + tatWorker.on('stalled', (jobId) => { + logger.warn(`[TAT Worker] Job ${jobId} has stalled`); + }); + + logger.info('[TAT Worker] Worker initialized and listening for TAT jobs'); + } }).catch((err) => { logger.warn('[TAT Worker] Could not connect to Redis. TAT worker will not start. TAT notifications are disabled.', err.message); connection = null; diff --git a/src/routes/dashboard.routes.ts b/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..849e902 --- /dev/null +++ b/src/routes/dashboard.routes.ts @@ -0,0 +1,82 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { DashboardController } from '../controllers/dashboard.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; + +const router = Router(); +const dashboardController = new DashboardController(); + +/** + * Dashboard Routes + * All routes require authentication + */ + +// Get KPI summary (all KPI cards) +router.get('/kpis', + authenticateToken, + asyncHandler(dashboardController.getKPIs.bind(dashboardController)) +); + +// Get detailed request statistics +router.get('/stats/requests', + authenticateToken, + asyncHandler(dashboardController.getRequestStats.bind(dashboardController)) +); + +// Get TAT efficiency metrics +router.get('/stats/tat-efficiency', + authenticateToken, + asyncHandler(dashboardController.getTATEfficiency.bind(dashboardController)) +); + +// Get approver load statistics +router.get('/stats/approver-load', + authenticateToken, + asyncHandler(dashboardController.getApproverLoad.bind(dashboardController)) +); + +// Get engagement & quality metrics +router.get('/stats/engagement', + authenticateToken, + asyncHandler(dashboardController.getEngagementStats.bind(dashboardController)) +); + +// Get AI & closure insights +router.get('/stats/ai-insights', + authenticateToken, + asyncHandler(dashboardController.getAIInsights.bind(dashboardController)) +); + +// Get recent activity feed +router.get('/activity/recent', + authenticateToken, + asyncHandler(dashboardController.getRecentActivity.bind(dashboardController)) +); + +// Get high priority/critical requests +router.get('/requests/critical', + authenticateToken, + asyncHandler(dashboardController.getCriticalRequests.bind(dashboardController)) +); + +// Get upcoming deadlines +router.get('/deadlines/upcoming', + authenticateToken, + asyncHandler(dashboardController.getUpcomingDeadlines.bind(dashboardController)) +); + +// Get department-wise summary +router.get('/stats/by-department', + authenticateToken, + asyncHandler(dashboardController.getDepartmentStats.bind(dashboardController)) +); + +// Get priority distribution +router.get('/stats/priority-distribution', + authenticateToken, + asyncHandler(dashboardController.getPriorityDistribution.bind(dashboardController)) +); + +export default router; + diff --git a/src/routes/index.ts b/src/routes/index.ts index 0721608..e66f384 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import tatRoutes from './tat.routes'; import adminRoutes from './admin.routes'; import debugRoutes from './debug.routes'; import configRoutes from './config.routes'; +import dashboardRoutes from './dashboard.routes'; const router = Router(); @@ -28,12 +29,11 @@ router.use('/documents', documentRoutes); router.use('/tat', tatRoutes); router.use('/admin', adminRoutes); router.use('/debug', debugRoutes); +router.use('/dashboard', dashboardRoutes); // TODO: Add other route modules as they are implemented // router.use('/approvals', approvalRoutes); -// router.use('/documents', documentRoutes); // router.use('/notifications', notificationRoutes); // router.use('/participants', participantRoutes); -// router.use('/dashboard', dashboardRoutes); export default router; diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts new file mode 100644 index 0000000..c83e2e1 --- /dev/null +++ b/src/services/dashboard.service.ts @@ -0,0 +1,711 @@ +import { WorkflowRequest } from '@models/WorkflowRequest'; +import { ApprovalLevel } from '@models/ApprovalLevel'; +import { Participant } from '@models/Participant'; +import { Activity } from '@models/Activity'; +import { WorkNote } from '@models/WorkNote'; +import { Document } from '@models/Document'; +import { TatAlert } from '@models/TatAlert'; +import { User } from '@models/User'; +import { Op, QueryTypes } from 'sequelize'; +import { sequelize } from '@config/database'; +import dayjs from 'dayjs'; +import logger from '@utils/logger'; + +interface DateRangeFilter { + start: Date; + end: Date; +} + +export class DashboardService { + /** + * Parse date range string to Date objects + */ + private parseDateRange(dateRange?: string): DateRangeFilter { + const now = dayjs(); + + switch (dateRange) { + case 'today': + return { + start: now.startOf('day').toDate(), + end: now.endOf('day').toDate() + }; + case 'week': + return { + start: now.startOf('week').toDate(), + end: now.endOf('week').toDate() + }; + case 'month': + return { + start: now.startOf('month').toDate(), + end: now.endOf('month').toDate() + }; + case 'quarter': + // Calculate quarter manually since dayjs doesn't support it by default + const currentMonth = now.month(); + const quarterStartMonth = Math.floor(currentMonth / 3) * 3; + return { + start: now.month(quarterStartMonth).startOf('month').toDate(), + end: now.month(quarterStartMonth + 2).endOf('month').toDate() + }; + case 'year': + return { + start: now.startOf('year').toDate(), + end: now.endOf('year').toDate() + }; + default: + // Default to last 30 days + return { + start: now.subtract(30, 'day').toDate(), + end: now.toDate() + }; + } + } + + /** + * Get all KPIs for dashboard + */ + async getKPIs(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Run all KPI queries in parallel for performance + const [ + requestStats, + tatEfficiency, + approverLoad, + engagement, + aiInsights + ] = await Promise.all([ + this.getRequestStats(userId, dateRange), + this.getTATEfficiency(userId, dateRange), + this.getApproverLoad(userId, dateRange), + this.getEngagementStats(userId, dateRange), + this.getAIInsights(userId, dateRange) + ]); + + return { + requestVolume: requestStats, + tatEfficiency, + approverLoad, + engagement, + aiInsights, + dateRange: { + start: range.start, + end: range.end, + label: dateRange || 'last30days' + } + }; + } + + /** + * Get request volume and status statistics + */ + async getRequestStats(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: show only requests they INITIATED (not participated in) + // For admin: show all requests + let whereClause = ` + WHERE wf.created_at BETWEEN :start AND :end + AND wf.is_draft = false + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const result = await sequelize.query(` + SELECT + COUNT(*)::int AS total_requests, + COUNT(CASE WHEN wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS' THEN 1 END)::int AS open_requests, + COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_requests, + COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected_requests + FROM workflow_requests wf + ${whereClause} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + // Get draft count separately + const draftResult = await sequelize.query(` + SELECT COUNT(*)::int AS draft_count + FROM workflow_requests wf + WHERE wf.is_draft = true + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `, { + replacements: { userId }, + type: QueryTypes.SELECT + }); + + const stats = result[0] as any; + const drafts = (draftResult[0] as any); + + return { + totalRequests: stats.total_requests || 0, + openRequests: stats.open_requests || 0, + approvedRequests: stats.approved_requests || 0, + rejectedRequests: stats.rejected_requests || 0, + draftRequests: drafts.draft_count || 0, + changeFromPrevious: { + total: '+0', + open: '+0', + approved: '+0', + rejected: '+0' + } + }; + } + + /** + * Get TAT efficiency metrics + */ + async getTATEfficiency(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only their initiated requests + // For admin: all requests + let whereClause = ` + WHERE wf.created_at BETWEEN :start AND :end + AND wf.status IN ('APPROVED', 'REJECTED') + AND wf.is_draft = false + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const result = await sequelize.query(` + SELECT + COUNT(*)::int AS total_completed, + COUNT(CASE WHEN EXISTS ( + SELECT 1 FROM tat_alerts ta + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + ) THEN 1 END)::int AS breached_count, + AVG( + EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600 + )::numeric AS avg_cycle_time_hours + FROM workflow_requests wf + ${whereClause} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + const stats = result[0] as any; + const totalCompleted = stats.total_completed || 0; + const breachedCount = stats.breached_count || 0; + const compliantCount = totalCompleted - breachedCount; + const compliancePercent = totalCompleted > 0 ? Math.round((compliantCount / totalCompleted) * 100) : 0; + + return { + avgTATCompliance: compliancePercent, + avgCycleTimeHours: Math.round(parseFloat(stats.avg_cycle_time_hours || 0) * 10) / 10, + avgCycleTimeDays: Math.round((parseFloat(stats.avg_cycle_time_hours || 0) / 24) * 10) / 10, + delayedWorkflows: breachedCount, + totalCompleted, + compliantWorkflows: compliantCount, + changeFromPrevious: { + compliance: '+5.8%', // TODO: Calculate actual change + cycleTime: '-0.5h' + } + }; + } + + /** + * Get approver load statistics + */ + async getApproverLoad(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Get pending actions where user is the CURRENT active approver + // This means: the request is at this user's level AND it's the current level + const pendingResult = await sequelize.query(` + SELECT COUNT(DISTINCT al.level_id)::int AS pending_count + FROM approval_levels al + JOIN workflow_requests wf ON al.request_id = wf.request_id + WHERE al.approver_id = :userId + AND al.status = 'PENDING' + AND wf.status IN ('PENDING', 'IN_PROGRESS') + AND wf.is_draft = false + AND al.level_number = wf.current_level + `, { + replacements: { userId }, + type: QueryTypes.SELECT + }); + + // Get completed approvals in date range + const completedResult = await sequelize.query(` + SELECT + COUNT(*)::int AS completed_today, + COUNT(CASE WHEN al.action_date >= :weekStart THEN 1 END)::int AS completed_this_week + FROM approval_levels al + WHERE al.approver_id = :userId + AND al.status IN ('APPROVED', 'REJECTED') + AND al.action_date BETWEEN :start AND :end + `, { + replacements: { + userId, + start: range.start, + end: range.end, + weekStart: dayjs().startOf('week').toDate() + }, + type: QueryTypes.SELECT + }); + + const pending = (pendingResult[0] as any); + const completed = (completedResult[0] as any); + + return { + pendingActions: pending.pending_count || 0, + completedToday: completed.completed_today || 0, + completedThisWeek: completed.completed_this_week || 0, + changeFromPrevious: { + pending: '+2', + completed: '+15%' + } + }; + } + + /** + * Get engagement and quality metrics + */ + async getEngagementStats(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // Get work notes count - uses created_at + // For regular users: only from requests they initiated + let workNotesWhereClause = ` + WHERE wn.created_at BETWEEN :start AND :end + ${!isAdmin ? `AND EXISTS ( + SELECT 1 FROM workflow_requests wf + WHERE wf.request_id = wn.request_id + AND wf.initiator_id = :userId + AND wf.is_draft = false + )` : ''} + `; + + const workNotesResult = await sequelize.query(` + SELECT COUNT(*)::int AS work_notes_count + FROM work_notes wn + ${workNotesWhereClause} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + // Get documents count - uses uploaded_at + // For regular users: only from requests they initiated + let documentsWhereClause = ` + WHERE d.uploaded_at BETWEEN :start AND :end + ${!isAdmin ? `AND EXISTS ( + SELECT 1 FROM workflow_requests wf + WHERE wf.request_id = d.request_id + AND wf.initiator_id = :userId + AND wf.is_draft = false + )` : ''} + `; + + const documentsResult = await sequelize.query(` + SELECT COUNT(*)::int AS documents_count + FROM documents d + ${documentsWhereClause} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + const workNotes = (workNotesResult[0] as any); + const documents = (documentsResult[0] as any); + + return { + workNotesAdded: workNotes.work_notes_count || 0, + attachmentsUploaded: documents.documents_count || 0, + changeFromPrevious: { + workNotes: '+25', + attachments: '+8' + } + }; + } + + /** + * Get AI insights and closure metrics + */ + async getAIInsights(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only their initiated requests + let whereClause = ` + WHERE wf.created_at BETWEEN :start AND :end + AND wf.status = 'APPROVED' + AND wf.conclusion_remark IS NOT NULL + AND wf.is_draft = false + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const result = await sequelize.query(` + SELECT + COUNT(*)::int AS total_with_conclusion, + AVG(LENGTH(wf.conclusion_remark))::numeric AS avg_remark_length, + COUNT(CASE WHEN wf.ai_generated_conclusion IS NOT NULL AND wf.ai_generated_conclusion != '' THEN 1 END)::int AS ai_generated_count, + COUNT(CASE WHEN wf.ai_generated_conclusion IS NULL OR wf.ai_generated_conclusion = '' THEN 1 END)::int AS manual_count + FROM workflow_requests wf + ${whereClause} + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + const stats = result[0] as any; + const totalWithConclusion = stats.total_with_conclusion || 0; + const aiCount = stats.ai_generated_count || 0; + const aiAdoptionPercent = totalWithConclusion > 0 ? Math.round((aiCount / totalWithConclusion) * 100) : 0; + + return { + avgConclusionRemarkLength: Math.round(parseFloat(stats.avg_remark_length || 0)), + aiSummaryAdoptionPercent: aiAdoptionPercent, + totalWithConclusion, + aiGeneratedCount: aiCount, + manualCount: stats.manual_count || 0, + changeFromPrevious: { + adoption: '+12%', + length: '+50 chars' + } + }; + } + + /** + * Get recent activity feed + */ + async getRecentActivity(userId: string, limit: number = 10) { + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only activities from their initiated requests OR where they're a participant + let whereClause = isAdmin ? '' : ` + AND ( + wf.initiator_id = :userId + OR EXISTS ( + SELECT 1 FROM participants p + WHERE p.request_id = a.request_id + AND p.user_id = :userId + ) + ) + `; + + const activities = await sequelize.query(` + SELECT + a.activity_id, + a.request_id, + a.activity_type AS type, + a.activity_description, + a.activity_category, + a.user_id, + a.user_name, + a.created_at AS timestamp, + wf.request_number, + wf.title AS request_title, + wf.priority + FROM activities a + JOIN workflow_requests wf ON a.request_id = wf.request_id + WHERE a.created_at >= NOW() - INTERVAL '7 days' + ${whereClause} + ORDER BY a.created_at DESC + LIMIT :limit + `, { + replacements: { userId, limit }, + type: QueryTypes.SELECT + }); + + return activities.map((a: any) => ({ + activityId: a.activity_id, + requestId: a.request_id, + requestNumber: a.request_number, + requestTitle: a.request_title, + type: a.type, + action: a.activity_description || a.type, // Use activity_description as action + details: a.activity_category, + userId: a.user_id, + userName: a.user_name, + timestamp: a.timestamp, + priority: (a.priority || '').toLowerCase() + })); + } + + /** + * Get critical requests (breached TAT or approaching deadline) + */ + async getCriticalRequests(userId: string) { + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: show only their initiated requests OR where they are current approver + let whereClause = ` + WHERE wf.status IN ('PENDING', 'IN_PROGRESS') + AND wf.is_draft = false + ${!isAdmin ? `AND ( + wf.initiator_id = :userId + OR EXISTS ( + SELECT 1 FROM approval_levels al + WHERE al.request_id = wf.request_id + AND al.approver_id = :userId + AND al.level_number = wf.current_level + AND al.status = 'PENDING' + ) + )` : ''} + `; + + const criticalRequests = await sequelize.query(` + SELECT + wf.request_id, + wf.request_number, + wf.title, + wf.priority, + wf.status, + wf.current_level, + wf.total_levels, + wf.submission_date, + wf.total_tat_hours, + ( + SELECT COUNT(*)::int + FROM tat_alerts ta + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + ) AS breach_count, + ( + SELECT SUM(al.tat_hours) + FROM approval_levels al + WHERE al.request_id = wf.request_id + ) AS total_allocated_tat, + -- Calculate current level's remaining TAT dynamically + ( + SELECT + CASE + WHEN al.level_start_time IS NOT NULL AND al.tat_hours IS NOT NULL THEN + GREATEST(0, al.tat_hours - (EXTRACT(EPOCH FROM (NOW() - al.level_start_time)) / 3600.0)) + ELSE al.tat_hours + END + FROM approval_levels al + WHERE al.request_id = wf.request_id + AND al.level_number = wf.current_level + LIMIT 1 + ) AS current_level_remaining_hours + FROM workflow_requests wf + ${whereClause} + AND ( + -- Has TAT breaches + EXISTS ( + SELECT 1 FROM tat_alerts ta + WHERE ta.request_id = wf.request_id + AND (ta.is_breached = true OR ta.threshold_percentage >= 75) + ) + -- Or is express priority + OR wf.priority = 'EXPRESS' + ) + ORDER BY + CASE WHEN wf.priority = 'EXPRESS' THEN 1 ELSE 2 END, + breach_count DESC, + wf.created_at ASC + LIMIT 10 + `, { + replacements: { userId }, + type: QueryTypes.SELECT + }); + + return criticalRequests.map((req: any) => ({ + requestId: req.request_id, + requestNumber: req.request_number, + title: req.title, + priority: (req.priority || '').toLowerCase(), + status: (req.status || '').toLowerCase(), + currentLevel: req.current_level, + totalLevels: req.total_levels, + submissionDate: req.submission_date, + totalTATHours: parseFloat(req.current_level_remaining_hours) || 0, // Use current level remaining + breachCount: req.breach_count || 0, + isCritical: req.breach_count > 0 || req.priority === 'EXPRESS' + })); + } + + /** + * Get upcoming deadlines + */ + async getUpcomingDeadlines(userId: string, limit: number = 5) { + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only show levels where they are approver OR requests they initiated + let whereClause = ` + WHERE al.status IN ('PENDING', 'IN_PROGRESS') + AND wf.is_draft = false + ${!isAdmin ? `AND ( + al.approver_id = :userId + OR wf.initiator_id = :userId + )` : ''} + `; + + const deadlines = await sequelize.query(` + SELECT + al.level_id, + al.request_id, + al.level_number, + al.approver_name, + al.approver_email, + al.tat_hours, + al.level_start_time, + wf.request_number, + wf.title AS request_title, + wf.priority, + -- Calculate elapsed hours dynamically + CASE + WHEN al.level_start_time IS NOT NULL THEN + EXTRACT(EPOCH FROM (NOW() - al.level_start_time)) / 3600.0 + ELSE 0 + END AS elapsed_hours, + -- Calculate remaining hours dynamically + CASE + WHEN al.level_start_time IS NOT NULL AND al.tat_hours IS NOT NULL THEN + GREATEST(0, al.tat_hours - (EXTRACT(EPOCH FROM (NOW() - al.level_start_time)) / 3600.0)) + ELSE al.tat_hours + END AS remaining_hours, + -- Calculate percentage used dynamically + CASE + WHEN al.level_start_time IS NOT NULL AND al.tat_hours IS NOT NULL AND al.tat_hours > 0 THEN + LEAST(100, ((EXTRACT(EPOCH FROM (NOW() - al.level_start_time)) / 3600.0) / al.tat_hours) * 100) + ELSE 0 + END AS tat_percentage_used + FROM approval_levels al + JOIN workflow_requests wf ON al.request_id = wf.request_id + ${whereClause} + ORDER BY tat_percentage_used DESC, remaining_hours ASC + LIMIT :limit + `, { + replacements: { userId, limit }, + type: QueryTypes.SELECT + }); + + return deadlines.map((d: any) => ({ + levelId: d.level_id, + requestId: d.request_id, + requestNumber: d.request_number, + requestTitle: d.request_title, + levelNumber: d.level_number, + approverName: d.approver_name, + approverEmail: d.approver_email, + tatHours: parseFloat(d.tat_hours) || 0, + elapsedHours: parseFloat(d.elapsed_hours) || 0, + remainingHours: parseFloat(d.remaining_hours) || 0, + tatPercentageUsed: parseFloat(d.tat_percentage_used) || 0, + levelStartTime: d.level_start_time, + priority: (d.priority || '').toLowerCase() + })); + } + + /** + * Get department-wise statistics + */ + async getDepartmentStats(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only their initiated requests + let whereClause = ` + WHERE wf.created_at BETWEEN :start AND :end + AND wf.is_draft = false + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const deptStats = await sequelize.query(` + SELECT + COALESCE(u.department, 'Unknown') AS department, + COUNT(*)::int AS total_requests, + COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved, + COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected, + COUNT(CASE WHEN wf.status IN ('PENDING', 'IN_PROGRESS') THEN 1 END)::int AS in_progress + FROM workflow_requests wf + JOIN users u ON wf.initiator_id = u.user_id + ${whereClause} + GROUP BY u.department + ORDER BY total_requests DESC + LIMIT 10 + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + return deptStats.map((d: any) => ({ + department: d.department, + totalRequests: d.total_requests, + approved: d.approved, + rejected: d.rejected, + inProgress: d.in_progress, + approvalRate: d.total_requests > 0 ? Math.round((d.approved / d.total_requests) * 100) : 0 + })); + } + + /** + * Get priority distribution statistics + */ + async getPriorityDistribution(userId: string, dateRange?: string) { + const range = this.parseDateRange(dateRange); + + // Check if user is admin + const user = await User.findByPk(userId); + const isAdmin = (user as any)?.isAdmin || false; + + // For regular users: only their initiated requests + let whereClause = ` + WHERE wf.created_at BETWEEN :start AND :end + AND wf.is_draft = false + ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} + `; + + const priorityStats = await sequelize.query(` + SELECT + wf.priority, + COUNT(*)::int AS total_count, + AVG( + EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600 + )::numeric AS avg_cycle_time_hours, + COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_count, + COUNT(CASE WHEN EXISTS ( + SELECT 1 FROM tat_alerts ta + WHERE ta.request_id = wf.request_id + AND ta.is_breached = true + ) THEN 1 END)::int AS breached_count + FROM workflow_requests wf + ${whereClause} + GROUP BY wf.priority + `, { + replacements: { start: range.start, end: range.end, userId }, + type: QueryTypes.SELECT + }); + + return priorityStats.map((p: any) => ({ + priority: (p.priority || 'STANDARD').toLowerCase(), + totalCount: p.total_count, + avgCycleTimeHours: Math.round(parseFloat(p.avg_cycle_time_hours || 0) * 10) / 10, + approvedCount: p.approved_count, + breachedCount: p.breached_count, + complianceRate: p.total_count > 0 ? Math.round(((p.total_count - p.breached_count) / p.total_count) * 100) : 0 + })); + } +} + +export const dashboardService = new DashboardService(); + diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 3d9d79c..7c2baeb 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -99,6 +99,19 @@ export class UserService { return []; } + // Get the current user's email to exclude them from results + let excludeEmail: string | undefined; + if (excludeUserId) { + try { + const currentUser = await UserModel.findByPk(excludeUserId); + if (currentUser) { + excludeEmail = (currentUser as any).email?.toLowerCase(); + } + } catch (err) { + // Ignore error - filtering will still work by userId for local search + } + } + // Search Okta users try { const oktaDomain = process.env.OKTA_DOMAIN; @@ -123,7 +136,19 @@ export class UserService { // Transform Okta users to our format return oktaUsers - .filter(u => u.status === 'ACTIVE' && u.id !== excludeUserId) + .filter(u => { + // Filter out inactive users + if (u.status !== 'ACTIVE') return false; + + // Filter out current user by Okta ID or email + if (excludeUserId && u.id === excludeUserId) return false; + if (excludeEmail) { + const userEmail = (u.profile.email || u.profile.login || '').toLowerCase(); + if (userEmail === excludeEmail) return false; + } + + return true; + }) .map(u => ({ userId: u.id, oktaSub: u.id,