Re_Backend/_archive/services/worknote.service.ts

447 lines
18 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 { User } from '@models/User';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { activityService } from './activity.service';
import { notificationService } from './notification.service';
import { emailNotificationService } from './emailNotification.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
});
}
// Send notifications for additional document added via work notes
if (attachments.length > 0) {
try {
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
if (workflow) {
const initiatorId = (workflow as any).initiatorId || (workflow as any).initiator_id;
const isInitiator = user.userId === initiatorId;
// Get all participants (spectators)
const spectators = await Participant.findAll({
where: {
requestId,
participantType: 'SPECTATOR'
},
include: [{
model: User,
as: 'user',
attributes: ['userId', 'email', 'displayName']
}]
});
// Get current approver (pending or in-progress approval level)
const currentApprovalLevel = await ApprovalLevel.findOne({
where: {
requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
},
order: [['levelNumber', 'ASC']],
include: [{
model: User,
as: 'approver',
attributes: ['userId', 'email', 'displayName']
}]
});
// Determine who to notify based on who uploaded
const recipientsToNotify: Array<{ userId: string; email: string; displayName: string }> = [];
if (isInitiator) {
// Initiator added → notify spectators and current approver
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== user.userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== user.userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Check if uploader is a spectator
const uploaderParticipant = await Participant.findOne({
where: {
requestId,
userId: user.userId,
participantType: 'SPECTATOR'
}
});
if (uploaderParticipant) {
// Spectator added → notify initiator and current approver
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== user.userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== user.userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Approver added → notify initiator and spectators
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== user.userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== user.userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
}
}
// Send notifications (email, in-app, and web-push)
const requestNumber = (workflow as any).requestNumber || requestId;
const requestData = {
requestNumber: requestNumber,
requestId: requestId,
title: (workflow as any).title || 'Request'
};
// Prepare user IDs for in-app and web-push notifications
const recipientUserIds = recipientsToNotify.map(r => r.userId);
// Send in-app and web-push notifications for each attachment
if (recipientUserIds.length > 0 && attachments.length > 0) {
try {
for (const attachment of attachments) {
await notificationService.sendToUsers(
recipientUserIds,
{
title: 'Additional Document Added',
body: `${user.name || 'User'} added "${attachment.fileName}" to ${requestNumber}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`,
type: 'document_added',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
documentName: attachment.fileName,
fileSize: attachment.fileSize,
addedByName: user.name || 'User',
source: 'Work Notes'
}
}
);
}
logger.info('[WorkNote] In-app and web-push notifications sent for additional documents', {
requestId,
attachmentsCount: attachments.length,
recipientsCount: recipientUserIds.length
});
} catch (notifyError) {
logger.error('[WorkNote] Failed to send in-app/web-push notifications for additional documents:', notifyError);
}
}
// Send email notifications for each attachment
for (const attachment of attachments) {
for (const recipient of recipientsToNotify) {
await emailNotificationService.sendAdditionalDocumentAdded(
requestData,
recipient,
{
documentName: attachment.fileName,
fileSize: attachment.fileSize,
addedByName: user.name || 'User',
source: 'Work Notes'
}
);
}
}
logger.info('[WorkNote] Additional document notifications sent', {
requestId,
attachmentsCount: attachments.length,
recipientsCount: recipientsToNotify.length,
isInitiator
});
}
} catch (notifyError) {
// Don't fail work note creation if notifications fail
logger.error('[WorkNote] Failed to send additional document notifications:', notifyError);
}
}
}
// 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();