import { Op } from 'sequelize'; import { WorkNote } from '@models/WorkNote'; import { WorkNoteAttachment } from '@models/WorkNoteAttachment'; import { Participant } from '@models/Participant'; import { WorkflowRequest } from '@models/WorkflowRequest'; import { activityService } from './activity.service'; import { notificationService } from './notification.service'; import { gcsStorageService } from './gcsStorage.service'; import logger from '@utils/logger'; import fs from 'fs'; import path from 'path'; export class WorkNoteService { async list(requestId: string) { const notes = await WorkNote.findAll({ where: { requestId }, order: [['created_at' as any, 'ASC']] }); // Load attachments for each note const enriched = await Promise.all(notes.map(async (note) => { const noteId = (note as any).noteId; const attachments = await WorkNoteAttachment.findAll({ where: { noteId } }); const noteData = (note as any).toJSON(); const mappedAttachments = attachments.map((a: any) => { const attData = typeof a.toJSON === 'function' ? a.toJSON() : a; return { attachmentId: attData.attachmentId || attData.attachment_id, fileName: attData.fileName || attData.file_name, fileType: attData.fileType || attData.file_type, fileSize: attData.fileSize || attData.file_size, filePath: attData.filePath || attData.file_path, storageUrl: attData.storageUrl || attData.storage_url, isDownloadable: attData.isDownloadable || attData.is_downloadable, uploadedAt: attData.uploadedAt || attData.uploaded_at }; }); return { noteId: noteData.noteId || noteData.note_id, requestId: noteData.requestId || noteData.request_id, userId: noteData.userId || noteData.user_id, userName: noteData.userName || noteData.user_name, userRole: noteData.userRole || noteData.user_role, message: noteData.message, isPriority: noteData.isPriority || noteData.is_priority, hasAttachment: noteData.hasAttachment || noteData.has_attachment, createdAt: noteData.createdAt || noteData.created_at, updatedAt: noteData.updatedAt || noteData.updated_at, attachments: mappedAttachments }; })); return enriched; } async getUserRole(requestId: string, userId: string): Promise { try { const participant = await Participant.findOne({ where: { requestId, userId } }); if (participant) { const type = (participant as any).participantType || (participant as any).participant_type; return type ? type.toString() : 'Participant'; } return 'Participant'; } catch (error) { logger.error('[WorkNote] Error fetching user role:', error); return 'Participant'; } } async create(requestId: string, user: { userId: string; name?: string; role?: string }, payload: { message: string; isPriority?: boolean; parentNoteId?: string | null; mentionedUsers?: string[] | null; }, files?: Array<{ path?: string | null; buffer?: Buffer; originalname: string; mimetype: string; size: number }>, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise { logger.info('[WorkNote] Creating note:', { requestId, user, messageLength: payload.message?.length }); const note = await WorkNote.create({ requestId, userId: user.userId, userName: user.name || null, userRole: user.role || null, // Store participant type (INITIATOR/APPROVER/SPECTATOR) message: payload.message, isPriority: !!payload.isPriority, parentNoteId: payload.parentNoteId || null, mentionedUsers: payload.mentionedUsers || null, hasAttachment: files && files.length > 0 ? true : false } as any); logger.info('[WorkNote] Created note:', { noteId: (note as any).noteId, userId: (note as any).userId, userName: (note as any).userName, userRole: (note as any).userRole }); const attachments = []; if (files && files.length) { // Get request number for folder structure const workflow = await WorkflowRequest.findOne({ where: { requestId } }); const requestNumber = workflow ? ((workflow as any).requestNumber || (workflow as any).request_number) : null; for (const f of files) { // Read file buffer if path exists, otherwise use provided buffer const fileBuffer = f.buffer || (f.path ? fs.readFileSync(f.path) : Buffer.from('')); // Upload with automatic fallback to local storage // If requestNumber is not available, use a default structure const effectiveRequestNumber = requestNumber || 'UNKNOWN'; const uploadResult = await gcsStorageService.uploadFileWithFallback({ buffer: fileBuffer, originalName: f.originalname, mimeType: f.mimetype, requestNumber: effectiveRequestNumber, fileType: 'attachments' }); const storageUrl = uploadResult.storageUrl; const gcsFilePath = uploadResult.filePath; // Clean up local temporary file if it exists (from multer disk storage) if (f.path && fs.existsSync(f.path)) { try { fs.unlinkSync(f.path); } catch (unlinkError) { logger.warn('[WorkNote] Failed to delete local temporary file:', unlinkError); } } const attachment = await WorkNoteAttachment.create({ noteId: (note as any).noteId, fileName: f.originalname, fileType: f.mimetype, fileSize: f.size, filePath: gcsFilePath, // Store GCS path or local path storageUrl: storageUrl, // Store GCS URL or local URL isDownloadable: true } as any); attachments.push({ attachmentId: (attachment as any).attachmentId, fileName: (attachment as any).fileName, fileType: (attachment as any).fileType, fileSize: (attachment as any).fileSize, filePath: (attachment as any).filePath, storageUrl: (attachment as any).storageUrl, isDownloadable: (attachment as any).isDownloadable }); } } // Log activity for work note activityService.log({ requestId, type: 'comment', user: { userId: user.userId, name: user.name || 'User' }, timestamp: new Date().toISOString(), action: 'Work Note Added', details: `${user.name || 'User'} added a work note: ${payload.message.substring(0, 100)}${payload.message.length > 100 ? '...' : ''}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); try { // Optional realtime emit (if socket layer is initialized) const { emitToRequestRoom } = require('../realtime/socket'); if (emitToRequestRoom) { // Emit note with all fields explicitly (to ensure camelCase fields are sent) const noteData = { noteId: (note as any).noteId, requestId: (note as any).requestId, userId: (note as any).userId, userName: (note as any).userName, userRole: (note as any).userRole, // Include participant role message: (note as any).message, createdAt: (note as any).createdAt, hasAttachment: (note as any).hasAttachment, attachments: attachments // Include attachments }; emitToRequestRoom(requestId, 'worknote:new', { note: noteData }); } } catch (e) { logger.warn('Realtime emit failed (not initialized)'); } // Send notifications to mentioned users if (payload.mentionedUsers && Array.isArray(payload.mentionedUsers) && payload.mentionedUsers.length > 0) { try { // Get workflow details for request number and title const workflow = await WorkflowRequest.findOne({ where: { requestId } }); const requestNumber = (workflow as any)?.requestNumber || requestId; const requestTitle = (workflow as any)?.title || 'Request'; logger.info(`[WorkNote] Sending mention notifications to ${payload.mentionedUsers.length} users`); await notificationService.sendToUsers( payload.mentionedUsers, { title: '💬 Mentioned in Work Note', body: `${user.name || 'Someone'} mentioned you in ${requestNumber}: "${payload.message.substring(0, 50)}${payload.message.length > 50 ? '...' : ''}"`, requestId, requestNumber, url: `/request/${requestNumber}`, type: 'mention' } ); logger.info(`[WorkNote] Mention notifications sent successfully`); } catch (notifyError) { logger.error('[WorkNote] Failed to send mention notifications:', notifyError); // Don't fail the work note creation if notifications fail } } return { ...note, attachments }; } async downloadAttachment(attachmentId: string) { const attachment = await WorkNoteAttachment.findOne({ where: { attachmentId } }); if (!attachment) { throw new Error('Attachment not found'); } const storageUrl = (attachment as any).storageUrl || (attachment as any).storage_url; const filePath = (attachment as any).filePath || (attachment as any).file_path; const fileName = (attachment as any).fileName || (attachment as any).file_name; const fileType = (attachment as any).fileType || (attachment as any).file_type; // Check if it's a GCS URL const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://')); return { filePath: filePath, storageUrl: storageUrl, fileName: fileName, fileType: fileType, isGcsUrl: isGcsUrl }; } } export const workNoteService = new WorkNoteService();