diff --git a/scripts/fix_activity_usernames.sql b/scripts/fix_activity_usernames.sql new file mode 100644 index 0000000..105cc1a --- /dev/null +++ b/scripts/fix_activity_usernames.sql @@ -0,0 +1,43 @@ +-- Script to fix NULL user_name values in activities table +-- This will populate user_name from the users table based on user_id + +-- First, let's see how many records have NULL user_name +SELECT COUNT(*) as null_username_count +FROM activities +WHERE user_name IS NULL AND user_id IS NOT NULL; + +-- Update activities with user names from users table +UPDATE activities a +SET user_name = u.display_name +FROM users u +WHERE a.user_id = u.user_id + AND a.user_name IS NULL + AND u.display_name IS NOT NULL; + +-- For users without display_name, use email +UPDATE activities a +SET user_name = u.email +FROM users u +WHERE a.user_id = u.user_id + AND a.user_name IS NULL + AND u.email IS NOT NULL; + +-- Verify the update +SELECT + COUNT(*) as total_activities, + COUNT(CASE WHEN user_name IS NULL THEN 1 END) as null_usernames, + COUNT(CASE WHEN user_name IS NOT NULL THEN 1 END) as populated_usernames +FROM activities; + +-- Show sample of fixed records +SELECT + activity_id, + user_id, + user_name, + activity_type, + activity_description, + created_at +FROM activities +ORDER BY created_at DESC +LIMIT 10; + diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index e60d11f..59591e6 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -2,7 +2,9 @@ import { Request, Response } from 'express'; import crypto from 'crypto'; import path from 'path'; import { Document } from '@models/Document'; +import { User } from '@models/User'; import { ResponseHandler } from '@utils/responseHandler'; +import { activityService } from '@services/activity.service'; import type { AuthenticatedRequest } from '../types/express'; export class DocumentController { @@ -51,6 +53,26 @@ export class DocumentController { downloadCount: 0, } as any); + // Get user details for activity logging + const user = await User.findByPk(userId); + const uploaderName = (user as any)?.displayName || (user as any)?.email || 'User'; + + // Log activity for document upload + await activityService.log({ + requestId, + type: 'document_added', + user: { userId, name: uploaderName }, + timestamp: new Date().toISOString(), + action: 'Document Added', + details: `Added ${file.originalname} as supporting document by ${uploaderName}`, + metadata: { + fileName: file.originalname, + fileSize: file.size, + fileType: extension, + category + } + }); + ResponseHandler.success(res, doc, 'File uploaded', 201); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 9c50671..067bea9 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -6,6 +6,7 @@ import type { AuthenticatedRequest } from '../types/express'; import { Priority } from '../types/common.types'; import type { UpdateWorkflowRequest } from '../types/workflow.types'; import { Document } from '@models/Document'; +import { User } from '@models/User'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; @@ -55,6 +56,10 @@ export class WorkflowController { const category = (req.body?.category as string) || 'OTHER'; const docs: any[] = []; if (files && files.length > 0) { + const { activityService } = require('../services/activity.service'); + const user = await User.findByPk(userId); + const uploaderName = (user as any)?.displayName || (user as any)?.email || 'User'; + for (const file of files) { const buffer = fs.readFileSync(file.path); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); @@ -80,6 +85,17 @@ export class WorkflowController { downloadCount: 0, } as any); docs.push(doc); + + // Log document upload activity + activityService.log({ + requestId: workflow.requestId, + type: 'document_added', + user: { userId, name: uploaderName }, + timestamp: new Date().toISOString(), + action: 'Document Added', + details: `Added ${file.originalname} as supporting document by ${uploaderName}`, + metadata: { fileName: file.originalname, fileSize: file.size, fileType: extension } + }); } } diff --git a/src/controllers/worknote.controller.ts b/src/controllers/worknote.controller.ts index bb2d147..4083935 100644 --- a/src/controllers/worknote.controller.ts +++ b/src/controllers/worknote.controller.ts @@ -17,7 +17,27 @@ export class WorkNoteController { const wf = await (this.workflowService as any).findWorkflowByIdentifier(req.params.id); if (!wf) { res.status(404).json({ success: false, error: 'Not found' }); return; } const requestId: string = wf.getDataValue('requestId'); - const user = { userId: req.user?.userId, name: req.user?.displayName }; + + // Get user's participant info (includes userName and role) + const { Participant } = require('@models/Participant'); + const participant = await Participant.findOne({ + where: { requestId, userId: req.user?.userId } + }); + + let userName = req.user?.email || 'Unknown User'; + let userRole = 'SPECTATOR'; + + if (participant) { + userName = (participant as any).userName || (participant as any).user_name || req.user?.email || 'Unknown User'; + userRole = (participant as any).participantType || (participant as any).participant_type || 'SPECTATOR'; + } + + const user = { + userId: req.user?.userId, + name: userName, + role: userRole + }; + const payload = req.body?.payload ? JSON.parse(req.body.payload) : (req.body || {}); const files = (req.files as any[])?.map(f => ({ path: f.path, originalname: f.originalname, mimetype: f.mimetype, size: f.size })) || []; const note = await workNoteService.create(requestId, user, payload, files); diff --git a/src/realtime/socket.ts b/src/realtime/socket.ts index 9d2b647..dfb5a01 100644 --- a/src/realtime/socket.ts +++ b/src/realtime/socket.ts @@ -2,6 +2,9 @@ import { Server } from 'socket.io'; let io: Server | null = null; +// Track online users per request: { requestId: Set } +const onlineUsersPerRequest = new Map>(); + export function initSocket(httpServer: any) { const defaultOrigins = [ 'http://localhost:3000', @@ -11,20 +14,79 @@ export function initSocket(httpServer: any) { ]; const configured = (process.env.FRONTEND_ORIGIN || '').split(',').map(s => s.trim()).filter(Boolean); const origins = configured.length ? configured : defaultOrigins; + + console.log('🔌 Initializing Socket.IO server with origins:', origins); + io = new Server(httpServer, { cors: { origin: origins, methods: ['GET', 'POST'], credentials: true }, - path: '/socket.io' + path: '/socket.io', + transports: ['websocket', 'polling'] }); + + console.log('✅ Socket.IO server initialized'); + io.on('connection', (socket: any) => { - socket.on('join:request', (requestId: string) => { + console.log('🔗 Client connected:', socket.id); + let currentRequestId: string | null = null; + let currentUserId: string | null = null; + + socket.on('join:request', (data: { requestId: string; userId?: string }) => { + const requestId = typeof data === 'string' ? data : data.requestId; + const userId = typeof data === 'object' ? data.userId : null; + socket.join(`request:${requestId}`); + currentRequestId = requestId; + currentUserId = userId || null; + + if (userId) { + // Track this user as online for this request + if (!onlineUsersPerRequest.has(requestId)) { + onlineUsersPerRequest.set(requestId, new Set()); + } + onlineUsersPerRequest.get(requestId)!.add(userId); + + const onlineUsers = Array.from(onlineUsersPerRequest.get(requestId) || []); + + // Broadcast presence to others in the room + socket.to(`request:${requestId}`).emit('presence:join', { userId, requestId }); + + // Send current online users to the newly joined socket + socket.emit('presence:online', { requestId, userIds: onlineUsers }); + } }); + socket.on('leave:request', (requestId: string) => { socket.leave(`request:${requestId}`); + + if (currentUserId && onlineUsersPerRequest.has(requestId)) { + onlineUsersPerRequest.get(requestId)!.delete(currentUserId); + if (onlineUsersPerRequest.get(requestId)!.size === 0) { + onlineUsersPerRequest.delete(requestId); + } + // Broadcast leave to others + socket.to(`request:${requestId}`).emit('presence:leave', { userId: currentUserId, requestId }); + } + + if (requestId === currentRequestId) { + currentRequestId = null; + currentUserId = null; + } + }); + + socket.on('disconnect', () => { + // Handle user going offline when connection drops + if (currentRequestId && currentUserId && onlineUsersPerRequest.has(currentRequestId)) { + onlineUsersPerRequest.get(currentRequestId)!.delete(currentUserId); + if (onlineUsersPerRequest.get(currentRequestId)!.size === 0) { + onlineUsersPerRequest.delete(currentRequestId); + } + // Broadcast disconnect to others in the room + socket.to(`request:${currentRequestId}`).emit('presence:leave', { userId: currentUserId, requestId: currentRequestId }); + } }); }); return io; diff --git a/src/routes/workflow.routes.ts b/src/routes/workflow.routes.ts index 7a5dbff..383b214 100644 --- a/src/routes/workflow.routes.ts +++ b/src/routes/workflow.routes.ts @@ -16,6 +16,8 @@ import { notificationService } from '../services/notification.service'; import { Activity } from '@models/Activity'; import { WorkflowService } from '../services/workflow.service'; import { WorkNoteController } from '../controllers/worknote.controller'; +import { workNoteService } from '../services/worknote.service'; +import logger from '@utils/logger'; const router = Router(); const workflowController = new WorkflowController(); @@ -184,4 +186,176 @@ router.post('/:id/work-notes', asyncHandler(workNoteController.create.bind(workNoteController)) ); +// Preview workflow document +router.get('/documents/:documentId/preview', + authenticateToken, + asyncHandler(async (req: any, res: Response) => { + const { documentId } = req.params; + const { Document } = require('@models/Document'); + + const document = await Document.findOne({ where: { documentId } }); + if (!document) { + res.status(404).json({ success: false, error: 'Document not found' }); + return; + } + + const filePath = (document as any).filePath; + const fileName = (document as any).originalFileName || (document as any).fileName; + const fileType = (document as any).fileType; + + // Check if file exists + if (!require('fs').existsSync(filePath)) { + res.status(404).json({ success: false, error: 'File not found on server' }); + return; + } + + // Set appropriate content type + res.contentType(fileType); + + // For images and PDFs, allow inline viewing + const isPreviewable = fileType.includes('image') || fileType.includes('pdf'); + if (isPreviewable) { + res.setHeader('Content-Disposition', `inline; filename="${fileName}"`); + } else { + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + } + + res.sendFile(filePath, (err) => { + if (err && !res.headersSent) { + res.status(500).json({ success: false, error: 'Failed to serve file' }); + } + }); + }) +); + +// Download workflow document +router.get('/documents/:documentId/download', + authenticateToken, + asyncHandler(async (req: any, res: Response) => { + const { documentId } = req.params; + const { Document } = require('@models/Document'); + + const document = await Document.findOne({ where: { documentId } }); + if (!document) { + res.status(404).json({ success: false, error: 'Document not found' }); + return; + } + + const filePath = (document as any).filePath; + const fileName = (document as any).originalFileName || (document as any).fileName; + + // Check if file exists + if (!require('fs').existsSync(filePath)) { + res.status(404).json({ success: false, error: 'File not found on server' }); + return; + } + + res.download(filePath, fileName, (err) => { + if (err && !res.headersSent) { + res.status(500).json({ success: false, error: 'Failed to download file' }); + } + }); + }) +); + +// Preview work note attachment (serves file for inline viewing) +router.get('/work-notes/attachments/:attachmentId/preview', + authenticateToken, + asyncHandler(async (req: any, res: Response) => { + const { attachmentId } = req.params; + const fileInfo = await workNoteService.downloadAttachment(attachmentId); + + // Check if file exists + if (!require('fs').existsSync(fileInfo.filePath)) { + res.status(404).json({ success: false, error: 'File not found' }); + return; + } + + // Set appropriate content type + res.contentType(fileInfo.fileType); + + // For images and PDFs, allow inline viewing + const isPreviewable = fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf'); + if (isPreviewable) { + res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`); + } else { + res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`); + } + + res.sendFile(fileInfo.filePath, (err) => { + if (err && !res.headersSent) { + res.status(500).json({ success: false, error: 'Failed to serve file' }); + } + }); + }) +); + +// Download work note attachment +router.get('/work-notes/attachments/:attachmentId/download', + authenticateToken, + asyncHandler(async (req: any, res: Response) => { + const { attachmentId } = req.params; + const fileInfo = await workNoteService.downloadAttachment(attachmentId); + + // Check if file exists + if (!require('fs').existsSync(fileInfo.filePath)) { + res.status(404).json({ success: false, error: 'File not found' }); + return; + } + + res.download(fileInfo.filePath, fileInfo.fileName, (err) => { + if (err && !res.headersSent) { + res.status(500).json({ success: false, error: 'Failed to download file' }); + } + }); + }) +); + +// Add participant routes +router.post('/:id/participants/approver', + authenticateToken, + validateParams(workflowParamsSchema), + asyncHandler(async (req: any, res: Response) => { + const workflowService = new WorkflowService(); + const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); + if (!wf) { + res.status(404).json({ success: false, error: 'Workflow not found' }); + return; + } + const requestId: string = wf.getDataValue('requestId'); + const { email } = req.body; + + if (!email) { + res.status(400).json({ success: false, error: 'Email is required' }); + return; + } + + const participant = await workflowService.addApprover(requestId, email, req.user?.userId); + res.status(201).json({ success: true, data: participant }); + }) +); + +router.post('/:id/participants/spectator', + authenticateToken, + validateParams(workflowParamsSchema), + asyncHandler(async (req: any, res: Response) => { + const workflowService = new WorkflowService(); + const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id); + if (!wf) { + res.status(404).json({ success: false, error: 'Workflow not found' }); + return; + } + const requestId: string = wf.getDataValue('requestId'); + const { email } = req.body; + + if (!email) { + res.status(400).json({ success: false, error: 'Email is required' }); + return; + } + + const participant = await workflowService.addSpectator(requestId, email, req.user?.userId); + res.status(201).json({ success: true, data: participant }); + }) +); + export default router; diff --git a/src/services/activity.service.ts b/src/services/activity.service.ts index 9b757aa..4e84021 100644 --- a/src/services/activity.service.ts +++ b/src/services/activity.service.ts @@ -1,36 +1,55 @@ +import logger from '@utils/logger'; + export type ActivityEntry = { requestId: string; - type: 'created' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder'; + type: 'created' | 'assignment' | 'approval' | 'rejection' | 'status_change' | 'comment' | 'reminder' | 'document_added' | 'sla_warning'; user?: { userId: string; name?: string; email?: string }; timestamp: string; action: string; details: string; + metadata?: any; }; class ActivityService { private byRequest: Map = new Map(); - log(entry: ActivityEntry) { + async log(entry: ActivityEntry) { const list = this.byRequest.get(entry.requestId) || []; list.push(entry); this.byRequest.set(entry.requestId, list); - // Persist best-effort (non-blocking) + + // Persist to database try { const { Activity } = require('@models/Activity'); - Activity.create({ + const userName = entry.user?.name || entry.user?.email || null; + + const activityData = { requestId: entry.requestId, userId: entry.user?.userId || null, - userName: entry.user?.name || null, + userName: userName, activityType: entry.type, activityDescription: entry.details, activityCategory: null, severity: null, - metadata: null, + metadata: entry.metadata || null, isSystemEvent: !entry.user, ipAddress: null, userAgent: null, + }; + + logger.info(`[Activity] Creating activity:`, { + requestId: entry.requestId, + userName, + userId: entry.user?.userId, + type: entry.type }); - } catch {} + + await Activity.create(activityData); + + logger.info(`[Activity] Successfully logged activity for request ${entry.requestId} by user: ${userName}`); + } catch (error) { + logger.error('[Activity] Failed to persist activity:', error); + } } get(requestId: string): ActivityEntry[] { diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index a1b89c8..a7c9efa 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -58,9 +58,10 @@ export class ApprovalService { activityService.log({ requestId: level.requestId, type: 'approval', + user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), - action: 'Final approval', - details: `${(wf as any).requestNumber} — ${(wf as any).title}` + action: 'Approved', + details: `Request approved and finalized by ${level.approverName || level.approverEmail}` }); } } else { @@ -97,10 +98,11 @@ export class ApprovalService { }); activityService.log({ requestId: level.requestId, - type: 'assignment', + type: 'approval', + user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), - action: 'Moved to next approver', - details: `${(wf as any).requestNumber} — ${(wf as any).title}` + action: 'Approved', + details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}` }); } } else { @@ -124,9 +126,10 @@ export class ApprovalService { activityService.log({ requestId: level.requestId, type: 'approval', + user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), - action: 'Workflow approved (no next level found)', - details: `${(wf as any).requestNumber} — ${(wf as any).title}` + action: 'Approved', + details: `Request approved and finalized by ${level.approverName || level.approverEmail}` }); } } @@ -174,9 +177,10 @@ export class ApprovalService { activityService.log({ requestId: level.requestId, type: 'rejection', + user: { userId: level.approverId, name: level.approverName }, timestamp: new Date().toISOString(), - action: 'Workflow rejected', - details: `${(wf as any).requestNumber} — ${(wf as any).title}` + action: 'Rejected', + details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}` }); } } diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index cb29577..170df46 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -17,6 +17,171 @@ import { notificationService } from './notification.service'; import { activityService } from './activity.service'; export class WorkflowService { + /** + * Helper method to map activity type to user-friendly action label + */ + private getActivityAction(type: string): string { + const actionMap: Record = { + 'created': 'Request Created', + 'assignment': 'Assigned', + 'approval': 'Approved', + 'rejection': 'Rejected', + 'status_change': 'Status Changed', + 'comment': 'Comment Added', + 'reminder': 'Reminder Sent', + 'document_added': 'Document Added', + 'sla_warning': 'SLA Warning' + }; + return actionMap[type] || 'Activity'; + } + + /** + * Add a new approver to an existing workflow + */ + async addApprover(requestId: string, email: string, addedBy: string): Promise { + try { + // Find user by email + const user = await User.findOne({ where: { email: email.toLowerCase() } }); + if (!user) { + throw new Error('User not found with this email'); + } + + const userId = (user as any).userId; + const userName = (user as any).displayName || (user as any).email; + + // Check if user is already a participant + const existing = await Participant.findOne({ + where: { requestId, userId } + }); + + if (existing) { + throw new Error('User is already a participant in this request'); + } + + // Add as approver participant + const participant = await Participant.create({ + requestId, + userId, + userEmail: email.toLowerCase(), + userName, + participantType: ParticipantType.APPROVER, + canComment: true, + canViewDocuments: true, + canDownloadDocuments: true, + notificationEnabled: true, + addedBy, + isActive: true + } as any); + + // Get workflow details for notification + const workflow = await WorkflowRequest.findOne({ where: { requestId } }); + const requestNumber = (workflow as any)?.requestNumber; + const title = (workflow as any)?.title; + + // Get the user who is adding the approver + const addedByUser = await User.findByPk(addedBy); + const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User'; + + // Log activity + await activityService.log({ + requestId, + type: 'assignment', + user: { userId: addedBy, name: addedByName }, + timestamp: new Date().toISOString(), + action: 'Added new approver', + details: `${userName} (${email}) has been added as an approver by ${addedByName}` + }); + + // Send notification to new approver + await notificationService.sendToUsers([userId], { + title: 'New Request Assignment', + body: `You have been added as an approver to request ${requestNumber}: ${title}`, + requestId, + requestNumber, + url: `/request/${requestNumber}` + }); + + logger.info(`[Workflow] Added approver ${email} to request ${requestId}`); + return participant; + } catch (error) { + logger.error(`[Workflow] Failed to add approver:`, error); + throw error; + } + } + + /** + * Add a new spectator to an existing workflow + */ + async addSpectator(requestId: string, email: string, addedBy: string): Promise { + try { + // Find user by email + const user = await User.findOne({ where: { email: email.toLowerCase() } }); + if (!user) { + throw new Error('User not found with this email'); + } + + const userId = (user as any).userId; + const userName = (user as any).displayName || (user as any).email; + + // Check if user is already a participant + const existing = await Participant.findOne({ + where: { requestId, userId } + }); + + if (existing) { + throw new Error('User is already a participant in this request'); + } + + // Add as spectator participant + const participant = await Participant.create({ + requestId, + userId, + userEmail: email.toLowerCase(), + userName, + participantType: ParticipantType.SPECTATOR, + canComment: true, + canViewDocuments: true, + canDownloadDocuments: false, + notificationEnabled: true, + addedBy, + isActive: true + } as any); + + // Get workflow details for notification + const workflow = await WorkflowRequest.findOne({ where: { requestId } }); + const requestNumber = (workflow as any)?.requestNumber; + const title = (workflow as any)?.title; + + // Get the user who is adding the spectator + const addedByUser = await User.findByPk(addedBy); + const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User'; + + // Log activity + await activityService.log({ + requestId, + type: 'assignment', + user: { userId: addedBy, name: addedByName }, + timestamp: new Date().toISOString(), + action: 'Added new spectator', + details: `${userName} (${email}) has been added as a spectator by ${addedByName}` + }); + + // Send notification to new spectator + await notificationService.sendToUsers([userId], { + title: 'Added to Request', + body: `You have been added as a spectator to request ${requestNumber}: ${title}`, + requestId, + requestNumber, + url: `/request/${requestNumber}` + }); + + logger.info(`[Workflow] Added spectator ${email} to request ${requestId}`); + return participant; + } catch (error) { + logger.error(`[Workflow] Failed to add spectator:`, error); + throw error; + } + } async listWorkflows(page: number, limit: number) { const offset = (page - 1) * limit; const { rows, count } = await WorkflowRequest.findAndCountAll({ @@ -51,6 +216,13 @@ export class WorkflowService { include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }] }); + // Fetch all approval levels for this request + const approvals = await ApprovalLevel.findAll({ + where: { requestId: (wf as any).requestId }, + order: [['levelNumber', 'ASC']], + attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status'] + }); + const totalTat = Number((wf as any).totalTatHours || 0); let percent = 0; let remainingText = ''; @@ -65,6 +237,11 @@ export class WorkflowService { remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`; } + // Calculate total TAT hours from all approvals + const totalTatHours = approvals.reduce((sum: number, a: any) => { + return sum + Number(a.tatHours || 0); + }, 0); + return { requestId: (wf as any).requestId, requestNumber: (wf as any).requestNumber, @@ -75,12 +252,24 @@ export class WorkflowService { submittedAt: (wf as any).submissionDate, initiator: (wf as any).initiator, totalLevels: (wf as any).totalLevels, + totalTatHours: totalTatHours, currentLevel: currentLevel ? (currentLevel as any).levelNumber : null, currentApprover: currentLevel ? { userId: (currentLevel as any).approverId, email: (currentLevel as any).approverEmail, name: (currentLevel as any).approverName, } : null, + approvals: approvals.map((a: any) => ({ + levelId: a.levelId, + levelNumber: a.levelNumber, + levelName: a.levelName, + approverId: a.approverId, + approverEmail: a.approverEmail, + approverName: a.approverName, + tatHours: a.tatHours, + tatDays: a.tatDays, + status: a.status + })), sla: { percent, remainingText }, }; })); @@ -126,13 +315,27 @@ export class WorkflowService { } } - // Only include requests where the current approver matches the user - const requestIds = Array.from(currentLevelByRequest.values()) + // Include requests where the current approver matches the user + const approverRequestIds = Array.from(currentLevelByRequest.values()) .filter(item => item.approverId === userId) .map(item => item.requestId); + + // Also include requests where the user is a spectator + const spectatorParticipants = await Participant.findAll({ + where: { + userId, + participantType: 'SPECTATOR', + }, + attributes: ['requestId'], + }); + const spectatorRequestIds = spectatorParticipants.map((p: any) => p.requestId); + + // Combine both sets of request IDs (unique) + const allRequestIds = Array.from(new Set([...approverRequestIds, ...spectatorRequestIds])); + const { rows, count } = await WorkflowRequest.findAndCountAll({ where: { - requestId: { [Op.in]: requestIds.length ? requestIds : ['00000000-0000-0000-0000-000000000000'] }, + requestId: { [Op.in]: allRequestIds.length ? allRequestIds : ['00000000-0000-0000-0000-000000000000'] }, status: { [Op.in]: [WorkflowStatus.PENDING as any, (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS'] as any }, }, offset, @@ -148,6 +351,8 @@ export class WorkflowService { async listClosedByMe(userId: string, page: number, limit: number) { const offset = (page - 1) * limit; + + // Get requests where user participated as approver const levelRows = await ApprovalLevel.findAll({ where: { approverId: userId, @@ -160,9 +365,32 @@ export class WorkflowService { }, attributes: ['requestId'], }); - const requestIds = Array.from(new Set(levelRows.map((l: any) => l.requestId))); + const approverRequestIds = Array.from(new Set(levelRows.map((l: any) => l.requestId))); + + // Also include requests where user is a spectator + const spectatorParticipants = await Participant.findAll({ + where: { + userId, + participantType: 'SPECTATOR', + }, + attributes: ['requestId'], + }); + const spectatorRequestIds = spectatorParticipants.map((p: any) => p.requestId); + + // Combine both sets of request IDs (unique) + const allRequestIds = Array.from(new Set([...approverRequestIds, ...spectatorRequestIds])); + + // Fetch closed/rejected requests const { rows, count } = await WorkflowRequest.findAndCountAll({ - where: { requestId: { [Op.in]: requestIds.length ? requestIds : ['00000000-0000-0000-0000-000000000000'] } }, + where: { + requestId: { [Op.in]: allRequestIds.length ? allRequestIds : ['00000000-0000-0000-0000-000000000000'] }, + status: { [Op.in]: [ + WorkflowStatus.APPROVED as any, + WorkflowStatus.REJECTED as any, + 'APPROVED', + 'REJECTED' + ] as any }, + }, offset, limit, order: [['createdAt', 'DESC']], @@ -232,13 +460,18 @@ export class WorkflowService { } logger.info(`Workflow created: ${requestNumber}`); + + // Get initiator details + const initiator = await User.findByPk(initiatorId); + const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; + activityService.log({ requestId: (workflow as any).requestId, type: 'created', - user: { userId: initiatorId }, + user: { userId: initiatorId, name: initiatorName }, timestamp: new Date().toISOString(), - action: 'Request created', - details: `${workflowData.title}` + action: 'Initial request submitted', + details: `Initial request submitted for ${workflowData.title} by ${initiatorName}` }); const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } }); if (firstLevel) { @@ -251,10 +484,10 @@ export class WorkflowService { activityService.log({ requestId: (workflow as any).requestId, type: 'assignment', - user: { userId: (firstLevel as any).approverId }, + user: { userId: initiatorId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Assigned to approver', - details: `${(firstLevel as any).approverName || ''}` + details: `Request assigned to ${(firstLevel as any).approverName || (firstLevel as any).approverEmail || 'approver'} for review` }); } return workflow; @@ -402,8 +635,23 @@ export class WorkflowService { let activities: any[] = []; try { const { Activity } = require('@models/Activity'); - activities = await Activity.findAll({ where: { requestId: actualRequestId }, order: [['created_at', 'ASC']] }); - } catch { + const rawActivities = await Activity.findAll({ + where: { requestId: actualRequestId }, + order: [['created_at', 'ASC']], + raw: true // Get raw data to access snake_case fields + }); + + // Transform activities to match frontend expected format + activities = rawActivities.map((act: any) => ({ + user: act.user_name || act.userName || 'System', + type: act.activity_type || act.activityType || 'status_change', + action: this.getActivityAction(act.activity_type || act.activityType), + details: act.activity_description || act.activityDescription || '', + timestamp: act.created_at || act.createdAt, + metadata: act.metadata + })); + } catch (error) { + logger.error('Error fetching activities:', error); activities = activityService.get(actualRequestId); } diff --git a/src/services/worknote.service.ts b/src/services/worknote.service.ts index c21cc1e..4ebad39 100644 --- a/src/services/worknote.service.ts +++ b/src/services/worknote.service.ts @@ -1,31 +1,100 @@ import { Op } from 'sequelize'; import { WorkNote } from '@models/WorkNote'; import { WorkNoteAttachment } from '@models/WorkNoteAttachment'; +import { Participant } from '@models/Participant'; +import { activityService } from './activity.service'; import logger from '@utils/logger'; export class WorkNoteService { async list(requestId: string) { - return await WorkNote.findAll({ + const notes = await WorkNote.findAll({ where: { requestId }, order: [['created_at' as any, 'ASC']] }); + + // Load attachments for each note + const enriched = await Promise.all(notes.map(async (note) => { + const noteId = (note as any).noteId; + const attachments = await WorkNoteAttachment.findAll({ + where: { noteId } + }); + + const noteData = (note as any).toJSON(); + + const mappedAttachments = attachments.map((a: any) => { + const attData = typeof a.toJSON === 'function' ? a.toJSON() : a; + return { + attachmentId: attData.attachmentId || attData.attachment_id, + fileName: attData.fileName || attData.file_name, + fileType: attData.fileType || attData.file_type, + fileSize: attData.fileSize || attData.file_size, + filePath: attData.filePath || attData.file_path, + storageUrl: attData.storageUrl || attData.storage_url, + isDownloadable: attData.isDownloadable || attData.is_downloadable, + uploadedAt: attData.uploadedAt || attData.uploaded_at + }; + }); + + return { + noteId: noteData.noteId || noteData.note_id, + requestId: noteData.requestId || noteData.request_id, + userId: noteData.userId || noteData.user_id, + userName: noteData.userName || noteData.user_name, + userRole: noteData.userRole || noteData.user_role, + message: noteData.message, + isPriority: noteData.isPriority || noteData.is_priority, + hasAttachment: noteData.hasAttachment || noteData.has_attachment, + createdAt: noteData.createdAt || noteData.created_at, + updatedAt: noteData.updatedAt || noteData.updated_at, + attachments: mappedAttachments + }; + })); + + return enriched; } - async create(requestId: string, user: { userId: string; name?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path: string; originalname: string; mimetype: string; size: number }>) { + async getUserRole(requestId: string, userId: string): Promise { + try { + const participant = await Participant.findOne({ + where: { requestId, userId } + }); + if (participant) { + const type = (participant as any).participantType || (participant as any).participant_type; + return type ? type.toString() : 'Participant'; + } + return 'Participant'; + } catch (error) { + logger.error('[WorkNote] Error fetching user role:', error); + return 'Participant'; + } + } + + async create(requestId: string, user: { userId: string; name?: string; role?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path: string; originalname: string; mimetype: string; size: number }>): Promise { + logger.info('[WorkNote] Creating note:', { requestId, user, messageLength: payload.message?.length }); + const note = await WorkNote.create({ requestId, userId: user.userId, userName: user.name || null, + userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR) message: payload.message, isPriority: !!payload.isPriority, parentNoteId: payload.parentNoteId || null, mentionedUsers: payload.mentionedUsers || null, hasAttachment: files && files.length > 0 ? true : false } as any); + + logger.info('[WorkNote] Created note:', { + noteId: (note as any).noteId, + userId: (note as any).userId, + userName: (note as any).userName, + userRole: (note as any).userRole + }); + const attachments = []; if (files && files.length) { for (const f of files) { - await WorkNoteAttachment.create({ + const attachment = await WorkNoteAttachment.create({ noteId: (note as any).noteId, fileName: f.originalname, fileType: f.mimetype, @@ -33,16 +102,65 @@ export class WorkNoteService { filePath: f.path, isDownloadable: true } as any); + + attachments.push({ + attachmentId: (attachment as any).attachmentId, + fileName: (attachment as any).fileName, + fileType: (attachment as any).fileType, + fileSize: (attachment as any).fileSize, + filePath: (attachment as any).filePath, + isDownloadable: (attachment as any).isDownloadable + }); } } + // Log activity for work note + activityService.log({ + requestId, + type: 'comment', + user: { userId: user.userId, name: user.name || 'User' }, + timestamp: new Date().toISOString(), + action: 'Work Note Added', + details: `${user.name || 'User'} added a work note: ${payload.message.substring(0, 100)}${payload.message.length > 100 ? '...' : ''}` + }); + try { // Optional realtime emit (if socket layer is initialized) const { emitToRequestRoom } = require('../realtime/socket'); - if (emitToRequestRoom) emitToRequestRoom(requestId, 'worknote:new', { note }); + if (emitToRequestRoom) { + // Emit note with all fields explicitly (to ensure camelCase fields are sent) + const noteData = { + noteId: (note as any).noteId, + requestId: (note as any).requestId, + userId: (note as any).userId, + userName: (note as any).userName, + userRole: (note as any).userRole, // Include participant role + message: (note as any).message, + createdAt: (note as any).createdAt, + hasAttachment: (note as any).hasAttachment, + attachments: attachments // Include attachments + }; + emitToRequestRoom(requestId, 'worknote:new', { note: noteData }); + } } catch (e) { logger.warn('Realtime emit failed (not initialized)'); } - return note; + return { ...note, attachments }; + } + + async downloadAttachment(attachmentId: string) { + const attachment = await WorkNoteAttachment.findOne({ + where: { attachmentId } + }); + + if (!attachment) { + throw new Error('Attachment not found'); + } + + return { + filePath: (attachment as any).filePath, + fileName: (attachment as any).fileName, + fileType: (attachment as any).fileType + }; } }