import { Response } from 'express'; import db from '../../database/models/index.js'; const { Worknote, User, WorkNoteTag, WorkNoteAttachment, DocumentVersion, RequestParticipant, Application, AuditLog, OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument } = db; import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; import { resolveEntityUuidByType, requestTypeQueryVariants } from '../../common/utils/requestResolver.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'; // --- Helpers --- const getDocumentModel = (requestType: string) => { switch (requestType?.toLowerCase()) { case 'relocation': return RelocationDocument; case 'resignation': return ResignationDocument; case 'constitutional': case 'constitutional-change': return ConstitutionalDocument; case 'termination': return TerminationDocument; case 'onboarding': case 'application': return OnboardingDocument; default: return OnboardingDocument; } }; const stitchWorknoteAttachments = async (worknotes: any[]) => { const notePromises = worknotes.map(async (note: any) => { const noteObj = note.toJSON ? note.toJSON() : note; if (noteObj.attachments && noteObj.attachments.length > 0) { // Group by documentType for batch fetching const typeGroups: Record = {}; noteObj.attachments.forEach((att: any) => { const type = att.documentType || 'onboarding'; if (!typeGroups[type]) typeGroups[type] = []; typeGroups[type].push(att.documentId); }); const attachmentsWithFiles = []; for (const [type, ids] of Object.entries(typeGroups)) { const DocModel = getDocumentModel(type); if (DocModel) { const docs = await (DocModel as any).findAll({ where: { id: ids } }); const docsByid = new Map(docs.map((d: any) => [d.id, d.toJSON()])); attachmentsWithFiles.push(...noteObj.attachments .filter((att: any) => (att.documentType || 'onboarding') === type) .map((att: any) => { const doc = docsByid.get(att.documentId) as any; return { ...att, fileName: doc?.fileName || 'Unknown', filePath: doc?.filePath || '', mimeType: doc?.mimeType || 'application/octet-stream', fileSize: doc?.fileSize }; }) ); } } noteObj.attachments = attachmentsWithFiles; } return noteObj; }); return Promise.all(notePromises); }; function worknoteListWhere(rawId: string, resolvedId: string, normalizedType: string) { const idVariants = Array.from(new Set([String(rawId || '').trim(), String(resolvedId || '').trim()].filter(Boolean))); const variants = requestTypeQueryVariants(normalizedType); const requestIdWhere = idVariants.length > 1 ? { [db.Sequelize.Op.in]: idVariants } : idVariants[0]; if (variants.length > 1) return { requestId: requestIdWhere, requestType: { [db.Sequelize.Op.in]: variants } }; return { requestId: requestIdWhere, requestType: variants[0] }; } // --- Worknotes --- export const addWorknote = async (req: AuthRequest, res: Response) => { try { const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body; const { resolvedId, normalizedType } = await resolveEntityUuidByType(db as any, requestId, requestType); logger.info(`Adding worknote for ${normalizedType} ${resolvedId}. Body:`, { noteText, tags, attachmentDocIds }); // Debug: Log participants const participants = await db.RequestParticipant.findAll({ where: { requestId: resolvedId, requestType: normalizedType }, 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 ${resolvedId}:`, simplifiedParticipants); const worknote = await Worknote.create({ requestId: resolvedId, requestType: normalizedType, 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, documentType: normalizedType || 'onboarding' }); } } // Add author as participant if (req.user?.id && resolvedId && normalizedType) { await db.RequestParticipant.findOrCreate({ where: { requestId: resolvedId, requestType: normalizedType, 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: WorkNoteAttachment, as: 'attachments' } ] }); const [stitchedNote] = await stitchWorknoteAttachments([fullWorknote]); // --- Real-time & Notifications --- try { const io = getIO(); io.to(resolvedId).emit('new_worknote', stitchedNote); // 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/${resolvedId}?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: resolvedId, newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) } }); res.status(201).json({ success: true, message: 'Worknote added', data: stitchedNote }); } 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 { resolvedId, normalizedType } = await resolveEntityUuidByType(db as any, requestId, requestType); const where = worknoteListWhere(String(requestId || ''), resolvedId, normalizedType); const worknotes = await Worknote.findAll({ where, include: [ { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, { model: WorkNoteAttachment, as: 'attachments' } ], order: [['createdAt', 'DESC']] }); const finalWorknotes = await stitchWorknoteAttachments(worknotes); res.json({ success: true, data: finalWorknotes }); } 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; const { resolvedId, normalizedType } = await resolveEntityUuidByType(db as any, requestId, requestType); if (!file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } const DocModel = getDocumentModel(normalizedType); let createData: any = { documentType: 'Worknote Attachment', fileName: file.originalname, filePath: file.path, mimeType: file.mimetype, fileSize: file.size, uploadedBy: req.user?.id, status: 'active' }; // Assign correct FK based on model (always UUID for self-service modules) if (DocModel === RelocationDocument) createData.relocationId = resolvedId; else if (DocModel === ResignationDocument) createData.resignationId = resolvedId; else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = resolvedId; else if (DocModel === TerminationDocument) createData.terminationRequestId = resolvedId; else createData.applicationId = resolvedId; const document = await DocModel.create(createData); // Create initial version await DocumentVersion.create({ documentId: document.id, documentType: normalizedType || 'onboarding', versionNumber: 1, filePath: file.path, uploadedBy: req.user?.id, changeReason: 'Initial Upload' }); // Audit log for attachment upload if (resolvedId) { await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED, entityType: normalizedType || 'application', entityId: resolvedId, 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 OnboardingDocument.create({ applicationId, dealerId, documentType: 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) // For simplicity assuming onboarding if not specified await OnboardingDocument.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.applicantName, // 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' }); } };