From db02d6eb010f199c22ebbb00d11aee63f87a015a Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 24 Dec 2025 14:20:13 +0530 Subject: [PATCH] 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 }); }