db ssl related changes postman submit bug resolved
This commit is contained in:
parent
e03049a861
commit
b925ee5217
@ -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 };
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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('/') || [];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user