From 90fe2c8e87139908553cddc2a5ba8d3ec7c1fcaa Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 24 Dec 2025 11:02:05 +0530 Subject: [PATCH 1/5] GCP related changes for the preview --- src/controllers/document.controller.ts | 114 ++++++++++++++- src/controllers/workflow.controller.ts | 160 +++++++++++++++++---- src/routes/workflow.routes.ts | 191 +++++++++++++++++++++++++ src/services/gcsStorage.service.ts | 15 +- src/services/workflow.service.ts | 12 +- 5 files changed, 454 insertions(+), 38 deletions(-) diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 640dd24..908cee5 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -96,16 +96,84 @@ export class DocumentController { } } - const doc = await Document.create({ + // Check if storageUrl exceeds database column limit (500 chars) + // GCS signed URLs can be very long (500-1000+ chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logWithContext('warn', 'Storage URL exceeds database column limit, truncating', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + }); + // For signed URLs, we can't truncate as it will break the URL + // Instead, store null and generate signed URLs on-demand when needed + // The filePath is sufficient to generate a new signed URL later + finalStorageUrl = null as any; + logWithContext('info', 'Storing null storageUrl - will generate signed URL on-demand', { + filePath: gcsFilePath, + reason: 'Signed URL too long for database column', + }); + } + + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logWithContext('warn', 'File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logWithContext('warn', 'Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Prepare document data + const documentData = { requestId, uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, fileType: extension, fileExtension: extension, fileSize: file.size, filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) mimeType: file.mimetype, checksum, isGoogleDoc: false, @@ -115,7 +183,43 @@ export class DocumentController { parentDocumentId: null as any, isDeleted: false, downloadCount: 0, - } as any); + }; + + logWithContext('info', 'Creating document record', { + requestId, + userId, + fileName: file.originalname, + filePath: gcsFilePath, + storageUrl: storageUrl, + documentData: JSON.stringify(documentData, null, 2), + }); + + let doc; + try { + doc = await Document.create(documentData as any); + logWithContext('info', 'Document record created successfully', { + documentId: doc.documentId, + requestId, + fileName: file.originalname, + }); + } catch (createError) { + const createErrorMessage = createError instanceof Error ? createError.message : 'Unknown error'; + const createErrorStack = createError instanceof Error ? createError.stack : undefined; + // Check if it's a Sequelize validation error + const sequelizeError = (createError as any)?.errors || (createError as any)?.parent; + logWithContext('error', 'Document.create() failed', { + error: createErrorMessage, + stack: createErrorStack, + sequelizeErrors: sequelizeError, + requestId, + userId, + fileName: file.originalname, + filePath: gcsFilePath, + storageUrl: storageUrl, + documentData: JSON.stringify(documentData, null, 2), + }); + throw createError; // Re-throw to be caught by outer catch block + } // Log document upload event logDocumentEvent('uploaded', doc.documentId, { diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 8260867..65b7cd5 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -587,10 +587,27 @@ export class WorkflowController { } // Update workflow - const workflow = await workflowService.updateWorkflow(id, updateData); - if (!workflow) { - ResponseHandler.notFound(res, 'Workflow not found'); - return; + let workflow; + try { + workflow = await workflowService.updateWorkflow(id, updateData); + if (!workflow) { + ResponseHandler.notFound(res, 'Workflow not found'); + return; + } + logger.info('[WorkflowController] Workflow updated successfully', { + requestId: id, + workflowId: (workflow as any).requestId, + }); + } catch (updateError) { + const updateErrorMessage = updateError instanceof Error ? updateError.message : 'Unknown error'; + const updateErrorStack = updateError instanceof Error ? updateError.stack : undefined; + logger.error('[WorkflowController] updateWorkflow failed', { + error: updateErrorMessage, + stack: updateErrorStack, + requestId: id, + updateData: JSON.stringify(updateData, null, 2), + }); + throw updateError; // Re-throw to be caught by outer catch block } // Attach new files as documents @@ -627,40 +644,129 @@ export class WorkflowController { } } + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Check if storageUrl exceeds database column limit (500 chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + filePath: gcsFilePath, + }); + // For signed URLs, store null and generate on-demand later + finalStorageUrl = null as any; + } + logger.info('[Workflow] Creating document record', { - fileName: file.originalname, + fileName: truncatedOriginalFileName, filePath: gcsFilePath, - storageUrl: storageUrl, + storageUrl: finalStorageUrl ? 'present' : 'null (too long)', requestId: actualRequestId }); - const doc = await Document.create({ - requestId: actualRequestId, - uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, - fileType: extension, - fileExtension: extension, - fileSize: file.size, - filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL - mimeType: file.mimetype, - checksum, - isGoogleDoc: false, - googleDocUrl: null as any, - category: category || 'OTHER', - version: 1, - parentDocumentId: null as any, - isDeleted: false, - downloadCount: 0, - } as any); - docs.push(doc); + try { + const doc = await Document.create({ + requestId: actualRequestId, + uploadedBy: userId, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: gcsFilePath, // Store GCS path or local path + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: category || 'OTHER', + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + docs.push(doc); + logger.info('[Workflow] Document record created successfully', { + documentId: doc.documentId, + fileName: file.originalname, + }); + } catch (docError) { + const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error'; + const docErrorStack = docError instanceof Error ? docError.stack : undefined; + logger.error('[Workflow] Failed to create document record', { + error: docErrorMessage, + stack: docErrorStack, + fileName: file.originalname, + requestId: actualRequestId, + filePath: gcsFilePath, + storageUrl: storageUrl, + }); + // Continue with other files, but log the error + // Don't throw here - let the workflow update complete + } } } ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error('[WorkflowController] updateWorkflowMultipart failed', { + error: errorMessage, + stack: errorStack, + requestId: req.params.id, + userId: req.user?.userId, + hasFiles: !!(req as any).files && (req as any).files.length > 0, + fileCount: (req as any).files ? (req as any).files.length : 0, + }); ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage); } } diff --git a/src/routes/workflow.routes.ts b/src/routes/workflow.routes.ts index 9a58857..ca7a5b4 100644 --- a/src/routes/workflow.routes.ts +++ b/src/routes/workflow.routes.ts @@ -21,6 +21,33 @@ import { pauseController } from '../controllers/pause.controller'; import logger from '@utils/logger'; const router = Router(); + +/** + * Helper function to create proper Content-Disposition header + * Returns clean filename header that browsers handle correctly + */ +function createContentDisposition(disposition: 'inline' | 'attachment', filename: string): string { + // Clean filename: only remove truly problematic characters for HTTP headers + // Keep spaces, dots, hyphens, underscores - these are safe + const cleanFilename = filename + .replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_') // Only replace truly problematic chars + .replace(/\\/g, '_') // Replace backslashes + .trim(); + + // For ASCII-only filenames, use simple format (browsers prefer this) + // Only use filename* for non-ASCII characters + const hasNonASCII = /[^\x00-\x7F]/.test(filename); + + if (hasNonASCII) { + // Use RFC 5987 encoding for non-ASCII characters + const encodedFilename = encodeURIComponent(filename); + return `${disposition}; filename="${cleanFilename}"; filename*=UTF-8''${encodedFilename}`; + } else { + // Simple ASCII filename - use clean version (no filename* needed) + // This prevents browsers from showing both filename and filename* + return `${disposition}; filename="${cleanFilename}"`; + } +} const workflowController = new WorkflowController(); const approvalController = new ApprovalController(); const workNoteController = new WorkNoteController(); @@ -223,6 +250,89 @@ router.get('/documents/:documentId/preview', return; } + // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS + if (!storageUrl && filePath && filePath.startsWith('requests/')) { + try { + // Use the existing GCS storage service instance + if (!gcsStorageService.isConfigured()) { + throw new Error('GCS not configured'); + } + + // Access the storage instance from the service + const { Storage } = require('@google-cloud/storage'); + const keyFilePath = process.env.GCP_KEY_FILE || ''; + const bucketName = process.env.GCP_BUCKET_NAME || ''; + const path = require('path'); + const resolvedKeyPath = path.isAbsolute(keyFilePath) + ? keyFilePath + : path.resolve(process.cwd(), keyFilePath); + + const storage = new Storage({ + projectId: process.env.GCP_PROJECT_ID || '', + keyFilename: resolvedKeyPath, + }); + + const bucket = storage.bucket(bucketName); + const file = bucket.file(filePath); + + // Check if file exists + const [exists] = await file.exists(); + if (!exists) { + res.status(404).json({ success: false, error: 'File not found in GCS' }); + return; + } + + // Get file metadata for content type + const [metadata] = await file.getMetadata(); + const contentType = metadata.contentType || fileType || 'application/octet-stream'; + + // Set CORS headers + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); + res.setHeader('Content-Type', contentType); + + // For images and PDFs, allow inline viewing + const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); + const disposition = isPreviewable ? 'inline' : 'attachment'; + res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName)); + + // Stream file from GCS to response + file.createReadStream() + .on('error', (streamError: Error) => { + const logger = require('../utils/logger').default; + logger.error('[Workflow] Failed to stream file from GCS', { + documentId, + filePath, + error: streamError.message, + }); + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: 'Failed to stream file from storage' + }); + } + }) + .pipe(res); + return; + } catch (gcsError) { + const logger = require('../utils/logger').default; + logger.error('[Workflow] Failed to access GCS file for preview', { + documentId, + filePath, + error: gcsError instanceof Error ? gcsError.message : 'Unknown error', + }); + res.status(500).json({ + success: false, + error: 'Failed to access file. Please try again.' + }); + return; + } + } + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // File is served by express.static middleware, redirect to the storage URL @@ -296,6 +406,87 @@ router.get('/documents/:documentId/download', return; } + // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS + if (!storageUrl && filePath && filePath.startsWith('requests/')) { + try { + // Use the existing GCS storage service instance + if (!gcsStorageService.isConfigured()) { + throw new Error('GCS not configured'); + } + + // Access the storage instance from the service + const { Storage } = require('@google-cloud/storage'); + const keyFilePath = process.env.GCP_KEY_FILE || ''; + const bucketName = process.env.GCP_BUCKET_NAME || ''; + const path = require('path'); + const resolvedKeyPath = path.isAbsolute(keyFilePath) + ? keyFilePath + : path.resolve(process.cwd(), keyFilePath); + + const storage = new Storage({ + projectId: process.env.GCP_PROJECT_ID || '', + keyFilename: resolvedKeyPath, + }); + + const bucket = storage.bucket(bucketName); + const file = bucket.file(filePath); + + // Check if file exists + const [exists] = await file.exists(); + if (!exists) { + res.status(404).json({ success: false, error: 'File not found in GCS' }); + return; + } + + // Get file metadata for content type + const [metadata] = await file.getMetadata(); + const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream'; + + // Set CORS headers + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); + + // Set headers for download + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName)); + + // Stream file from GCS to response + file.createReadStream() + .on('error', (streamError: Error) => { + const logger = require('../utils/logger').default; + logger.error('[Workflow] Failed to stream file from GCS for download', { + documentId, + filePath, + error: streamError.message, + }); + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: 'Failed to stream file from storage' + }); + } + }) + .pipe(res); + return; + } catch (gcsError) { + const logger = require('../utils/logger').default; + logger.error('[Workflow] Failed to access GCS file for download', { + documentId, + filePath, + error: gcsError instanceof Error ? gcsError.message : 'Unknown error', + }); + res.status(500).json({ + success: false, + error: 'Failed to access file. Please try again.' + }); + return; + } + } + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // File is served by express.static middleware, redirect to the storage URL diff --git a/src/services/gcsStorage.service.ts b/src/services/gcsStorage.service.ts index 8e2f7aa..35f5932 100644 --- a/src/services/gcsStorage.service.ts +++ b/src/services/gcsStorage.service.ts @@ -128,12 +128,15 @@ class GCSStorageService { // Ensure bucket exists before uploading await this.ensureBucketExists(); - // Generate unique file name + // Generate unique file name with original name first for readability + // Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf) const timestamp = Date.now(); const randomHash = Math.random().toString(36).substring(2, 8); const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const extension = path.extname(originalName); - const fileName = `${timestamp}-${randomHash}-${safeName}`; + // Extract name without extension, then add timestamp and hash before extension + const nameWithoutExt = safeName.substring(0, safeName.length - extension.length); + const fileName = `${nameWithoutExt}-${timestamp}-${randomHash}${extension}`; // Build GCS path: requests/{requestNumber}/{fileType}/{fileName} // Example: requests/REQ-2025-12-0001/documents/proposal.pdf @@ -265,11 +268,15 @@ class GCSStorageService { } try { - // Generate unique file name (same format as GCS) + // Generate unique file name (same format as GCS) - original name first for readability + // Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf) const timestamp = Date.now(); const randomHash = Math.random().toString(36).substring(2, 8); const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); - const fileName = `${timestamp}-${randomHash}-${safeName}`; + const extension = path.extname(originalName); + // Extract name without extension, then add timestamp and hash before extension + const nameWithoutExt = safeName.substring(0, safeName.length - extension.length); + const fileName = `${nameWithoutExt}-${timestamp}-${randomHash}${extension}`; // Build local path: uploads/requests/{requestNumber}/{fileType}/{fileName} // This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName} diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 7160818..34fc41e 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -3074,8 +3074,16 @@ export class WorkflowService { 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'); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error(`Failed to update workflow ${requestId}:`, { + error: errorMessage, + stack: errorStack, + requestId, + updateData: JSON.stringify(updateData, null, 2), + }); + // Preserve original error message for better debugging + throw new Error(`Failed to update workflow: ${errorMessage}`); } } From db02d6eb010f199c22ebbb00d11aee63f87a015a Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 24 Dec 2025 14:20:13 +0530 Subject: [PATCH 2/5] template preview modified --- src/controllers/workflow.controller.ts | 136 ++++++++++++++++++---- src/services/approval.service.ts | 78 ++++++++++--- src/services/emailNotification.service.ts | 113 +++++++++++++++++- src/services/notification.service.ts | 79 ++++++++++++- src/services/pause.service.ts | 34 ++++-- src/services/workflow.service.ts | 20 +++- 6 files changed, 403 insertions(+), 57 deletions(-) diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 65b7cd5..a49042c 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -279,27 +279,114 @@ export class WorkflowController { } } - const doc = await Document.create({ - requestId: workflow.requestId, - uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, - fileType: extension, - fileExtension: extension, - fileSize: file.size, - filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL - mimeType: file.mimetype, - checksum, - isGoogleDoc: false, - googleDocUrl: null as any, - category: category || 'OTHER', - version: 1, - parentDocumentId: null as any, - isDeleted: false, - downloadCount: 0, - } as any); - docs.push(doc); + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Check if storageUrl exceeds database column limit (500 chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + filePath: gcsFilePath, + }); + // For signed URLs, store null and generate on-demand later + finalStorageUrl = null as any; + } + + logger.info('[Workflow] Creating document record', { + fileName: truncatedOriginalFileName, + filePath: gcsFilePath, + storageUrl: finalStorageUrl ? 'present' : 'null (too long)', + requestId: workflow.requestId + }); + + try { + const doc = await Document.create({ + requestId: workflow.requestId, + uploadedBy: userId, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: gcsFilePath, // Store GCS path or local path + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: category || 'OTHER', + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + docs.push(doc); + logger.info('[Workflow] Document record created successfully', { + documentId: doc.documentId, + fileName: file.originalname, + }); + } catch (docError) { + const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error'; + const docErrorStack = docError instanceof Error ? docError.stack : undefined; + logger.error('[Workflow] Failed to create document record', { + error: docErrorMessage, + stack: docErrorStack, + fileName: file.originalname, + requestId: workflow.requestId, + filePath: gcsFilePath, + storageUrl: storageUrl, + }); + // Re-throw to be caught by outer catch block + throw docError; + } // Log document upload activity const requestMeta = getRequestMetadata(req); @@ -320,6 +407,13 @@ export class WorkflowController { ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error('[WorkflowController] createWorkflowMultipart failed', { + error: errorMessage, + stack: errorStack, + userId: req.user?.userId, + filesCount: (req as any).files?.length || 0, + }); ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage); } } diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index db61d98..92bed44 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -260,7 +260,7 @@ export class ApprovalService { activityService.log({ requestId: level.requestId, type: 'ai_conclusion_generated', - user: { userId: 'system', name: 'System' }, + user: { userId: null as any, name: 'System' }, // Use null instead of 'system' for UUID field timestamp: new Date().toISOString(), action: 'AI Conclusion Generated', details: 'AI-powered conclusion remark generated for review by initiator', @@ -291,7 +291,7 @@ export class ApprovalService { activityService.log({ requestId: level.requestId, type: 'summary_generated', - user: { userId: 'system', name: 'System' }, + user: { userId: null as any, name: 'System' }, // Use null instead of 'system' for UUID field timestamp: new Date().toISOString(), action: 'Summary Auto-Generated', details: 'Request summary auto-generated after final approval', @@ -339,15 +339,15 @@ export class ApprovalService { targetUserIds.add(p.userId); // Includes spectators } - // Send notification to initiator (with action required) + // Send notification to initiator about final approval (triggers email) const initiatorId = (wf as any).initiatorId; await notificationService.sendToUsers([initiatorId], { - title: `Request Approved - Closure Pending`, - body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`, + title: `Request Approved - All Approvals Complete`, + body: `Your request "${(wf as any).title}" has been fully approved by all approvers. Please review and finalize the conclusion remark to close the request.`, requestNumber: (wf as any).requestNumber, requestId: level.requestId, url: `/request/${(wf as any).requestNumber}`, - type: 'approval_pending_closure', + type: 'approval', priority: 'HIGH', actionRequired: true }); @@ -425,23 +425,43 @@ export class ApprovalService { ); logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); + + // Log approval activity + activityService.log({ + requestId: level.requestId, + type: 'approval', + user: { userId: level.approverId, name: level.approverName }, + timestamp: new Date().toISOString(), + action: 'Approved', + details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined + }); + + // Notify initiator about the approval (triggers email) + if (wf) { + await notificationService.sendToUsers([(wf as any).initiatorId], { + title: `Request Approved - Level ${level.levelNumber}`, + body: `Your request "${(wf as any).title}" has been approved by ${level.approverName || level.approverEmail} and forwarded to the next approver.`, + requestNumber: (wf as any).requestNumber, + requestId: level.requestId, + url: `/request/${(wf as any).requestNumber}`, + type: 'approval', + priority: 'MEDIUM' + }); + } + // Notify next approver if (wf && nextLevel) { await notificationService.sendToUsers([ (nextLevel as any).approverId ], { title: `Action required: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, - url: `/request/${(wf as any).requestNumber}` - }); - activityService.log({ requestId: level.requestId, - type: 'approval', - user: { userId: level.approverId, name: level.approverName }, - timestamp: new Date().toISOString(), - action: 'Approved', - details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`, - ipAddress: requestMetadata?.ipAddress || undefined, - userAgent: requestMetadata?.userAgent || undefined + url: `/request/${(wf as any).requestNumber}`, + type: 'assignment', + priority: 'HIGH', + actionRequired: true }); } } else { @@ -528,12 +548,34 @@ export class ApprovalService { for (const p of participants as any[]) { targetUserIds.add(p.userId); } - await notificationService.sendToUsers(Array.from(targetUserIds), { + + // Send notification to initiator with type 'rejection' to trigger email + await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Rejected: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, - url: `/request/${(wf as any).requestNumber}` + requestId: level.requestId, + url: `/request/${(wf as any).requestNumber}`, + type: 'rejection', + priority: 'HIGH', + metadata: { + rejectionReason: action.rejectionReason || action.comments || 'No reason provided' + } }); + + // Send notification to other participants (spectators) for transparency (no email, just in-app) + const participantUserIds = Array.from(targetUserIds).filter(id => id !== (wf as any).initiatorId); + if (participantUserIds.length > 0) { + await notificationService.sendToUsers(participantUserIds, { + title: `Rejected: ${(wf as any).requestNumber}`, + body: `Request "${(wf as any).title}" has been rejected.`, + requestNumber: (wf as any).requestNumber, + requestId: level.requestId, + url: `/request/${(wf as any).requestNumber}`, + type: 'status_change', // Use status_change to avoid triggering emails for participants + priority: 'MEDIUM' + }); + } } // Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved) diff --git a/src/services/emailNotification.service.ts b/src/services/emailNotification.service.ts index f3b4c45..4b540b4 100644 --- a/src/services/emailNotification.service.ts +++ b/src/services/emailNotification.service.ts @@ -90,7 +90,7 @@ export class EmailNotificationService { requestTitle: requestData.title, initiatorName: initiatorData.displayName || initiatorData.email, firstApproverName: firstApproverData.displayName || firstApproverData.email, - requestType: requestData.requestType || 'General', + requestType: requestData.templateType || requestData.requestType || 'CUSTOM', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), requestTime: this.formatTime(requestData.createdAt), @@ -157,7 +157,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, approverName: approverData.displayName || approverData.email, initiatorName: initiatorData.displayName || initiatorData.email, - requestType: requestData.requestType || 'General', + requestType: requestData.templateType || requestData.requestType || 'CUSTOM', requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), @@ -188,7 +188,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, approverName: approverData.displayName || approverData.email, initiatorName: initiatorData.displayName || initiatorData.email, - requestType: requestData.requestType || 'General', + requestType: requestData.templateType || requestData.requestType || 'CUSTOM', requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', requestDate: this.formatDate(requestData.createdAt), @@ -243,7 +243,7 @@ export class EmailNotificationService { approverName: approverData.displayName || approverData.email, approvalDate: this.formatDate(approverData.approvedAt || new Date()), approvalTime: this.formatTime(approverData.approvedAt || new Date()), - requestType: requestData.requestType || 'General', + requestType: requestData.templateType || requestData.requestType || 'CUSTOM', approverComments: approverData.comments || undefined, isFinalApproval, nextApproverName: nextApproverData?.displayName || nextApproverData?.email, @@ -296,7 +296,7 @@ export class EmailNotificationService { approverName: approverData.displayName || approverData.email, rejectionDate: this.formatDate(approverData.rejectedAt || new Date()), rejectionTime: this.formatTime(approverData.rejectedAt || new Date()), - requestType: requestData.requestType || 'General', + requestType: requestData.templateType || requestData.requestType || 'CUSTOM', rejectionReason, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name @@ -575,6 +575,109 @@ export class EmailNotificationService { } } + /** + * 9. Send Approver Skipped Email + */ + async sendApproverSkipped( + requestData: any, + skippedApproverData: any, + skippedByData: any, + nextApproverData: any, + skipReason: string + ): Promise { + try { + const canSend = await shouldSendEmail( + skippedApproverData.userId, + EmailNotificationType.APPROVER_SKIPPED + ); + + if (!canSend) { + logger.info(`Email skipped (preferences): Approver Skipped for ${skippedApproverData.email}`); + return; + } + + const data: ApproverSkippedData = { + recipientName: skippedApproverData.displayName || skippedApproverData.email, + requestId: requestData.requestNumber, + requestTitle: requestData.title, + skippedApproverName: skippedApproverData.displayName || skippedApproverData.email, + skippedByName: skippedByData.displayName || skippedByData.email, + skippedDate: this.formatDate(new Date()), + skippedTime: this.formatTime(new Date()), + nextApproverName: nextApproverData?.displayName || nextApproverData?.email || 'Next Approver', + skipReason: skipReason || 'Not provided', + viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), + companyName: CompanyInfo.name + }; + + const html = getApproverSkippedEmail(data); + const subject = `[${requestData.requestNumber}] Approver Skipped`; + + const result = await emailService.sendEmail({ + to: skippedApproverData.email, + subject, + html + }); + + if (result.previewUrl) { + logger.info(`📧 Approver Skipped Email Preview: ${result.previewUrl}`); + } + } catch (error) { + logger.error('Failed to send Approver Skipped email:', error); + } + } + + /** + * 10. Send Workflow Paused Email + */ + async sendWorkflowPaused( + requestData: any, + recipientData: any, + pausedByData: any, + pauseReason: string, + resumeDate: Date | string + ): Promise { + try { + const canSend = await shouldSendEmail( + recipientData.userId, + EmailNotificationType.WORKFLOW_PAUSED + ); + + if (!canSend) { + logger.info(`Email skipped (preferences): Workflow Paused for ${recipientData.email}`); + return; + } + + const data: WorkflowPausedData = { + recipientName: recipientData.displayName || recipientData.email, + requestId: requestData.requestNumber, + requestTitle: requestData.title, + pausedByName: pausedByData?.displayName || pausedByData?.email || 'System', + pausedDate: this.formatDate(new Date()), + pausedTime: this.formatTime(new Date()), + resumeDate: this.formatDate(resumeDate), + pauseReason: pauseReason || 'Not provided', + viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), + companyName: CompanyInfo.name + }; + + const html = getWorkflowPausedEmail(data); + const subject = `[${requestData.requestNumber}] Workflow Paused`; + + const result = await emailService.sendEmail({ + to: recipientData.email, + subject, + html + }); + + if (result.previewUrl) { + logger.info(`📧 Workflow Paused Email Preview: ${result.previewUrl}`); + } + } catch (error) { + logger.error('Failed to send Workflow Paused email:', error); + } + } + // Add more email methods as needed... } diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 89c98be..8d7e663 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -275,7 +275,8 @@ class NotificationService { 'status_change': null, 'ai_conclusion_generated': null, 'summary_generated': null, - 'workflow_paused': null, // Conditional - handled separately + 'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED, + 'approver_skipped': EmailNotificationType.APPROVER_SKIPPED, 'pause_retriggered': null }; @@ -358,8 +359,17 @@ class NotificationService { const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null; + // Get first approver's TAT hours (not total TAT) + const firstApproverTatHours = firstLevel ? (firstLevel as any).tatHours : null; + + // Add first approver's TAT to requestData for the email + const requestDataWithFirstTat = { + ...requestData, + tatHours: firstApproverTatHours || (requestData as any).totalTatHours || 24 + }; + await emailNotificationService.sendRequestCreated( - requestData, + requestDataWithFirstTat, initiatorData, firstApprover ? firstApprover.toJSON() : null ); @@ -407,7 +417,7 @@ class NotificationService { requestId: payload.requestId, status: 'APPROVED' }, - order: [['approvedAt', 'DESC']] + order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']] }); const allLevels = await ApprovalLevel.findAll({ @@ -421,9 +431,21 @@ class NotificationService { const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING'); const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; + // Get the approver who just approved from the approved level + let approverData = user; // Fallback to user if we can't find the approver + if (approvedLevel) { + const approverUser = await User.findByPk((approvedLevel as any).approverId); + if (approverUser) { + approverData = approverUser.toJSON(); + // Add approval metadata + (approverData as any).approvedAt = (approvedLevel as any).actionDate; + (approverData as any).comments = (approvedLevel as any).comments; + } + } + await emailNotificationService.sendApprovalConfirmation( requestData, - user, // Approver who just approved + approverData, // Approver who just approved initiatorData, isFinalApproval, nextApprover ? nextApprover.toJSON() : undefined @@ -520,6 +542,55 @@ class NotificationService { } break; + case 'approver_skipped': + { + const skippedLevel = await ApprovalLevel.findOne({ + where: { + requestId: payload.requestId, + isSkipped: true + }, + order: [['skippedAt', 'DESC']] + }); + + const nextLevel = await ApprovalLevel.findOne({ + where: { + requestId: payload.requestId, + status: 'PENDING' + }, + order: [['levelNumber', 'ASC']] + }); + + const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; + const skippedBy = payload.metadata?.skippedBy ? await User.findByPk(payload.metadata.skippedBy) : null; + const skippedApprover = skippedLevel ? await User.findByPk((skippedLevel as any).approverId) : null; + + if (skippedApprover) { + await emailNotificationService.sendApproverSkipped( + requestData, + skippedApprover.toJSON(), + skippedBy ? skippedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' }, + nextApprover ? nextApprover.toJSON() : null, + payload.metadata?.skipReason || (skippedLevel as any)?.skipReason || 'Not provided' + ); + } + } + break; + + case 'workflow_paused': + { + const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null; + const resumeDate = payload.metadata?.resumeDate || new Date(); + + await emailNotificationService.sendWorkflowPaused( + requestData, + user, + pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' }, + payload.metadata?.pauseReason || 'Not provided', + resumeDate + ); + } + break; + default: logger.info(`[Email] No email configured for notification type: ${notificationType}`); } diff --git a/src/services/pause.service.ts b/src/services/pause.service.ts index b910ad0..56cb2a6 100644 --- a/src/services/pause.service.ts +++ b/src/services/pause.service.ts @@ -182,18 +182,23 @@ export class PauseService { url: `/request/${requestNumber}`, type: 'workflow_paused', priority: 'HIGH', - actionRequired: false + actionRequired: false, + metadata: { + pauseReason: reason, + resumeDate: resumeDate.toISOString(), + pausedBy: userId + } }); } - // Notify the user who paused (confirmation) + // Notify the user who paused (confirmation) - no email for self-action await notificationService.sendToUsers([userId], { title: 'Workflow Paused Successfully', body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`, requestId, requestNumber, url: `/request/${requestNumber}`, - type: 'workflow_paused', + type: 'status_change', // Use status_change to avoid email for self-action priority: 'MEDIUM', actionRequired: false }); @@ -210,7 +215,12 @@ export class PauseService { url: `/request/${requestNumber}`, type: 'workflow_paused', priority: 'HIGH', - actionRequired: false + actionRequired: false, + metadata: { + pauseReason: reason, + resumeDate: resumeDate.toISOString(), + pausedBy: userId + } }); } @@ -459,7 +469,11 @@ export class PauseService { url: `/request/${requestNumber}`, type: 'workflow_resumed', priority: 'HIGH', - actionRequired: false + actionRequired: false, + metadata: { + resumedBy: userId ? { userId, name: resumeUserName } : null, + pauseDuration: pauseDuration + } }); } @@ -474,11 +488,15 @@ export class PauseService { url: `/request/${requestNumber}`, type: 'workflow_resumed', priority: 'HIGH', - actionRequired: true + actionRequired: true, + metadata: { + resumedBy: userId ? { userId, name: resumeUserName } : null, + pauseDuration: pauseDuration + } }); } - // Send confirmation to the user who resumed (if manual resume) + // Send confirmation to the user who resumed (if manual resume) - no email for self-action if (userId) { await notificationService.sendToUsers([userId], { title: 'Workflow Resumed Successfully', @@ -486,7 +504,7 @@ export class PauseService { requestId, requestNumber, url: `/request/${requestNumber}`, - type: 'workflow_resumed', + type: 'status_change', // Use status_change to avoid email for self-action priority: 'MEDIUM', actionRequired: isResumedByApprover }); diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 34fc41e..072280d 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -221,13 +221,31 @@ export class WorkflowService { // Update workflow current level await workflow.update({ currentLevel: nextLevelNumber }); + // Notify skipped approver (triggers email) + await notificationService.sendToUsers([(level as any).approverId], { + title: 'Approver Skipped', + body: `You have been skipped in request ${(workflow as any).requestNumber}. The workflow has moved to the next approver.`, + requestId, + requestNumber: (workflow as any).requestNumber, + url: `/request/${(workflow as any).requestNumber}`, + type: 'approver_skipped', + priority: 'MEDIUM', + metadata: { + skipReason: skipReason, + skippedBy: skippedBy + } + }); + // Notify next approver await notificationService.sendToUsers([(nextLevel as any).approverId], { title: 'Request Escalated', body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`, requestId, requestNumber: (workflow as any).requestNumber, - url: `/request/${(workflow as any).requestNumber}` + url: `/request/${(workflow as any).requestNumber}`, + type: 'assignment', + priority: 'HIGH', + actionRequired: true }); } From 47077552cf4be509640fed84e8f3dd6ffd082c2d Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 24 Dec 2025 18:22:58 +0530 Subject: [PATCH 3/5] mail templates reviewd and checked for the tat breach and pause resume workflow --- .../approvalConfirmation.template.ts | 35 +- .../approvalRequest.template.ts | 2 +- .../approverSkipped.template.ts | 7 +- src/emailtemplates/helpers.ts | 107 +++++- .../multiApproverRequest.template.ts | 2 +- .../participantAdded.template.ts | 7 +- .../rejectionNotification.template.ts | 7 +- src/emailtemplates/requestClosed.template.ts | 2 +- src/emailtemplates/requestCreated.template.ts | 34 +- src/emailtemplates/tatBreached.template.ts | 9 +- src/emailtemplates/tatReminder.template.ts | 2 +- src/emailtemplates/workflowPaused.template.ts | 7 +- .../workflowResumed.template.ts | 9 +- src/queues/tatProcessor.ts | 19 +- src/services/ai.service.ts | 59 ++- src/services/email.service.ts | 42 ++- src/services/emailNotification.service.ts | 138 ++++++- src/services/notification.service.ts | 337 +++++++++++++++--- src/services/pause.service.ts | 6 + 19 files changed, 705 insertions(+), 126 deletions(-) diff --git a/src/emailtemplates/approvalConfirmation.template.ts b/src/emailtemplates/approvalConfirmation.template.ts index d17e25b..97f1e1b 100644 --- a/src/emailtemplates/approvalConfirmation.template.ts +++ b/src/emailtemplates/approvalConfirmation.template.ts @@ -21,21 +21,24 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st - + + + Request Approved + ${getResponsiveStyles()}
- +
${getEmailHeader(getBrandedHeader({ title: 'Request Approved', ...HeaderStyles.success }))} -