From b925ee521792becfcf924690631aa3b80e645bb6 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Fri, 6 Feb 2026 19:53:47 +0530 Subject: [PATCH] db ssl related changes postman submit bug resolved --- src/config/database.ts | 17 +- src/controllers/workflow.controller.ts | 6 +- src/scripts/auto-setup.ts | 4 +- src/services/googleSecretManager.service.ts | 66 ++-- src/services/workflow.service.ts | 351 +++++++++++--------- src/types/workflow.types.ts | 2 + src/validators/workflow.validator.ts | 2 + 7 files changed, 250 insertions(+), 198 deletions(-) diff --git a/src/config/database.ts b/src/config/database.ts index a00500e..653346d 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -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 }; \ No newline at end of file diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 3a482fa..8f31117 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -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; } diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index baf81a2..e819aa9 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -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 { 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 { 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 { user: DB_USER, password: DB_PASSWORD, database: DB_NAME, + ssl: isSSL ? { rejectUnauthorized: false } : undefined, }); await newDbClient.connect(); diff --git a/src/services/googleSecretManager.service.ts b/src/services/googleSecretManager.service.ts index 92064c1..9ff0ff2 100644 --- a/src/services/googleSecretManager.service.ts +++ b/src/services/googleSecretManager.service.ts @@ -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 { 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 { 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 { 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('/') || []; diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index e2c30bb..f801496 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -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 { + // 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; + } } diff --git a/src/types/workflow.types.ts b/src/types/workflow.types.ts index 95c60b9..3df1799 100644 --- a/src/types/workflow.types.ts +++ b/src/types/workflow.types.ts @@ -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 { diff --git a/src/validators/workflow.validator.ts b/src/validators/workflow.validator.ts index bf48fe3..44a5472 100644 --- a/src/validators/workflow.validator.ts +++ b/src/validators/workflow.validator.ts @@ -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