Dealer_Onboarding_Backend/src/modules/collaboration/collaboration.controller.ts

443 lines
18 KiB
TypeScript

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<string, string[]> = {};
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<string>();
// 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' });
}
};