Re_Backend/src/services/workflow.service.ts

872 lines
34 KiB
TypeScript

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<string, string> = {
'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<any> {
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<any> {
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<string, { requestId: string; levelNumber: number; approverId: string }>();
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<WorkflowRequest> {
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<WorkflowRequest | null> {
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<WorkflowRequest | null> {
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<WorkflowRequest | null> {
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');
}
}
}