import { WorkflowRequest } from '@models/WorkflowRequest'; // duplicate import removed import { User } from '@models/User'; import { ApprovalLevel } from '@models/ApprovalLevel'; import { Participant } from '@models/Participant'; import { Document } from '@models/Document'; // Ensure associations are initialized by importing models index import '@models/index'; import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.types'; import { generateRequestNumber, calculateTATDays } from '@utils/helpers'; import logger from '@utils/logger'; import { WorkflowStatus, ParticipantType, ApprovalStatus } from '../types/common.types'; import { Op } from 'sequelize'; import fs from 'fs'; import path from 'path'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; export class WorkflowService { /** * Helper method to map activity type to user-friendly action label */ private getActivityAction(type: string): string { const actionMap: Record = { 'created': 'Request Created', 'assignment': 'Assigned', 'approval': 'Approved', 'rejection': 'Rejected', 'status_change': 'Status Changed', 'comment': 'Comment Added', 'reminder': 'Reminder Sent', 'document_added': 'Document Added', 'sla_warning': 'SLA Warning' }; return actionMap[type] || 'Activity'; } /** * Add a new approver to an existing workflow */ async addApprover(requestId: string, email: string, addedBy: string): Promise { try { // Find user by email const user = await User.findOne({ where: { email: email.toLowerCase() } }); if (!user) { throw new Error('User not found with this email'); } const userId = (user as any).userId; const userName = (user as any).displayName || (user as any).email; // Check if user is already a participant const existing = await Participant.findOne({ where: { requestId, userId } }); if (existing) { throw new Error('User is already a participant in this request'); } // Add as approver participant const participant = await Participant.create({ requestId, userId, userEmail: email.toLowerCase(), userName, participantType: ParticipantType.APPROVER, canComment: true, canViewDocuments: true, canDownloadDocuments: true, notificationEnabled: true, addedBy, isActive: true } as any); // Get workflow details for notification const workflow = await WorkflowRequest.findOne({ where: { requestId } }); const requestNumber = (workflow as any)?.requestNumber; const title = (workflow as any)?.title; // Get the user who is adding the approver const addedByUser = await User.findByPk(addedBy); const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User'; // Log activity await activityService.log({ requestId, type: 'assignment', user: { userId: addedBy, name: addedByName }, timestamp: new Date().toISOString(), action: 'Added new approver', details: `${userName} (${email}) has been added as an approver by ${addedByName}` }); // Send notification to new approver await notificationService.sendToUsers([userId], { title: 'New Request Assignment', body: `You have been added as an approver to request ${requestNumber}: ${title}`, requestId, requestNumber, url: `/request/${requestNumber}` }); logger.info(`[Workflow] Added approver ${email} to request ${requestId}`); return participant; } catch (error) { logger.error(`[Workflow] Failed to add approver:`, error); throw error; } } /** * Add a new spectator to an existing workflow */ async addSpectator(requestId: string, email: string, addedBy: string): Promise { try { // Find user by email const user = await User.findOne({ where: { email: email.toLowerCase() } }); if (!user) { throw new Error('User not found with this email'); } const userId = (user as any).userId; const userName = (user as any).displayName || (user as any).email; // Check if user is already a participant const existing = await Participant.findOne({ where: { requestId, userId } }); if (existing) { throw new Error('User is already a participant in this request'); } // Add as spectator participant const participant = await Participant.create({ requestId, userId, userEmail: email.toLowerCase(), userName, participantType: ParticipantType.SPECTATOR, canComment: true, canViewDocuments: true, canDownloadDocuments: false, notificationEnabled: true, addedBy, isActive: true } as any); // Get workflow details for notification const workflow = await WorkflowRequest.findOne({ where: { requestId } }); const requestNumber = (workflow as any)?.requestNumber; const title = (workflow as any)?.title; // Get the user who is adding the spectator const addedByUser = await User.findByPk(addedBy); const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User'; // Log activity await activityService.log({ requestId, type: 'assignment', user: { userId: addedBy, name: addedByName }, timestamp: new Date().toISOString(), action: 'Added new spectator', details: `${userName} (${email}) has been added as a spectator by ${addedByName}` }); // Send notification to new spectator await notificationService.sendToUsers([userId], { title: 'Added to Request', body: `You have been added as a spectator to request ${requestNumber}: ${title}`, requestId, requestNumber, url: `/request/${requestNumber}` }); logger.info(`[Workflow] Added spectator ${email} to request ${requestId}`); return participant; } catch (error) { logger.error(`[Workflow] Failed to add spectator:`, error); throw error; } } async listWorkflows(page: number, limit: number) { const offset = (page - 1) * limit; const { rows, count } = await WorkflowRequest.findAndCountAll({ offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1, }, }; } private async enrichForCards(rows: WorkflowRequest[]) { const data = await Promise.all(rows.map(async (wf) => { const currentLevel = await ApprovalLevel.findOne({ where: { requestId: (wf as any).requestId, status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any }, }, order: [['levelNumber', 'ASC']], include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }] }); // Fetch all approval levels for this request const approvals = await ApprovalLevel.findAll({ where: { requestId: (wf as any).requestId }, order: [['levelNumber', 'ASC']], attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status'] }); const totalTat = Number((wf as any).totalTatHours || 0); let percent = 0; let remainingText = ''; if ((wf as any).submissionDate && totalTat > 0) { const startedAt = new Date((wf as any).submissionDate); const now = new Date(); const elapsedHrs = Math.max(0, (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60)); percent = Math.min(100, Math.round((elapsedHrs / totalTat) * 100)); const remaining = Math.max(0, totalTat - elapsedHrs); const days = Math.floor(remaining / 24); const hours = Math.floor(remaining % 24); remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`; } // Calculate total TAT hours from all approvals const totalTatHours = approvals.reduce((sum: number, a: any) => { return sum + Number(a.tatHours || 0); }, 0); return { requestId: (wf as any).requestId, requestNumber: (wf as any).requestNumber, title: (wf as any).title, description: (wf as any).description, status: (wf as any).status, priority: (wf as any).priority, submittedAt: (wf as any).submissionDate, initiator: (wf as any).initiator, totalLevels: (wf as any).totalLevels, totalTatHours: totalTatHours, currentLevel: currentLevel ? (currentLevel as any).levelNumber : null, currentApprover: currentLevel ? { userId: (currentLevel as any).approverId, email: (currentLevel as any).approverEmail, name: (currentLevel as any).approverName, } : null, approvals: approvals.map((a: any) => ({ levelId: a.levelId, levelNumber: a.levelNumber, levelName: a.levelName, approverId: a.approverId, approverEmail: a.approverEmail, approverName: a.approverName, tatHours: a.tatHours, tatDays: a.tatDays, status: a.status })), sla: { percent, remainingText }, }; })); return data; } async listMyRequests(userId: string, page: number, limit: number) { const offset = (page - 1) * limit; const { rows, count } = await WorkflowRequest.findAndCountAll({ where: { initiatorId: userId }, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } async listOpenForMe(userId: string, page: number, limit: number) { const offset = (page - 1) * limit; // Find all pending/in-progress approval levels across requests ordered by levelNumber const pendingLevels = await ApprovalLevel.findAll({ where: { status: { [Op.in]: [ApprovalStatus.PENDING as any, (ApprovalStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', 'PENDING', 'IN_PROGRESS'] as any }, }, order: [['requestId', 'ASC'], ['levelNumber', 'ASC']], attributes: ['requestId', 'levelNumber', 'approverId'], }); // For each request, pick the first (current) pending level const currentLevelByRequest = new Map(); for (const lvl of pendingLevels as any[]) { const rid = lvl.requestId as string; if (!currentLevelByRequest.has(rid)) { currentLevelByRequest.set(rid, { requestId: rid, levelNumber: lvl.levelNumber, approverId: lvl.approverId, }); } } // Include requests where the current approver matches the user const approverRequestIds = Array.from(currentLevelByRequest.values()) .filter(item => item.approverId === userId) .map(item => item.requestId); // Also include requests where the user is a spectator const spectatorParticipants = await Participant.findAll({ where: { userId, participantType: 'SPECTATOR', }, attributes: ['requestId'], }); const spectatorRequestIds = spectatorParticipants.map((p: any) => p.requestId); // Combine both sets of request IDs (unique) const allRequestIds = Array.from(new Set([...approverRequestIds, ...spectatorRequestIds])); const { rows, count } = await WorkflowRequest.findAndCountAll({ where: { requestId: { [Op.in]: allRequestIds.length ? allRequestIds : ['00000000-0000-0000-0000-000000000000'] }, status: { [Op.in]: [WorkflowStatus.PENDING as any, (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS'] as any }, }, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } async listClosedByMe(userId: string, page: number, limit: number) { const offset = (page - 1) * limit; // Get requests where user participated as approver const levelRows = await ApprovalLevel.findAll({ where: { approverId: userId, status: { [Op.in]: [ ApprovalStatus.APPROVED as any, (ApprovalStatus as any).REJECTED ?? 'REJECTED', 'APPROVED', 'REJECTED' ] as any }, }, attributes: ['requestId'], }); const approverRequestIds = Array.from(new Set(levelRows.map((l: any) => l.requestId))); // Also include requests where user is a spectator const spectatorParticipants = await Participant.findAll({ where: { userId, participantType: 'SPECTATOR', }, attributes: ['requestId'], }); const spectatorRequestIds = spectatorParticipants.map((p: any) => p.requestId); // Combine both sets of request IDs (unique) const allRequestIds = Array.from(new Set([...approverRequestIds, ...spectatorRequestIds])); // Fetch closed/rejected requests const { rows, count } = await WorkflowRequest.findAndCountAll({ where: { requestId: { [Op.in]: allRequestIds.length ? allRequestIds : ['00000000-0000-0000-0000-000000000000'] }, status: { [Op.in]: [ WorkflowStatus.APPROVED as any, WorkflowStatus.REJECTED as any, 'APPROVED', 'REJECTED' ] as any }, }, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest): Promise { try { const requestNumber = generateRequestNumber(); const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0); const workflow = await WorkflowRequest.create({ requestNumber, initiatorId, templateType: workflowData.templateType, title: workflowData.title, description: workflowData.description, priority: workflowData.priority, currentLevel: 1, totalLevels: workflowData.approvalLevels.length, totalTatHours, status: WorkflowStatus.DRAFT, isDraft: true, isDeleted: false }); // Create approval levels for (const levelData of workflowData.approvalLevels) { await ApprovalLevel.create({ requestId: workflow.requestId, levelNumber: levelData.levelNumber, levelName: levelData.levelName, approverId: levelData.approverId, approverEmail: levelData.approverEmail, approverName: levelData.approverName, tatHours: levelData.tatHours, tatDays: calculateTATDays(levelData.tatHours), status: ApprovalStatus.PENDING, elapsedHours: 0, remainingHours: levelData.tatHours, tatPercentageUsed: 0, isFinalApprover: levelData.isFinalApprover || false }); } // Create participants if provided if (workflowData.participants) { for (const participantData of workflowData.participants) { await Participant.create({ requestId: workflow.requestId, userId: participantData.userId, userEmail: participantData.userEmail, userName: participantData.userName, participantType: (participantData.participantType as unknown as ParticipantType), canComment: participantData.canComment ?? true, canViewDocuments: participantData.canViewDocuments ?? true, canDownloadDocuments: participantData.canDownloadDocuments ?? false, notificationEnabled: participantData.notificationEnabled ?? true, addedBy: initiatorId, isActive: true }); } } logger.info(`Workflow created: ${requestNumber}`); // Get initiator details const initiator = await User.findByPk(initiatorId); const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; activityService.log({ requestId: (workflow as any).requestId, type: 'created', user: { userId: initiatorId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Initial request submitted', details: `Initial request submitted for ${workflowData.title} by ${initiatorName}` }); const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } }); if (firstLevel) { await notificationService.sendToUsers([(firstLevel as any).approverId], { title: 'New request assigned', body: `${workflowData.title}`, requestNumber: requestNumber, url: `/request/${requestNumber}` }); activityService.log({ requestId: (workflow as any).requestId, type: 'assignment', user: { userId: initiatorId, name: initiatorName }, timestamp: new Date().toISOString(), action: 'Assigned to approver', details: `Request assigned to ${(firstLevel as any).approverName || (firstLevel as any).approverEmail || 'approver'} for review` }); } return workflow; } catch (error) { logger.error('Failed to create workflow:', error); throw new Error('Failed to create workflow'); } } // Helper to determine if identifier is UUID or requestNumber private isUuid(identifier: string): boolean { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(identifier); } // Helper to find workflow by either requestId or requestNumber private async findWorkflowByIdentifier(identifier: string) { if (this.isUuid(identifier)) { return await WorkflowRequest.findByPk(identifier); } else { return await WorkflowRequest.findOne({ where: { requestNumber: identifier } }); } } async getWorkflowById(requestId: string): Promise { try { const workflow = await this.findWorkflowByIdentifier(requestId); if (!workflow) return null; return await WorkflowRequest.findByPk(workflow.requestId, { include: [ { association: 'initiator' }, { association: 'approvalLevels' }, { association: 'participants' }, { association: 'documents' } ] }); } catch (error) { logger.error(`Failed to get workflow ${requestId}:`, error); throw new Error('Failed to get workflow'); } } async getWorkflowDetails(requestId: string) { try { const workflowBase = await this.findWorkflowByIdentifier(requestId); if (!workflowBase) { logger.warn(`Workflow not found for identifier: ${requestId}`); return null; } // Get requestId - try both property access and getDataValue for safety const actualRequestId = (workflowBase as any).getDataValue ? (workflowBase as any).getDataValue('requestId') : (workflowBase as any).requestId; if (!actualRequestId) { logger.error(`Could not extract requestId from workflow. Identifier: ${requestId}, Workflow data:`, JSON.stringify(workflowBase, null, 2)); throw new Error('Failed to extract requestId from workflow'); } // Reload with associations const workflow = await WorkflowRequest.findByPk(actualRequestId, { include: [ { association: 'initiator' } ] }); if (!workflow) return null; // Compute current approver and SLA summary (same logic used in lists) const currentLevel = await ApprovalLevel.findOne({ where: { requestId: actualRequestId, status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any }, }, order: [['levelNumber', 'ASC']], include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }] }); const totalTat = Number((workflow as any).totalTatHours || 0); let percent = 0; let remainingText = ''; if ((workflow as any).submissionDate && totalTat > 0) { const startedAt = new Date((workflow as any).submissionDate); const now = new Date(); const elapsedHrs = Math.max(0, (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60)); percent = Math.min(100, Math.round((elapsedHrs / totalTat) * 100)); const remaining = Math.max(0, totalTat - elapsedHrs); const days = Math.floor(remaining / 24); const hours = Math.floor(remaining % 24); remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`; } const summary = { requestId: (workflow as any).requestId, requestNumber: (workflow as any).requestNumber, title: (workflow as any).title, status: (workflow as any).status, priority: (workflow as any).priority, submittedAt: (workflow as any).submissionDate, totalLevels: (workflow as any).totalLevels, currentLevel: currentLevel ? (currentLevel as any).levelNumber : null, currentApprover: currentLevel ? { userId: (currentLevel as any).approverId, email: (currentLevel as any).approverEmail, name: (currentLevel as any).approverName, } : null, sla: { percent, remainingText }, }; // Ensure actualRequestId is valid UUID (not requestNumber) if (!actualRequestId || typeof actualRequestId !== 'string') { logger.error(`Invalid requestId extracted: ${actualRequestId}, original identifier: ${requestId}`); throw new Error('Invalid workflow identifier'); } // Verify it's a UUID format const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (!uuidRegex.test(actualRequestId)) { logger.error(`Extracted requestId is not a valid UUID: ${actualRequestId}, original identifier: ${requestId}`); throw new Error('Invalid workflow identifier format'); } logger.info(`Fetching participants for requestId: ${actualRequestId} (original identifier: ${requestId})`); // Load related entities explicitly to avoid alias issues // Use the actual UUID requestId for all queries const approvals = await ApprovalLevel.findAll({ where: { requestId: actualRequestId }, order: [['levelNumber','ASC']] }) as any[]; const participants = await Participant.findAll({ where: { requestId: actualRequestId } }) as any[]; logger.info(`Found ${participants.length} participants for requestId: ${actualRequestId}`); const documents = await Document.findAll({ where: { requestId: actualRequestId, isDeleted: false // Only fetch non-deleted documents } }) as any[]; let activities: any[] = []; try { const { Activity } = require('@models/Activity'); const rawActivities = await Activity.findAll({ where: { requestId: actualRequestId }, order: [['created_at', 'ASC']], raw: true // Get raw data to access snake_case fields }); // Transform activities to match frontend expected format activities = rawActivities.map((act: any) => ({ user: act.user_name || act.userName || 'System', type: act.activity_type || act.activityType || 'status_change', action: this.getActivityAction(act.activity_type || act.activityType), details: act.activity_description || act.activityDescription || '', timestamp: act.created_at || act.createdAt, metadata: act.metadata })); } catch (error) { logger.error('Error fetching activities:', error); activities = activityService.get(actualRequestId); } return { workflow, approvals, participants, documents, activities, summary }; } catch (error) { logger.error(`Failed to get workflow details ${requestId}:`, error); throw new Error('Failed to get workflow details'); } } async updateWorkflow(requestId: string, updateData: UpdateWorkflowRequest): Promise { try { const workflow = await this.findWorkflowByIdentifier(requestId); if (!workflow) return null; const actualRequestId = (workflow as any).getDataValue ? (workflow as any).getDataValue('requestId') : (workflow as any).requestId; // Only allow full updates (approval levels, participants) for DRAFT workflows const isDraft = (workflow as any).status === WorkflowStatus.DRAFT || (workflow as any).isDraft; // Update basic workflow fields const basicUpdate: any = {}; if (updateData.title) basicUpdate.title = updateData.title; if (updateData.description) basicUpdate.description = updateData.description; if (updateData.priority) basicUpdate.priority = updateData.priority; if (updateData.status) basicUpdate.status = updateData.status; if (updateData.conclusionRemark !== undefined) basicUpdate.conclusionRemark = updateData.conclusionRemark; await workflow.update(basicUpdate); // Update approval levels if provided (only for drafts) if (isDraft && updateData.approvalLevels && Array.isArray(updateData.approvalLevels)) { // Delete all existing approval levels for this draft await ApprovalLevel.destroy({ where: { requestId: actualRequestId } }); // Create new approval levels const totalTatHours = updateData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0); for (const levelData of updateData.approvalLevels) { await ApprovalLevel.create({ requestId: actualRequestId, levelNumber: levelData.levelNumber, levelName: levelData.levelName || `Level ${levelData.levelNumber}`, approverId: levelData.approverId, approverEmail: levelData.approverEmail, approverName: levelData.approverName, tatHours: levelData.tatHours, tatDays: calculateTATDays(levelData.tatHours), status: ApprovalStatus.PENDING, elapsedHours: 0, remainingHours: levelData.tatHours, tatPercentageUsed: 0, isFinalApprover: levelData.isFinalApprover || false }); } // Update workflow totals await workflow.update({ totalLevels: updateData.approvalLevels.length, totalTatHours, currentLevel: 1 }); logger.info(`Updated ${updateData.approvalLevels.length} approval levels for workflow ${actualRequestId}`); } // Update participants if provided (only for drafts) if (isDraft && updateData.participants && Array.isArray(updateData.participants)) { // Get existing participants const existingParticipants = await Participant.findAll({ where: { requestId: actualRequestId } }); // Create a map of existing participants by userId const existingMap = new Map(existingParticipants.map((p: any) => [ (p as any).userId, p ])); // Create a set of new participant userIds const newUserIds = new Set(updateData.participants.map(p => p.userId)); // Delete participants that are no longer in the new list (except INITIATOR) for (const existing of existingParticipants) { const userId = (existing as any).userId; const participantType = (existing as any).participantType; // Never delete INITIATOR if (participantType === 'INITIATOR') continue; // Delete if not in new list if (!newUserIds.has(userId)) { await existing.destroy(); logger.info(`Deleted participant ${userId} from workflow ${actualRequestId}`); } } // Add or update participants from the new list for (const participantData of updateData.participants) { const existing = existingMap.get(participantData.userId); if (existing) { // Update existing participant await existing.update({ userEmail: participantData.userEmail, userName: participantData.userName, participantType: participantData.participantType as any, canComment: participantData.canComment ?? true, canViewDocuments: participantData.canViewDocuments ?? true, canDownloadDocuments: participantData.canDownloadDocuments ?? false, notificationEnabled: participantData.notificationEnabled ?? true, isActive: true }); } else { // Create new participant await Participant.create({ requestId: actualRequestId, userId: participantData.userId, userEmail: participantData.userEmail, userName: participantData.userName, participantType: participantData.participantType as any, canComment: participantData.canComment ?? true, canViewDocuments: participantData.canViewDocuments ?? true, canDownloadDocuments: participantData.canDownloadDocuments ?? false, notificationEnabled: participantData.notificationEnabled ?? true, addedBy: (workflow as any).initiatorId, isActive: true }); logger.info(`Added new participant ${participantData.userId} to workflow ${actualRequestId}`); } } logger.info(`Synced ${updateData.participants.length} participants for workflow ${actualRequestId}`); } // Delete documents if requested (only for drafts) if (isDraft && updateData.deleteDocumentIds && updateData.deleteDocumentIds.length > 0) { logger.info(`Attempting to delete ${updateData.deleteDocumentIds.length} documents for workflow ${actualRequestId}. Document IDs:`, updateData.deleteDocumentIds); // First get documents with file paths before deleting const documentsToDelete = await Document.findAll({ where: { requestId: actualRequestId, documentId: { [Op.in]: updateData.deleteDocumentIds } }, attributes: ['documentId', 'originalFileName', 'filePath', 'isDeleted'] }); logger.info(`Found ${documentsToDelete.length} documents matching delete IDs. Existing:`, documentsToDelete.map((d: any) => ({ id: d.documentId, name: d.originalFileName, filePath: d.filePath, isDeleted: d.isDeleted }))); // Delete physical files from filesystem for (const doc of documentsToDelete) { const filePath = (doc as any).filePath; if (filePath && fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); logger.info(`Deleted physical file: ${filePath} for document ${(doc as any).documentId}`); } catch (error) { logger.error(`Failed to delete physical file ${filePath}:`, error); // Continue with soft-delete even if file deletion fails } } else if (filePath) { logger.warn(`File path does not exist, skipping file deletion: ${filePath}`); } } // Mark documents as deleted in database const deleteResult = await Document.update( { isDeleted: true }, { where: { requestId: actualRequestId, documentId: { [Op.in]: updateData.deleteDocumentIds } } } ); logger.info(`Marked ${deleteResult[0]} documents as deleted in database (out of ${updateData.deleteDocumentIds.length} requested)`); } // Reload the workflow instance to get latest data (without associations to avoid the error) // The associations issue occurs when trying to include them, so we skip that const refreshed = await WorkflowRequest.findByPk(actualRequestId); return refreshed; } catch (error) { logger.error(`Failed to update workflow ${requestId}:`, error); throw new Error('Failed to update workflow'); } } async submitWorkflow(requestId: string): Promise { try { const workflow = await this.findWorkflowByIdentifier(requestId); if (!workflow) return null; const updated = await workflow.update({ status: WorkflowStatus.PENDING, isDraft: false, submissionDate: new Date() }); activityService.log({ requestId: (updated as any).requestId, type: 'status_change', timestamp: new Date().toISOString(), action: 'Submitted', details: 'Request moved to PENDING' }); const current = await ApprovalLevel.findOne({ where: { requestId: (updated as any).requestId, levelNumber: (updated as any).currentLevel || 1 } }); if (current) { await notificationService.sendToUsers([(current as any).approverId], { title: 'Request submitted', body: `${(updated as any).title}`, requestNumber: (updated as any).requestNumber, url: `/request/${(updated as any).requestNumber}` }); } return updated; } catch (error) { logger.error(`Failed to submit workflow ${requestId}:`, error); throw new Error('Failed to submit workflow'); } } }