Re_Backend/_archive/services/workflow.service.ts

3450 lines
136 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, { logWorkflowEvent, logWithContext } from '@utils/logger';
import { WorkflowStatus, ParticipantType, ApprovalStatus } from '../types/common.types';
import { Op, QueryTypes, literal } 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';
import { emitToRequestRoom } from '../realtime/socket';
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
* Auto-creates user from Okta/AD if not in database
*/
async addApprover(requestId: string, email: string, addedBy: string): Promise<any> {
try {
const emailLower = email.toLowerCase();
// Find or create user from AD
let user = await User.findOne({ where: { email: emailLower } });
if (!user) {
logger.info(`[Workflow] User not found in DB, syncing from AD: ${emailLower}`);
const { UserService } = await import('./user.service');
const userService = new UserService();
try {
user = await userService.ensureUserExists({ email: emailLower }) as any;
} catch (adError: any) {
logger.error(`[Workflow] Failed to sync user from AD: ${emailLower}`, adError);
throw new Error(`Approver email '${email}' not found in organization directory. Please verify the email address.`);
}
}
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
// APPROVERS: Can approve, download documents, and need action
const participant = await Participant.create({
requestId,
userId,
userEmail: email.toLowerCase(),
userName,
participantType: ParticipantType.APPROVER, // Differentiates from SPECTATOR in database
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true, // Approvers can download
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 (in-app, email, and web push)
// APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
// This differentiates from 'spectator_added' type used for spectators
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}`,
type: 'assignment', // CRITICAL: Differentiates from 'spectator_added' - triggers approval request email
priority: 'HIGH',
actionRequired: true // Approvers need to take action
});
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<any> {
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');
}
// Block skip if workflow is paused - must resume first
if ((workflow as any).isPaused || (workflow as any).status === 'PAUSED') {
throw new Error('Cannot skip approver while workflow is paused. Please resume the workflow first before skipping.');
}
// 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) {
// Check if next level is paused - if so, don't activate it
if ((nextLevel as any).isPaused || (nextLevel as any).status === 'PAUSED') {
logger.warn(`[Workflow] Cannot activate next level ${nextLevelNumber} - level is paused`);
throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.');
}
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 skipped approver (triggers email)
await notificationService.sendToUsers([(level as any).approverId], {
title: 'Approver Skipped',
body: `You have been skipped in request ${(workflow as any).requestNumber}. The workflow has moved to the next approver.`,
requestId,
requestNumber: (workflow as any).requestNumber,
url: `/request/${(workflow as any).requestNumber}`,
type: 'approver_skipped',
priority: 'MEDIUM',
metadata: {
skipReason: skipReason,
skippedBy: skippedBy
}
});
// 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}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// 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}`);
// Emit real-time update to all users viewing this request
const wfForEmit = await WorkflowRequest.findByPk(requestId);
emitToRequestRoom(requestId, 'request:updated', {
requestId,
requestNumber: (wfForEmit as any)?.requestNumber,
action: 'SKIP',
levelNumber: levelNumber,
timestamp: new Date().toISOString()
});
return level;
} catch (error) {
logger.error(`[Workflow] Failed to skip approver:`, error);
throw error;
}
}
/**
* Add a new approver at specific level (with level shifting)
* Auto-creates user from Okta/AD if not in database
*/
async addApproverAtLevel(
requestId: string,
email: string,
tatHours: number,
targetLevel: number,
addedBy: string
): Promise<any> {
try {
const emailLower = email.toLowerCase();
// Find or create user from AD
let user = await User.findOne({ where: { email: emailLower } });
if (!user) {
logger.info(`[Workflow] User not found in DB, syncing from AD: ${emailLower}`);
const { UserService } = await import('./user.service');
const userService = new UserService();
try {
user = await userService.ensureUserExists({ email: emailLower }) as any;
} catch (adError: any) {
logger.error(`[Workflow] Failed to sync user from AD: ${emailLower}`, adError);
throw new Error(`Approver email '${email}' not found in organization directory. Please verify the email address.`);
}
}
const userId = (user as any).userId;
const userName = (user as any).displayName || (user as any).email;
const designation = (user as any).designation || (user as any).jobTitle;
const department = (user as any).department;
// 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
// IMPORTANT: Shift in REVERSE order to avoid unique constraint violations
// IMPORTANT: Preserve original level names when shifting (don't overwrite them)
// IMPORTANT: Update status of shifted levels - if they were IN_PROGRESS, set to PENDING
// because they're no longer the current active step (new approver is being added before them)
const levelsToShift = allLevels
.filter(l => (l as any).levelNumber >= targetLevel)
.sort((a, b) => (b as any).levelNumber - (a as any).levelNumber); // Sort descending
for (const levelToShift of levelsToShift) {
const oldLevelNumber = (levelToShift as any).levelNumber;
const newLevelNumber = oldLevelNumber + 1;
const existingLevelName = (levelToShift as any).levelName;
const currentStatus = (levelToShift as any).status;
// If the level being shifted was IN_PROGRESS or PENDING, set it to PENDING
// because it's no longer the current active step (a new approver is being added before it)
const newStatus = (currentStatus === ApprovalStatus.IN_PROGRESS || currentStatus === ApprovalStatus.PENDING)
? ApprovalStatus.PENDING
: currentStatus; // Keep APPROVED, REJECTED, SKIPPED as-is
// Preserve the original level name - don't overwrite it
await levelToShift.update({
levelNumber: newLevelNumber,
// Keep existing levelName if it exists, otherwise use generic
levelName: existingLevelName || `Level ${newLevelNumber}`,
status: newStatus,
// Clear levelStartTime and tatStartTime since this is no longer the active step
levelStartTime: undefined,
tatStartTime: undefined,
} as any);
logger.info(`[Workflow] Shifted level ${oldLevelNumber}${newLevelNumber}, preserved levelName: ${existingLevelName || 'N/A'}, updated status: ${currentStatus}${newStatus}`);
}
// Update total levels in workflow
await workflow.update({ totalLevels: allLevels.length + 1 });
// Auto-generate smart level name for newly added approver
// Use "Additional Approver" to identify dynamically added approvers
let levelName = `Additional Approver`;
if (designation) {
levelName = `Additional Approver - ${designation}`;
} else if (department) {
levelName = `Additional Approver - ${department}`;
} else if (userName) {
levelName = `Additional Approver - ${userName}`;
}
// Check if request is currently APPROVED - if so, we need to reactivate it
const workflowStatus = (workflow as any).status;
const isRequestApproved = workflowStatus === 'APPROVED' || workflowStatus === WorkflowStatus.APPROVED;
// Determine if the new level should be IN_PROGRESS
// If we're adding at the current level OR request was approved, the new approver becomes the active approver
const workflowCurrentLevel = (workflow as any).currentLevel;
const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel;
const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved;
// Create new approval level at target position
const newLevel = await ApprovalLevel.create({
requestId,
levelNumber: targetLevel,
levelName,
approverId: userId,
approverEmail: emailLower,
approverName: userName,
tatHours,
// tatDays is auto-calculated by database as a generated column
status: shouldBeActive ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING,
isFinalApprover: targetLevel === allLevels.length + 1,
levelStartTime: shouldBeActive ? new Date() : null,
tatStartTime: shouldBeActive ? new Date() : null
} as any);
// If request was APPROVED and we're adding a new approver, reactivate the request
if (isRequestApproved) {
// Change request status back to PENDING
await workflow.update({
status: WorkflowStatus.PENDING,
currentLevel: targetLevel // Set new approver as current level
} as any);
logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`);
} else if (isAddingAtCurrentLevel) {
// If we're adding at the current level, the workflow's currentLevel stays the same
// (it's still the same level number, just with a new approver)
// No need to update workflow.currentLevel - it's already correct
} else {
// If adding after current level, update currentLevel to the new approver
await workflow.update({ currentLevel: targetLevel } 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);
// Schedule TAT jobs if new approver is active (either at current level or request was approved)
if (shouldBeActive) {
const workflowPriority = (workflow as any)?.priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs(
requestId,
(newLevel as any).levelId,
userId,
tatHours,
new Date(),
workflowPriority
);
logger.info(`[Workflow] TAT jobs scheduled for new approver at level ${targetLevel} (request was ${isRequestApproved ? 'APPROVED - reactivated' : 'active'})`);
}
// 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 additional approver (in-app, email, and web push)
// ADDITIONAL APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
// This works the same as regular approvers - they need to review and approve
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}`,
type: 'assignment', // CRITICAL: This triggers the approval request email notification
priority: 'HIGH',
actionRequired: true // Additional approvers need to take action
});
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
* Auto-creates user from Okta/AD if not in database
*/
async addSpectator(requestId: string, email: string, addedBy: string): Promise<any> {
try {
const emailLower = email.toLowerCase();
// Find or create user from AD
let user = await User.findOne({ where: { email: emailLower } });
if (!user) {
logger.info(`[Workflow] User not found in DB, syncing from AD: ${emailLower}`);
const { UserService } = await import('./user.service');
const userService = new UserService();
try {
user = await userService.ensureUserExists({ email: emailLower }) as any;
} catch (adError: any) {
logger.error(`[Workflow] Failed to sync user from AD: ${emailLower}`, adError);
throw new Error(`Spectator email '${email}' not found in organization directory. Please verify the email address.`);
}
}
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
// SPECTATORS: View-only access, no approval rights, no document downloads
const participant = await Participant.create({
requestId,
userId,
userEmail: email.toLowerCase(),
userName,
participantType: ParticipantType.SPECTATOR, // Differentiates from APPROVER in database
canComment: true,
canViewDocuments: true,
canDownloadDocuments: false, // Spectators cannot download
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 (in-app, email, and web push)
// SPECTATOR NOTIFICATION: Uses 'spectator_added' type to trigger spectator added email
// This differentiates from 'assignment' type used for approvers
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}`,
type: 'spectator_added', // CRITICAL: Differentiates from 'assignment' - triggers spectator added email
priority: 'MEDIUM', // Lower priority than approvers (no action required)
metadata: {
addedBy: addedBy // Used in email to show who added the spectator
}
});
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; templateType?: 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, paused)
if (filters?.status && filters.status !== 'all') {
const statusUpper = filters.status.toUpperCase();
if (statusUpper === 'PENDING') {
// Pending requests (not paused)
whereConditions.push({
status: 'PENDING',
isPaused: false
});
} else if (statusUpper === 'PAUSED') {
// Paused requests - can filter by status or isPaused flag
whereConditions.push({
[Op.or]: [
{ status: 'PAUSED' },
{ isPaused: true }
]
});
} 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 templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
whereConditions.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
whereConditions.push({ templateType: templateTypeUpper });
}
}
// 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<string, string> = {
'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', 'PAUSED'] as any }, // Include PAUSED to show SLA for paused levels
},
order: [['levelNumber', 'ASC']],
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }],
// Include pause-related fields for SLA calculation
attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName',
'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime', 'levelEndTime',
'isPaused', 'pausedAt', 'pauseElapsedHours', 'pauseResumeDate', 'elapsedHours']
});
// Fetch all approval levels for this request (including pause fields for SLA calculation)
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', 'isPaused', 'pausedAt', 'pauseElapsedHours', 'pauseResumeDate', 'elapsedHours']
});
// 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;
// Determine closure type for CLOSED requests
// If ANY level was rejected, it's a "rejected" closure
// If ALL completed levels were approved, it's an "approved" closure
const hasRejectedLevel = approvals.some((a: any) => a.status === 'REJECTED');
const closureType = hasRejectedLevel ? 'rejected' : 'approved';
const priority = ((wf as any).priority || 'standard').toString().toLowerCase();
// Calculate OVERALL request SLA based on cumulative elapsed hours from all levels
// This correctly accounts for pause periods since each level's elapsed is pause-adjusted
const { calculateSLAStatus, addWorkingHours, addWorkingHoursExpress } = require('@utils/tatTimeUtils');
const submissionDate = (wf as any).submissionDate;
const closureDate = (wf as any).closureDate;
let overallSLA = null;
if (submissionDate && totalTatHours > 0) {
try {
// Calculate total elapsed hours by summing from all levels (pause-adjusted)
let totalElapsedHours = 0;
for (const approval of approvals) {
const status = ((approval as any).status || '').toString().toUpperCase();
if (status === 'APPROVED' || status === 'REJECTED') {
// For completed levels, use stored elapsedHours
totalElapsedHours += Number((approval as any).elapsedHours || 0);
} else if (status === 'SKIPPED') {
continue;
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
// For active/paused levels, calculate with pause handling
const levelStartTime = (approval as any).levelStartTime || (approval as any).tatStartTime;
const levelTatHours = Number((approval as any).tatHours || 0);
if (levelStartTime && levelTatHours > 0) {
const isPausedLevel = status === 'PAUSED' || (approval as any).isPaused;
const wasResumed = !isPausedLevel &&
(approval as any).pauseElapsedHours !== null &&
(approval as any).pauseElapsedHours !== undefined &&
(approval as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pauseElapsedHours: (approval as any).pauseElapsedHours
} : wasResumed ? {
isPaused: false,
pauseElapsedHours: Number((approval as any).pauseElapsedHours),
pauseResumeDate: (approval as any).pauseResumeDate
} : undefined;
const levelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, null, pauseInfo);
totalElapsedHours += levelSLA.elapsedHours || 0;
}
}
}
// Calculate overall SLA metrics
const totalRemainingHours = Math.max(0, totalTatHours - totalElapsedHours);
const percentageUsed = totalTatHours > 0
? Math.min(100, Math.round((totalElapsedHours / totalTatHours) * 100))
: 0;
// Determine status
let overallStatus: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
if (percentageUsed >= 100) overallStatus = 'breached';
else if (percentageUsed >= 80) overallStatus = 'critical';
else if (percentageUsed >= 60) overallStatus = 'approaching';
// Format time display
const formatTime = (hours: number) => {
if (hours < 1) return `${Math.round(hours * 60)}m`;
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (minutes > 0) return `${wholeHours}h ${minutes}m`;
return `${wholeHours}h`;
};
// Check if any level is paused
const isAnyLevelPaused = approvals.some((a: any) =>
((a.status || '').toString().toUpperCase() === 'PAUSED' || a.isPaused === true)
);
// Calculate deadline
const deadline = priority === 'express'
? (await addWorkingHoursExpress(submissionDate, totalTatHours)).toDate()
: (await addWorkingHours(submissionDate, totalTatHours)).toDate();
overallSLA = {
elapsedHours: totalElapsedHours,
remainingHours: totalRemainingHours,
percentageUsed,
status: overallStatus,
isPaused: isAnyLevelPaused,
deadline: deadline.toISOString(),
elapsedText: formatTime(totalElapsedHours),
remainingText: formatTime(totalRemainingHours)
};
} catch (error) {
logger.error('[Workflow] Error calculating overall SLA:', error);
}
}
// Calculate current level SLA (if there's an active level, including paused)
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).levelEndTime || closureDate || null;
// Prepare pause info for SLA calculation
const isPausedLevel = (currentLevel as any).status === 'PAUSED' || (currentLevel as any).isPaused;
const wasResumed = !isPausedLevel &&
(currentLevel as any).pauseElapsedHours !== null &&
(currentLevel as any).pauseElapsedHours !== undefined &&
(currentLevel as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pausedAt: (currentLevel as any).pausedAt,
pauseElapsedHours: (currentLevel as any).pauseElapsedHours,
pauseResumeDate: (currentLevel as any).pauseResumeDate
} : wasResumed ? {
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number((currentLevel as any).pauseElapsedHours),
pauseResumeDate: (currentLevel as any).pauseResumeDate
} : undefined;
if (levelStartTime && levelTatHours > 0) {
try {
currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate, pauseInfo);
} 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,
closureType: closureType, // 'approved' or 'rejected' - indicates path to closure
workflowType: (wf as any).workflowType || null, // 'CLAIM_MANAGEMENT', 'NON_TEMPLATIZED', etc.
templateType: (wf as any).templateType || null, // 'CUSTOM', 'TEMPLATE', 'DEALER CLAIM'
templateId: (wf as any).templateId || null, // Reference to workflow_templates if using admin template
initiator: (wf as any).initiator,
department: (wf as any).initiator?.department,
totalLevels: (wf as any).totalLevels,
totalTatHours: totalTatHours,
isPaused: (wf as any).isPaused || false, // Workflow pause status
pauseInfo: (wf as any).isPaused ? {
isPaused: true,
pausedAt: (wf as any).pausedAt,
pauseReason: (wf as any).pauseReason,
pauseResumeDate: (wf as any).pauseResumeDate,
} : null,
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,
isPaused: (currentLevel as any).status === 'PAUSED' || (currentLevel as any).isPaused,
pauseElapsedHours: (currentLevel as any).pauseElapsedHours,
sla: currentLevelSLA, // ← Backend-calculated SLA for current level (includes pause handling)
} : 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 requests only (IN_PROGRESS is treated as PENDING)
whereConditions.push({
[Op.or]: [
{ status: 'PENDING' },
{ status: 'IN_PROGRESS' } // Legacy support - will be migrated to PENDING
]
});
} 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<string, string> = {
'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 ALL requests where user is INVOLVED for REGULAR USERS - "All Requests" page
* This is a dedicated method for regular users' "All Requests" screen
* Shows requests where user is:
* - Initiator (created the request)
* - Approver (in any approval level)
* - Participant/spectator
* Completely separate from listWorkflows (admin) to avoid interference
*/
async listParticipantRequests(
userId: string,
page: number,
limit: number,
filters?: {
search?: string;
status?: string;
priority?: string;
templateType?: 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 INVOLVED in any capacity:
// 1. As initiator (created the request)
// 2. As approver (in any approval level)
// 3. As participant/spectator
// Get requests where user is the initiator
const initiatorRequests = await WorkflowRequest.findAll({
where: { initiatorId: userId, isDraft: false },
attributes: ['requestId'],
});
const initiatorRequestIds = initiatorRequests.map((r: any) => r.requestId);
// 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 ALL request IDs where user is involved (initiator + approver + spectator)
const allRequestIds = Array.from(new Set([
...initiatorRequestIds,
...approverRequestIds,
...participantRequestIds
]));
// Build where clause with filters
const whereConditions: any[] = [];
// Filter by request IDs where user is involved in any capacity
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 requests only (IN_PROGRESS is treated as PENDING)
whereConditions.push({
[Op.or]: [
{ status: 'PENDING' },
{ status: 'IN_PROGRESS' } // Legacy support - will be migrated to PENDING
]
});
} 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 templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
whereConditions.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
whereConditions.push({ templateType: templateTypeUpper });
}
}
// 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<string, string> = {
'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;
templateType?: string;
department?: string;
slaCompliance?: 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
// Include drafts in "My Requests" - users may keep drafts for some time
// whereConditions.push({ isDraft: false }); // Removed to include drafts
// 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 if (statusUpper === 'DRAFT') {
// Draft status - filter by isDraft flag
whereConditions.push({ isDraft: true });
} else {
whereConditions.push({ status: statusUpper });
}
}
// Apply priority filter
if (filters?.priority && filters.priority !== 'all') {
whereConditions.push({ priority: filters.priority.toUpperCase() });
}
// Apply templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
whereConditions.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
whereConditions.push({ templateType: templateTypeUpper });
}
}
// 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 } : {};
// 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<string, string> = {
'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 } };
}
async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; templateType?: string }, sortBy?: string, sortOrder?: string) {
const offset = (page - 1) * limit;
// Find all pending/in-progress/paused approval levels across requests ordered by levelNumber
// Include PAUSED status so paused requests where user is the current approver are shown
const pendingLevels = await ApprovalLevel.findAll({
where: {
status: {
[Op.in]: [
ApprovalStatus.PENDING as any,
(ApprovalStatus as any).IN_PROGRESS ?? 'IN_PROGRESS',
ApprovalStatus.PAUSED as any,
'PENDING',
'IN_PROGRESS',
'PAUSED'
] 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]));
// 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 - include PAUSED so paused requests are shown
baseConditions.push({
[Op.or]: [
{
status: {
[Op.in]: [
WorkflowStatus.PENDING as any,
WorkflowStatus.APPROVED as any,
WorkflowStatus.PAUSED as any,
'PENDING',
'IN_PROGRESS', // Legacy support - will be migrated to PENDING
'APPROVED',
'PAUSED'
] as any
}
},
// Also include requests with isPaused = true (even if status is PENDING)
{
isPaused: true
}
]
});
// Apply status filter if provided (overrides default status filter)
if (filters?.status && filters.status !== 'all') {
const statusUpper = filters.status.toUpperCase();
baseConditions.pop(); // Remove default status condition
if (statusUpper === 'PAUSED') {
// For paused filter, include both PAUSED status and isPaused flag
baseConditions.push({
[Op.or]: [
{ status: 'PAUSED' },
{ isPaused: true }
]
});
} else {
// For other statuses, filter normally but exclude paused requests
baseConditions.push({
[Op.and]: [
{ status: statusUpper },
{
[Op.or]: [
{ isPaused: { [Op.is]: null } },
{ isPaused: false }
]
}
]
});
}
}
// Apply priority filter
if (filters?.priority && filters.priority !== 'all') {
baseConditions.push({ priority: filters.priority.toUpperCase() });
}
// Apply templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
baseConditions.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
baseConditions.push({ templateType: templateTypeUpper });
}
}
// 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; templateType?: 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 ONLY CLOSED)
// Closed requests are the final state after approval/rejection + conclusion
const closedStatus = [
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
'CLOSED'
] as any;
if (allRequestIds.length > 0) {
const approverConditionParts: any[] = [
{ requestId: { [Op.in]: allRequestIds } },
{ status: { [Op.in]: closedStatus } } // Only CLOSED requests
];
// Apply closure type filter (approved/rejected before closure)
if (filters?.status && filters?.status !== 'all') {
const filterStatus = filters.status.toLowerCase();
if (filterStatus === 'rejected') {
// Closed after rejection: has at least one REJECTED approval level
approverConditionParts.push({
[Op.and]: [
literal(`EXISTS (
SELECT 1 FROM approval_levels al
WHERE al.request_id = "WorkflowRequest"."request_id"
AND al.status = 'REJECTED'
)`)
]
});
} else if (filterStatus === 'approved') {
// Closed after approval: no REJECTED levels (all approved)
approverConditionParts.push({
[Op.and]: [
literal(`NOT EXISTS (
SELECT 1 FROM approval_levels al
WHERE al.request_id = "WorkflowRequest"."request_id"
AND al.status = 'REJECTED'
)`)
]
});
}
}
// Apply priority filter
if (filters?.priority && filters.priority !== 'all') {
approverConditionParts.push({ priority: filters.priority.toUpperCase() });
}
// Apply templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
approverConditionParts.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
approverConditionParts.push({ templateType: templateTypeUpper });
}
}
// 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 CLOSED)
// CLOSED means request has been finalized with conclusion
const initiatorStatuses = [
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
'CLOSED'
] as any;
const initiatorConditionParts: any[] = [
{ initiatorId: userId },
{ status: { [Op.in]: initiatorStatuses } } // Only CLOSED requests
];
// Apply closure type filter (approved/rejected before closure)
if (filters?.status && filters?.status !== 'all') {
const filterStatus = filters.status.toLowerCase();
if (filterStatus === 'rejected') {
// Closed after rejection: has at least one REJECTED approval level
initiatorConditionParts.push({
[Op.and]: [
literal(`EXISTS (
SELECT 1 FROM approval_levels al
WHERE al.request_id = "WorkflowRequest"."request_id"
AND al.status = 'REJECTED'
)`)
]
});
} else if (filterStatus === 'approved') {
// Closed after approval: no REJECTED levels (all approved)
initiatorConditionParts.push({
[Op.and]: [
literal(`NOT EXISTS (
SELECT 1 FROM approval_levels al
WHERE al.request_id = "WorkflowRequest"."request_id"
AND al.status = 'REJECTED'
)`)
]
});
}
}
// Apply priority filter
if (filters?.priority && filters.priority !== 'all') {
initiatorConditionParts.push({ priority: filters.priority.toUpperCase() });
}
// Apply templateType filter
if (filters?.templateType && filters.templateType !== 'all') {
const templateTypeUpper = filters.templateType.toUpperCase();
// For CUSTOM, also include null values (legacy requests without templateType)
if (templateTypeUpper === 'CUSTOM') {
initiatorConditionParts.push({
[Op.or]: [
{ templateType: 'CUSTOM' },
{ templateType: null }
]
});
} else {
initiatorConditionParts.push({ templateType: templateTypeUpper });
}
}
// 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 only CLOSED requests (already finalized with conclusion)
const { rows, count } = await WorkflowRequest.findAndCountAll({
where,
offset,
limit,
order,
include: [
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
],
});
// Enrich with SLA and closure type
const enrichedData = await this.enrichForCards(rows);
return {
data: enrichedData,
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<WorkflowRequest> {
try {
const requestNumber = await 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<string, typeof workflowData.participants[0]>();
const rolePriority: Record<string, number> = {
'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
});
}
}
logWorkflowEvent('created', workflow.requestId, {
requestNumber,
priority: workflowData.priority,
userId: initiatorId,
status: workflow.status,
});
// 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
});
// NOTE: Notifications are NOT sent here because workflows are created as DRAFTS
// Notifications will be sent in submitWorkflow() when the draft is actually submitted
// This prevents approvers from being notified about draft requests
return workflow;
} catch (error) {
logWithContext('error', 'Failed to create workflow', {
userId: initiatorId,
priority: workflowData.priority,
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');
}
}
/**
* Check if a user has access to view a specific request.
* User has access if they are:
* 1. Admin/Management (has management access)
* 2. The initiator of the request
* 3. An approver at any level of the request
* 4. A spectator/participant of the request
*
* @param userId - The user ID to check access for
* @param requestId - The request ID or request number
* @returns Object with hasAccess boolean and reason string
*/
async checkUserRequestAccess(userId: string, requestId: string): Promise<{ hasAccess: boolean; reason?: string }> {
try {
// First, find the workflow
const workflowBase = await this.findWorkflowByIdentifier(requestId);
if (!workflowBase) {
return { hasAccess: false, reason: 'Request not found' };
}
const actualRequestId = (workflowBase as any).getDataValue
? (workflowBase as any).getDataValue('requestId')
: (workflowBase as any).requestId;
// Check 1: Is the user an admin/management?
const user = await User.findByPk(userId);
if (user && user.hasManagementAccess()) {
return { hasAccess: true };
}
// Check 2: Is the user the initiator?
const initiatorId = (workflowBase as any).initiatorId || (workflowBase as any).initiator_id;
if (initiatorId === userId) {
return { hasAccess: true };
}
// Check 3: Is the user an approver at any level?
const isApprover = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
approverId: userId
}
});
if (isApprover) {
return { hasAccess: true };
}
// Check 4: Is the user a spectator/participant?
const isParticipant = await Participant.findOne({
where: {
requestId: actualRequestId,
userId: userId
}
});
if (isParticipant) {
return { hasAccess: true };
}
// No access
return {
hasAccess: false,
reason: 'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.'
};
} catch (error) {
logger.error(`Failed to check user access for request ${requestId}:`, error);
throw new Error('Failed to verify access permissions');
}
}
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)
// When paused, use the workflow's currentLevel field directly to get the paused level
// Otherwise, find the first PENDING/IN_PROGRESS level
const workflowCurrentLevel = (workflow as any).currentLevel;
const isPaused = (workflow as any).isPaused || (workflow as any).status === 'PAUSED';
let currentLevel: ApprovalLevel | null = null;
if (isPaused && workflowCurrentLevel) {
// When paused, get the level at the workflow's currentLevel (the paused level)
// This ensures we show SLA for the paused approver, not the next one
currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
levelNumber: workflowCurrentLevel,
},
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
});
} else {
// When not paused, find the first active level (exclude PAUSED to avoid showing wrong level)
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'] }]
});
}
// Fallback: if currentLevel not found but workflow has currentLevel, use it
if (!currentLevel && workflowCurrentLevel) {
currentLevel = await ApprovalLevel.findOne({
where: {
requestId: actualRequestId,
levelNumber: workflowCurrentLevel,
},
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,
// When paused, ensure we use the paused level's number, not the next level
currentLevel: currentLevel ? (currentLevel as any).levelNumber : (isPaused ? workflowCurrentLevel : 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();
const isPausedLevel = status === 'PAUSED' || approval.isPaused;
const approvalLevelNumber = approval.levelNumber || 0;
const workflowCurrentLevelNumber = currentLevel ? (currentLevel as any).levelNumber : ((workflow as any).currentLevel || 1);
// Calculate SLA ONLY for the CURRENT active level (matching currentLevel)
// This ensures that when in step 1, only step 1 has elapsed time, others have 0
// Include PAUSED so we show SLA for the paused approver, not the next one
const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber;
const shouldCalculateSLA = isCurrentLevel && (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED');
if (shouldCalculateSLA) {
const levelStartTime = approval.levelStartTime || approval.tatStartTime || approval.createdAt;
const tatHours = Number(approval.tatHours || 0);
if (levelStartTime && tatHours > 0) {
try {
// Prepare pause info for SLA calculation
// Case 1: Level is currently paused
// Case 2: Level was paused and resumed (pauseElapsedHours and pauseResumeDate are set)
const wasResumed = !isPausedLevel &&
approval.pauseElapsedHours !== null &&
approval.pauseElapsedHours !== undefined &&
approval.pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pausedAt: approval.pausedAt,
pauseElapsedHours: approval.pauseElapsedHours,
pauseResumeDate: approval.pauseResumeDate
} : wasResumed ? {
// Level was paused but has been resumed
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number(approval.pauseElapsedHours), // Pre-pause elapsed hours
pauseResumeDate: approval.pauseResumeDate // Actual resume timestamp
} : undefined;
// Get comprehensive SLA status from backend utility
const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority, null, pauseInfo);
// 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: isPausedLevel ? (approval.pauseElapsedHours || 0) : 0,
remainingHours: tatHours,
percentageUsed: 0,
isPaused: isPausedLevel,
status: 'on_track',
remainingText: `${tatHours}h`,
elapsedText: '0h'
}
};
}
}
}
// For waiting levels (future levels that haven't started), set elapsedHours to 0
// This ensures that when in step 1, steps 2-8 show elapsedHours = 0
if (approvalLevelNumber > workflowCurrentLevelNumber && status !== 'APPROVED' && status !== 'REJECTED') {
return {
...approvalData,
elapsedHours: 0,
remainingHours: Number(approval.tatHours || 0),
tatPercentageUsed: 0,
};
}
// For completed/rejected levels, return as-is (already has final values from database)
return approvalData;
}));
// Calculate overall request SLA based on cumulative elapsed hours from all levels
// This correctly accounts for pause periods since each level's elapsedHours is pause-adjusted
// Use submissionDate if available, otherwise fallback to createdAt for SLA calculation
const submissionDate = (workflow as any).submissionDate || (workflow as any).createdAt;
const totalTatHours = updatedApprovals.reduce((sum, a) => sum + Number(a.tatHours || 0), 0);
let overallSLA = null;
if (submissionDate && totalTatHours > 0) {
// Calculate total elapsed hours by summing elapsed hours from all levels
// CRITICAL: Only count elapsed hours from completed levels + current active level
// Waiting levels (future steps) should contribute 0 elapsed hours
// This ensures that when in step 1, only step 1's elapsed hours are counted
let totalElapsedHours = 0;
const workflowCurrentLevelNumber = currentLevel ? (currentLevel as any).levelNumber : ((workflow as any).currentLevel || 1);
for (const approval of updatedApprovals) {
const status = (approval.status || '').toString().toUpperCase();
const approvalLevelNumber = approval.levelNumber || 0;
if (status === 'APPROVED' || status === 'REJECTED') {
// For completed levels, use the stored elapsedHours (already pause-adjusted from when level was completed)
totalElapsedHours += Number(approval.elapsedHours || 0);
} else if (status === 'SKIPPED') {
// Skipped levels don't contribute to elapsed time
continue;
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
// CRITICAL: Only count elapsed hours for the CURRENT active level
// Waiting levels (future steps) should NOT contribute elapsed hours
// This ensures request-level elapsed time matches the current step's elapsed time
const isCurrentLevel = approvalLevelNumber === workflowCurrentLevelNumber;
if (isCurrentLevel) {
// For active/paused levels, use the SLA-calculated elapsedHours (pause-adjusted)
if (approval.sla?.elapsedHours !== undefined) {
totalElapsedHours += Number(approval.sla.elapsedHours);
} else {
totalElapsedHours += Number(approval.elapsedHours || 0);
}
}
// Waiting levels (approvalLevelNumber > workflowCurrentLevelNumber) contribute 0 elapsed hours
}
// WAITING levels haven't started yet, so no elapsed time
}
// Calculate overall SLA metrics based on cumulative elapsed hours
const totalRemainingHours = Math.max(0, totalTatHours - totalElapsedHours);
const percentageUsed = totalTatHours > 0
? Math.min(100, Math.round((totalElapsedHours / totalTatHours) * 100))
: 0;
// Determine overall status
let overallStatus: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
if (percentageUsed >= 100) {
overallStatus = 'breached';
} else if (percentageUsed >= 80) {
overallStatus = 'critical';
} else if (percentageUsed >= 60) {
overallStatus = 'approaching';
}
// Format time display (simple format - frontend will handle detailed formatting)
const formatTime = (hours: number) => {
if (hours < 1) return `${Math.round(hours * 60)}m`;
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (minutes > 0) return `${wholeHours}h ${minutes}m`;
return `${wholeHours}h`;
};
// Check if any level is currently paused
const isAnyLevelPaused = updatedApprovals.some(a =>
(a.status || '').toString().toUpperCase() === 'PAUSED' || a.isPaused === true
);
// Calculate deadline using the original method (for deadline display only)
const { addWorkingHours, addWorkingHoursExpress } = require('@utils/tatTimeUtils');
const deadline = priority === 'express'
? (await addWorkingHoursExpress(submissionDate, totalTatHours)).toDate()
: (await addWorkingHours(submissionDate, totalTatHours)).toDate();
overallSLA = {
elapsedHours: totalElapsedHours,
remainingHours: totalRemainingHours,
percentageUsed,
status: overallStatus,
isPaused: isAnyLevelPaused,
deadline: deadline.toISOString(),
elapsedText: formatTime(totalElapsedHours),
remainingText: formatTime(totalRemainingHours)
};
}
// 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<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 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)
// IMPORTANT: Skip if participants array is empty - this means "don't update participants"
// Frontend sends empty array when it expects backend to auto-generate, but we should preserve existing participants
if (isDraft && updateData.participants && Array.isArray(updateData.participants) && updateData.participants.length > 0) {
// 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}`);
} else if (isDraft && updateData.participants && Array.isArray(updateData.participants) && updateData.participants.length === 0) {
// Empty array means "preserve existing participants" - don't delete them
logger.info(`[Workflow] Empty participants array provided for draft ${actualRequestId} - preserving existing participants`);
}
// 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) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
logger.error(`Failed to update workflow ${requestId}:`, {
error: errorMessage,
stack: errorStack,
requestId,
updateData: JSON.stringify(updateData, null, 2),
});
// Preserve original error message for better debugging
throw new Error(`Failed to update workflow: ${errorMessage}`);
}
}
async submitWorkflow(requestId: string): Promise<WorkflowRequest | null> {
try {
const workflow = await this.findWorkflowByIdentifier(requestId);
if (!workflow) return null;
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
const actualRequestId = (workflow as any).getDataValue
? (workflow as any).getDataValue('requestId')
: (workflow as any).requestId;
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';
const requestNumber = (updated as any).requestNumber;
// Check if this was a previously saved draft (has activity history before submission)
// or a direct submission (createWorkflow + submitWorkflow in same flow)
const { Activity } = require('@models/Activity');
const existingActivities = await Activity.count({
where: { requestId: actualRequestId }
});
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
// For direct submissions, createWorkflow already logs "Initial request submitted"
if (existingActivities > 1) {
// This is a saved draft being submitted later
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Draft submitted',
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
});
} else {
// Direct submission - just update the status, createWorkflow already logged the activity
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Request submitted',
details: `Request "${workflowTitle}" submitted for approval`
});
}
const current = await ApprovalLevel.findOne({
where: { requestId: actualRequestId, 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: actualRequestId,
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(
actualRequestId,
(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 ${requestNumber} (Priority: ${workflowPriority})`);
} catch (tatError) {
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
// Don't fail the submission if TAT scheduling fails
}
// Send notifications when workflow is submitted (not when created as draft)
// Send notification to INITIATOR confirming submission
await notificationService.sendToUsers([initiatorId], {
title: 'Request Submitted Successfully',
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'request_submitted',
priority: 'MEDIUM'
});
// Send notification to FIRST APPROVER for assignment
await notificationService.sendToUsers([(current as any).approverId], {
title: 'New Request Assigned',
body: `${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// Send notifications to SPECTATORS (in-app, email, and web push)
// Moved outside the if(current) block to ensure spectators are always notified on submission
try {
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
const spectators = await Participant.findAll({
where: {
requestId: actualRequestId, // Use the actual UUID requestId
participantType: ParticipantType.SPECTATOR,
isActive: true,
notificationEnabled: true
},
attributes: ['userId', 'userEmail', 'userName']
});
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
if (spectators.length > 0) {
const spectatorUserIds = spectators.map((s: any) => s.userId);
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
await notificationService.sendToUsers(spectatorUserIds, {
title: 'Added to Request',
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'spectator_added',
priority: 'MEDIUM'
});
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
} else {
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
}
} catch (spectatorError) {
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
// Don't fail the submission if spectator notifications fail
}
return updated;
} catch (error) {
logger.error(`Failed to submit workflow ${requestId}:`, error);
throw new Error('Failed to submit workflow');
}
}
}