248 lines
9.8 KiB
TypeScript
248 lines
9.8 KiB
TypeScript
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<string> {
|
|
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<any> {
|
|
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();
|
|
|
|
|