db ssl related changes postman submit bug resolved

This commit is contained in:
laxmanhalaki 2026-02-06 19:53:47 +05:30
parent e03049a861
commit b925ee5217
7 changed files with 250 additions and 198 deletions

View File

@ -3,6 +3,16 @@ import dotenv from 'dotenv';
dotenv.config();
// 1. Debugging: Print what the app actually sees
console.log('--- Database Config Debug ---');
console.log(`DB_HOST: ${process.env.DB_HOST}`);
console.log(`DB_SSL (Raw): '${process.env.DB_SSL}`); // Quotes help see trailing spaces
// 2. Fix: Trim whitespace to ensure "true " becomes "true"
const isSSL = (process.env.DB_SSL || '').trim() === 'true';
console.log(`SSL Enabled: ${isSSL}`);
console.log('---------------------------');
const sequelize = new Sequelize({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
@ -10,7 +20,7 @@ const sequelize = new Sequelize({
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
dialect: 'postgres',
logging: false, // Disable SQL query logging for cleaner console output
logging: false,
pool: {
min: parseInt(process.env.DB_POOL_MIN || '2', 10),
max: parseInt(process.env.DB_POOL_MAX || '10', 10),
@ -18,11 +28,12 @@ const sequelize = new Sequelize({
idle: 10000,
},
dialectOptions: {
ssl: process.env.DB_SSL === 'true' ? {
// 3. Use the robust boolean we calculated above
ssl: isSSL ? {
require: true,
rejectUnauthorized: false,
} : false,
},
});
export { sequelize };
export { sequelize };

View File

@ -236,6 +236,7 @@ export class WorkflowController {
priority: validated.priority as Priority,
approvalLevels: enrichedApprovalLevels,
participants: autoGeneratedParticipants,
isDraft: parsed.isDraft === true, // Submit by default unless isDraft is explicitly true
} as any;
const requestMeta = getRequestMetadata(req);
@ -682,7 +683,10 @@ export class WorkflowController {
}
const parsed = JSON.parse(raw);
const validated = validateUpdateWorkflow(parsed);
const updateData: UpdateWorkflowRequest = { ...validated } as any;
const updateData: UpdateWorkflowRequest = {
...validated,
isDraft: parsed.isDraft !== undefined ? (parsed.isDraft === true) : undefined
} as any;
if (validated.priority) {
updateData.priority = validated.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD;
}

View File

@ -22,7 +22,7 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const execAsync = promisify(exec);
// DB constants moved inside functions to ensure secrets are loaded first
const isSSL = (process.env.DB_SSL || '').trim() === 'true';
async function checkAndCreateDatabase(): Promise<boolean> {
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = parseInt(process.env.DB_PORT || '5432');
@ -36,6 +36,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
user: DB_USER,
password: DB_PASSWORD,
database: 'postgres', // Connect to default postgres database
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
});
try {
@ -64,6 +65,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
});
await newDbClient.connect();

View File

@ -34,7 +34,7 @@ class GoogleSecretManagerService {
constructor() {
this.projectId = process.env.GCP_PROJECT_ID || '';
this.secretPrefix = process.env.GCP_SECRET_PREFIX || '';
// Load secret mapping file if provided
const mapFile = process.env.GCP_SECRET_MAP_FILE;
if (mapFile) {
@ -66,7 +66,7 @@ class GoogleSecretManagerService {
try {
const keyFilePath = process.env.GCP_KEY_FILE || '';
let originalCredentialsEnv: string | undefined;
// If GCP_KEY_FILE is specified, set GOOGLE_APPLICATION_CREDENTIALS temporarily
if (keyFilePath) {
const resolvedKeyPath = path.isAbsolute(keyFilePath)
@ -121,27 +121,27 @@ class GoogleSecretManagerService {
return null;
}
const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}`
const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}`
: secretName;
const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`;
try {
const [version] = await this.client.accessSecretVersion({ name });
if (version.payload?.data) {
const secretValue = version.payload.data.toString();
logger.debug(`[Secret Manager] ✅ Fetched secret: ${fullSecretName}`);
return secretValue;
}
logger.debug(`[Secret Manager] Secret ${fullSecretName} exists but payload is empty`);
return null;
} catch (error: any) {
const isOktaSecret = /OKTA_/i.test(secretName);
const logLevel = isOktaSecret ? logger.info.bind(logger) : logger.debug.bind(logger);
// Handle "not found" errors (code 5 = NOT_FOUND)
if (error.code === 5 || error.code === 'NOT_FOUND' || error.message?.includes('not found')) {
logLevel(`[Secret Manager] Secret not found: ${fullSecretName} (project: ${this.projectId})`);
@ -150,7 +150,7 @@ class GoogleSecretManagerService {
}
return null;
}
// Handle permission errors (code 7 = PERMISSION_DENIED)
if (error.code === 7 || error.code === 'PERMISSION_DENIED' || error.message?.includes('Permission denied')) {
logger.warn(`[Secret Manager] ❌ Permission denied for secret '${fullSecretName}'`);
@ -165,7 +165,7 @@ class GoogleSecretManagerService {
logger.warn(`[Secret Manager] --project=${this.projectId}`);
return null;
}
// Log full error details for debugging (info level for OKTA secrets)
const errorLogLevel = isOktaSecret ? logger.warn.bind(logger) : logger.warn.bind(logger);
errorLogLevel(`[Secret Manager] Failed to fetch secret '${fullSecretName}' (project: ${this.projectId})`);
@ -173,7 +173,7 @@ class GoogleSecretManagerService {
if (error.details) {
errorLogLevel(`[Secret Manager] Error details: ${JSON.stringify(error.details)}`);
}
return null;
}
}
@ -186,7 +186,7 @@ class GoogleSecretManagerService {
if (this.secretMap[secretName]) {
return this.secretMap[secretName];
}
// Default: convert secret name to uppercase and replace hyphens with underscores
// Example: "db-password" -> "DB_PASSWORD", "JWT_SECRET" -> "JWT_SECRET"
return secretName.toUpperCase().replace(/-/g, '_');
@ -200,7 +200,7 @@ class GoogleSecretManagerService {
*/
async loadSecrets(secretNames?: string[]): Promise<void> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager) {
logger.debug('[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)');
return;
@ -238,19 +238,19 @@ class GoogleSecretManagerService {
// Load each secret
for (const secretName of secretsToLoad) {
const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}`
const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}`
: secretName;
// Log OKTA and EMAIL secret attempts in detail
const isOktaSecret = /^OKTA_/i.test(secretName);
const isEmailSecret = /^EMAIL_|^SMTP_/i.test(secretName);
if (isOktaSecret || isEmailSecret) {
logger.info(`[Secret Manager] Attempting to load: ${secretName} (full name: ${fullSecretName})`);
}
const secretValue = await this.getSecret(secretName);
if (secretValue !== null) {
const envVarName = this.getEnvVarName(secretName);
loadedSecrets[envVarName] = secretValue;
@ -272,9 +272,9 @@ class GoogleSecretManagerService {
for (const [envVar, value] of Object.entries(loadedSecrets)) {
const existingValue = process.env[envVar];
const isOverriding = existingValue !== undefined;
process.env[envVar] = value;
// Log override behavior for debugging
if (isOverriding) {
logger.debug(`[Secret Manager] 🔄 Overrode existing env var: ${envVar} (was: ${existingValue ? 'set' : 'undefined'}, now: from Secret Manager)`);
@ -284,7 +284,7 @@ class GoogleSecretManagerService {
}
logger.info(`[Secret Manager] ✅ Successfully loaded ${loadedCount}/${secretsToLoad.length} secrets`);
if (loadedCount > 0) {
const loadedVars = Object.keys(loadedSecrets);
logger.info(`[Secret Manager] Loaded env vars: ${loadedVars.join(', ')}`);
@ -300,7 +300,7 @@ class GoogleSecretManagerService {
if (notFoundSecrets.length > 0) {
const notFoundOkta = notFoundSecrets.filter(name => /OKTA_/i.test(name));
const notFoundEmail = notFoundSecrets.filter(name => /EMAIL_|SMTP_/i.test(name));
if (notFoundOkta.length > 0) {
logger.warn(`[Secret Manager] ⚠️ OKTA secrets not found (${notFoundOkta.length}): ${notFoundOkta.join(', ')}`);
logger.info(`[Secret Manager] 💡 To create OKTA secrets, use:`);
@ -308,7 +308,7 @@ class GoogleSecretManagerService {
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
});
}
if (notFoundEmail.length > 0) {
logger.warn(`[Secret Manager] ⚠️ EMAIL secrets not found (${notFoundEmail.length}): ${notFoundEmail.join(', ')}`);
logger.info(`[Secret Manager] 💡 To create EMAIL secrets, use:`);
@ -316,7 +316,7 @@ class GoogleSecretManagerService {
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
});
}
const otherNotFound = notFoundSecrets.filter(name => !/OKTA_|EMAIL_|SMTP_/i.test(name));
if (otherNotFound.length > 0) {
logger.debug(`[Secret Manager] Other secrets not found (${otherNotFound.length}): ${otherNotFound.slice(0, 10).join(', ')}${otherNotFound.length > 10 ? '...' : ''}`);
@ -337,18 +337,18 @@ class GoogleSecretManagerService {
private getDefaultSecretNames(): string[] {
return [
// Database
//'DB_PASSWORD',
'DB_PASSWORD',
// JWT & Session
'JWT_SECRET',
'REFRESH_TOKEN_SECRET',
'SESSION_SECRET',
// Okta/SSO
//'OKTA_CLIENT_ID',
//'OKTA_CLIENT_SECRET',
//'OKTA_API_TOKEN',
// Email
'SMTP_HOST',
'SMTP_PORT',
@ -364,7 +364,7 @@ class GoogleSecretManagerService {
*/
async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager || !this.projectId) {
return null;
}
@ -375,13 +375,13 @@ class GoogleSecretManagerService {
}
const secretValue = await this.getSecret(secretName);
if (secretValue !== null) {
const envVar = envVarName || this.getEnvVarName(secretName);
process.env[envVar] = secretValue;
logger.debug(`[Secret Manager] ✅ Loaded secret ${secretName} -> ${envVar}`);
}
return secretValue;
} catch (error: any) {
logger.error(`[Secret Manager] Failed to get secret ${secretName}:`, error);
@ -402,7 +402,7 @@ class GoogleSecretManagerService {
*/
async listSecrets(): Promise<string[]> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager || !this.projectId) {
logger.warn('[Secret Manager] Cannot list secrets: Secret Manager not enabled or project ID not set');
return [];
@ -419,7 +419,7 @@ class GoogleSecretManagerService {
const parent = `projects/${this.projectId}`;
const [secrets] = await this.client.listSecrets({ parent });
const secretNames = secrets.map(secret => {
// Extract secret name from full path: projects/PROJECT/secrets/NAME -> NAME
const nameParts = secret.name?.split('/') || [];

View File

@ -2450,6 +2450,9 @@ export class WorkflowService {
try {
const requestNumber = await generateRequestNumber();
const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0);
const isDraftRequested = workflowData.isDraft === true;
const initialStatus = isDraftRequested ? WorkflowStatus.DRAFT : WorkflowStatus.PENDING;
const now = new Date();
const workflow = await WorkflowRequest.create({
requestNumber,
@ -2461,9 +2464,10 @@ export class WorkflowService {
currentLevel: 1,
totalLevels: workflowData.approvalLevels.length,
totalTatHours,
status: WorkflowStatus.DRAFT,
isDraft: true,
isDeleted: false
status: initialStatus,
isDraft: isDraftRequested,
isDeleted: false,
submissionDate: isDraftRequested ? undefined : now
});
// Create approval levels
@ -2549,15 +2553,18 @@ export class WorkflowService {
type: 'created',
user: { userId: initiatorId, name: initiatorName },
timestamp: new Date().toISOString(),
action: 'Initial request submitted',
details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
action: isDraftRequested ? 'Draft request created' : 'Initial request submitted',
details: isDraftRequested
? `Draft request "${workflowData.title}" created by ${initiatorName}`
: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// NOTE: Notifications are NOT sent here because workflows are created as DRAFTS
// Notifications will be sent in submitWorkflow() when the draft is actually submitted
// This prevents approvers from being notified about draft requests
// If not a draft, initiate the workflow (approvals, notifications, etc.)
if (!isDraftRequested) {
return await this.internalSubmitWorkflow(workflow, now);
}
return workflow;
} catch (error) {
@ -3112,6 +3119,9 @@ export class WorkflowService {
// Only allow full updates (approval levels, participants) for DRAFT workflows
const isDraft = (workflow as any).status === WorkflowStatus.DRAFT || (workflow as any).isDraft;
// Determine if this is a transition from draft to submitted
const isTransitioningToSubmitted = updateData.isDraft === false && (workflow as any).isDraft;
// Update basic workflow fields
const basicUpdate: any = {};
if (updateData.title) basicUpdate.title = updateData.title;
@ -3119,6 +3129,13 @@ export class WorkflowService {
if (updateData.priority) basicUpdate.priority = updateData.priority;
if (updateData.status) basicUpdate.status = updateData.status;
if (updateData.conclusionRemark !== undefined) basicUpdate.conclusionRemark = updateData.conclusionRemark;
if (updateData.isDraft !== undefined) basicUpdate.isDraft = updateData.isDraft;
// If transitioning, ensure status and submissionDate are set
if (isTransitioningToSubmitted) {
basicUpdate.status = WorkflowStatus.PENDING;
basicUpdate.submissionDate = new Date();
}
await workflow.update(basicUpdate);
@ -3267,8 +3284,13 @@ export class WorkflowService {
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
// If transitioning, call the internal submission logic (notifications, TAT, etc.)
if (isTransitioningToSubmitted) {
logger.info(`[Workflow] Transitioning draft ${actualRequestId} to submitted state`);
return await this.internalSubmitWorkflow(workflow, (workflow as any).submissionDate);
}
// Reload the workflow instance to get latest data
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
return refreshed;
} catch (error) {
@ -3290,160 +3312,169 @@ export class WorkflowService {
const workflow = await this.findWorkflowByIdentifier(requestId);
if (!workflow) return null;
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
const actualRequestId = (workflow as any).getDataValue
? (workflow as any).getDataValue('requestId')
: (workflow as any).requestId;
const now = new Date();
const updated = await workflow.update({
status: WorkflowStatus.PENDING,
isDraft: false,
submissionDate: now
});
// Get initiator details for activity logging
const initiatorId = (updated as any).initiatorId;
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
const workflowTitle = (updated as any).title || 'Request';
const requestNumber = (updated as any).requestNumber;
// Check if this was a previously saved draft (has activity history before submission)
// or a direct submission (createWorkflow + submitWorkflow in same flow)
const { Activity } = require('@models/Activity');
const existingActivities = await Activity.count({
where: { requestId: actualRequestId }
});
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
// For direct submissions, createWorkflow already logs "Initial request submitted"
if (existingActivities > 1) {
// This is a saved draft being submitted later
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Draft submitted',
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
});
} else {
// Direct submission - just update the status, createWorkflow already logged the activity
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Request submitted',
details: `Request "${workflowTitle}" submitted for approval`
});
}
const current = await ApprovalLevel.findOne({
where: { requestId: actualRequestId, levelNumber: (updated as any).currentLevel || 1 }
});
if (current) {
// Set the first level's start time and schedule TAT jobs
await current.update({
levelStartTime: now,
tatStartTime: now,
status: ApprovalStatus.IN_PROGRESS
});
// Log assignment activity for the first approver (similar to createWorkflow)
activityService.log({
requestId: actualRequestId,
type: 'assignment',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review`
});
// Schedule TAT notification jobs for the first level
try {
const workflowPriority = (updated as any).priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs(
actualRequestId,
(current as any).levelId,
(current as any).approverId,
Number((current as any).tatHours),
now,
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
);
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${requestNumber} (Priority: ${workflowPriority})`);
} catch (tatError) {
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
// Don't fail the submission if TAT scheduling fails
}
// Send notifications when workflow is submitted (not when created as draft)
// Send notification to INITIATOR confirming submission
await notificationService.sendToUsers([initiatorId], {
title: 'Request Submitted Successfully',
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'request_submitted',
priority: 'MEDIUM'
});
// Send notification to FIRST APPROVER for assignment
await notificationService.sendToUsers([(current as any).approverId], {
title: 'New Request Assigned',
body: `${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// Send notifications to SPECTATORS (in-app, email, and web push)
// Moved outside the if(current) block to ensure spectators are always notified on submission
try {
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
const spectators = await Participant.findAll({
where: {
requestId: actualRequestId, // Use the actual UUID requestId
participantType: ParticipantType.SPECTATOR,
isActive: true,
notificationEnabled: true
},
attributes: ['userId', 'userEmail', 'userName']
});
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
if (spectators.length > 0) {
const spectatorUserIds = spectators.map((s: any) => s.userId);
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
await notificationService.sendToUsers(spectatorUserIds, {
title: 'Added to Request',
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'spectator_added',
priority: 'MEDIUM'
});
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
} else {
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
}
} catch (spectatorError) {
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
// Don't fail the submission if spectator notifications fail
}
return updated;
return await this.internalSubmitWorkflow(workflow, now);
} catch (error) {
logger.error(`Failed to submit workflow ${requestId}:`, error);
throw new Error('Failed to submit workflow');
}
}
/**
* Internal method to handle workflow submission logic (status update, notifications, TAT scheduling)
* Centralized here to support both direct creation-submission and draft-to-submission flows.
*/
private async internalSubmitWorkflow(workflow: WorkflowRequest, now: Date): Promise<WorkflowRequest> {
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
const actualRequestId = (workflow as any).getDataValue
? (workflow as any).getDataValue('requestId')
: (workflow as any).requestId;
const updated = await workflow.update({
status: WorkflowStatus.PENDING,
isDraft: false,
submissionDate: now
});
// Get initiator details for activity logging
const initiatorId = (updated as any).initiatorId;
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
const workflowTitle = (updated as any).title || 'Request';
const requestNumber = (updated as any).requestNumber;
// Check if this was a previously saved draft (has activity history before submission)
// or a direct submission (createWorkflow + submitWorkflow in same flow)
const { Activity } = require('@models/Activity');
const existingActivities = await Activity.count({
where: { requestId: actualRequestId }
});
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
// For direct submissions, createWorkflow already logs "Initial request submitted"
if (existingActivities > 1) {
// This is a saved draft being submitted later
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Draft submitted',
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
});
} else {
// Direct submission - just update the status, createWorkflow already logged the activity
activityService.log({
requestId: actualRequestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Request submitted',
details: `Request "${workflowTitle}" submitted for approval`
});
}
const current = await ApprovalLevel.findOne({
where: { requestId: actualRequestId, levelNumber: (updated as any).currentLevel || 1 }
});
if (current) {
// Set the first level's start time and schedule TAT jobs
await current.update({
levelStartTime: now,
tatStartTime: now,
status: ApprovalStatus.IN_PROGRESS
});
// Log assignment activity for the first approver (similar to createWorkflow)
activityService.log({
requestId: actualRequestId,
type: 'assignment',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Assigned to approver',
details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review`
});
// Schedule TAT notification jobs for the first level
try {
const workflowPriority = (updated as any).priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs(
actualRequestId,
(current as any).levelId,
(current as any).approverId,
Number((current as any).tatHours),
now,
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
);
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${requestNumber} (Priority: ${workflowPriority})`);
} catch (tatError) {
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
// Don't fail the submission if TAT scheduling fails
}
// Send notifications when workflow is submitted (not when created as draft)
// Send notification to INITIATOR confirming submission
await notificationService.sendToUsers([initiatorId], {
title: 'Request Submitted Successfully',
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'request_submitted',
priority: 'MEDIUM'
});
// Send notification to FIRST APPROVER for assignment
await notificationService.sendToUsers([(current as any).approverId], {
title: 'New Request Assigned',
body: `${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// Send notifications to SPECTATORS (in-app, email, and web push)
// Moved outside the if(current) block to ensure spectators are always notified on submission
try {
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
const spectators = await Participant.findAll({
where: {
requestId: actualRequestId, // Use the actual UUID requestId
participantType: ParticipantType.SPECTATOR,
isActive: true,
notificationEnabled: true
},
attributes: ['userId', 'userEmail', 'userName']
});
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
if (spectators.length > 0) {
const spectatorUserIds = spectators.map((s: any) => s.userId);
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
await notificationService.sendToUsers(spectatorUserIds, {
title: 'Added to Request',
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
requestNumber: requestNumber,
requestId: actualRequestId,
url: `/request/${requestNumber}`,
type: 'spectator_added',
priority: 'MEDIUM'
});
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
} else {
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
}
} catch (spectatorError) {
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
// Don't fail the submission if spectator notifications fail
}
return updated;
}
}

View File

@ -29,6 +29,7 @@ export interface CreateWorkflowRequest {
priority: Priority;
approvalLevels: CreateApprovalLevel[];
participants?: CreateParticipant[];
isDraft?: boolean;
}
export interface UpdateWorkflowRequest {
@ -42,6 +43,7 @@ export interface UpdateWorkflowRequest {
participants?: CreateParticipant[];
// Document updates (add new documents via multipart, delete via IDs)
deleteDocumentIds?: string[];
isDraft?: boolean;
}
export interface CreateApprovalLevel {

View File

@ -44,6 +44,7 @@ export const createWorkflowSchema = z.object({
priorityUi: z.string().optional(),
templateId: z.string().optional(),
ccList: z.array(z.any()).optional(),
isDraft: z.boolean().optional(),
});
export const updateWorkflowSchema = z.object({
@ -73,6 +74,7 @@ export const updateWorkflowSchema = z.object({
notificationEnabled: z.boolean().optional(),
})).optional(),
deleteDocumentIds: z.array(z.string().uuid()).optional(),
isDraft: z.boolean().optional(),
});
// Helper to validate UUID or requestNumber format