added websocket implementaion and enhanced activity logs
This commit is contained in:
parent
eb0bca123b
commit
4f4c456450
43
scripts/fix_activity_usernames.sql
Normal file
43
scripts/fix_activity_usernames.sql
Normal 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;
|
||||||
|
|
||||||
@ -2,7 +2,9 @@ import { Request, Response } from 'express';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Document } from '@models/Document';
|
import { Document } from '@models/Document';
|
||||||
|
import { User } from '@models/User';
|
||||||
import { ResponseHandler } from '@utils/responseHandler';
|
import { ResponseHandler } from '@utils/responseHandler';
|
||||||
|
import { activityService } from '@services/activity.service';
|
||||||
import type { AuthenticatedRequest } from '../types/express';
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
|
|
||||||
export class DocumentController {
|
export class DocumentController {
|
||||||
@ -51,6 +53,26 @@ export class DocumentController {
|
|||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} 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);
|
ResponseHandler.success(res, doc, 'File uploaded', 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { AuthenticatedRequest } from '../types/express';
|
|||||||
import { Priority } from '../types/common.types';
|
import { Priority } from '../types/common.types';
|
||||||
import type { UpdateWorkflowRequest } from '../types/workflow.types';
|
import type { UpdateWorkflowRequest } from '../types/workflow.types';
|
||||||
import { Document } from '@models/Document';
|
import { Document } from '@models/Document';
|
||||||
|
import { User } from '@models/User';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@ -55,6 +56,10 @@ export class WorkflowController {
|
|||||||
const category = (req.body?.category as string) || 'OTHER';
|
const category = (req.body?.category as string) || 'OTHER';
|
||||||
const docs: any[] = [];
|
const docs: any[] = [];
|
||||||
if (files && files.length > 0) {
|
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) {
|
for (const file of files) {
|
||||||
const buffer = fs.readFileSync(file.path);
|
const buffer = fs.readFileSync(file.path);
|
||||||
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
@ -80,6 +85,17 @@ export class WorkflowController {
|
|||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} as any);
|
||||||
docs.push(doc);
|
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 }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,27 @@ export class WorkNoteController {
|
|||||||
const wf = await (this.workflowService as any).findWorkflowByIdentifier(req.params.id);
|
const wf = await (this.workflowService as any).findWorkflowByIdentifier(req.params.id);
|
||||||
if (!wf) { res.status(404).json({ success: false, error: 'Not found' }); return; }
|
if (!wf) { res.status(404).json({ success: false, error: 'Not found' }); return; }
|
||||||
const requestId: string = wf.getDataValue('requestId');
|
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 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 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);
|
const note = await workNoteService.create(requestId, user, payload, files);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { Server } from 'socket.io';
|
|||||||
|
|
||||||
let io: Server | null = null;
|
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) {
|
export function initSocket(httpServer: any) {
|
||||||
const defaultOrigins = [
|
const defaultOrigins = [
|
||||||
'http://localhost:3000',
|
'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 configured = (process.env.FRONTEND_ORIGIN || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const origins = configured.length ? configured : defaultOrigins;
|
const origins = configured.length ? configured : defaultOrigins;
|
||||||
|
|
||||||
|
console.log('🔌 Initializing Socket.IO server with origins:', origins);
|
||||||
|
|
||||||
io = new Server(httpServer, {
|
io = new Server(httpServer, {
|
||||||
cors: {
|
cors: {
|
||||||
origin: origins,
|
origin: origins,
|
||||||
methods: ['GET', 'POST'],
|
methods: ['GET', 'POST'],
|
||||||
credentials: true
|
credentials: true
|
||||||
},
|
},
|
||||||
path: '/socket.io'
|
path: '/socket.io',
|
||||||
|
transports: ['websocket', 'polling']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('✅ Socket.IO server initialized');
|
||||||
|
|
||||||
io.on('connection', (socket: any) => {
|
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}`);
|
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.on('leave:request', (requestId: string) => {
|
||||||
socket.leave(`request:${requestId}`);
|
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;
|
return io;
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import { notificationService } from '../services/notification.service';
|
|||||||
import { Activity } from '@models/Activity';
|
import { Activity } from '@models/Activity';
|
||||||
import { WorkflowService } from '../services/workflow.service';
|
import { WorkflowService } from '../services/workflow.service';
|
||||||
import { WorkNoteController } from '../controllers/worknote.controller';
|
import { WorkNoteController } from '../controllers/worknote.controller';
|
||||||
|
import { workNoteService } from '../services/worknote.service';
|
||||||
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const workflowController = new WorkflowController();
|
const workflowController = new WorkflowController();
|
||||||
@ -184,4 +186,176 @@ router.post('/:id/work-notes',
|
|||||||
asyncHandler(workNoteController.create.bind(workNoteController))
|
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;
|
export default router;
|
||||||
|
|||||||
@ -1,36 +1,55 @@
|
|||||||
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
export type ActivityEntry = {
|
export type ActivityEntry = {
|
||||||
requestId: string;
|
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 };
|
user?: { userId: string; name?: string; email?: string };
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
action: string;
|
action: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
metadata?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ActivityService {
|
class ActivityService {
|
||||||
private byRequest: Map<string, ActivityEntry[]> = new Map();
|
private byRequest: Map<string, ActivityEntry[]> = new Map();
|
||||||
|
|
||||||
log(entry: ActivityEntry) {
|
async log(entry: ActivityEntry) {
|
||||||
const list = this.byRequest.get(entry.requestId) || [];
|
const list = this.byRequest.get(entry.requestId) || [];
|
||||||
list.push(entry);
|
list.push(entry);
|
||||||
this.byRequest.set(entry.requestId, list);
|
this.byRequest.set(entry.requestId, list);
|
||||||
// Persist best-effort (non-blocking)
|
|
||||||
|
// Persist to database
|
||||||
try {
|
try {
|
||||||
const { Activity } = require('@models/Activity');
|
const { Activity } = require('@models/Activity');
|
||||||
Activity.create({
|
const userName = entry.user?.name || entry.user?.email || null;
|
||||||
|
|
||||||
|
const activityData = {
|
||||||
requestId: entry.requestId,
|
requestId: entry.requestId,
|
||||||
userId: entry.user?.userId || null,
|
userId: entry.user?.userId || null,
|
||||||
userName: entry.user?.name || null,
|
userName: userName,
|
||||||
activityType: entry.type,
|
activityType: entry.type,
|
||||||
activityDescription: entry.details,
|
activityDescription: entry.details,
|
||||||
activityCategory: null,
|
activityCategory: null,
|
||||||
severity: null,
|
severity: null,
|
||||||
metadata: null,
|
metadata: entry.metadata || null,
|
||||||
isSystemEvent: !entry.user,
|
isSystemEvent: !entry.user,
|
||||||
ipAddress: null,
|
ipAddress: null,
|
||||||
userAgent: 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[] {
|
get(requestId: string): ActivityEntry[] {
|
||||||
|
|||||||
@ -58,9 +58,10 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Final approval',
|
action: 'Approved',
|
||||||
details: `${(wf as any).requestNumber} — ${(wf as any).title}`
|
details: `Request approved and finalized by ${level.approverName || level.approverEmail}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -97,10 +98,11 @@ export class ApprovalService {
|
|||||||
});
|
});
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'assignment',
|
type: 'approval',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Moved to next approver',
|
action: 'Approved',
|
||||||
details: `${(wf as any).requestNumber} — ${(wf as any).title}`
|
details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -124,9 +126,10 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Workflow approved (no next level found)',
|
action: 'Approved',
|
||||||
details: `${(wf as any).requestNumber} — ${(wf as any).title}`
|
details: `Request approved and finalized by ${level.approverName || level.approverEmail}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -174,9 +177,10 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'rejection',
|
type: 'rejection',
|
||||||
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Workflow rejected',
|
action: 'Rejected',
|
||||||
details: `${(wf as any).requestNumber} — ${(wf as any).title}`
|
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,171 @@ import { notificationService } from './notification.service';
|
|||||||
import { activityService } from './activity.service';
|
import { activityService } from './activity.service';
|
||||||
|
|
||||||
export class WorkflowService {
|
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) {
|
async listWorkflows(page: number, limit: number) {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||||
@ -51,6 +216,13 @@ export class WorkflowService {
|
|||||||
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
|
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);
|
const totalTat = Number((wf as any).totalTatHours || 0);
|
||||||
let percent = 0;
|
let percent = 0;
|
||||||
let remainingText = '';
|
let remainingText = '';
|
||||||
@ -65,6 +237,11 @@ export class WorkflowService {
|
|||||||
remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`;
|
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 {
|
return {
|
||||||
requestId: (wf as any).requestId,
|
requestId: (wf as any).requestId,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
@ -75,12 +252,24 @@ export class WorkflowService {
|
|||||||
submittedAt: (wf as any).submissionDate,
|
submittedAt: (wf as any).submissionDate,
|
||||||
initiator: (wf as any).initiator,
|
initiator: (wf as any).initiator,
|
||||||
totalLevels: (wf as any).totalLevels,
|
totalLevels: (wf as any).totalLevels,
|
||||||
|
totalTatHours: totalTatHours,
|
||||||
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
|
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
|
||||||
currentApprover: currentLevel ? {
|
currentApprover: currentLevel ? {
|
||||||
userId: (currentLevel as any).approverId,
|
userId: (currentLevel as any).approverId,
|
||||||
email: (currentLevel as any).approverEmail,
|
email: (currentLevel as any).approverEmail,
|
||||||
name: (currentLevel as any).approverName,
|
name: (currentLevel as any).approverName,
|
||||||
} : null,
|
} : 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 },
|
sla: { percent, remainingText },
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
@ -126,13 +315,27 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include requests where the current approver matches the user
|
// Include requests where the current approver matches the user
|
||||||
const requestIds = Array.from(currentLevelByRequest.values())
|
const approverRequestIds = Array.from(currentLevelByRequest.values())
|
||||||
.filter(item => item.approverId === userId)
|
.filter(item => item.approverId === userId)
|
||||||
.map(item => item.requestId);
|
.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({
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||||
where: {
|
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 },
|
status: { [Op.in]: [WorkflowStatus.PENDING as any, (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS'] as any },
|
||||||
},
|
},
|
||||||
offset,
|
offset,
|
||||||
@ -148,6 +351,8 @@ export class WorkflowService {
|
|||||||
|
|
||||||
async listClosedByMe(userId: string, page: number, limit: number) {
|
async listClosedByMe(userId: string, page: number, limit: number) {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get requests where user participated as approver
|
||||||
const levelRows = await ApprovalLevel.findAll({
|
const levelRows = await ApprovalLevel.findAll({
|
||||||
where: {
|
where: {
|
||||||
approverId: userId,
|
approverId: userId,
|
||||||
@ -160,9 +365,32 @@ export class WorkflowService {
|
|||||||
},
|
},
|
||||||
attributes: ['requestId'],
|
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({
|
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,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
@ -232,13 +460,18 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Workflow created: ${requestNumber}`);
|
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({
|
activityService.log({
|
||||||
requestId: (workflow as any).requestId,
|
requestId: (workflow as any).requestId,
|
||||||
type: 'created',
|
type: 'created',
|
||||||
user: { userId: initiatorId },
|
user: { userId: initiatorId, name: initiatorName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Request created',
|
action: 'Initial request submitted',
|
||||||
details: `${workflowData.title}`
|
details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`
|
||||||
});
|
});
|
||||||
const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } });
|
const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } });
|
||||||
if (firstLevel) {
|
if (firstLevel) {
|
||||||
@ -251,10 +484,10 @@ export class WorkflowService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: (workflow as any).requestId,
|
requestId: (workflow as any).requestId,
|
||||||
type: 'assignment',
|
type: 'assignment',
|
||||||
user: { userId: (firstLevel as any).approverId },
|
user: { userId: initiatorId, name: initiatorName },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Assigned to approver',
|
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;
|
return workflow;
|
||||||
@ -402,8 +635,23 @@ export class WorkflowService {
|
|||||||
let activities: any[] = [];
|
let activities: any[] = [];
|
||||||
try {
|
try {
|
||||||
const { Activity } = require('@models/Activity');
|
const { Activity } = require('@models/Activity');
|
||||||
activities = await Activity.findAll({ where: { requestId: actualRequestId }, order: [['created_at', 'ASC']] });
|
const rawActivities = await Activity.findAll({
|
||||||
} catch {
|
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);
|
activities = activityService.get(actualRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,82 @@
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { WorkNote } from '@models/WorkNote';
|
import { WorkNote } from '@models/WorkNote';
|
||||||
import { WorkNoteAttachment } from '@models/WorkNoteAttachment';
|
import { WorkNoteAttachment } from '@models/WorkNoteAttachment';
|
||||||
|
import { Participant } from '@models/Participant';
|
||||||
|
import { activityService } from './activity.service';
|
||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
export class WorkNoteService {
|
export class WorkNoteService {
|
||||||
async list(requestId: string) {
|
async list(requestId: string) {
|
||||||
return await WorkNote.findAll({
|
const notes = await WorkNote.findAll({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
order: [['created_at' as any, 'ASC']]
|
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({
|
const note = await WorkNote.create({
|
||||||
requestId,
|
requestId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
userName: user.name || null,
|
userName: user.name || null,
|
||||||
|
userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR)
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
isPriority: !!payload.isPriority,
|
isPriority: !!payload.isPriority,
|
||||||
parentNoteId: payload.parentNoteId || null,
|
parentNoteId: payload.parentNoteId || null,
|
||||||
@ -23,9 +84,17 @@ export class WorkNoteService {
|
|||||||
hasAttachment: files && files.length > 0 ? true : false
|
hasAttachment: files && files.length > 0 ? true : false
|
||||||
} as any);
|
} 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) {
|
if (files && files.length) {
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
await WorkNoteAttachment.create({
|
const attachment = await WorkNoteAttachment.create({
|
||||||
noteId: (note as any).noteId,
|
noteId: (note as any).noteId,
|
||||||
fileName: f.originalname,
|
fileName: f.originalname,
|
||||||
fileType: f.mimetype,
|
fileType: f.mimetype,
|
||||||
@ -33,16 +102,65 @@ export class WorkNoteService {
|
|||||||
filePath: f.path,
|
filePath: f.path,
|
||||||
isDownloadable: true
|
isDownloadable: true
|
||||||
} as any);
|
} 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 {
|
try {
|
||||||
// Optional realtime emit (if socket layer is initialized)
|
// Optional realtime emit (if socket layer is initialized)
|
||||||
const { emitToRequestRoom } = require('../realtime/socket');
|
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)'); }
|
} 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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user