template preview modified
This commit is contained in:
parent
90fe2c8e87
commit
db02d6eb01
@ -279,16 +279,85 @@ 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: truncatedOriginalFileName,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
|
||||||
|
requestId: workflow.requestId
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const doc = await Document.create({
|
const doc = await Document.create({
|
||||||
requestId: workflow.requestId,
|
requestId: workflow.requestId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
fileName: path.basename(file.filename || file.originalname),
|
fileName: truncatedFileName,
|
||||||
originalFileName: file.originalname,
|
originalFileName: truncatedOriginalFileName,
|
||||||
fileType: extension,
|
fileType: extension,
|
||||||
fileExtension: extension,
|
fileExtension: extension,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
filePath: gcsFilePath, // Store GCS path or local path
|
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,
|
mimeType: file.mimetype,
|
||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
@ -300,6 +369,24 @@ export class WorkflowController {
|
|||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} as any);
|
||||||
docs.push(doc);
|
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
|
// Log document upload activity
|
||||||
const requestMeta = getRequestMetadata(req);
|
const requestMeta = getRequestMetadata(req);
|
||||||
@ -320,6 +407,13 @@ export class WorkflowController {
|
|||||||
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
|
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown 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);
|
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -260,7 +260,7 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'ai_conclusion_generated',
|
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(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'AI Conclusion Generated',
|
action: 'AI Conclusion Generated',
|
||||||
details: 'AI-powered conclusion remark generated for review by initiator',
|
details: 'AI-powered conclusion remark generated for review by initiator',
|
||||||
@ -291,7 +291,7 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'summary_generated',
|
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(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Summary Auto-Generated',
|
action: 'Summary Auto-Generated',
|
||||||
details: 'Request summary auto-generated after final approval',
|
details: 'Request summary auto-generated after final approval',
|
||||||
@ -339,15 +339,15 @@ export class ApprovalService {
|
|||||||
targetUserIds.add(p.userId); // Includes spectators
|
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;
|
const initiatorId = (wf as any).initiatorId;
|
||||||
await notificationService.sendToUsers([initiatorId], {
|
await notificationService.sendToUsers([initiatorId], {
|
||||||
title: `Request Approved - Closure Pending`,
|
title: `Request Approved - All Approvals Complete`,
|
||||||
body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`,
|
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,
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
type: 'approval_pending_closure',
|
type: 'approval',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true
|
actionRequired: true
|
||||||
});
|
});
|
||||||
@ -425,14 +425,8 @@ export class ApprovalService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
logger.info(`Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
|
||||||
// Notify next approver
|
|
||||||
if (wf && nextLevel) {
|
// Log approval activity
|
||||||
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({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
@ -443,6 +437,32 @@ export class ApprovalService {
|
|||||||
ipAddress: requestMetadata?.ipAddress || undefined,
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
userAgent: requestMetadata?.userAgent || 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,
|
||||||
|
requestId: level.requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'assignment',
|
||||||
|
priority: 'HIGH',
|
||||||
|
actionRequired: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No next level found but not final approver - this shouldn't happen
|
// No next level found but not final approver - this shouldn't happen
|
||||||
@ -528,12 +548,34 @@ export class ApprovalService {
|
|||||||
for (const p of participants as any[]) {
|
for (const p of participants as any[]) {
|
||||||
targetUserIds.add(p.userId);
|
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}`,
|
title: `Rejected: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
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)
|
// Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved)
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export class EmailNotificationService {
|
|||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
firstApproverName: firstApproverData.displayName || firstApproverData.email,
|
firstApproverName: firstApproverData.displayName || firstApproverData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
requestTime: this.formatTime(requestData.createdAt),
|
requestTime: this.formatTime(requestData.createdAt),
|
||||||
@ -157,7 +157,7 @@ export class EmailNotificationService {
|
|||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
requestDescription: requestData.description || '',
|
requestDescription: requestData.description || '',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
@ -188,7 +188,7 @@ export class EmailNotificationService {
|
|||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
requestDescription: requestData.description || '',
|
requestDescription: requestData.description || '',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
@ -243,7 +243,7 @@ export class EmailNotificationService {
|
|||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
approvalDate: this.formatDate(approverData.approvedAt || new Date()),
|
approvalDate: this.formatDate(approverData.approvedAt || new Date()),
|
||||||
approvalTime: this.formatTime(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,
|
approverComments: approverData.comments || undefined,
|
||||||
isFinalApproval,
|
isFinalApproval,
|
||||||
nextApproverName: nextApproverData?.displayName || nextApproverData?.email,
|
nextApproverName: nextApproverData?.displayName || nextApproverData?.email,
|
||||||
@ -296,7 +296,7 @@ export class EmailNotificationService {
|
|||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
rejectionDate: this.formatDate(approverData.rejectedAt || new Date()),
|
rejectionDate: this.formatDate(approverData.rejectedAt || new Date()),
|
||||||
rejectionTime: this.formatTime(approverData.rejectedAt || new Date()),
|
rejectionTime: this.formatTime(approverData.rejectedAt || new Date()),
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
companyName: CompanyInfo.name
|
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...
|
// Add more email methods as needed...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -275,7 +275,8 @@ class NotificationService {
|
|||||||
'status_change': null,
|
'status_change': null,
|
||||||
'ai_conclusion_generated': null,
|
'ai_conclusion_generated': null,
|
||||||
'summary_generated': null,
|
'summary_generated': null,
|
||||||
'workflow_paused': null, // Conditional - handled separately
|
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
|
||||||
|
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
|
||||||
'pause_retriggered': null
|
'pause_retriggered': null
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -358,8 +359,17 @@ class NotificationService {
|
|||||||
|
|
||||||
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
|
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(
|
await emailNotificationService.sendRequestCreated(
|
||||||
requestData,
|
requestDataWithFirstTat,
|
||||||
initiatorData,
|
initiatorData,
|
||||||
firstApprover ? firstApprover.toJSON() : null
|
firstApprover ? firstApprover.toJSON() : null
|
||||||
);
|
);
|
||||||
@ -407,7 +417,7 @@ class NotificationService {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
status: 'APPROVED'
|
status: 'APPROVED'
|
||||||
},
|
},
|
||||||
order: [['approvedAt', 'DESC']]
|
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
const allLevels = await ApprovalLevel.findAll({
|
const allLevels = await ApprovalLevel.findAll({
|
||||||
@ -421,9 +431,21 @@ class NotificationService {
|
|||||||
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
|
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
|
||||||
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
|
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(
|
await emailNotificationService.sendApprovalConfirmation(
|
||||||
requestData,
|
requestData,
|
||||||
user, // Approver who just approved
|
approverData, // Approver who just approved
|
||||||
initiatorData,
|
initiatorData,
|
||||||
isFinalApproval,
|
isFinalApproval,
|
||||||
nextApprover ? nextApprover.toJSON() : undefined
|
nextApprover ? nextApprover.toJSON() : undefined
|
||||||
@ -520,6 +542,55 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,18 +182,23 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'workflow_paused',
|
||||||
priority: 'HIGH',
|
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], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'Workflow Paused Successfully',
|
title: 'Workflow Paused Successfully',
|
||||||
body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`,
|
body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`,
|
||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'status_change', // Use status_change to avoid email for self-action
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: false
|
actionRequired: false
|
||||||
});
|
});
|
||||||
@ -210,7 +215,12 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'workflow_paused',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: false
|
actionRequired: false,
|
||||||
|
metadata: {
|
||||||
|
pauseReason: reason,
|
||||||
|
resumeDate: resumeDate.toISOString(),
|
||||||
|
pausedBy: userId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,7 +469,11 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'workflow_resumed',
|
||||||
priority: 'HIGH',
|
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}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'workflow_resumed',
|
||||||
priority: 'HIGH',
|
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) {
|
if (userId) {
|
||||||
await notificationService.sendToUsers([userId], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'Workflow Resumed Successfully',
|
title: 'Workflow Resumed Successfully',
|
||||||
@ -486,7 +504,7 @@ export class PauseService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'status_change', // Use status_change to avoid email for self-action
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: isResumedByApprover
|
actionRequired: isResumedByApprover
|
||||||
});
|
});
|
||||||
|
|||||||
@ -221,13 +221,31 @@ export class WorkflowService {
|
|||||||
// Update workflow current level
|
// Update workflow current level
|
||||||
await workflow.update({ currentLevel: nextLevelNumber });
|
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
|
// Notify next approver
|
||||||
await notificationService.sendToUsers([(nextLevel as any).approverId], {
|
await notificationService.sendToUsers([(nextLevel as any).approverId], {
|
||||||
title: 'Request Escalated',
|
title: 'Request Escalated',
|
||||||
body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`,
|
body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`,
|
||||||
requestId,
|
requestId,
|
||||||
requestNumber: (workflow as any).requestNumber,
|
requestNumber: (workflow as any).requestNumber,
|
||||||
url: `/request/${(workflow as any).requestNumber}`
|
url: `/request/${(workflow as any).requestNumber}`,
|
||||||
|
type: 'assignment',
|
||||||
|
priority: 'HIGH',
|
||||||
|
actionRequired: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user