import { Response } from 'express'; import db from '../../database/models/index.js'; const { Worknote, User, WorkNoteTag, WorkNoteAttachment, Document, DocumentVersion, RequestParticipant, Application, AuditLog } = db; import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import * as EmailService from '../../common/utils/email.service.js'; import { getIO } from '../../common/utils/socket.js'; import * as NotificationService from '../../common/utils/notification.service.js'; import logger from '../../common/utils/logger.js'; // --- Worknotes --- export const addWorknote = async (req: AuthRequest, res: Response) => { try { const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body; logger.info(`Adding worknote for ${requestType} ${requestId}. Body:`, { noteText, tags, attachmentDocIds }); // Debug: Log participants const participants = await db.RequestParticipant.findAll({ where: { requestId, requestType }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }] }); const simplifiedParticipants = participants.map((p: any) => ({ id: p.user?.id, name: p.user?.fullName })); logger.info(`Participants for ${requestId}:`, simplifiedParticipants); const worknote = await Worknote.create({ requestId, requestType, // application, opportunity, etc. userId: req.user?.id, noteText, noteType: noteType || 'General', status: 'Active' }); if (tags && tags.length > 0) { for (const tag of tags) { await WorkNoteTag.create({ noteId: worknote.id, tagName: tag }); } } if (attachmentDocIds && attachmentDocIds.length > 0) { for (const docId of attachmentDocIds) { await WorkNoteAttachment.create({ noteId: worknote.id, documentId: docId }); } } // Add author as participant if (req.user?.id && requestId && requestType) { await db.RequestParticipant.findOrCreate({ where: { requestId, requestType, userId: req.user.id }, defaults: { participantType: 'contributor', joinedMethod: 'worknote' } }); } // Reload with associations for the response and socket emission const fullWorknote = await Worknote.findByPk(worknote.id, { include: [ { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, { model: Document, as: 'attachments' } ] }); // --- Real-time & Notifications --- try { const io = getIO(); io.to(requestId).emit('new_worknote', fullWorknote); // Handle Mentions/Notifications const notifiedUserIds = new Set(); // 1. Check structured mentions in noteText logger.info(`Checking worknote for mentions. Text: "${noteText}"`); if (noteText && typeof noteText === 'string' && noteText.includes('@')) { const mentionRegex = /@\[([^\]]+)\]\(user:([^\)]+)\)/g; let match; while ((match = mentionRegex.exec(noteText)) !== null) { const userId = match[2]; if (userId && userId !== req.user?.id) { notifiedUserIds.add(userId); } } } // 2. Check tags (fallback/robustness) if (tags && Array.isArray(tags)) { tags.forEach((tag: any) => { // If tag is a UUID-like string and not the current user if (typeof tag === 'string' && tag.length > 20 && tag !== req.user?.id) { notifiedUserIds.add(tag); } }); } // Send Notifications for (const userId of notifiedUserIds) { logger.info(`Sending notification to user ID: ${userId}`); try { await NotificationService.sendNotification({ userId, title: 'New Mention', message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`, type: 'info', link: `/applications/${requestId}?tab=worknotes` }); } catch (notifyErr) { logger.warn(`Failed to send notification to ${userId}:`, notifyErr); } } if (notifiedUserIds.size === 0 && noteText && noteText.includes('@')) { logger.warn('Worknote contains "@" but no mentions were successfully identified.'); } } catch (err) { console.warn('Real-time notification failed:', err); } // Audit log for worknote await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.WORKNOTE_ADDED, entityType: 'application', entityId: requestId, newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) } }); res.status(201).json({ success: true, message: 'Worknote added', data: fullWorknote }); } catch (error) { console.error('Add worknote error:', error); res.status(500).json({ success: false, message: 'Error adding worknote' }); } }; export const getWorknotes = async (req: AuthRequest, res: Response) => { try { const { requestId, requestType } = req.query as any; const worknotes = await Worknote.findAll({ where: { requestId, requestType }, include: [ { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, { model: Document, as: 'attachments' } ], order: [['createdAt', 'DESC']] }); res.json({ success: true, data: worknotes }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching worknotes' }); } }; export const uploadWorknoteAttachment = async (req: any, res: Response) => { try { const file = req.file; const { requestId, requestType } = req.body; if (!file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } const document = await Document.create({ requestId: requestId || null, requestType: requestType || null, documentType: 'Worknote Attachment', fileName: file.originalname, filePath: file.path, mimeType: file.mimetype, fileSize: file.size, uploadedBy: req.user?.id, status: 'active' }); // Create initial version await DocumentVersion.create({ documentId: document.id, versionNumber: 1, filePath: file.path, uploadedBy: req.user?.id, changeReason: 'Initial Upload' }); // Audit log for attachment upload if (requestId) { await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED, entityType: requestType || 'application', entityId: requestId, newData: { fileName: file.originalname, mimeType: file.mimetype } }); } res.status(201).json({ success: true, message: 'Attachment uploaded', data: { id: document.id, fileName: document.fileName, filePath: document.filePath, mimeType: document.mimeType } }); } catch (error) { console.error('Upload worknote attachment error:', error); res.status(500).json({ success: false, message: 'Error uploading attachment' }); } }; // --- Documents --- export const uploadDocument = async (req: AuthRequest, res: Response) => { try { const { applicationId, dealerId, docType, fileName, fileUrl, mimeType } = req.body; const document = await Document.create({ applicationId, dealerId, docType, fileName, filePath: fileUrl, // Assuming URL from cloud storage mimeType, uploadedBy: req.user?.id }); // Create Initial Version await DocumentVersion.create({ documentId: document.id, versionNumber: 1, filePath: fileUrl, uploadedBy: req.user?.id, changeReason: 'Initial Upload' }); // Audit log for document upload await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.DOCUMENT_UPLOADED, entityType: 'application', entityId: applicationId, newData: { docType, fileName } }); res.status(201).json({ success: true, message: 'Document uploaded', data: document }); } catch (error) { console.error('Upload document error:', error); res.status(500).json({ success: false, message: 'Error uploading document' }); } }; export const uploadNewVersion = async (req: AuthRequest, res: Response) => { try { const { documentId, fileUrl, changeReason } = req.body; const lastVersion = await DocumentVersion.findOne({ where: { documentId }, order: [['versionNumber', 'DESC']] }); const nextVersion = (lastVersion?.versionNumber || 0) + 1; await DocumentVersion.create({ documentId, versionNumber: nextVersion, filePath: fileUrl, uploadedBy: req.user?.id, changeReason }); // Update main document pointer if needed (usually main doc points to latest or metadata) await Document.update({ filePath: fileUrl }, { where: { id: documentId } }); res.status(201).json({ success: true, message: 'New version uploaded' }); } catch (error) { res.status(500).json({ success: false, message: 'Error uploading version' }); } }; // --- Participants --- export const addParticipant = async (req: AuthRequest, res: Response) => { try { const { requestId, requestType, userId, participantType } = req.body; const [participant, created] = await RequestParticipant.findOrCreate({ where: { requestId, requestType, userId }, defaults: { participantType: participantType || 'contributor', joinedMethod: 'manual' } }); if (!created) { await participant.update({ participantType }); } // Notify the user via email const user = await User.findByPk(userId); const application = await Application.findByPk(requestId); if (user && application) { await EmailService.sendUserAssignedEmail( user.email, user.fullName, application.applicationId || application.id, application.name, // The dealer's name participantType || 'participant' ); } // Audit log for participant added await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.PARTICIPANT_ADDED, entityType: requestType || 'application', entityId: requestId, newData: { addedUserId: userId, participantType: participantType || 'contributor', userName: user?.fullName } }); res.status(201).json({ success: true, message: 'Participant added', data: participant }); } catch (error) { console.error('Add participant error:', error); res.status(500).json({ success: false, message: 'Error adding participant' }); } }; export const removeParticipant = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const participant = await RequestParticipant.findByPk(id); await RequestParticipant.destroy({ where: { id } }); // Audit log for participant removed if (participant) { await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, entityType: (participant as any).requestType || 'application', entityId: (participant as any).requestId, newData: { removedUserId: (participant as any).userId } }); } res.json({ success: true, message: 'Participant removed' }); } catch (error) { res.status(500).json({ success: false, message: 'Error removing participant' }); } };