355 lines
13 KiB
TypeScript
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' });
|
|
}
|
|
};
|