2625 lines
101 KiB
TypeScript
2625 lines
101 KiB
TypeScript
import { WorkflowRequest } from '@models/WorkflowRequest';
|
|
// duplicate import removed
|
|
import { User } from '@models/User';
|
|
import { ApprovalLevel } from '@models/ApprovalLevel';
|
|
import { Participant } from '@models/Participant';
|
|
import { Document } from '@models/Document';
|
|
// Ensure associations are initialized by importing models index
|
|
import '@models/index';
|
|
import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.types';
|
|
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
|
|
import logger from '@utils/logger';
|
|
import { WorkflowStatus, ParticipantType, ApprovalStatus } from '../types/common.types';
|
|
import { Op, QueryTypes } from 'sequelize';
|
|
import { sequelize } from '@config/database';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import dayjs from 'dayjs';
|
|
import { notificationService } from './notification.service';
|
|
import { activityService } from './activity.service';
|
|
import { tatSchedulerService } from './tatScheduler.service';
|
|
|
|
export class WorkflowService {
|
|
/**
|
|
* Helper method to map activity type to user-friendly action label
|
|
*/
|
|
private getActivityAction(type: string): string {
|
|
const actionMap: Record<string, string> = {
|
|
'created': 'Request Created',
|
|
'assignment': 'Assigned',
|
|
'approval': 'Approved',
|
|
'rejection': 'Rejected',
|
|
'status_change': 'Status Changed',
|
|
'comment': 'Comment Added',
|
|
'reminder': 'Reminder Sent',
|
|
'document_added': 'Document Added',
|
|
'sla_warning': 'SLA Warning'
|
|
};
|
|
return actionMap[type] || 'Activity';
|
|
}
|
|
|
|
/**
|
|
* Add a new approver to an existing workflow
|
|
*/
|
|
async addApprover(requestId: string, email: string, addedBy: string): Promise<any> {
|
|
try {
|
|
// Find user by email
|
|
const user = await User.findOne({ where: { email: email.toLowerCase() } });
|
|
if (!user) {
|
|
throw new Error('User not found with this email');
|
|
}
|
|
|
|
const userId = (user as any).userId;
|
|
const userName = (user as any).displayName || (user as any).email;
|
|
|
|
// Check if user is already a participant
|
|
const existing = await Participant.findOne({
|
|
where: { requestId, userId }
|
|
});
|
|
|
|
if (existing) {
|
|
throw new Error('User is already a participant in this request');
|
|
}
|
|
|
|
// Add as approver participant
|
|
const participant = await Participant.create({
|
|
requestId,
|
|
userId,
|
|
userEmail: email.toLowerCase(),
|
|
userName,
|
|
participantType: ParticipantType.APPROVER,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy,
|
|
isActive: true
|
|
} as any);
|
|
|
|
// Get workflow details for notification
|
|
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
|
|
const requestNumber = (workflow as any)?.requestNumber;
|
|
const title = (workflow as any)?.title;
|
|
|
|
// Get the user who is adding the approver
|
|
const addedByUser = await User.findByPk(addedBy);
|
|
const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User';
|
|
|
|
// Log activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'assignment',
|
|
user: { userId: addedBy, name: addedByName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Added new approver',
|
|
details: `${userName} (${email}) has been added as an approver by ${addedByName}`
|
|
});
|
|
|
|
// Send notification to new approver
|
|
await notificationService.sendToUsers([userId], {
|
|
title: 'New Request Assignment',
|
|
body: `You have been added as an approver to request ${requestNumber}: ${title}`,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`
|
|
});
|
|
|
|
logger.info(`[Workflow] Added approver ${email} to request ${requestId}`);
|
|
return participant;
|
|
} catch (error) {
|
|
logger.error(`[Workflow] Failed to add approver:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
// Mark as skipped
|
|
await level.update({
|
|
status: ApprovalStatus.SKIPPED,
|
|
levelEndTime: new Date(),
|
|
actionDate: new Date()
|
|
});
|
|
|
|
// Update additional skip fields if migration was run
|
|
try {
|
|
await sequelize.query(`
|
|
UPDATE approval_levels
|
|
SET is_skipped = TRUE,
|
|
skipped_at = NOW(),
|
|
skipped_by = :skippedBy,
|
|
skip_reason = :skipReason
|
|
WHERE level_id = :levelId
|
|
`, {
|
|
replacements: { levelId, skippedBy, skipReason },
|
|
type: QueryTypes.UPDATE
|
|
});
|
|
} catch (err) {
|
|
logger.warn('[Workflow] is_skipped column not available (migration not run), using status only');
|
|
}
|
|
|
|
// Cancel TAT jobs for skipped level
|
|
await tatSchedulerService.cancelTatJobs(requestId, levelId);
|
|
|
|
// Move to next level
|
|
const nextLevelNumber = levelNumber + 1;
|
|
const nextLevel = await ApprovalLevel.findOne({
|
|
where: { requestId, levelNumber: nextLevelNumber }
|
|
});
|
|
|
|
if (nextLevel) {
|
|
const now = new Date();
|
|
await nextLevel.update({
|
|
status: ApprovalStatus.IN_PROGRESS,
|
|
levelStartTime: now,
|
|
tatStartTime: now
|
|
});
|
|
|
|
// Schedule TAT jobs for next level
|
|
const workflowPriority = (workflow as any)?.priority || 'STANDARD';
|
|
await tatSchedulerService.scheduleTatJobs(
|
|
requestId,
|
|
(nextLevel as any).levelId,
|
|
(nextLevel as any).approverId,
|
|
Number((nextLevel as any).tatHours),
|
|
now,
|
|
workflowPriority
|
|
);
|
|
|
|
// Update workflow current level
|
|
await workflow.update({ currentLevel: nextLevelNumber });
|
|
|
|
// Notify next approver
|
|
await notificationService.sendToUsers([(nextLevel as any).approverId], {
|
|
title: 'Request Escalated',
|
|
body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`,
|
|
requestId,
|
|
requestNumber: (workflow as any).requestNumber,
|
|
url: `/request/${(workflow as any).requestNumber}`
|
|
});
|
|
}
|
|
|
|
// Get user who skipped
|
|
const skipUser = await User.findByPk(skippedBy);
|
|
const skipUserName = (skipUser as any)?.displayName || (skipUser as any)?.email || 'User';
|
|
|
|
// Log activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'status_change',
|
|
user: { userId: skippedBy, name: skipUserName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Approver Skipped',
|
|
details: `Level ${levelNumber} approver (${(level as any).approverName}) was skipped by ${skipUserName}. Reason: ${skipReason || 'Not provided'}`
|
|
});
|
|
|
|
logger.info(`[Workflow] Skipped approver at level ${levelNumber} for request ${requestId}`);
|
|
return level;
|
|
} catch (error) {
|
|
logger.error(`[Workflow] Failed to skip approver:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new approver at specific level (with level shifting)
|
|
*/
|
|
async addApproverAtLevel(
|
|
requestId: string,
|
|
email: string,
|
|
tatHours: number,
|
|
targetLevel: number,
|
|
addedBy: string
|
|
): Promise<any> {
|
|
try {
|
|
// Find user by email
|
|
const user = await User.findOne({ where: { email: email.toLowerCase() } });
|
|
if (!user) {
|
|
throw new Error('User not found with this email');
|
|
}
|
|
|
|
const userId = (user as any).userId;
|
|
const userName = (user as any).displayName || (user as any).email;
|
|
|
|
// Check if user is already a participant
|
|
const existing = await Participant.findOne({
|
|
where: { requestId, userId }
|
|
});
|
|
|
|
if (existing) {
|
|
throw new Error('User is already a participant in this request');
|
|
}
|
|
|
|
// Get workflow
|
|
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
|
|
if (!workflow) {
|
|
throw new Error('Workflow not found');
|
|
}
|
|
|
|
// Get all approval levels
|
|
const allLevels = await ApprovalLevel.findAll({
|
|
where: { requestId },
|
|
order: [['levelNumber', 'ASC']]
|
|
});
|
|
|
|
// Validate target level
|
|
// New approver must be placed after all approved/rejected/skipped levels
|
|
const completedLevels = allLevels.filter(l => {
|
|
const status = (l as any).status;
|
|
return status === 'APPROVED' || status === 'REJECTED' || status === 'SKIPPED';
|
|
});
|
|
const minAllowedLevel = completedLevels.length + 1;
|
|
|
|
if (targetLevel < minAllowedLevel) {
|
|
throw new Error(`Cannot add approver at level ${targetLevel}. Minimum allowed level is ${minAllowedLevel} (after completed levels)`);
|
|
}
|
|
|
|
// Shift existing levels at and after target level
|
|
const levelsToShift = allLevels.filter(l => (l as any).levelNumber >= targetLevel);
|
|
|
|
for (const levelToShift of levelsToShift) {
|
|
const newLevelNumber = (levelToShift as any).levelNumber + 1;
|
|
await levelToShift.update({
|
|
levelNumber: newLevelNumber,
|
|
levelName: `Level ${newLevelNumber}`
|
|
});
|
|
logger.info(`[Workflow] Shifted level ${(levelToShift as any).levelNumber - 1} → ${newLevelNumber}`);
|
|
}
|
|
|
|
// Update total levels in workflow
|
|
await workflow.update({ totalLevels: allLevels.length + 1 });
|
|
|
|
// Create new approval level at target position
|
|
const newLevel = await ApprovalLevel.create({
|
|
requestId,
|
|
levelNumber: targetLevel,
|
|
levelName: `Level ${targetLevel}`,
|
|
approverId: userId,
|
|
approverEmail: email.toLowerCase(),
|
|
approverName: userName,
|
|
tatHours,
|
|
// tatDays is auto-calculated by database as a generated column
|
|
status: targetLevel === (workflow as any).currentLevel ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING,
|
|
isFinalApprover: targetLevel === allLevels.length + 1,
|
|
levelStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null,
|
|
tatStartTime: targetLevel === (workflow as any).currentLevel ? new Date() : null
|
|
} as any);
|
|
|
|
// Update isFinalApprover for previous final approver (now it's not final anymore)
|
|
if (allLevels.length > 0) {
|
|
const previousFinal = allLevels.find(l => (l as any).isFinalApprover);
|
|
if (previousFinal && targetLevel > (previousFinal as any).levelNumber) {
|
|
await previousFinal.update({ isFinalApprover: false });
|
|
}
|
|
}
|
|
|
|
// Add as participant
|
|
await Participant.create({
|
|
requestId,
|
|
userId,
|
|
userEmail: email.toLowerCase(),
|
|
userName,
|
|
participantType: ParticipantType.APPROVER,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy,
|
|
isActive: true
|
|
} as any);
|
|
|
|
// If new approver is at current level, schedule TAT jobs
|
|
if (targetLevel === (workflow as any).currentLevel) {
|
|
const workflowPriority = (workflow as any)?.priority || 'STANDARD';
|
|
await tatSchedulerService.scheduleTatJobs(
|
|
requestId,
|
|
(newLevel as any).levelId,
|
|
userId,
|
|
tatHours,
|
|
new Date(),
|
|
workflowPriority
|
|
);
|
|
}
|
|
|
|
// Get the user who is adding the approver
|
|
const addedByUser = await User.findByPk(addedBy);
|
|
const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User';
|
|
|
|
// Log activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'assignment',
|
|
user: { userId: addedBy, name: addedByName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Added new approver',
|
|
details: `${userName} (${email}) has been added as approver at Level ${targetLevel} with TAT of ${tatHours} hours by ${addedByName}`
|
|
});
|
|
|
|
// Send notification to new approver
|
|
await notificationService.sendToUsers([userId], {
|
|
title: 'New Request Assignment',
|
|
body: `You have been added as Level ${targetLevel} approver to request ${(workflow as any).requestNumber}: ${(workflow as any).title}`,
|
|
requestId,
|
|
requestNumber: (workflow as any).requestNumber,
|
|
url: `/request/${(workflow as any).requestNumber}`
|
|
});
|
|
|
|
logger.info(`[Workflow] Added approver ${email} at level ${targetLevel} to request ${requestId}`);
|
|
return newLevel;
|
|
} catch (error) {
|
|
logger.error(`[Workflow] Failed to add approver at level:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new spectator to an existing workflow
|
|
*/
|
|
async addSpectator(requestId: string, email: string, addedBy: string): Promise<any> {
|
|
try {
|
|
// Find user by email
|
|
const user = await User.findOne({ where: { email: email.toLowerCase() } });
|
|
if (!user) {
|
|
throw new Error('User not found with this email');
|
|
}
|
|
|
|
const userId = (user as any).userId;
|
|
const userName = (user as any).displayName || (user as any).email;
|
|
|
|
// Check if user is already a participant
|
|
const existing = await Participant.findOne({
|
|
where: { requestId, userId }
|
|
});
|
|
|
|
if (existing) {
|
|
throw new Error('User is already a participant in this request');
|
|
}
|
|
|
|
// Add as spectator participant
|
|
const participant = await Participant.create({
|
|
requestId,
|
|
userId,
|
|
userEmail: email.toLowerCase(),
|
|
userName,
|
|
participantType: ParticipantType.SPECTATOR,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: false,
|
|
notificationEnabled: true,
|
|
addedBy,
|
|
isActive: true
|
|
} as any);
|
|
|
|
// Get workflow details for notification
|
|
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
|
|
const requestNumber = (workflow as any)?.requestNumber;
|
|
const title = (workflow as any)?.title;
|
|
|
|
// Get the user who is adding the spectator
|
|
const addedByUser = await User.findByPk(addedBy);
|
|
const addedByName = (addedByUser as any)?.displayName || (addedByUser as any)?.email || 'User';
|
|
|
|
// Log activity
|
|
await activityService.log({
|
|
requestId,
|
|
type: 'assignment',
|
|
user: { userId: addedBy, name: addedByName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Added new spectator',
|
|
details: `${userName} (${email}) has been added as a spectator by ${addedByName}`
|
|
});
|
|
|
|
// Send notification to new spectator
|
|
await notificationService.sendToUsers([userId], {
|
|
title: 'Added to Request',
|
|
body: `You have been added as a spectator to request ${requestNumber}: ${title}`,
|
|
requestId,
|
|
requestNumber,
|
|
url: `/request/${requestNumber}`
|
|
});
|
|
|
|
logger.info(`[Workflow] Added spectator ${email} to request ${requestId}`);
|
|
return participant;
|
|
} catch (error) {
|
|
logger.error(`[Workflow] Failed to add spectator:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* List all workflows for ADMIN/MANAGEMENT users (organization-level)
|
|
* Shows ALL requests in the organization, including where admin is initiator
|
|
* Used by: "All Requests" page for admin users
|
|
*/
|
|
async listWorkflows(page: number, limit: number, filters?: { search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: 'current' | 'any'; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string }) {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Build where clause with filters
|
|
const whereConditions: any[] = [];
|
|
|
|
// Exclude drafts only
|
|
whereConditions.push({ isDraft: false });
|
|
|
|
// NOTE: NO initiator exclusion here - admin sees ALL requests
|
|
|
|
// Apply status filter (pending, approved, rejected, closed)
|
|
if (filters?.status && filters.status !== 'all') {
|
|
const statusUpper = filters.status.toUpperCase();
|
|
if (statusUpper === 'PENDING') {
|
|
// Pending includes both PENDING and IN_PROGRESS
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ status: 'PENDING' },
|
|
{ status: 'IN_PROGRESS' }
|
|
]
|
|
});
|
|
} else if (statusUpper === 'CLOSED') {
|
|
whereConditions.push({ status: 'CLOSED' });
|
|
} else if (statusUpper === 'REJECTED') {
|
|
whereConditions.push({ status: 'REJECTED' });
|
|
} else if (statusUpper === 'APPROVED') {
|
|
whereConditions.push({ status: 'APPROVED' });
|
|
} else {
|
|
// Fallback: use the uppercase value as-is
|
|
whereConditions.push({ status: statusUpper });
|
|
}
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Apply department filter (through initiator)
|
|
if (filters?.department && filters.department !== 'all') {
|
|
whereConditions.push({
|
|
'$initiator.department$': filters.department
|
|
});
|
|
}
|
|
|
|
// Apply initiator filter
|
|
if (filters?.initiator && filters.initiator !== 'all') {
|
|
whereConditions.push({ initiatorId: filters.initiator });
|
|
}
|
|
|
|
// Apply approver filter (with current vs any logic)
|
|
if (filters?.approver && filters.approver !== 'all') {
|
|
const approverId = filters.approver;
|
|
const approverType = filters.approverType || 'current'; // Default to 'current'
|
|
|
|
if (approverType === 'current') {
|
|
// Filter by current active approver only
|
|
// Find request IDs where this approver is the current active approver
|
|
const currentApproverLevels = await ApprovalLevel.findAll({
|
|
where: {
|
|
approverId: approverId,
|
|
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
|
},
|
|
attributes: ['requestId', 'levelNumber'],
|
|
});
|
|
|
|
// Get the current level for each request to match only if this approver is at the current level
|
|
const requestIds: string[] = [];
|
|
for (const level of currentApproverLevels) {
|
|
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
|
attributes: ['requestId', 'currentLevel'],
|
|
});
|
|
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
|
requestIds.push((level as any).requestId);
|
|
}
|
|
}
|
|
|
|
if (requestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
} else {
|
|
// Filter by any approver (past or current)
|
|
// Find all request IDs where this user is an approver at any level
|
|
const allApproverLevels = await ApprovalLevel.findAll({
|
|
where: { approverId: approverId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
|
|
|
if (approverRequestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply date range filter
|
|
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
|
let dateStart: Date | null = null;
|
|
let dateEnd: Date | null = null;
|
|
|
|
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.dateRange) {
|
|
const now = dayjs();
|
|
switch (filters.dateRange) {
|
|
case 'today':
|
|
dateStart = now.startOf('day').toDate();
|
|
dateEnd = now.endOf('day').toDate();
|
|
break;
|
|
case 'week':
|
|
dateStart = now.startOf('week').toDate();
|
|
dateEnd = now.endOf('week').toDate();
|
|
break;
|
|
case 'month':
|
|
dateStart = now.startOf('month').toDate();
|
|
dateEnd = now.endOf('month').toDate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dateStart && dateEnd) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
|
// Fallback to createdAt if submissionDate is null
|
|
{
|
|
[Op.and]: [
|
|
{ submissionDate: null },
|
|
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
}
|
|
|
|
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
|
|
|
// If SLA compliance filter is active, we need to:
|
|
// 1. Fetch all matching records (or a larger batch)
|
|
// 2. Enrich them (which calculates SLA)
|
|
// 3. Filter by SLA compliance
|
|
// 4. Then paginate
|
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
|
// Fetch a larger batch to filter by SLA (up to 1000 records)
|
|
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
|
order: [['createdAt', 'DESC']],
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
|
|
// Enrich all records (calculates SLA)
|
|
const enrichedData = await this.enrichForCards(allRows);
|
|
|
|
// Filter by SLA compliance
|
|
const slaFilteredData = enrichedData.filter((req: any) => {
|
|
const slaCompliance = filters.slaCompliance || '';
|
|
|
|
// Get SLA status from various possible locations
|
|
const slaStatus = req.currentLevelSLA?.status ||
|
|
req.currentApprover?.sla?.status ||
|
|
req.sla?.status ||
|
|
req.summary?.sla?.status;
|
|
|
|
if (slaCompliance.toLowerCase() === 'compliant') {
|
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
|
if (!isCompleted) return false;
|
|
if (!slaStatus) return true;
|
|
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
|
}
|
|
|
|
if (!slaStatus) {
|
|
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
|
}
|
|
|
|
const statusMap: Record<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'] as any },
|
|
},
|
|
order: [['levelNumber', 'ASC']],
|
|
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
|
|
});
|
|
|
|
// Fetch all approval levels for this request
|
|
const approvals = await ApprovalLevel.findAll({
|
|
where: { requestId: (wf as any).requestId },
|
|
order: [['levelNumber', 'ASC']],
|
|
attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime']
|
|
});
|
|
|
|
// Calculate total TAT hours from all approvals
|
|
const totalTatHours = approvals.reduce((sum: number, a: any) => {
|
|
return sum + Number(a.tatHours || 0);
|
|
}, 0);
|
|
|
|
// Calculate approved levels count
|
|
const approvedLevelsCount = approvals.filter((a: any) => a.status === 'APPROVED').length;
|
|
|
|
const priority = ((wf as any).priority || 'standard').toString().toLowerCase();
|
|
|
|
// Calculate OVERALL request SLA (from submission to total deadline)
|
|
const { calculateSLAStatus } = require('@utils/tatTimeUtils');
|
|
const submissionDate = (wf as any).submissionDate;
|
|
const closureDate = (wf as any).closureDate;
|
|
// For completed requests, use closure_date; for active requests, use current time
|
|
const overallEndDate = closureDate || null;
|
|
|
|
let overallSLA = null;
|
|
|
|
if (submissionDate && totalTatHours > 0) {
|
|
try {
|
|
overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority, overallEndDate);
|
|
} catch (error) {
|
|
logger.error('[Workflow] Error calculating overall SLA:', error);
|
|
}
|
|
}
|
|
|
|
// Calculate current level SLA (if there's an active level)
|
|
let currentLevelSLA = null;
|
|
if (currentLevel) {
|
|
const levelStartTime = (currentLevel as any).levelStartTime || (currentLevel as any).tatStartTime;
|
|
const levelTatHours = Number((currentLevel as any).tatHours || 0);
|
|
// For completed levels, use the level's completion time (if available)
|
|
// Otherwise, if request is completed, use closure_date
|
|
const levelEndDate = (currentLevel as any).completedAt || closureDate || null;
|
|
|
|
if (levelStartTime && levelTatHours > 0) {
|
|
try {
|
|
currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate);
|
|
} catch (error) {
|
|
logger.error('[Workflow] Error calculating current level SLA:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
requestId: (wf as any).requestId,
|
|
requestNumber: (wf as any).requestNumber,
|
|
title: (wf as any).title,
|
|
description: (wf as any).description,
|
|
status: (wf as any).status,
|
|
priority: (wf as any).priority,
|
|
submittedAt: (wf as any).submissionDate,
|
|
createdAt: (wf as any).createdAt,
|
|
closureDate: (wf as any).closureDate,
|
|
conclusionRemark: (wf as any).conclusionRemark,
|
|
initiator: (wf as any).initiator,
|
|
department: (wf as any).initiator?.department,
|
|
totalLevels: (wf as any).totalLevels,
|
|
totalTatHours: totalTatHours,
|
|
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
|
|
currentApprover: currentLevel ? {
|
|
userId: (currentLevel as any).approverId,
|
|
email: (currentLevel as any).approverEmail,
|
|
name: (currentLevel as any).approverName,
|
|
levelStartTime: (currentLevel as any).levelStartTime,
|
|
tatHours: (currentLevel as any).tatHours,
|
|
sla: currentLevelSLA, // ← Backend-calculated SLA for current level
|
|
} : null,
|
|
approvals: approvals.map((a: any) => ({
|
|
levelId: a.levelId,
|
|
levelNumber: a.levelNumber,
|
|
levelName: a.levelName,
|
|
approverId: a.approverId,
|
|
approverEmail: a.approverEmail,
|
|
approverName: a.approverName,
|
|
tatHours: a.tatHours,
|
|
tatDays: a.tatDays,
|
|
status: a.status,
|
|
levelStartTime: a.levelStartTime || a.tatStartTime
|
|
})),
|
|
summary: {
|
|
approvedLevels: approvedLevelsCount,
|
|
totalLevels: (wf as any).totalLevels,
|
|
sla: overallSLA || {
|
|
elapsedHours: 0,
|
|
remainingHours: totalTatHours,
|
|
percentageUsed: 0,
|
|
remainingText: `${totalTatHours}h remaining`,
|
|
isPaused: false,
|
|
status: 'on_track'
|
|
}
|
|
},
|
|
sla: overallSLA || {
|
|
elapsedHours: 0,
|
|
remainingHours: totalTatHours,
|
|
percentageUsed: 0,
|
|
remainingText: `${totalTatHours}h remaining`,
|
|
isPaused: false,
|
|
status: 'on_track'
|
|
}, // ← Overall request SLA (all levels combined)
|
|
currentLevelSLA: currentLevelSLA, // ← Also provide at root level for easy access
|
|
};
|
|
}));
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* List requests where user is a PARTICIPANT (not initiator) for REGULAR USERS
|
|
* Shows only requests where user is approver or spectator, EXCLUDES initiator requests
|
|
* Used by: "All Requests" page for regular users
|
|
* NOTE: This is SEPARATE from listWorkflows (admin) - they don't interfere with each other
|
|
* @deprecated Use listParticipantRequests instead for clarity
|
|
*/
|
|
async listMyRequests(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
filters?: {
|
|
search?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
department?: string;
|
|
initiator?: string;
|
|
approver?: string;
|
|
approverType?: 'current' | 'any';
|
|
slaCompliance?: string;
|
|
dateRange?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}
|
|
) {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Find all request IDs where user is a participant (NOT initiator):
|
|
// 1. As approver (in any approval level)
|
|
// 2. As participant/spectator
|
|
// NOTE: Exclude requests where user is initiator (those are shown in "My Requests" page)
|
|
|
|
// Get requests where user is an approver (in any approval level)
|
|
const approverLevels = await ApprovalLevel.findAll({
|
|
where: { approverId: userId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = approverLevels.map((l: any) => l.requestId);
|
|
|
|
// Get requests where user is a participant/spectator
|
|
const participants = await Participant.findAll({
|
|
where: { userId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const participantRequestIds = participants.map((p: any) => p.requestId);
|
|
|
|
// Combine request IDs where user is participant (approver or spectator)
|
|
const allRequestIds = Array.from(new Set([
|
|
...approverRequestIds,
|
|
...participantRequestIds
|
|
]));
|
|
|
|
// Build where clause with filters
|
|
const whereConditions: any[] = [];
|
|
|
|
// ALWAYS exclude requests where user is initiator (for regular users only)
|
|
// This ensures "All Requests" only shows participant requests, not initiator requests
|
|
whereConditions.push({ initiatorId: { [Op.ne]: userId } });
|
|
|
|
// Filter by request IDs where user is involved as participant (approver or spectator)
|
|
if (allRequestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: allRequestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
|
|
// Exclude drafts
|
|
whereConditions.push({ isDraft: false });
|
|
|
|
// Apply status filter (pending, approved, rejected, closed)
|
|
// Same logic as listWorkflows but applied to participant requests only
|
|
if (filters?.status && filters.status !== 'all') {
|
|
const statusUpper = filters.status.toUpperCase();
|
|
if (statusUpper === 'PENDING') {
|
|
// Pending includes both PENDING and IN_PROGRESS
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ status: 'PENDING' },
|
|
{ status: 'IN_PROGRESS' }
|
|
]
|
|
});
|
|
} else if (statusUpper === 'CLOSED') {
|
|
whereConditions.push({ status: 'CLOSED' });
|
|
} else if (statusUpper === 'REJECTED') {
|
|
whereConditions.push({ status: 'REJECTED' });
|
|
} else if (statusUpper === 'APPROVED') {
|
|
whereConditions.push({ status: 'APPROVED' });
|
|
} else {
|
|
// Fallback: use the uppercase value as-is
|
|
whereConditions.push({ status: statusUpper });
|
|
}
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Apply department filter (through initiator)
|
|
if (filters?.department && filters.department !== 'all') {
|
|
whereConditions.push({
|
|
'$initiator.department$': filters.department
|
|
});
|
|
}
|
|
|
|
// Apply initiator filter
|
|
if (filters?.initiator && filters.initiator !== 'all') {
|
|
whereConditions.push({ initiatorId: filters.initiator });
|
|
}
|
|
|
|
// Apply approver filter (with current vs any logic) - for listParticipantRequests
|
|
if (filters?.approver && filters.approver !== 'all') {
|
|
const approverId = filters.approver;
|
|
const approverType = filters.approverType || 'current'; // Default to 'current'
|
|
|
|
if (approverType === 'current') {
|
|
// Filter by current active approver only
|
|
// Find request IDs where this approver is the current active approver
|
|
const currentApproverLevels = await ApprovalLevel.findAll({
|
|
where: {
|
|
approverId: approverId,
|
|
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
|
},
|
|
attributes: ['requestId', 'levelNumber'],
|
|
});
|
|
|
|
// Get the current level for each request to match only if this approver is at the current level
|
|
const requestIds: string[] = [];
|
|
for (const level of currentApproverLevels) {
|
|
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
|
attributes: ['requestId', 'currentLevel'],
|
|
});
|
|
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
|
requestIds.push((level as any).requestId);
|
|
}
|
|
}
|
|
|
|
if (requestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
} else {
|
|
// Filter by any approver (past or current)
|
|
// Find all request IDs where this user is an approver at any level
|
|
const allApproverLevels = await ApprovalLevel.findAll({
|
|
where: { approverId: approverId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
|
|
|
if (approverRequestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply date range filter (same logic as listWorkflows)
|
|
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
|
let dateStart: Date | null = null;
|
|
let dateEnd: Date | null = null;
|
|
|
|
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.dateRange) {
|
|
const now = dayjs();
|
|
switch (filters.dateRange) {
|
|
case 'today':
|
|
dateStart = now.startOf('day').toDate();
|
|
dateEnd = now.endOf('day').toDate();
|
|
break;
|
|
case 'week':
|
|
dateStart = now.startOf('week').toDate();
|
|
dateEnd = now.endOf('week').toDate();
|
|
break;
|
|
case 'month':
|
|
dateStart = now.startOf('month').toDate();
|
|
dateEnd = now.endOf('month').toDate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dateStart && dateEnd) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
|
// Fallback to createdAt if submissionDate is null
|
|
{
|
|
[Op.and]: [
|
|
{ submissionDate: null },
|
|
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
}
|
|
|
|
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
|
|
|
// If SLA compliance filter is active, fetch all, enrich, filter, then paginate
|
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
|
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
|
order: [['createdAt', 'DESC']],
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
|
|
const enrichedData = await this.enrichForCards(allRows);
|
|
|
|
// Filter by SLA compliance
|
|
const slaFilteredData = enrichedData.filter((req: any) => {
|
|
const slaCompliance = filters.slaCompliance || '';
|
|
const slaStatus = req.currentLevelSLA?.status ||
|
|
req.currentApprover?.sla?.status ||
|
|
req.sla?.status ||
|
|
req.summary?.sla?.status;
|
|
|
|
if (slaCompliance.toLowerCase() === 'compliant') {
|
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
|
if (!isCompleted) return false;
|
|
if (!slaStatus) return true;
|
|
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
|
}
|
|
|
|
if (!slaStatus) {
|
|
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
|
}
|
|
|
|
const statusMap: Record<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 a PARTICIPANT (not initiator) for REGULAR USERS - "All Requests" page
|
|
* This is a dedicated method for regular users' "All Requests" screen
|
|
* Shows only requests where user is approver or spectator, EXCLUDES initiator requests
|
|
* Completely separate from listWorkflows (admin) to avoid interference
|
|
*/
|
|
async listParticipantRequests(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
filters?: {
|
|
search?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
department?: string;
|
|
initiator?: string;
|
|
approver?: string;
|
|
approverType?: 'current' | 'any';
|
|
slaCompliance?: string;
|
|
dateRange?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}
|
|
) {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Find all request IDs where user is a participant (NOT initiator):
|
|
// 1. As approver (in any approval level)
|
|
// 2. As participant/spectator
|
|
// NOTE: Exclude requests where user is initiator (those are shown in "My Requests" page)
|
|
|
|
// Get requests where user is an approver (in any approval level)
|
|
const approverLevels = await ApprovalLevel.findAll({
|
|
where: { approverId: userId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = approverLevels.map((l: any) => l.requestId);
|
|
|
|
// Get requests where user is a participant/spectator
|
|
const participants = await Participant.findAll({
|
|
where: { userId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const participantRequestIds = participants.map((p: any) => p.requestId);
|
|
|
|
// Combine request IDs where user is participant (approver or spectator)
|
|
const allRequestIds = Array.from(new Set([
|
|
...approverRequestIds,
|
|
...participantRequestIds
|
|
]));
|
|
|
|
// Build where clause with filters
|
|
const whereConditions: any[] = [];
|
|
|
|
// ALWAYS exclude requests where user is initiator (for regular users only)
|
|
// This ensures "All Requests" only shows participant requests, not initiator requests
|
|
whereConditions.push({ initiatorId: { [Op.ne]: userId } });
|
|
|
|
// Filter by request IDs where user is involved as participant (approver or spectator)
|
|
if (allRequestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: allRequestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
|
|
// Exclude drafts
|
|
whereConditions.push({ isDraft: false });
|
|
|
|
// Apply status filter (pending, approved, rejected, closed)
|
|
// Same logic as listWorkflows but applied to participant requests only
|
|
if (filters?.status && filters.status !== 'all') {
|
|
const statusUpper = filters.status.toUpperCase();
|
|
if (statusUpper === 'PENDING') {
|
|
// Pending includes both PENDING and IN_PROGRESS
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ status: 'PENDING' },
|
|
{ status: 'IN_PROGRESS' }
|
|
]
|
|
});
|
|
} else if (statusUpper === 'CLOSED') {
|
|
whereConditions.push({ status: 'CLOSED' });
|
|
} else if (statusUpper === 'REJECTED') {
|
|
whereConditions.push({ status: 'REJECTED' });
|
|
} else if (statusUpper === 'APPROVED') {
|
|
whereConditions.push({ status: 'APPROVED' });
|
|
} else {
|
|
// Fallback: use the uppercase value as-is
|
|
whereConditions.push({ status: statusUpper });
|
|
}
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Apply department filter (through initiator)
|
|
if (filters?.department && filters.department !== 'all') {
|
|
whereConditions.push({
|
|
'$initiator.department$': filters.department
|
|
});
|
|
}
|
|
|
|
// Apply initiator filter
|
|
if (filters?.initiator && filters.initiator !== 'all') {
|
|
whereConditions.push({ initiatorId: filters.initiator });
|
|
}
|
|
|
|
// Apply approver filter (with current vs any logic) - for listParticipantRequests
|
|
if (filters?.approver && filters.approver !== 'all') {
|
|
const approverId = filters.approver;
|
|
const approverType = filters.approverType || 'current'; // Default to 'current'
|
|
|
|
if (approverType === 'current') {
|
|
// Filter by current active approver only
|
|
// Find request IDs where this approver is the current active approver
|
|
const currentApproverLevels = await ApprovalLevel.findAll({
|
|
where: {
|
|
approverId: approverId,
|
|
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
|
|
},
|
|
attributes: ['requestId', 'levelNumber'],
|
|
});
|
|
|
|
// Get the current level for each request to match only if this approver is at the current level
|
|
const requestIds: string[] = [];
|
|
for (const level of currentApproverLevels) {
|
|
const request = await WorkflowRequest.findByPk((level as any).requestId, {
|
|
attributes: ['requestId', 'currentLevel'],
|
|
});
|
|
if (request && (request as any).currentLevel === (level as any).levelNumber) {
|
|
requestIds.push((level as any).requestId);
|
|
}
|
|
}
|
|
|
|
if (requestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: requestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
} else {
|
|
// Filter by any approver (past or current)
|
|
// Find all request IDs where this user is an approver at any level
|
|
const allApproverLevels = await ApprovalLevel.findAll({
|
|
where: { approverId: approverId },
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = allApproverLevels.map((l: any) => l.requestId);
|
|
|
|
if (approverRequestIds.length > 0) {
|
|
whereConditions.push({ requestId: { [Op.in]: approverRequestIds } });
|
|
} else {
|
|
// No matching requests - return empty result
|
|
whereConditions.push({ requestId: { [Op.in]: ['00000000-0000-0000-0000-000000000000'] } });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply date range filter (same logic as listWorkflows)
|
|
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
|
let dateStart: Date | null = null;
|
|
let dateEnd: Date | null = null;
|
|
|
|
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.dateRange) {
|
|
const now = dayjs();
|
|
switch (filters.dateRange) {
|
|
case 'today':
|
|
dateStart = now.startOf('day').toDate();
|
|
dateEnd = now.endOf('day').toDate();
|
|
break;
|
|
case 'week':
|
|
dateStart = now.startOf('week').toDate();
|
|
dateEnd = now.endOf('week').toDate();
|
|
break;
|
|
case 'month':
|
|
dateStart = now.startOf('month').toDate();
|
|
dateEnd = now.endOf('month').toDate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dateStart && dateEnd) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
|
// Fallback to createdAt if submissionDate is null
|
|
{
|
|
[Op.and]: [
|
|
{ submissionDate: null },
|
|
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
}
|
|
|
|
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
|
|
|
// If SLA compliance filter is active, fetch all, enrich, filter, then paginate
|
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') {
|
|
const { rows: allRows } = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
limit: 1000, // Fetch up to 1000 records for SLA filtering
|
|
order: [['createdAt', 'DESC']],
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
|
|
const enrichedData = await this.enrichForCards(allRows);
|
|
|
|
// Filter by SLA compliance
|
|
const slaFilteredData = enrichedData.filter((req: any) => {
|
|
const slaCompliance = filters.slaCompliance || '';
|
|
const slaStatus = req.currentLevelSLA?.status ||
|
|
req.currentApprover?.sla?.status ||
|
|
req.sla?.status ||
|
|
req.summary?.sla?.status;
|
|
|
|
if (slaCompliance.toLowerCase() === 'compliant') {
|
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED';
|
|
if (!isCompleted) return false;
|
|
if (!slaStatus) return true;
|
|
return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached';
|
|
}
|
|
|
|
if (!slaStatus) {
|
|
return slaCompliance === 'on-track' || slaCompliance === 'on_track';
|
|
}
|
|
|
|
const statusMap: Record<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;
|
|
department?: string;
|
|
dateRange?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}
|
|
) {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Build where clause with filters - only requests where user is initiator
|
|
const whereConditions: any[] = [{ initiatorId: userId }];
|
|
|
|
// Exclude drafts
|
|
whereConditions.push({ isDraft: false });
|
|
|
|
// Apply status filter
|
|
if (filters?.status && filters.status !== 'all') {
|
|
const statusUpper = filters.status.toUpperCase();
|
|
if (statusUpper === 'PENDING') {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ status: 'PENDING' },
|
|
{ status: 'IN_PROGRESS' }
|
|
]
|
|
});
|
|
} else {
|
|
whereConditions.push({ status: statusUpper });
|
|
}
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
whereConditions.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Apply department filter (through initiator)
|
|
if (filters?.department && filters.department !== 'all') {
|
|
whereConditions.push({
|
|
'$initiator.department$': filters.department
|
|
});
|
|
}
|
|
|
|
// Apply date range filter (same logic as listWorkflows)
|
|
if (filters?.dateRange || filters?.startDate || filters?.endDate) {
|
|
let dateStart: Date | null = null;
|
|
let dateEnd: Date | null = null;
|
|
|
|
if (filters.dateRange === 'custom' && filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.startDate && filters.endDate) {
|
|
dateStart = dayjs(filters.startDate).startOf('day').toDate();
|
|
dateEnd = dayjs(filters.endDate).endOf('day').toDate();
|
|
} else if (filters.dateRange) {
|
|
const now = dayjs();
|
|
switch (filters.dateRange) {
|
|
case 'today':
|
|
dateStart = now.startOf('day').toDate();
|
|
dateEnd = now.endOf('day').toDate();
|
|
break;
|
|
case 'week':
|
|
dateStart = now.startOf('week').toDate();
|
|
dateEnd = now.endOf('week').toDate();
|
|
break;
|
|
case 'month':
|
|
dateStart = now.startOf('month').toDate();
|
|
dateEnd = now.endOf('month').toDate();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (dateStart && dateEnd) {
|
|
whereConditions.push({
|
|
[Op.or]: [
|
|
{ submissionDate: { [Op.between]: [dateStart, dateEnd] } },
|
|
// Fallback to createdAt if submissionDate is null
|
|
{
|
|
[Op.and]: [
|
|
{ submissionDate: null },
|
|
{ createdAt: { [Op.between]: [dateStart, dateEnd] } }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
}
|
|
|
|
const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {};
|
|
|
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
offset,
|
|
limit,
|
|
order: [['createdAt', 'DESC']],
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
const data = await this.enrichForCards(rows);
|
|
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
|
}
|
|
|
|
async listOpenForMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) {
|
|
const offset = (page - 1) * limit;
|
|
// Find all pending/in-progress approval levels across requests ordered by levelNumber
|
|
const pendingLevels = await ApprovalLevel.findAll({
|
|
where: {
|
|
status: { [Op.in]: [ApprovalStatus.PENDING as any, (ApprovalStatus as any).IN_PROGRESS ?? 'IN_PROGRESS', 'PENDING', 'IN_PROGRESS'] as any },
|
|
},
|
|
order: [['requestId', 'ASC'], ['levelNumber', 'ASC']],
|
|
attributes: ['requestId', 'levelNumber', 'approverId'],
|
|
});
|
|
|
|
// For each request, pick the first (current) pending level
|
|
const currentLevelByRequest = new Map<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
|
|
baseConditions.push({
|
|
status: { [Op.in]: [
|
|
WorkflowStatus.PENDING as any,
|
|
(WorkflowStatus as any).IN_PROGRESS ?? 'IN_PROGRESS',
|
|
WorkflowStatus.APPROVED as any,
|
|
'PENDING',
|
|
'IN_PROGRESS',
|
|
'APPROVED'
|
|
] as any }
|
|
});
|
|
|
|
// Apply status filter if provided (overrides default status filter)
|
|
if (filters?.status && filters.status !== 'all') {
|
|
baseConditions.pop(); // Remove default status
|
|
baseConditions.push({ status: filters.status.toUpperCase() });
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
baseConditions.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
baseConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
const where = baseConditions.length > 0 ? { [Op.and]: baseConditions } : {};
|
|
|
|
// Build order clause based on sortBy parameter
|
|
// For computed fields (due, sla), we'll sort after enrichment
|
|
let order: any[] = [['createdAt', 'DESC']]; // Default order
|
|
const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC');
|
|
|
|
if (sortBy) {
|
|
switch (sortBy.toLowerCase()) {
|
|
case 'created':
|
|
order = [['createdAt', validSortOrder]];
|
|
break;
|
|
case 'priority':
|
|
// Map priority values: EXPRESS = 1, STANDARD = 2 for ascending (standard first), or reverse for descending
|
|
// For simplicity, we'll sort alphabetically: EXPRESS < STANDARD
|
|
order = [['priority', validSortOrder], ['createdAt', 'DESC']]; // Secondary sort by createdAt
|
|
break;
|
|
// For 'due' and 'sla', we need to sort after enrichment (handled below)
|
|
case 'due':
|
|
case 'sla':
|
|
// Keep default order - will sort after enrichment
|
|
break;
|
|
default:
|
|
// Unknown sortBy, use default
|
|
break;
|
|
}
|
|
}
|
|
|
|
// For computed field sorting (due, sla), we need to fetch all matching records first,
|
|
// enrich them, sort, then paginate. For DB fields, we can use SQL pagination.
|
|
const needsPostEnrichmentSort = sortBy && ['due', 'sla'].includes(sortBy.toLowerCase());
|
|
|
|
let rows: any[];
|
|
let count: number;
|
|
|
|
if (needsPostEnrichmentSort) {
|
|
// Fetch all matching records (no pagination yet)
|
|
const result = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
|
|
// Enrich all records
|
|
const allEnriched = await this.enrichForCards(result.rows);
|
|
|
|
// Sort enriched data
|
|
allEnriched.sort((a: any, b: any) => {
|
|
let aValue: any, bValue: any;
|
|
|
|
if (sortBy.toLowerCase() === 'due') {
|
|
aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
|
bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
|
} else if (sortBy.toLowerCase() === 'sla') {
|
|
aValue = a.currentLevelSLA?.percentageUsed || 0;
|
|
bValue = b.currentLevelSLA?.percentageUsed || 0;
|
|
} else {
|
|
return 0;
|
|
}
|
|
|
|
if (validSortOrder === 'ASC') {
|
|
return aValue > bValue ? 1 : -1;
|
|
} else {
|
|
return aValue < bValue ? 1 : -1;
|
|
}
|
|
});
|
|
|
|
count = result.count;
|
|
|
|
// Apply pagination after sorting
|
|
const startIndex = offset;
|
|
const endIndex = startIndex + limit;
|
|
rows = allEnriched.slice(startIndex, endIndex);
|
|
} else {
|
|
// Use database sorting for simple fields (created, priority)
|
|
const result = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
offset,
|
|
limit,
|
|
order,
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
rows = result.rows;
|
|
count = result.count;
|
|
}
|
|
|
|
const data = needsPostEnrichmentSort ? rows : await this.enrichForCards(rows);
|
|
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
|
}
|
|
|
|
async listClosedByMe(userId: string, page: number, limit: number, filters?: { search?: string; status?: string; priority?: string }, sortBy?: string, sortOrder?: string) {
|
|
const offset = (page - 1) * limit;
|
|
|
|
// Get requests where user participated as approver
|
|
const levelRows = await ApprovalLevel.findAll({
|
|
where: {
|
|
approverId: userId,
|
|
status: { [Op.in]: [
|
|
ApprovalStatus.APPROVED as any,
|
|
(ApprovalStatus as any).REJECTED ?? 'REJECTED',
|
|
'APPROVED',
|
|
'REJECTED'
|
|
] as any },
|
|
},
|
|
attributes: ['requestId'],
|
|
});
|
|
const approverRequestIds = Array.from(new Set(levelRows.map((l: any) => l.requestId)));
|
|
|
|
// Also include requests where user is a spectator
|
|
const spectatorParticipants = await Participant.findAll({
|
|
where: {
|
|
userId,
|
|
participantType: 'SPECTATOR',
|
|
},
|
|
attributes: ['requestId'],
|
|
});
|
|
const spectatorRequestIds = spectatorParticipants.map((p: any) => p.requestId);
|
|
|
|
// Combine both sets of request IDs (unique)
|
|
const allRequestIds = Array.from(new Set([...approverRequestIds, ...spectatorRequestIds]));
|
|
|
|
// Build query conditions
|
|
const whereConditions: any[] = [];
|
|
|
|
// 1. Requests where user was approver/spectator (show APPROVED, REJECTED, CLOSED)
|
|
const approverSpectatorStatuses = [
|
|
WorkflowStatus.APPROVED as any,
|
|
WorkflowStatus.REJECTED as any,
|
|
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
|
'APPROVED',
|
|
'REJECTED',
|
|
'CLOSED'
|
|
] as any;
|
|
|
|
if (allRequestIds.length > 0) {
|
|
const approverConditionParts: any[] = [
|
|
{ requestId: { [Op.in]: allRequestIds } }
|
|
];
|
|
|
|
// Apply status filter
|
|
if (filters?.status && filters.status !== 'all') {
|
|
approverConditionParts.push({ status: filters.status.toUpperCase() });
|
|
} else {
|
|
approverConditionParts.push({ status: { [Op.in]: approverSpectatorStatuses } });
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
approverConditionParts.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
approverConditionParts.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
const approverCondition = approverConditionParts.length > 0
|
|
? { [Op.and]: approverConditionParts }
|
|
: { requestId: { [Op.in]: allRequestIds } };
|
|
|
|
whereConditions.push(approverCondition);
|
|
}
|
|
|
|
// 2. Requests where user is initiator (show ONLY REJECTED or CLOSED, NOT APPROVED)
|
|
// APPROVED means initiator still needs to finalize conclusion
|
|
const initiatorStatuses = [
|
|
WorkflowStatus.REJECTED as any,
|
|
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
|
'REJECTED',
|
|
'CLOSED'
|
|
] as any;
|
|
|
|
const initiatorConditionParts: any[] = [
|
|
{ initiatorId: userId }
|
|
];
|
|
|
|
// Apply status filter
|
|
if (filters?.status && filters.status !== 'all') {
|
|
const filterStatus = filters.status.toUpperCase();
|
|
// Only apply if status is REJECTED or CLOSED (not APPROVED for initiator)
|
|
if (filterStatus === 'REJECTED' || filterStatus === 'CLOSED') {
|
|
initiatorConditionParts.push({ status: filterStatus });
|
|
} else {
|
|
// If filtering for APPROVED, don't include initiator requests
|
|
initiatorConditionParts.push({ status: { [Op.in]: [] } }); // Empty set - no results
|
|
}
|
|
} else {
|
|
initiatorConditionParts.push({ status: { [Op.in]: initiatorStatuses } });
|
|
}
|
|
|
|
// Apply priority filter
|
|
if (filters?.priority && filters.priority !== 'all') {
|
|
initiatorConditionParts.push({ priority: filters.priority.toUpperCase() });
|
|
}
|
|
|
|
// Apply search filter (title, description, or requestNumber)
|
|
if (filters?.search && filters.search.trim()) {
|
|
initiatorConditionParts.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ description: { [Op.iLike]: `%${filters.search.trim()}%` } },
|
|
{ requestNumber: { [Op.iLike]: `%${filters.search.trim()}%` } }
|
|
]
|
|
});
|
|
}
|
|
|
|
const initiatorCondition = initiatorConditionParts.length > 0
|
|
? { [Op.and]: initiatorConditionParts }
|
|
: { initiatorId: userId };
|
|
|
|
whereConditions.push(initiatorCondition);
|
|
|
|
// Build where clause with OR conditions
|
|
const where: any = whereConditions.length > 0 ? { [Op.or]: whereConditions } : {};
|
|
|
|
// Build order clause based on sortBy parameter
|
|
let order: any[] = [['createdAt', 'DESC']]; // Default order
|
|
const validSortOrder = (sortOrder?.toLowerCase() === 'asc' ? 'ASC' : 'DESC');
|
|
|
|
if (sortBy) {
|
|
switch (sortBy.toLowerCase()) {
|
|
case 'created':
|
|
order = [['createdAt', validSortOrder]];
|
|
break;
|
|
case 'due':
|
|
// Sort by closureDate or updatedAt (closed date)
|
|
order = [['updatedAt', validSortOrder], ['createdAt', 'DESC']];
|
|
break;
|
|
case 'priority':
|
|
order = [['priority', validSortOrder], ['createdAt', 'DESC']];
|
|
break;
|
|
default:
|
|
// Unknown sortBy, use default
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fetch closed/rejected/approved requests (including finalized ones)
|
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
|
where,
|
|
offset,
|
|
limit,
|
|
order,
|
|
include: [
|
|
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
|
],
|
|
});
|
|
const data = await this.enrichForCards(rows);
|
|
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
|
}
|
|
async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<WorkflowRequest> {
|
|
try {
|
|
const requestNumber = generateRequestNumber();
|
|
const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0);
|
|
|
|
const workflow = await WorkflowRequest.create({
|
|
requestNumber,
|
|
initiatorId,
|
|
templateType: workflowData.templateType,
|
|
title: workflowData.title,
|
|
description: workflowData.description,
|
|
priority: workflowData.priority,
|
|
currentLevel: 1,
|
|
totalLevels: workflowData.approvalLevels.length,
|
|
totalTatHours,
|
|
status: WorkflowStatus.DRAFT,
|
|
isDraft: true,
|
|
isDeleted: false
|
|
});
|
|
|
|
// Create approval levels
|
|
for (const levelData of workflowData.approvalLevels) {
|
|
await ApprovalLevel.create({
|
|
requestId: workflow.requestId,
|
|
levelNumber: levelData.levelNumber,
|
|
levelName: levelData.levelName,
|
|
approverId: levelData.approverId,
|
|
approverEmail: levelData.approverEmail,
|
|
approverName: levelData.approverName,
|
|
tatHours: levelData.tatHours,
|
|
// tatDays 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
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info(`Workflow created: ${requestNumber}`);
|
|
|
|
// Get initiator details
|
|
const initiator = await User.findByPk(initiatorId);
|
|
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
|
|
|
|
// Log creation activity
|
|
activityService.log({
|
|
requestId: (workflow as any).requestId,
|
|
type: 'created',
|
|
user: { userId: initiatorId, name: initiatorName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Initial request submitted',
|
|
details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
|
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
userAgent: requestMetadata?.userAgent || undefined
|
|
});
|
|
|
|
// Send notification to INITIATOR confirming submission
|
|
await notificationService.sendToUsers([initiatorId], {
|
|
title: 'Request Submitted Successfully',
|
|
body: `Your request "${workflowData.title}" has been submitted and is now with the first approver.`,
|
|
requestNumber: requestNumber,
|
|
requestId: (workflow as any).requestId,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'request_submitted',
|
|
priority: 'MEDIUM'
|
|
});
|
|
|
|
// Send notification to FIRST APPROVER for assignment
|
|
const firstLevel = await ApprovalLevel.findOne({ where: { requestId: (workflow as any).requestId, levelNumber: 1 } });
|
|
if (firstLevel) {
|
|
await notificationService.sendToUsers([(firstLevel as any).approverId], {
|
|
title: 'New Request Assigned',
|
|
body: `${workflowData.title}`,
|
|
requestNumber: requestNumber,
|
|
requestId: (workflow as any).requestId,
|
|
url: `/request/${requestNumber}`,
|
|
type: 'assignment',
|
|
priority: 'HIGH',
|
|
actionRequired: true
|
|
});
|
|
|
|
activityService.log({
|
|
requestId: (workflow as any).requestId,
|
|
type: 'assignment',
|
|
user: { userId: initiatorId, name: initiatorName },
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Assigned to approver',
|
|
details: `Request assigned to ${(firstLevel as any).approverName || (firstLevel as any).approverEmail || 'approver'} for review`
|
|
});
|
|
}
|
|
|
|
return workflow;
|
|
} catch (error) {
|
|
logger.error('Failed to create workflow:', error);
|
|
throw new Error('Failed to create workflow');
|
|
}
|
|
}
|
|
|
|
// Helper to determine if identifier is UUID or requestNumber
|
|
private isUuid(identifier: string): boolean {
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
return uuidRegex.test(identifier);
|
|
}
|
|
|
|
// Helper to find workflow by either requestId or requestNumber
|
|
private async findWorkflowByIdentifier(identifier: string) {
|
|
if (this.isUuid(identifier)) {
|
|
return await WorkflowRequest.findByPk(identifier);
|
|
} else {
|
|
return await WorkflowRequest.findOne({
|
|
where: { requestNumber: identifier }
|
|
});
|
|
}
|
|
}
|
|
|
|
async getWorkflowById(requestId: string): Promise<WorkflowRequest | null> {
|
|
try {
|
|
const workflow = await this.findWorkflowByIdentifier(requestId);
|
|
if (!workflow) return null;
|
|
|
|
return await WorkflowRequest.findByPk(workflow.requestId, {
|
|
include: [
|
|
{ association: 'initiator' },
|
|
{ association: 'approvalLevels' },
|
|
{ association: 'participants' },
|
|
{ association: 'documents' }
|
|
]
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to get workflow ${requestId}:`, error);
|
|
throw new Error('Failed to get workflow');
|
|
}
|
|
}
|
|
|
|
async getWorkflowDetails(requestId: string) {
|
|
try {
|
|
const workflowBase = await this.findWorkflowByIdentifier(requestId);
|
|
if (!workflowBase) {
|
|
logger.warn(`Workflow not found for identifier: ${requestId}`);
|
|
return null;
|
|
}
|
|
|
|
// Get requestId - try both property access and getDataValue for safety
|
|
const actualRequestId = (workflowBase as any).getDataValue
|
|
? (workflowBase as any).getDataValue('requestId')
|
|
: (workflowBase as any).requestId;
|
|
|
|
if (!actualRequestId) {
|
|
logger.error(`Could not extract requestId from workflow. Identifier: ${requestId}, Workflow data:`, JSON.stringify(workflowBase, null, 2));
|
|
throw new Error('Failed to extract requestId from workflow');
|
|
}
|
|
|
|
// Reload with associations
|
|
const workflow = await WorkflowRequest.findByPk(actualRequestId, {
|
|
include: [ { association: 'initiator' } ]
|
|
});
|
|
if (!workflow) return null;
|
|
|
|
// Compute current approver and SLA summary (same logic used in lists)
|
|
const currentLevel = await ApprovalLevel.findOne({
|
|
where: {
|
|
requestId: actualRequestId,
|
|
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any },
|
|
},
|
|
order: [['levelNumber', 'ASC']],
|
|
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
|
|
});
|
|
|
|
const totalTat = Number((workflow as any).totalTatHours || 0);
|
|
let percent = 0;
|
|
let remainingText = '';
|
|
if ((workflow as any).submissionDate && totalTat > 0) {
|
|
const startedAt = new Date((workflow as any).submissionDate);
|
|
const now = new Date();
|
|
const elapsedHrs = Math.max(0, (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60));
|
|
percent = Math.min(100, Math.round((elapsedHrs / totalTat) * 100));
|
|
const remaining = Math.max(0, totalTat - elapsedHrs);
|
|
const days = Math.floor(remaining / 24);
|
|
const hours = Math.floor(remaining % 24);
|
|
remainingText = days > 0 ? `${days} days ${hours} hours remaining` : `${hours} hours remaining`;
|
|
}
|
|
|
|
const summary = {
|
|
requestId: (workflow as any).requestId,
|
|
requestNumber: (workflow as any).requestNumber,
|
|
title: (workflow as any).title,
|
|
status: (workflow as any).status,
|
|
priority: (workflow as any).priority,
|
|
submittedAt: (workflow as any).submissionDate,
|
|
totalLevels: (workflow as any).totalLevels,
|
|
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
|
|
currentApprover: currentLevel ? {
|
|
userId: (currentLevel as any).approverId,
|
|
email: (currentLevel as any).approverEmail,
|
|
name: (currentLevel as any).approverName,
|
|
} : null,
|
|
sla: { percent, remainingText },
|
|
};
|
|
|
|
// Ensure actualRequestId is valid UUID (not requestNumber)
|
|
if (!actualRequestId || typeof actualRequestId !== 'string') {
|
|
logger.error(`Invalid requestId extracted: ${actualRequestId}, original identifier: ${requestId}`);
|
|
throw new Error('Invalid workflow identifier');
|
|
}
|
|
|
|
// Verify it's a UUID format
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
if (!uuidRegex.test(actualRequestId)) {
|
|
logger.error(`Extracted requestId is not a valid UUID: ${actualRequestId}, original identifier: ${requestId}`);
|
|
throw new Error('Invalid workflow identifier format');
|
|
}
|
|
|
|
// logger.info(`Fetching participants for requestId: ${actualRequestId} (original identifier: ${requestId})`);
|
|
|
|
// Load related entities explicitly to avoid alias issues
|
|
// Use the actual UUID requestId for all queries
|
|
const approvals = await ApprovalLevel.findAll({
|
|
where: { requestId: actualRequestId },
|
|
order: [['levelNumber','ASC']]
|
|
}) as any[];
|
|
|
|
const participants = await Participant.findAll({
|
|
where: { requestId: actualRequestId }
|
|
}) as any[];
|
|
|
|
// logger.info(`Found ${participants.length} participants for requestId: ${actualRequestId}`);
|
|
|
|
const documents = await Document.findAll({
|
|
where: {
|
|
requestId: actualRequestId,
|
|
isDeleted: false // Only fetch non-deleted documents
|
|
}
|
|
}) as any[];
|
|
let activities: any[] = [];
|
|
try {
|
|
const { Activity } = require('@models/Activity');
|
|
const rawActivities = await Activity.findAll({
|
|
where: {
|
|
requestId: actualRequestId,
|
|
activityType: { [Op.ne]: 'comment' } // Exclude comment type activities
|
|
},
|
|
order: [['created_at', 'ASC']],
|
|
raw: true // Get raw data to access snake_case fields
|
|
});
|
|
|
|
// Transform activities to match frontend expected format
|
|
activities = rawActivities
|
|
.filter((act: any) => {
|
|
const activityType = act.activity_type || act.activityType || '';
|
|
const description = (act.activity_description || act.activityDescription || '').toLowerCase();
|
|
|
|
// Filter out status changes to pending
|
|
if (activityType === 'status_change' && description.includes('pending')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((act: any) => ({
|
|
user: act.user_name || act.userName || 'System',
|
|
type: act.activity_type || act.activityType || 'status_change',
|
|
action: this.getActivityAction(act.activity_type || act.activityType),
|
|
details: act.activity_description || act.activityDescription || '',
|
|
timestamp: act.created_at || act.createdAt,
|
|
metadata: act.metadata
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error fetching activities:', error);
|
|
activities = activityService.get(actualRequestId);
|
|
}
|
|
|
|
// Fetch TAT alerts for all approval levels
|
|
let tatAlerts: any[] = [];
|
|
try {
|
|
// Use raw SQL query to ensure all fields are returned
|
|
const rawAlerts = await sequelize.query(`
|
|
SELECT
|
|
alert_id,
|
|
request_id,
|
|
level_id,
|
|
approver_id,
|
|
alert_type,
|
|
threshold_percentage,
|
|
tat_hours_allocated,
|
|
tat_hours_elapsed,
|
|
tat_hours_remaining,
|
|
level_start_time,
|
|
alert_sent_at,
|
|
expected_completion_time,
|
|
alert_message,
|
|
notification_sent,
|
|
notification_channels,
|
|
is_breached,
|
|
was_completed_on_time,
|
|
completion_time,
|
|
metadata,
|
|
created_at
|
|
FROM tat_alerts
|
|
WHERE request_id = :requestId
|
|
ORDER BY alert_sent_at ASC
|
|
`, {
|
|
replacements: { requestId: actualRequestId },
|
|
type: QueryTypes.SELECT
|
|
});
|
|
|
|
// Transform to frontend format
|
|
tatAlerts = (rawAlerts as any[]).map((alert: any) => ({
|
|
alertId: alert.alert_id,
|
|
requestId: alert.request_id,
|
|
levelId: alert.level_id,
|
|
approverId: alert.approver_id,
|
|
alertType: alert.alert_type,
|
|
thresholdPercentage: Number(alert.threshold_percentage || 0),
|
|
tatHoursAllocated: Number(alert.tat_hours_allocated || 0),
|
|
tatHoursElapsed: Number(alert.tat_hours_elapsed || 0),
|
|
tatHoursRemaining: Number(alert.tat_hours_remaining || 0),
|
|
levelStartTime: alert.level_start_time,
|
|
alertSentAt: alert.alert_sent_at,
|
|
expectedCompletionTime: alert.expected_completion_time,
|
|
alertMessage: alert.alert_message,
|
|
notificationSent: alert.notification_sent,
|
|
notificationChannels: alert.notification_channels || [],
|
|
isBreached: alert.is_breached,
|
|
wasCompletedOnTime: alert.was_completed_on_time,
|
|
completionTime: alert.completion_time,
|
|
metadata: alert.metadata || {}
|
|
}));
|
|
|
|
// logger.info(`Found ${tatAlerts.length} TAT alerts for request ${actualRequestId}`);
|
|
} catch (error) {
|
|
logger.error('Error fetching TAT alerts:', error);
|
|
tatAlerts = [];
|
|
}
|
|
|
|
// Recalculate SLA for all approval levels with comprehensive data
|
|
const priority = ((workflow as any)?.priority || 'standard').toString().toLowerCase();
|
|
const { calculateSLAStatus } = require('@utils/tatTimeUtils');
|
|
|
|
const updatedApprovals = await Promise.all(approvals.map(async (approval: any) => {
|
|
const status = (approval.status || '').toString().toUpperCase();
|
|
const approvalData = approval.toJSON();
|
|
|
|
// Calculate SLA for active approvals (pending/in-progress)
|
|
if (status === 'PENDING' || status === 'IN_PROGRESS') {
|
|
const levelStartTime = approval.levelStartTime || approval.tatStartTime || approval.createdAt;
|
|
const tatHours = Number(approval.tatHours || 0);
|
|
|
|
if (levelStartTime && tatHours > 0) {
|
|
try {
|
|
// Get comprehensive SLA status from backend utility
|
|
const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority);
|
|
|
|
// Return updated approval with comprehensive SLA data
|
|
return {
|
|
...approvalData,
|
|
elapsedHours: slaData.elapsedHours,
|
|
remainingHours: slaData.remainingHours,
|
|
tatPercentageUsed: slaData.percentageUsed,
|
|
sla: slaData // ← Full SLA object with deadline, isPaused, status, etc.
|
|
};
|
|
} catch (error) {
|
|
logger.error(`[Workflow] Error calculating SLA for level ${approval.levelNumber}:`, error);
|
|
// Return with fallback values if SLA calculation fails
|
|
return {
|
|
...approvalData,
|
|
sla: {
|
|
elapsedHours: 0,
|
|
remainingHours: tatHours,
|
|
percentageUsed: 0,
|
|
isPaused: false,
|
|
status: 'on_track',
|
|
remainingText: `${tatHours}h`,
|
|
elapsedText: '0h'
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// For completed/rejected levels, return as-is (already has final values from database)
|
|
return approvalData;
|
|
}));
|
|
|
|
// Calculate overall request SLA
|
|
const submissionDate = (workflow as any).submissionDate;
|
|
const totalTatHours = updatedApprovals.reduce((sum, a) => sum + Number(a.tatHours || 0), 0);
|
|
let overallSLA = null;
|
|
|
|
if (submissionDate && totalTatHours > 0) {
|
|
overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority);
|
|
}
|
|
|
|
// Update summary to include comprehensive SLA
|
|
const updatedSummary = {
|
|
...summary,
|
|
sla: overallSLA || summary.sla
|
|
};
|
|
|
|
return { workflow, approvals: updatedApprovals, participants, documents, activities, summary: updatedSummary, tatAlerts };
|
|
} catch (error) {
|
|
logger.error(`Failed to get workflow details ${requestId}:`, error);
|
|
throw new Error('Failed to get workflow details');
|
|
}
|
|
}
|
|
|
|
async updateWorkflow(requestId: string, updateData: UpdateWorkflowRequest): Promise<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)
|
|
if (isDraft && updateData.participants && Array.isArray(updateData.participants)) {
|
|
// Get existing participants
|
|
const existingParticipants = await Participant.findAll({
|
|
where: { requestId: actualRequestId }
|
|
});
|
|
|
|
// Create a map of existing participants by userId
|
|
const existingMap = new Map(existingParticipants.map((p: any) => [
|
|
(p as any).userId,
|
|
p
|
|
]));
|
|
|
|
// Create a set of new participant userIds
|
|
const newUserIds = new Set(updateData.participants.map(p => p.userId));
|
|
|
|
// Delete participants that are no longer in the new list (except INITIATOR)
|
|
for (const existing of existingParticipants) {
|
|
const userId = (existing as any).userId;
|
|
const participantType = (existing as any).participantType;
|
|
|
|
// Never delete INITIATOR
|
|
if (participantType === 'INITIATOR') continue;
|
|
|
|
// Delete if not in new list
|
|
if (!newUserIds.has(userId)) {
|
|
await existing.destroy();
|
|
logger.info(`Deleted participant ${userId} from workflow ${actualRequestId}`);
|
|
}
|
|
}
|
|
|
|
// Add or update participants from the new list
|
|
for (const participantData of updateData.participants) {
|
|
const existing = existingMap.get(participantData.userId);
|
|
|
|
if (existing) {
|
|
// Update existing participant
|
|
await existing.update({
|
|
userEmail: participantData.userEmail,
|
|
userName: participantData.userName,
|
|
participantType: participantData.participantType as any,
|
|
canComment: participantData.canComment ?? true,
|
|
canViewDocuments: participantData.canViewDocuments ?? true,
|
|
canDownloadDocuments: participantData.canDownloadDocuments ?? false,
|
|
notificationEnabled: participantData.notificationEnabled ?? true,
|
|
isActive: true
|
|
});
|
|
} else {
|
|
// Create new participant
|
|
await Participant.create({
|
|
requestId: actualRequestId,
|
|
userId: participantData.userId,
|
|
userEmail: participantData.userEmail,
|
|
userName: participantData.userName,
|
|
participantType: participantData.participantType as any,
|
|
canComment: participantData.canComment ?? true,
|
|
canViewDocuments: participantData.canViewDocuments ?? true,
|
|
canDownloadDocuments: participantData.canDownloadDocuments ?? false,
|
|
notificationEnabled: participantData.notificationEnabled ?? true,
|
|
addedBy: (workflow as any).initiatorId,
|
|
isActive: true
|
|
});
|
|
logger.info(`Added new participant ${participantData.userId} to workflow ${actualRequestId}`);
|
|
}
|
|
}
|
|
|
|
logger.info(`Synced ${updateData.participants.length} participants for workflow ${actualRequestId}`);
|
|
}
|
|
|
|
// Delete documents if requested (only for drafts)
|
|
if (isDraft && updateData.deleteDocumentIds && updateData.deleteDocumentIds.length > 0) {
|
|
logger.info(`Attempting to delete ${updateData.deleteDocumentIds.length} documents for workflow ${actualRequestId}. Document IDs:`, updateData.deleteDocumentIds);
|
|
|
|
// First get documents with file paths before deleting
|
|
const documentsToDelete = await Document.findAll({
|
|
where: { requestId: actualRequestId, documentId: { [Op.in]: updateData.deleteDocumentIds } },
|
|
attributes: ['documentId', 'originalFileName', 'filePath', 'isDeleted']
|
|
});
|
|
logger.info(`Found ${documentsToDelete.length} documents matching delete IDs. Existing:`, documentsToDelete.map((d: any) => ({ id: d.documentId, name: d.originalFileName, filePath: d.filePath, isDeleted: d.isDeleted })));
|
|
|
|
// Delete physical files from filesystem
|
|
for (const doc of documentsToDelete) {
|
|
const filePath = (doc as any).filePath;
|
|
if (filePath && fs.existsSync(filePath)) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
logger.info(`Deleted physical file: ${filePath} for document ${(doc as any).documentId}`);
|
|
} catch (error) {
|
|
logger.error(`Failed to delete physical file ${filePath}:`, error);
|
|
// Continue with soft-delete even if file deletion fails
|
|
}
|
|
} else if (filePath) {
|
|
logger.warn(`File path does not exist, skipping file deletion: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
// Mark documents as deleted in database
|
|
const deleteResult = await Document.update(
|
|
{ isDeleted: true },
|
|
{ where: { requestId: actualRequestId, documentId: { [Op.in]: updateData.deleteDocumentIds } } }
|
|
);
|
|
logger.info(`Marked ${deleteResult[0]} documents as deleted in database (out of ${updateData.deleteDocumentIds.length} requested)`);
|
|
}
|
|
|
|
// Reload the workflow instance to get latest data (without associations to avoid the error)
|
|
// The associations issue occurs when trying to include them, so we skip that
|
|
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
|
return refreshed;
|
|
} catch (error) {
|
|
logger.error(`Failed to update workflow ${requestId}:`, error);
|
|
throw new Error('Failed to update workflow');
|
|
}
|
|
}
|
|
|
|
async submitWorkflow(requestId: string): Promise<WorkflowRequest | null> {
|
|
try {
|
|
const workflow = await this.findWorkflowByIdentifier(requestId);
|
|
if (!workflow) return null;
|
|
|
|
const now = new Date();
|
|
const updated = await workflow.update({
|
|
status: WorkflowStatus.PENDING,
|
|
isDraft: false,
|
|
submissionDate: now
|
|
});
|
|
|
|
// Get initiator details for activity logging
|
|
const initiatorId = (updated as any).initiatorId;
|
|
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
|
|
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
|
|
const workflowTitle = (updated as any).title || 'Request';
|
|
|
|
// Log submitted activity (similar to created activity in createWorkflow)
|
|
activityService.log({
|
|
requestId: (updated as any).requestId,
|
|
type: 'submitted',
|
|
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Request submitted',
|
|
details: `Request "${workflowTitle}" submitted by ${initiatorName}`
|
|
});
|
|
|
|
// Log status change activity
|
|
activityService.log({
|
|
requestId: (updated as any).requestId,
|
|
type: 'status_change',
|
|
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Submitted',
|
|
details: 'Request moved from DRAFT to PENDING'
|
|
});
|
|
|
|
const current = await ApprovalLevel.findOne({
|
|
where: { requestId: (updated as any).requestId, levelNumber: (updated as any).currentLevel || 1 }
|
|
});
|
|
if (current) {
|
|
// Set the first level's start time and schedule TAT jobs
|
|
await current.update({
|
|
levelStartTime: now,
|
|
tatStartTime: now,
|
|
status: ApprovalStatus.IN_PROGRESS
|
|
});
|
|
|
|
// Log assignment activity for the first approver (similar to createWorkflow)
|
|
activityService.log({
|
|
requestId: (updated as any).requestId,
|
|
type: 'assignment',
|
|
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
action: 'Assigned to approver',
|
|
details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review`
|
|
});
|
|
|
|
// Schedule TAT notification jobs for the first level
|
|
try {
|
|
const workflowPriority = (updated as any).priority || 'STANDARD';
|
|
await tatSchedulerService.scheduleTatJobs(
|
|
(updated as any).requestId,
|
|
(current as any).levelId,
|
|
(current as any).approverId,
|
|
Number((current as any).tatHours),
|
|
now,
|
|
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
|
|
);
|
|
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${(updated as any).requestNumber} (Priority: ${workflowPriority})`);
|
|
} catch (tatError) {
|
|
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
|
|
// Don't fail the submission if TAT scheduling fails
|
|
}
|
|
|
|
// NOTE: Notifications are already sent in createWorkflow() when the workflow is created
|
|
// We should NOT send "Request submitted" to the approver here - that's incorrect
|
|
// The approver should only receive "New Request Assigned" notification (sent in createWorkflow)
|
|
// The initiator receives "Request Submitted Successfully" notification (sent in createWorkflow)
|
|
//
|
|
// If this is a draft being submitted, notifications were already sent during creation,
|
|
// so we don't need to send them again here to avoid duplicates
|
|
}
|
|
return updated;
|
|
} catch (error) {
|
|
logger.error(`Failed to submit workflow ${requestId}:`, error);
|
|
throw new Error('Failed to submit workflow');
|
|
}
|
|
}
|
|
}
|