template preview modified

This commit is contained in:
laxmanhalaki 2025-12-24 14:20:13 +05:30
parent 90fe2c8e87
commit db02d6eb01
6 changed files with 403 additions and 57 deletions

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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<void> {
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<void> {
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...
}

View File

@ -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}`);
}

View File

@ -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
});

View File

@ -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
});
}