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, QueryTypes } from 'sequelize'; import { sequelize } from '@config/database'; import fs from 'fs'; import path from 'path'; import dayjs from 'dayjs'; import { notificationService } from './notification.service'; import { activityService } from './activity.service'; import { tatSchedulerService } from './tatScheduler.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; } } /** * Skip an approver level (initiator can skip non-responding approver) */ async skipApprover(requestId: string, levelId: string, skipReason: string, skippedBy: string): Promise { try { // Get the approval level const level = await ApprovalLevel.findOne({ where: { levelId } }); if (!level) { throw new Error('Approval level not found'); } // Verify it's skippable (not already approved/rejected/skipped) const currentStatus = (level as any).status; if (currentStatus === 'APPROVED' || currentStatus === 'REJECTED' || currentStatus === 'SKIPPED') { throw new Error(`Cannot skip approver - level is already ${currentStatus}`); } // Get workflow to verify current level const workflow = await WorkflowRequest.findOne({ where: { requestId } }); if (!workflow) { throw new Error('Workflow not found'); } const currentLevel = (workflow as any).currentLevel; const levelNumber = (level as any).levelNumber; // Only allow skipping current level (not future levels) if (levelNumber > currentLevel) { throw new Error('Cannot skip future approval levels'); } // Mark as skipped await level.update({ status: ApprovalStatus.SKIPPED, levelEndTime: new Date(), actionDate: new Date() }); // Update additional skip fields if migration was run try { await sequelize.query(` UPDATE approval_levels SET is_skipped = TRUE, skipped_at = NOW(), skipped_by = :skippedBy, skip_reason = :skipReason WHERE level_id = :levelId `, { replacements: { levelId, skippedBy, skipReason }, type: QueryTypes.UPDATE }); } catch (err) { logger.warn('[Workflow] is_skipped column not available (migration not run), using status only'); } // Cancel TAT jobs for skipped level await tatSchedulerService.cancelTatJobs(requestId, levelId); // Move to next level const nextLevelNumber = levelNumber + 1; const nextLevel = await ApprovalLevel.findOne({ where: { requestId, levelNumber: nextLevelNumber } }); if (nextLevel) { const now = new Date(); await nextLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: now, tatStartTime: now }); // Schedule TAT jobs for next level const workflowPriority = (workflow as any)?.priority || 'STANDARD'; await tatSchedulerService.scheduleTatJobs( requestId, (nextLevel as any).levelId, (nextLevel as any).approverId, Number((nextLevel as any).tatHours), now, workflowPriority ); // Update workflow current level await workflow.update({ currentLevel: nextLevelNumber }); // Notify next approver await notificationService.sendToUsers([(nextLevel as any).approverId], { title: 'Request Escalated', body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`, requestId, requestNumber: (workflow as any).requestNumber, url: `/request/${(workflow as any).requestNumber}` }); } // Get user who skipped const skipUser = await User.findByPk(skippedBy); const skipUserName = (skipUser as any)?.displayName || (skipUser as any)?.email || 'User'; // Log activity await activityService.log({ requestId, type: 'status_change', user: { userId: skippedBy, name: skipUserName }, timestamp: new Date().toISOString(), action: 'Approver Skipped', details: `Level ${levelNumber} approver (${(level as any).approverName}) was skipped by ${skipUserName}. Reason: ${skipReason || 'Not provided'}` }); logger.info(`[Workflow] Skipped approver at level ${levelNumber} for request ${requestId}`); return level; } catch (error) { logger.error(`[Workflow] Failed to skip approver:`, error); throw error; } } /** * Add a new approver at specific level (with level shifting) */ async addApproverAtLevel( requestId: string, email: string, tatHours: number, targetLevel: number, 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'); } // Get workflow const workflow = await WorkflowRequest.findOne({ where: { requestId } }); if (!workflow) { throw new Error('Workflow not found'); } // Get all approval levels const allLevels = await ApprovalLevel.findAll({ where: { requestId }, order: [['levelNumber', 'ASC']] }); // Validate target level // New approver must be placed after all approved/rejected/skipped levels const completedLevels = allLevels.filter(l => { const status = (l as any).status; return status === 'APPROVED' || status === 'REJECTED' || status === 'SKIPPED'; }); const minAllowedLevel = completedLevels.length + 1; if (targetLevel < minAllowedLevel) { throw new Error(`Cannot add approver at level ${targetLevel}. Minimum allowed level is ${minAllowedLevel} (after completed levels)`); } // Shift existing levels at and after target level const levelsToShift = allLevels.filter(l => (l as any).levelNumber >= targetLevel); for (const levelToShift of levelsToShift) { const newLevelNumber = (levelToShift as any).levelNumber + 1; await levelToShift.update({ levelNumber: newLevelNumber, levelName: `Level ${newLevelNumber}` }); logger.info(`[Workflow] Shifted level ${(levelToShift as any).levelNumber - 1} → ${newLevelNumber}`); } // Update total levels in workflow await workflow.update({ totalLevels: allLevels.length + 1 }); // Create new approval level at target position const newLevel = await ApprovalLevel.create({ requestId, levelNumber: targetLevel, levelName: `Level ${targetLevel}`, approverId: userId, approverEmail: email.toLowerCase(), approverName: userName, tatHours, // tatDays is auto-calculated by database as a generated column status: targetLevel === (workflow as any).currentLevel ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING, isFinalApprover: targetLevel === allLevels.length + 1, levelStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null, tatStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null } as any); // Update isFinalApprover for previous final approver (now it's not final anymore) if (allLevels.length > 0) { const previousFinal = allLevels.find(l => (l as any).isFinalApprover); if (previousFinal && targetLevel > (previousFinal as any).levelNumber) { await previousFinal.update({ isFinalApprover: false }); } } // Add as 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); // If new approver is at current level, schedule TAT jobs if (targetLevel === (workflow as any).currentLevel) { const workflowPriority = (workflow as any)?.priority || 'STANDARD'; await tatSchedulerService.scheduleTatJobs( requestId, (newLevel as any).levelId, userId, tatHours, new Date(), workflowPriority ); } // 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 approver at Level ${targetLevel} with TAT of ${tatHours} hours by ${addedByName}` }); // Send notification to new approver await notificationService.sendToUsers([userId], { title: 'New Request Assignment', body: `You have been added as Level ${targetLevel} approver to request ${(workflow as any).requestNumber}: ${(workflow as any).title}`, requestId, requestNumber: (workflow as any).requestNumber, url: `/request/${(workflow as any).requestNumber}` }); logger.info(`[Workflow] Added approver ${email} at level ${targetLevel} to request ${requestId}`); return newLevel; } catch (error) { logger.error(`[Workflow] Failed to add approver at level:`, 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; } } /** * List all workflows for ADMIN/MANAGEMENT users (organization-level) * Shows ALL requests in the organization, including where admin is initiator * Used by: "All Requests" page for admin users */ async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) { const offset = (page - 1) * limit; // Build where clause with filters const whereConditions: any[] = []; // Exclude drafts only whereConditions.push({ isDraft: false }); // NOTE: NO initiator exclusion here - admin sees ALL requests // Apply status filter (pending, approved, rejected, closed) if (filters?.status && filters.status !== 'all') { const statusUpper = filters.status.toUpperCase(); if (statusUpper === 'PENDING') { // Pending includes both PENDING and IN_PROGRESS whereConditions.push({ [Op.or]: [ { status: 'PENDING' }, { status: 'IN_PROGRESS' } ] }); } else if (statusUpper === 'CLOSED') { whereConditions.push({ status: 'CLOSED' }); } else if (statusUpper === 'REJECTED') { whereConditions.push({ status: 'REJECTED' }); } else if (statusUpper === 'APPROVED') { whereConditions.push({ status: 'APPROVED' }); } else { // Fallback: use the uppercase value as-is whereConditions.push({ status: statusUpper }); } } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { whereConditions.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { whereConditions.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } // Apply department filter (through initiator) if (filters?.department && filters.department !== 'all') { whereConditions.push({ '$initiator.department$': filters.department }); } // Apply initiator filter if (filters?.initiator && filters.initiator !== 'all') { whereConditions.push({ initiatorId: filters.initiator }); } // Apply approver filter (with current vs any logic) if (filters?.approver && filters.approver !== 'all') { const approverId = filters.approver; const approverType = filters.approverType || 'current'; // Default to 'current' if (approverType === 'current') { // Filter by current active approver only // Find request IDs where this approver is the current active approver const currentApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId, status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] } }, attributes: ['requestId', 'levelNumber'], }); // Get the current level for each request to match only if this approver is at the current level const requestIds: string[] = []; for (const level of currentApproverLevels) { const request = await WorkflowRequest.findByPk((level as any).requestId, { attributes: ['requestId', 'currentLevel'], }); if (request && (request as any).currentLevel === (level as any).levelNumber) { requestIds.push((level as any).requestId); } } if (requestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: requestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } else { // Filter by any approver (past or current) // Find all request IDs where this user is an approver at any level const allApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId }, attributes: ['requestId'], }); const approverRequestIds = allApproverLevels.map((l: any) => l.requestId); if (approverRequestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: approverRequestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } } // Apply date range filter if (filters?.dateRange || filters?.startDate || filters?.endDate) { let dateStart: Date | null = null; let dateEnd: Date | null = null; if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.dateRange) { const now = dayjs(); switch (filters.dateRange) { case 'today': dateStart = now.startOf('day').toDate(); dateEnd = now.endOf('day').toDate(); break; case 'week': dateStart = now.startOf('week').toDate(); dateEnd = now.endOf('week').toDate(); break; case 'month': dateStart = now.startOf('month').toDate(); dateEnd = now.endOf('month').toDate(); break; } } if (dateStart && dateEnd) { whereConditions.push({ [Op.or]: [ { submissionDate: { [Op.between]: [dateStart, dateEnd] } }, // Fallback to createdAt if submissionDate is null { [Op.and]: [ { submissionDate: null }, { createdAt: { [Op.between]: [dateStart, dateEnd] } } ] } ] }); } } const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; // If SLA compliance filter is active, we need to: // 1. Fetch all matching records (or a larger batch) // 2. Enrich them (which calculates SLA) // 3. Filter by SLA compliance // 4. Then paginate if (filters?.slaCompliance && filters.slaCompliance !== 'all') { // Fetch a larger batch to filter by SLA (up to 1000 records) const { rows: allRows } = await WorkflowRequest.findAndCountAll({ where, limit: 1000, // Fetch up to 1000 records for SLA filtering order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); // Enrich all records (calculates SLA) const enrichedData = await this.enrichForCards(allRows); // Filter by SLA compliance const slaFilteredData = enrichedData.filter((req: any) => { const slaCompliance = filters.slaCompliance || ''; // Get SLA status from various possible locations const slaStatus = req.currentLevelSLA?.status || req.currentApprover?.sla?.status || req.sla?.status || req.summary?.sla?.status; if (slaCompliance.toLowerCase() === 'compliant') { const reqStatus = (req.status || '').toString().toUpperCase(); const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED'; if (!isCompleted) return false; if (!slaStatus) return true; return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached'; } if (!slaStatus) { return slaCompliance === 'on-track' || slaCompliance === 'on_track'; } const statusMap: Record = { 'on-track': 'on_track', 'on_track': 'on_track', 'approaching': 'approaching', 'critical': 'critical', 'breached': 'breached' }; const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase(); return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus; }); // Apply pagination to filtered results const totalFiltered = slaFilteredData.length; const paginatedData = slaFilteredData.slice(offset, offset + limit); return { data: paginatedData, pagination: { page, limit, total: totalFiltered, totalPages: Math.ceil(totalFiltered / limit) || 1, }, }; } // Normal pagination (no SLA filter) const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); 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', 'levelStartTime', 'tatStartTime'] }); // Calculate total TAT hours from all approvals const totalTatHours = approvals.reduce((sum: number, a: any) => { return sum + Number(a.tatHours || 0); }, 0); // Calculate approved levels count const approvedLevelsCount = approvals.filter((a: any) => a.status === 'APPROVED').length; const priority = ((wf as any).priority || 'standard').toString().toLowerCase(); // Calculate OVERALL request SLA (from submission to total deadline) const { calculateSLAStatus } = require('@utils/tatTimeUtils'); const submissionDate = (wf as any).submissionDate; const closureDate = (wf as any).closureDate; // For completed requests, use closure_date; for active requests, use current time const overallEndDate = closureDate || null; let overallSLA = null; if (submissionDate && totalTatHours > 0) { try { overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority, overallEndDate); } catch (error) { logger.error('[Workflow] Error calculating overall SLA:', error); } } // Calculate current level SLA (if there's an active level) let currentLevelSLA = null; if (currentLevel) { const levelStartTime = (currentLevel as any).levelStartTime || (currentLevel as any).tatStartTime; const levelTatHours = Number((currentLevel as any).tatHours || 0); // For completed levels, use the level's completion time (if available) // Otherwise, if request is completed, use closure_date const levelEndDate = (currentLevel as any).completedAt || closureDate || null; if (levelStartTime && levelTatHours > 0) { try { currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate); } catch (error) { logger.error('[Workflow] Error calculating current level SLA:', error); } } } 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, createdAt: (wf as any).createdAt, closureDate: (wf as any).closureDate, conclusionRemark: (wf as any).conclusionRemark, initiator: (wf as any).initiator, department: (wf as any).initiator?.department, 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, levelStartTime: (currentLevel as any).levelStartTime, tatHours: (currentLevel as any).tatHours, sla: currentLevelSLA, // ← Backend-calculated SLA for current level } : 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, levelStartTime: a.levelStartTime || a.tatStartTime })), summary: { approvedLevels: approvedLevelsCount, totalLevels: (wf as any).totalLevels, sla: overallSLA || { elapsedHours: 0, remainingHours: totalTatHours, percentageUsed: 0, remainingText: `${totalTatHours}h remaining`, isPaused: false, status: 'on_track' } }, sla: overallSLA || { elapsedHours: 0, remainingHours: totalTatHours, percentageUsed: 0, remainingText: `${totalTatHours}h remaining`, isPaused: false, status: 'on_track' }, // ← Overall request SLA (all levels combined) currentLevelSLA: currentLevelSLA, // ← Also provide at root level for easy access }; })); return data; } /** * List requests where user is a PARTICIPANT (not initiator) for REGULAR USERS * Shows only requests where user is approver or spectator, EXCLUDES initiator requests * Used by: "All Requests" page for regular users * NOTE: This is SEPARATE from listWorkflows (admin) - they don't interfere with each other * @deprecated Use listParticipantRequests instead for clarity */ async listMyRequests( userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string; } ) { const offset = (page - 1) * limit; // Find all request IDs where user is a participant (NOT initiator): // 1. As approver (in any approval level) // 2. As participant/spectator // NOTE: Exclude requests where user is initiator (those are shown in "My Requests" page) // Get requests where user is an approver (in any approval level) const approverLevels = await ApprovalLevel.findAll({ where: { approverId: userId }, attributes: ['requestId'], }); const approverRequestIds = approverLevels.map((l: any) => l.requestId); // Get requests where user is a participant/spectator const participants = await Participant.findAll({ where: { userId }, attributes: ['requestId'], }); const participantRequestIds = participants.map((p: any) => p.requestId); // Combine request IDs where user is participant (approver or spectator) const allRequestIds = Array.from(new Set([ ...approverRequestIds, ...participantRequestIds ])); // Build where clause with filters const whereConditions: any[] = []; // ALWAYS exclude requests where user is initiator (for regular users only) // This ensures "All Requests" only shows participant requests, not initiator requests whereConditions.push({ initiatorId: { [Op.ne]: userId } }); // Filter by request IDs where user is involved as participant (approver or spectator) if (allRequestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: allRequestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } // Exclude drafts whereConditions.push({ isDraft: false }); // Apply status filter (pending, approved, rejected, closed) // Same logic as listWorkflows but applied to participant requests only if (filters?.status && filters.status !== 'all') { const statusUpper = filters.status.toUpperCase(); if (statusUpper === 'PENDING') { // Pending includes both PENDING and IN_PROGRESS whereConditions.push({ [Op.or]: [ { status: 'PENDING' }, { status: 'IN_PROGRESS' } ] }); } else if (statusUpper === 'CLOSED') { whereConditions.push({ status: 'CLOSED' }); } else if (statusUpper === 'REJECTED') { whereConditions.push({ status: 'REJECTED' }); } else if (statusUpper === 'APPROVED') { whereConditions.push({ status: 'APPROVED' }); } else { // Fallback: use the uppercase value as-is whereConditions.push({ status: statusUpper }); } } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { whereConditions.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { whereConditions.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } // Apply department filter (through initiator) if (filters?.department && filters.department !== 'all') { whereConditions.push({ '$initiator.department$': filters.department }); } // Apply initiator filter if (filters?.initiator && filters.initiator !== 'all') { whereConditions.push({ initiatorId: filters.initiator }); } // Apply approver filter (with current vs any logic) - for listParticipantRequests if (filters?.approver && filters.approver !== 'all') { const approverId = filters.approver; const approverType = filters.approverType || 'current'; // Default to 'current' if (approverType === 'current') { // Filter by current active approver only // Find request IDs where this approver is the current active approver const currentApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId, status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] } }, attributes: ['requestId', 'levelNumber'], }); // Get the current level for each request to match only if this approver is at the current level const requestIds: string[] = []; for (const level of currentApproverLevels) { const request = await WorkflowRequest.findByPk((level as any).requestId, { attributes: ['requestId', 'currentLevel'], }); if (request && (request as any).currentLevel === (level as any).levelNumber) { requestIds.push((level as any).requestId); } } if (requestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: requestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } else { // Filter by any approver (past or current) // Find all request IDs where this user is an approver at any level const allApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId }, attributes: ['requestId'], }); const approverRequestIds = allApproverLevels.map((l: any) => l.requestId); if (approverRequestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: approverRequestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } } // Apply date range filter (same logic as listWorkflows) if (filters?.dateRange || filters?.startDate || filters?.endDate) { let dateStart: Date | null = null; let dateEnd: Date | null = null; if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.dateRange) { const now = dayjs(); switch (filters.dateRange) { case 'today': dateStart = now.startOf('day').toDate(); dateEnd = now.endOf('day').toDate(); break; case 'week': dateStart = now.startOf('week').toDate(); dateEnd = now.endOf('week').toDate(); break; case 'month': dateStart = now.startOf('month').toDate(); dateEnd = now.endOf('month').toDate(); break; } } if (dateStart && dateEnd) { whereConditions.push({ [Op.or]: [ { submissionDate: { [Op.between]: [dateStart, dateEnd] } }, // Fallback to createdAt if submissionDate is null { [Op.and]: [ { submissionDate: null }, { createdAt: { [Op.between]: [dateStart, dateEnd] } } ] } ] }); } } const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; // If SLA compliance filter is active, fetch all, enrich, filter, then paginate if (filters?.slaCompliance && filters.slaCompliance !== 'all') { const { rows: allRows } = await WorkflowRequest.findAndCountAll({ where, limit: 1000, // Fetch up to 1000 records for SLA filtering order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); const enrichedData = await this.enrichForCards(allRows); // Filter by SLA compliance const slaFilteredData = enrichedData.filter((req: any) => { const slaCompliance = filters.slaCompliance || ''; const slaStatus = req.currentLevelSLA?.status || req.currentApprover?.sla?.status || req.sla?.status || req.summary?.sla?.status; if (slaCompliance.toLowerCase() === 'compliant') { const reqStatus = (req.status || '').toString().toUpperCase(); const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED'; if (!isCompleted) return false; if (!slaStatus) return true; return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached'; } if (!slaStatus) { return slaCompliance === 'on-track' || slaCompliance === 'on_track'; } const statusMap: Record = { 'on-track': 'on_track', 'on_track': 'on_track', 'approaching': 'approaching', 'critical': 'critical', 'breached': 'breached' }; const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase(); return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus; }); const totalFiltered = slaFilteredData.length; const paginatedData = slaFilteredData.slice(offset, offset + limit); return { data: paginatedData, pagination: { page, limit, total: totalFiltered, totalPages: Math.ceil(totalFiltered / limit) || 1 } }; } // Normal pagination (no SLA filter) const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } /** * List requests where user is a PARTICIPANT (not initiator) for REGULAR USERS - "All Requests" page * This is a dedicated method for regular users' "All Requests" screen * Shows only requests where user is approver or spectator, EXCLUDES initiator requests * Completely separate from listWorkflows (admin) to avoid interference */ async listParticipantRequests( userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string; } ) { const offset = (page - 1) * limit; // Find all request IDs where user is a participant (NOT initiator): // 1. As approver (in any approval level) // 2. As participant/spectator // NOTE: Exclude requests where user is initiator (those are shown in "My Requests" page) // Get requests where user is an approver (in any approval level) const approverLevels = await ApprovalLevel.findAll({ where: { approverId: userId }, attributes: ['requestId'], }); const approverRequestIds = approverLevels.map((l: any) => l.requestId); // Get requests where user is a participant/spectator const participants = await Participant.findAll({ where: { userId }, attributes: ['requestId'], }); const participantRequestIds = participants.map((p: any) => p.requestId); // Combine request IDs where user is participant (approver or spectator) const allRequestIds = Array.from(new Set([ ...approverRequestIds, ...participantRequestIds ])); // Build where clause with filters const whereConditions: any[] = []; // ALWAYS exclude requests where user is initiator (for regular users only) // This ensures "All Requests" only shows participant requests, not initiator requests whereConditions.push({ initiatorId: { [Op.ne]: userId } }); // Filter by request IDs where user is involved as participant (approver or spectator) if (allRequestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: allRequestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } // Exclude drafts whereConditions.push({ isDraft: false }); // Apply status filter (pending, approved, rejected, closed) // Same logic as listWorkflows but applied to participant requests only if (filters?.status && filters.status !== 'all') { const statusUpper = filters.status.toUpperCase(); if (statusUpper === 'PENDING') { // Pending includes both PENDING and IN_PROGRESS whereConditions.push({ [Op.or]: [ { status: 'PENDING' }, { status: 'IN_PROGRESS' } ] }); } else if (statusUpper === 'CLOSED') { whereConditions.push({ status: 'CLOSED' }); } else if (statusUpper === 'REJECTED') { whereConditions.push({ status: 'REJECTED' }); } else if (statusUpper === 'APPROVED') { whereConditions.push({ status: 'APPROVED' }); } else { // Fallback: use the uppercase value as-is whereConditions.push({ status: statusUpper }); } } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { whereConditions.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { whereConditions.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } // Apply department filter (through initiator) if (filters?.department && filters.department !== 'all') { whereConditions.push({ '$initiator.department$': filters.department }); } // Apply initiator filter if (filters?.initiator && filters.initiator !== 'all') { whereConditions.push({ initiatorId: filters.initiator }); } // Apply approver filter (with current vs any logic) - for listParticipantRequests if (filters?.approver && filters.approver !== 'all') { const approverId = filters.approver; const approverType = filters.approverType || 'current'; // Default to 'current' if (approverType === 'current') { // Filter by current active approver only // Find request IDs where this approver is the current active approver const currentApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId, status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] } }, attributes: ['requestId', 'levelNumber'], }); // Get the current level for each request to match only if this approver is at the current level const requestIds: string[] = []; for (const level of currentApproverLevels) { const request = await WorkflowRequest.findByPk((level as any).requestId, { attributes: ['requestId', 'currentLevel'], }); if (request && (request as any).currentLevel === (level as any).levelNumber) { requestIds.push((level as any).requestId); } } if (requestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: requestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } else { // Filter by any approver (past or current) // Find all request IDs where this user is an approver at any level const allApproverLevels = await ApprovalLevel.findAll({ where: { approverId: approverId }, attributes: ['requestId'], }); const approverRequestIds = allApproverLevels.map((l: any) => l.requestId); if (approverRequestIds.length > 0) { whereConditions.push({ requestId: { [Op.in]: approverRequestIds } }); } else { // No matching requests - return empty result whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } } } // Apply date range filter (same logic as listWorkflows) if (filters?.dateRange || filters?.startDate || filters?.endDate) { let dateStart: Date | null = null; let dateEnd: Date | null = null; if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.dateRange) { const now = dayjs(); switch (filters.dateRange) { case 'today': dateStart = now.startOf('day').toDate(); dateEnd = now.endOf('day').toDate(); break; case 'week': dateStart = now.startOf('week').toDate(); dateEnd = now.endOf('week').toDate(); break; case 'month': dateStart = now.startOf('month').toDate(); dateEnd = now.endOf('month').toDate(); break; } } if (dateStart && dateEnd) { whereConditions.push({ [Op.or]: [ { submissionDate: { [Op.between]: [dateStart, dateEnd] } }, // Fallback to createdAt if submissionDate is null { [Op.and]: [ { submissionDate: null }, { createdAt: { [Op.between]: [dateStart, dateEnd] } } ] } ] }); } } const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; // If SLA compliance filter is active, fetch all, enrich, filter, then paginate if (filters?.slaCompliance && filters.slaCompliance !== 'all') { const { rows: allRows } = await WorkflowRequest.findAndCountAll({ where, limit: 1000, // Fetch up to 1000 records for SLA filtering order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); const enrichedData = await this.enrichForCards(allRows); // Filter by SLA compliance const slaFilteredData = enrichedData.filter((req: any) => { const slaCompliance = filters.slaCompliance || ''; const slaStatus = req.currentLevelSLA?.status || req.currentApprover?.sla?.status || req.sla?.status || req.summary?.sla?.status; if (slaCompliance.toLowerCase() === 'compliant') { const reqStatus = (req.status || '').toString().toUpperCase(); const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED'; if (!isCompleted) return false; if (!slaStatus) return true; return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached'; } if (!slaStatus) { return slaCompliance === 'on-track' || slaCompliance === 'on_track'; } const statusMap: Record = { 'on-track': 'on_track', 'on_track': 'on_track', 'approaching': 'approaching', 'critical': 'critical', 'breached': 'breached' }; const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase(); return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus; }); const totalFiltered = slaFilteredData.length; const paginatedData = slaFilteredData.slice(offset, offset + limit); return { data: paginatedData, pagination: { page, limit, total: totalFiltered, totalPages: Math.ceil(totalFiltered / limit) || 1 } }; } // Normal pagination (no SLA filter) const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); const data = await this.enrichForCards(rows); return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } }; } /** * List requests where user is the initiator (for "My Requests" page) */ async listMyInitiatedRequests( userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; dateRange?: string; startDate?: string; endDate?: string; } ) { const offset = (page - 1) * limit; // Build where clause with filters - only requests where user is initiator const whereConditions: any[] = [{ initiatorId: userId }]; // Exclude drafts whereConditions.push({ isDraft: false }); // Apply status filter if (filters?.status && filters.status !== 'all') { const statusUpper = filters.status.toUpperCase(); if (statusUpper === 'PENDING') { whereConditions.push({ [Op.or]: [ { status: 'PENDING' }, { status: 'IN_PROGRESS' } ] }); } else { whereConditions.push({ status: statusUpper }); } } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { whereConditions.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { whereConditions.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } // Apply department filter (through initiator) if (filters?.department && filters.department !== 'all') { whereConditions.push({ '$initiator.department$': filters.department }); } // Apply date range filter (same logic as listWorkflows) if (filters?.dateRange || filters?.startDate || filters?.endDate) { let dateStart: Date | null = null; let dateEnd: Date | null = null; if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.startDate && filters.endDate) { dateStart = dayjs(filters.startDate).startOf('day').toDate(); dateEnd = dayjs(filters.endDate).endOf('day').toDate(); } else if (filters.dateRange) { const now = dayjs(); switch (filters.dateRange) { case 'today': dateStart = now.startOf('day').toDate(); dateEnd = now.endOf('day').toDate(); break; case 'week': dateStart = now.startOf('week').toDate(); dateEnd = now.endOf('week').toDate(); break; case 'month': dateStart = now.startOf('month').toDate(); dateEnd = now.endOf('month').toDate(); break; } } if (dateStart && dateEnd) { whereConditions.push({ [Op.or]: [ { submissionDate: { [Op.between]: [dateStart, dateEnd] } }, // Fallback to createdAt if submissionDate is null { [Op.and]: [ { submissionDate: null }, { createdAt: { [Op.between]: [dateStart, dateEnd] } } ] } ] }); } } const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset, limit, order: [['createdAt', 'DESC']], include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); 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, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) { 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])); // Also include APPROVED requests where the user is the initiator (awaiting closure) const approvedAsInitiator = await WorkflowRequest.findAll({ where: { initiatorId: userId, status: { [Op.in]: [WorkflowStatus.APPROVED as any, 'APPROVED'] as any }, }, attributes: ['requestId'], }); const approvedInitiatorRequestIds = approvedAsInitiator.map((r: any) => r.requestId); // Combine all request IDs (approver, spectator, and approved as initiator) const allOpenRequestIds = Array.from(new Set([...allRequestIds, ...approvedInitiatorRequestIds])); // Build base where conditions const baseConditions: any[] = []; // Add the main OR condition for request IDs if (allOpenRequestIds.length > 0) { baseConditions.push({ requestId: { [Op.in]: allOpenRequestIds } }); } else { // No matching requests baseConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } }); } // Add status condition baseConditions.push({ status: { [Op.in]: [ WorkflowStatus.PENDING as any, (WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', WorkflowStatus.APPROVED as any, 'PENDING', 'IN_PROGRESS', 'APPROVED' ] as any } }); // Apply status filter if provided (overrides default status filter) if (filters?.status && filters.status !== 'all') { baseConditions.pop(); // Remove default status baseConditions.push({ status: filters.status.toUpperCase() }); } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { baseConditions.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { baseConditions.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } const where = baseConditions.length > 0 ? { [Op.and]: baseConditions } : {}; // Build order clause based on sortBy parameter // For computed fields (due, sla), we'll sort after enrichment let order: any[] = [['createdAt', 'DESC']]; // Default order const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC'); if (sortBy) { switch (sortBy.toLowerCase()) { case 'created': order = [['createdAt', validSortOrder]]; break; case 'priority': // Map priority values: EXPRESS = 1, STANDARD = 2 for ascending (standard first), or reverse for descending // For simplicity, we'll sort alphabetically: EXPRESS < STANDARD order = [['priority', validSortOrder], ['createdAt', 'DESC']]; // Secondary sort by createdAt break; // For 'due' and 'sla', we need to sort after enrichment (handled below) case 'due': case 'sla': // Keep default order - will sort after enrichment break; default: // Unknown sortBy, use default break; } } // For computed field sorting (due, sla), we need to fetch all matching records first, // enrich them, sort, then paginate. For DB fields, we can use SQL pagination. const needsPostEnrichmentSort = sortBy && ['due', 'sla'].includes(sortBy.toLowerCase()); let rows: any[]; let count: number; if (needsPostEnrichmentSort) { // Fetch all matching records (no pagination yet) const result = await WorkflowRequest.findAndCountAll({ where, include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); // Enrich all records const allEnriched = await this.enrichForCards(result.rows); // Sort enriched data allEnriched.sort((a: any, b: any) => { let aValue: any, bValue: any; if (sortBy.toLowerCase() === 'due') { aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; } else if (sortBy.toLowerCase() === 'sla') { aValue = a.currentLevelSLA?.percentageUsed || 0; bValue = b.currentLevelSLA?.percentageUsed || 0; } else { return 0; } if (validSortOrder === 'ASC') { return aValue > bValue ? 1 : -1; } else { return aValue < bValue ? 1 : -1; } }); count = result.count; // Apply pagination after sorting const startIndex = offset; const endIndex = startIndex + limit; rows = allEnriched.slice(startIndex, endIndex); } else { // Use database sorting for simple fields (created, priority) const result = await WorkflowRequest.findAndCountAll({ where, offset, limit, order, include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); rows = result.rows; count = result.count; } const data = needsPostEnrichmentSort ? rows : 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, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) { 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])); // Build query conditions const whereConditions: any[] = []; // 1. Requests where user was approver/spectator (show APPROVED, REJECTED, CLOSED) const approverSpectatorStatuses = [ WorkflowStatus.APPROVED as any, WorkflowStatus.REJECTED as any, (WorkflowStatus as any).CLOSED ?? 'CLOSED', 'APPROVED', 'REJECTED', 'CLOSED' ] as any; if (allRequestIds.length > 0) { const approverConditionParts: any[] = [ { requestId: { [Op.in]: allRequestIds } } ]; // Apply status filter if (filters?.status && filters.status !== 'all') { approverConditionParts.push({ status: filters.status.toUpperCase() }); } else { approverConditionParts.push({ status: { [Op.in]: approverSpectatorStatuses } }); } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { approverConditionParts.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { approverConditionParts.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } const approverCondition = approverConditionParts.length > 0 ? { [Op.and]: approverConditionParts } : { requestId: { [Op.in]: allRequestIds } }; whereConditions.push(approverCondition); } // 2. Requests where user is initiator (show ONLY REJECTED or CLOSED, NOT APPROVED) // APPROVED means initiator still needs to finalize conclusion const initiatorStatuses = [ WorkflowStatus.REJECTED as any, (WorkflowStatus as any).CLOSED ?? 'CLOSED', 'REJECTED', 'CLOSED' ] as any; const initiatorConditionParts: any[] = [ { initiatorId: userId } ]; // Apply status filter if (filters?.status && filters.status !== 'all') { const filterStatus = filters.status.toUpperCase(); // Only apply if status is REJECTED or CLOSED (not APPROVED for initiator) if (filterStatus === 'REJECTED' || filterStatus === 'CLOSED') { initiatorConditionParts.push({ status: filterStatus }); } else { // If filtering for APPROVED, don't include initiator requests initiatorConditionParts.push({ status: { [Op.in]: [] } }); // Empty set - no results } } else { initiatorConditionParts.push({ status: { [Op.in]: initiatorStatuses } }); } // Apply priority filter if (filters?.priority && filters.priority !== 'all') { initiatorConditionParts.push({ priority: filters.priority.toUpperCase() }); } // Apply search filter (title, description, or requestNumber) if (filters?.search && filters.search.trim()) { initiatorConditionParts.push({ [Op.or]: [ { title: { [Op.iLike]: `%${filters.search.trim()}%` } }, { description: { [Op.iLike]: `%${filters.search.trim()}%` } }, { requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } } ] }); } const initiatorCondition = initiatorConditionParts.length > 0 ? { [Op.and]: initiatorConditionParts } : { initiatorId: userId }; whereConditions.push(initiatorCondition); // Build where clause with OR conditions const where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {}; // Build order clause based on sortBy parameter let order: any[] = [['createdAt', 'DESC']]; // Default order const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC'); if (sortBy) { switch (sortBy.toLowerCase()) { case 'created': order = [['createdAt', validSortOrder]]; break; case 'due': // Sort by closureDate or updatedAt (closed date) order = [['updatedAt', validSortOrder], ['createdAt', 'DESC']]; break; case 'priority': order = [['priority', validSortOrder], ['createdAt', 'DESC']]; break; default: // Unknown sortBy, use default break; } } // Fetch closed/rejected/approved requests (including finalized ones) const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset, limit, order, include: [ { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, ], }); 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, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): 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 is auto-calculated by database as a generated column status: ApprovalStatus.PENDING, elapsedHours: 0, remainingHours: levelData.tatHours, tatPercentageUsed: 0, isFinalApprover: levelData.isFinalApprover || false }); } // Create participants if provided // Deduplicate participants by userId (database has unique constraint on request_id + user_id) // Priority: INITIATOR > APPROVER > SPECTATOR (keep the highest privilege role) if (workflowData.participants) { const participantMap = new Map(); const rolePriority: Record = { 'INITIATOR': 3, 'APPROVER': 2, 'SPECTATOR': 1 }; for (const participantData of workflowData.participants) { const existing = participantMap.get(participantData.userId); if (existing) { // User already exists, check if we should replace with higher priority role const existingPriority = rolePriority[existing.participantType] || 0; const newPriority = rolePriority[participantData.participantType] || 0; if (newPriority > existingPriority) { logger.info(`[Workflow] User ${participantData.userId} (${participantData.userEmail}) has multiple roles. Keeping ${participantData.participantType} over ${existing.participantType}`); participantMap.set(participantData.userId, participantData); } else { logger.info(`[Workflow] User ${participantData.userId} (${participantData.userEmail}) has multiple roles. Keeping ${existing.participantType} over ${participantData.participantType}`); } } else { participantMap.set(participantData.userId, participantData); } } for (const participantData of participantMap.values()) { 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'; // Log creation activity 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}`, ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); // Send notification to INITIATOR confirming submission await notificationService.sendToUsers([initiatorId], { title: 'Request Submitted Successfully', body: `Your request "${workflowData.title}" has been submitted and is now with the first approver.`, requestNumber: requestNumber, requestId: (workflow as any).requestId, url: `/request/${requestNumber}`, type: 'request_submitted', priority: 'MEDIUM' }); // Send notification to FIRST APPROVER for assignment 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, requestId: (workflow as any).requestId, url: `/request/${requestNumber}`, type: 'assignment', priority: 'HIGH', actionRequired: true }); 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, activityType: { [Op.ne]: 'comment' } // Exclude comment type activities }, order: [['created_at', 'ASC']], raw: true // Get raw data to access snake_case fields }); // Transform activities to match frontend expected format activities = rawActivities .filter((act: any) => { const activityType = act.activity_type || act.activityType || ''; const description = (act.activity_description || act.activityDescription || '').toLowerCase(); // Filter out status changes to pending if (activityType === 'status_change' && description.includes('pending')) { return false; } return true; }) .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); } // Fetch TAT alerts for all approval levels let tatAlerts: any[] = []; try { // Use raw SQL query to ensure all fields are returned const rawAlerts = await sequelize.query(` SELECT alert_id, request_id, level_id, approver_id, alert_type, threshold_percentage, tat_hours_allocated, tat_hours_elapsed, tat_hours_remaining, level_start_time, alert_sent_at, expected_completion_time, alert_message, notification_sent, notification_channels, is_breached, was_completed_on_time, completion_time, metadata, created_at FROM tat_alerts WHERE request_id = :requestId ORDER BY alert_sent_at ASC `, { replacements: { requestId: actualRequestId }, type: QueryTypes.SELECT }); // Transform to frontend format tatAlerts = (rawAlerts as any[]).map((alert: any) => ({ alertId: alert.alert_id, requestId: alert.request_id, levelId: alert.level_id, approverId: alert.approver_id, alertType: alert.alert_type, thresholdPercentage: Number(alert.threshold_percentage || 0), tatHoursAllocated: Number(alert.tat_hours_allocated || 0), tatHoursElapsed: Number(alert.tat_hours_elapsed || 0), tatHoursRemaining: Number(alert.tat_hours_remaining || 0), levelStartTime: alert.level_start_time, alertSentAt: alert.alert_sent_at, expectedCompletionTime: alert.expected_completion_time, alertMessage: alert.alert_message, notificationSent: alert.notification_sent, notificationChannels: alert.notification_channels || [], isBreached: alert.is_breached, wasCompletedOnTime: alert.was_completed_on_time, completionTime: alert.completion_time, metadata: alert.metadata || {} })); // logger.info(`Found ${tatAlerts.length} TAT alerts for request ${actualRequestId}`); } catch (error) { logger.error('Error fetching TAT alerts:', error); tatAlerts = []; } // Recalculate SLA for all approval levels with comprehensive data const priority = ((workflow as any)?.priority || 'standard').toString().toLowerCase(); const { calculateSLAStatus } = require('@utils/tatTimeUtils'); const updatedApprovals = await Promise.all(approvals.map(async (approval: any) => { const status = (approval.status || '').toString().toUpperCase(); const approvalData = approval.toJSON(); // Calculate SLA for active approvals (pending/in-progress) if (status === 'PENDING' || status === 'IN_PROGRESS') { const levelStartTime = approval.levelStartTime || approval.tatStartTime || approval.createdAt; const tatHours = Number(approval.tatHours || 0); if (levelStartTime && tatHours > 0) { try { // Get comprehensive SLA status from backend utility const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority); // Return updated approval with comprehensive SLA data return { ...approvalData, elapsedHours: slaData.elapsedHours, remainingHours: slaData.remainingHours, tatPercentageUsed: slaData.percentageUsed, sla: slaData // ← Full SLA object with deadline, isPaused, status, etc. }; } catch (error) { logger.error(`[Workflow] Error calculating SLA for level ${approval.levelNumber}:`, error); // Return with fallback values if SLA calculation fails return { ...approvalData, sla: { elapsedHours: 0, remainingHours: tatHours, percentageUsed: 0, isPaused: false, status: 'on_track', remainingText: `${tatHours}h`, elapsedText: '0h' } }; } } } // For completed/rejected levels, return as-is (already has final values from database) return approvalData; })); // Calculate overall request SLA const submissionDate = (workflow as any).submissionDate; const totalTatHours = updatedApprovals.reduce((sum, a) => sum + Number(a.tatHours || 0), 0); let overallSLA = null; if (submissionDate && totalTatHours > 0) { overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority); } // Update summary to include comprehensive SLA const updatedSummary = { ...summary, sla: overallSLA || summary.sla }; return { workflow, approvals: updatedApprovals, participants, documents, activities, summary: updatedSummary, tatAlerts }; } 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 is auto-calculated by database as a generated column 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 now = new Date(); const updated = await workflow.update({ status: WorkflowStatus.PENDING, isDraft: false, submissionDate: now }); // Get initiator details for activity logging const initiatorId = (updated as any).initiatorId; const initiator = initiatorId ? await User.findByPk(initiatorId) : null; const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User'; const workflowTitle = (updated as any).title || 'Request'; // Log submitted activity (similar to created activity in createWorkflow) activityService.log({ requestId: (updated as any).requestId, type: 'submitted', user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined, timestamp: new Date().toISOString(), action: 'Request submitted', details: `Request "${workflowTitle}" submitted by ${initiatorName}` }); // Log status change activity activityService.log({ requestId: (updated as any).requestId, type: 'status_change', user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined, timestamp: new Date().toISOString(), action: 'Submitted', details: 'Request moved from DRAFT to PENDING' }); const current = await ApprovalLevel.findOne({ where: { requestId: (updated as any).requestId, levelNumber: (updated as any).currentLevel || 1 } }); if (current) { // Set the first level's start time and schedule TAT jobs await current.update({ levelStartTime: now, tatStartTime: now, status: ApprovalStatus.IN_PROGRESS }); // Log assignment activity for the first approver (similar to createWorkflow) activityService.log({ requestId: (updated as any).requestId, type: 'assignment', user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined, timestamp: new Date().toISOString(), action: 'Assigned to approver', details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review` }); // Schedule TAT notification jobs for the first level try { const workflowPriority = (updated as any).priority || 'STANDARD'; await tatSchedulerService.scheduleTatJobs( (updated as any).requestId, (current as any).levelId, (current as any).approverId, Number((current as any).tatHours), now, workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours) ); logger.info(`[Workflow] TAT jobs scheduled for first level of request ${(updated as any).requestNumber} (Priority: ${workflowPriority})`); } catch (tatError) { logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError); // Don't fail the submission if TAT scheduling fails } // NOTE: Notifications are already sent in createWorkflow() when the workflow is created // We should NOT send "Request submitted" to the approver here - that's incorrect // The approver should only receive "New Request Assigned" notification (sent in createWorkflow) // The initiator receives "Request Submitted Successfully" notification (sent in createWorkflow) // // If this is a draft being submitted, notifications were already sent during creation, // so we don't need to send them again here to avoid duplicates } return updated; } catch (error) { logger.error(`Failed to submit workflow ${requestId}:`, error); throw new Error('Failed to submit workflow'); } } }