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

355 lines
13 KiB
TypeScript

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<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/${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' });
}
};