added websocket implementaion and enhanced activity logs

This commit is contained in:
laxmanhalaki 2025-11-03 21:18:05 +05:30
parent eb0bca123b
commit 4f4c456450
10 changed files with 762 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,9 @@ import { Server } from 'socket.io';
let io: Server | null = null;
// Track online users per request: { requestId: Set<userId> }
const onlineUsersPerRequest = new Map<string, Set<string>>();
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;

View File

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

View File

@ -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<string, ActivityEntry[]> = 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[] {

View File

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

View File

@ -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<string, string> = {
'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<any> {
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<any> {
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);
}

View File

@ -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<string> {
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<any> {
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
};
}
}