Compare commits
No commits in common. "47077552cf4be509640fed84e8f3dd6ffd082c2d" and "53302fea21a8f3cf0dfd793c24e4454f9ff2afa2" have entirely different histories.
47077552cf
...
53302fea21
@ -96,84 +96,16 @@ export class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if storageUrl exceeds database column limit (500 chars)
|
const doc = await Document.create({
|
||||||
// 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,
|
requestId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
fileName: truncatedFileName,
|
fileName: path.basename(file.filename || file.originalname),
|
||||||
originalFileName: truncatedOriginalFileName,
|
originalFileName: file.originalname,
|
||||||
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: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
storageUrl: storageUrl, // Store GCS URL or local URL
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
@ -183,43 +115,7 @@ export class DocumentController {
|
|||||||
parentDocumentId: null as any,
|
parentDocumentId: null as any,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
downloadCount: 0,
|
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
|
// Log document upload event
|
||||||
logDocumentEvent('uploaded', doc.documentId, {
|
logDocumentEvent('uploaded', doc.documentId, {
|
||||||
|
|||||||
@ -279,85 +279,16 @@ 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: truncatedFileName,
|
fileName: path.basename(file.filename || file.originalname),
|
||||||
originalFileName: truncatedOriginalFileName,
|
originalFileName: file.originalname,
|
||||||
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: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
storageUrl: storageUrl, // Store GCS URL or local URL
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
@ -369,24 +300,6 @@ 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);
|
||||||
@ -407,13 +320,6 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -681,28 +587,11 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update workflow
|
// Update workflow
|
||||||
let workflow;
|
const workflow = await workflowService.updateWorkflow(id, updateData);
|
||||||
try {
|
|
||||||
workflow = await workflowService.updateWorkflow(id, updateData);
|
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
ResponseHandler.notFound(res, 'Workflow not found');
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
return;
|
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
|
// Attach new files as documents
|
||||||
const files = (req as any).files as Express.Multer.File[] | undefined;
|
const files = (req as any).files as Express.Multer.File[] | undefined;
|
||||||
@ -738,85 +627,23 @@ 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', {
|
logger.info('[Workflow] Creating document record', {
|
||||||
fileName: truncatedOriginalFileName,
|
fileName: file.originalname,
|
||||||
filePath: gcsFilePath,
|
filePath: gcsFilePath,
|
||||||
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
|
storageUrl: storageUrl,
|
||||||
requestId: actualRequestId
|
requestId: actualRequestId
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
const doc = await Document.create({
|
const doc = await Document.create({
|
||||||
requestId: actualRequestId,
|
requestId: actualRequestId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
fileName: truncatedFileName,
|
fileName: path.basename(file.filename || file.originalname),
|
||||||
originalFileName: truncatedOriginalFileName,
|
originalFileName: file.originalname,
|
||||||
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: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
storageUrl: storageUrl, // Store GCS URL or local URL
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
@ -828,39 +655,12 @@ 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: 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);
|
ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200);
|
||||||
} 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] 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);
|
ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,24 +21,21 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Request Approved</title>
|
<title>Request Approved</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Approved',
|
title: 'Request Approved',
|
||||||
...HeaderStyles.success
|
...HeaderStyles.success
|
||||||
}))}
|
}))}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="email-content">
|
<td style="padding: 40px 30px;">
|
||||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||||
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
||||||
</p>
|
</p>
|
||||||
@ -49,47 +46,47 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
|
|||||||
|
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-box" style="padding: 30px;">
|
<td style="padding: 25px;">
|
||||||
<h2 style="margin: 0 0 25px; color: #155724; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
<h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||||
|
|
||||||
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
|
||||||
<strong>Request ID:</strong>
|
<strong>Request ID:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
${data.requestId}
|
${data.requestId}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
<strong>Approved By:</strong>
|
<strong>Approved By:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
${data.approverName}
|
${data.approverName}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
<strong>Approved On:</strong>
|
<strong>Approved On:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
${data.approvalDate}
|
${data.approvalDate}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
<strong>Time:</strong>
|
<strong>Time:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
${data.approvalTime}
|
${data.approvalTime}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
<strong>Request Type:</strong>
|
<strong>Request Type:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||||
${data.requestType}
|
${data.requestType}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Approval Request',
|
title: 'Approval Request',
|
||||||
|
|||||||
@ -12,17 +12,14 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Approver Skipped</title>
|
<title>Approver Skipped</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Approval Level Skipped',
|
title: 'Approval Level Skipped',
|
||||||
...HeaderStyles.infoSecondary
|
...HeaderStyles.infoSecondary
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export function wrapRichText(htmlContent: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate all email styles (responsive + rich text)
|
* Generate all email styles (responsive + rich text)
|
||||||
* Desktop-first design (optimized for browser) with mobile responsive breakpoints
|
* Optimized for screens up to 600px width
|
||||||
*/
|
*/
|
||||||
export function getResponsiveStyles(): string {
|
export function getResponsiveStyles(): string {
|
||||||
return `
|
return `
|
||||||
@ -173,86 +173,6 @@ export function getResponsiveStyles(): string {
|
|||||||
border-collapse: collapse !important;
|
border-collapse: collapse !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop-first base styles */
|
|
||||||
.email-container {
|
|
||||||
width: 95%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content {
|
|
||||||
padding: 50px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-header {
|
|
||||||
padding: 40px 40px 35px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer {
|
|
||||||
padding: 30px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop typography */
|
|
||||||
.header-title {
|
|
||||||
font-size: 24px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-subtitle {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop detail tables - side by side */
|
|
||||||
.detail-table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-table td {
|
|
||||||
font-size: 15px;
|
|
||||||
padding: 10px 0;
|
|
||||||
display: table-cell;
|
|
||||||
width: auto;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-label {
|
|
||||||
width: 200px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-box {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop button styles */
|
|
||||||
.cta-button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 16px 45px;
|
|
||||||
font-size: 16px;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet responsive styles */
|
|
||||||
@media only screen and (max-width: 1200px) {
|
|
||||||
.email-container {
|
|
||||||
width: 95% !important;
|
|
||||||
max-width: 95% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-content {
|
|
||||||
padding: 40px 30px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-header {
|
|
||||||
padding: 35px 30px 30px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.email-footer {
|
|
||||||
padding: 25px 30px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive styles */
|
/* Mobile responsive styles */
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
/* Container adjustments */
|
/* Container adjustments */
|
||||||
@ -260,12 +180,11 @@ export function getResponsiveStyles(): string {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header adjustments */
|
/* Header adjustments */
|
||||||
.email-header {
|
.email-header {
|
||||||
padding: 25px 20px 30px !important;
|
padding: 25px 15px 30px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content adjustments */
|
/* Content adjustments */
|
||||||
@ -288,7 +207,7 @@ export function getResponsiveStyles(): string {
|
|||||||
/* Typography adjustments */
|
/* Typography adjustments */
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 20px !important;
|
font-size: 20px !important;
|
||||||
letter-spacing: 0.5px !important;
|
letter-spacing: 1px !important;
|
||||||
line-height: 1.4 !important;
|
line-height: 1.4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,22 +215,21 @@ export function getResponsiveStyles(): string {
|
|||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail tables - stack on mobile */
|
/* Detail tables */
|
||||||
.detail-box {
|
.detail-box {
|
||||||
padding: 20px 15px !important;
|
padding: 20px 15px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-table td {
|
.detail-table td {
|
||||||
font-size: 14px !important;
|
font-size: 13px !important;
|
||||||
padding: 8px 0 !important;
|
padding: 6px 0 !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin-bottom: 4px !important;
|
margin-bottom: 2px !important;
|
||||||
width: 100% !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button adjustments */
|
/* Button adjustments */
|
||||||
@ -322,28 +240,27 @@ export function getResponsiveStyles(): string {
|
|||||||
padding: 16px 20px !important;
|
padding: 16px 20px !important;
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
min-width: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section adjustments */
|
/* Section adjustments */
|
||||||
.info-section {
|
.info-section {
|
||||||
padding: 18px 15px !important;
|
padding: 15px !important;
|
||||||
margin-bottom: 20px !important;
|
margin-bottom: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 16px !important;
|
font-size: 15px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-text {
|
.section-text {
|
||||||
font-size: 14px !important;
|
font-size: 13px !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List items */
|
/* List items */
|
||||||
.info-section ul {
|
.info-section ul {
|
||||||
padding-left: 20px !important;
|
padding-left: 15px !important;
|
||||||
font-size: 14px !important;
|
font-size: 13px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section li {
|
.info-section li {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Multi-Level Approval Request',
|
title: 'Multi-Level Approval Request',
|
||||||
|
|||||||
@ -12,17 +12,14 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Added to Request</title>
|
<title>Added to Request</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: `You've Been Added as ${data.participantRole}`,
|
title: `You've Been Added as ${data.participantRole}`,
|
||||||
...HeaderStyles.info
|
...HeaderStyles.info
|
||||||
|
|||||||
@ -12,17 +12,14 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Request Rejected</title>
|
<title>Request Rejected</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Rejected',
|
title: 'Request Rejected',
|
||||||
...HeaderStyles.error
|
...HeaderStyles.error
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function getRequestClosedEmail(data: RequestClosedData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Closed',
|
title: 'Request Closed',
|
||||||
...HeaderStyles.complete
|
...HeaderStyles.complete
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Created Successfully',
|
title: 'Request Created Successfully',
|
||||||
@ -31,7 +31,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="email-content">
|
<td class="email-content" style="padding: 40px 30px;">
|
||||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||||
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
|
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
|
||||||
</p>
|
</p>
|
||||||
@ -43,55 +43,55 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
<!-- Request Details Box -->
|
<!-- Request Details Box -->
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-box" style="padding: 30px;">
|
<td style="padding: 25px;">
|
||||||
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||||
|
|
||||||
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||||
<strong>Request ID:</strong>
|
<strong>Request ID:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.requestId}
|
${data.requestId}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||||
<strong>Title:</strong>
|
<strong>Title:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.requestTitle || 'N/A'}
|
${data.requestTitle || 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||||
<strong>Request Type:</strong>
|
<strong>Request Type:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.requestType}
|
${data.requestType}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||||
<strong>Priority:</strong>
|
<strong>Priority:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.priority}
|
${data.priority}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||||
<strong>Created On:</strong>
|
<strong>Created On:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.requestDate} at ${data.requestTime}
|
${data.requestDate} at ${data.requestTime}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||||
<strong>Total Approvers:</strong>
|
<strong>Total Approvers:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||||
${data.totalApprovers}
|
${data.totalApprovers}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TATBreachedData } from './types';
|
import { TATBreachedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getTATBreachedEmail(data: TATBreachedData): string {
|
export function getTATBreachedEmail(data: TATBreachedData): string {
|
||||||
@ -12,17 +12,14 @@ export function getTATBreachedEmail(data: TATBreachedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>TAT Breached - Urgent Action Required</title>
|
<title>TAT Breached - Urgent Action Required</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'TAT Breached',
|
title: 'TAT Breached',
|
||||||
subtitle: 'Immediate Action Required',
|
subtitle: 'Immediate Action Required',
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function getTATReminderEmail(data: TATReminderData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'TAT Reminder',
|
title: 'TAT Reminder',
|
||||||
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
||||||
|
|||||||
@ -12,17 +12,14 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Workflow Paused</title>
|
<title>Workflow Paused</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Workflow Paused',
|
title: 'Workflow Paused',
|
||||||
...HeaderStyles.neutral
|
...HeaderStyles.neutral
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WorkflowResumedData } from './types';
|
import { WorkflowResumedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
||||||
@ -12,17 +12,14 @@ export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<title>Workflow Resumed</title>
|
<title>Workflow Resumed</title>
|
||||||
${getResponsiveStyles()}
|
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Workflow Resumed',
|
title: 'Workflow Resumed',
|
||||||
...HeaderStyles.success
|
...HeaderStyles.success
|
||||||
|
|||||||
@ -210,13 +210,6 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
|||||||
type === 'threshold2' ? 'HIGH' :
|
type === 'threshold2' ? 'HIGH' :
|
||||||
'MEDIUM';
|
'MEDIUM';
|
||||||
|
|
||||||
// Format time remaining/overdue for email
|
|
||||||
const timeRemainingText = remainingHours > 0
|
|
||||||
? `${remainingHours.toFixed(1)} hours remaining`
|
|
||||||
: type === 'breach'
|
|
||||||
? `${Math.abs(remainingHours).toFixed(1)} hours overdue`
|
|
||||||
: 'Time exceeded';
|
|
||||||
|
|
||||||
// Send notification to approver (with error handling to prevent job failure)
|
// Send notification to approver (with error handling to prevent job failure)
|
||||||
try {
|
try {
|
||||||
await notificationService.sendToUsers([approverId], {
|
await notificationService.sendToUsers([approverId], {
|
||||||
@ -227,17 +220,7 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: type,
|
type: type,
|
||||||
priority: notificationPriority,
|
priority: notificationPriority,
|
||||||
actionRequired: type === 'breach' || type === 'threshold2', // Require action for critical alerts
|
actionRequired: type === 'breach' || type === 'threshold2' // Require action for critical alerts
|
||||||
metadata: {
|
|
||||||
thresholdPercentage: thresholdPercentage,
|
|
||||||
tatInfo: {
|
|
||||||
thresholdPercentage: thresholdPercentage,
|
|
||||||
timeRemaining: timeRemainingText,
|
|
||||||
tatDeadline: expectedCompletionTime,
|
|
||||||
assignedDate: levelStartTime,
|
|
||||||
timeOverdue: type === 'breach' ? timeRemainingText : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
|
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
|
||||||
} catch (notificationError: any) {
|
} catch (notificationError: any) {
|
||||||
|
|||||||
@ -21,33 +21,6 @@ import { pauseController } from '../controllers/pause.controller';
|
|||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
const router = Router();
|
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 workflowController = new WorkflowController();
|
||||||
const approvalController = new ApprovalController();
|
const approvalController = new ApprovalController();
|
||||||
const workNoteController = new WorkNoteController();
|
const workNoteController = new WorkNoteController();
|
||||||
@ -250,89 +223,6 @@ router.get('/documents/:documentId/preview',
|
|||||||
return;
|
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/)
|
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||||
// File is served by express.static middleware, redirect to the storage URL
|
// File is served by express.static middleware, redirect to the storage URL
|
||||||
@ -406,87 +296,6 @@ router.get('/documents/:documentId/download',
|
|||||||
return;
|
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/)
|
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||||
// File is served by express.static middleware, redirect to the storage URL
|
// File is served by express.static middleware, redirect to the storage URL
|
||||||
|
|||||||
@ -115,66 +115,11 @@ class AIService {
|
|||||||
const streamingResp = await generativeModel.generateContent(request);
|
const streamingResp = await generativeModel.generateContent(request);
|
||||||
const response = streamingResp.response;
|
const response = streamingResp.response;
|
||||||
|
|
||||||
// Log full response structure for debugging if empty
|
|
||||||
if (!response.candidates || response.candidates.length === 0) {
|
|
||||||
logger.error('[AI Service] No candidates in Vertex AI response:', {
|
|
||||||
response: JSON.stringify(response, null, 2),
|
|
||||||
promptLength: prompt.length,
|
|
||||||
model: this.model
|
|
||||||
});
|
|
||||||
throw new Error('Vertex AI returned no candidates. The response may have been blocked by safety filters.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = response.candidates[0];
|
|
||||||
|
|
||||||
// Check for safety ratings or blocked reasons
|
|
||||||
if (candidate.safetyRatings && candidate.safetyRatings.length > 0) {
|
|
||||||
const blockedRatings = candidate.safetyRatings.filter((rating: any) =>
|
|
||||||
rating.probability === 'HIGH' || rating.probability === 'MEDIUM'
|
|
||||||
);
|
|
||||||
if (blockedRatings.length > 0) {
|
|
||||||
logger.warn('[AI Service] Vertex AI safety filters triggered:', {
|
|
||||||
ratings: blockedRatings.map((r: any) => ({
|
|
||||||
category: r.category,
|
|
||||||
probability: r.probability
|
|
||||||
})),
|
|
||||||
finishReason: candidate.finishReason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check finish reason
|
|
||||||
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
|
||||||
logger.warn('[AI Service] Vertex AI finish reason:', {
|
|
||||||
finishReason: candidate.finishReason,
|
|
||||||
safetyRatings: candidate.safetyRatings
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text from response
|
// Extract text from response
|
||||||
const text = candidate.content?.parts?.[0]?.text || '';
|
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
// Log detailed response structure for debugging
|
throw new Error('Empty response from Vertex AI');
|
||||||
logger.error('[AI Service] Empty text in Vertex AI response:', {
|
|
||||||
candidate: JSON.stringify(candidate, null, 2),
|
|
||||||
finishReason: candidate.finishReason,
|
|
||||||
safetyRatings: candidate.safetyRatings,
|
|
||||||
promptLength: prompt.length,
|
|
||||||
promptPreview: prompt.substring(0, 200) + '...',
|
|
||||||
model: this.model
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provide more helpful error message
|
|
||||||
if (candidate.finishReason === 'SAFETY') {
|
|
||||||
throw new Error('Vertex AI blocked the response due to safety filters. The prompt may contain content that violates safety policies.');
|
|
||||||
} else if (candidate.finishReason === 'MAX_TOKENS') {
|
|
||||||
throw new Error('Vertex AI response was truncated due to token limit.');
|
|
||||||
} else if (candidate.finishReason === 'RECITATION') {
|
|
||||||
throw new Error('Vertex AI blocked the response due to recitation concerns.');
|
|
||||||
} else {
|
|
||||||
throw new Error(`Empty response from Vertex AI. Finish reason: ${candidate.finishReason || 'UNKNOWN'}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@ -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: null as any, name: 'System' }, // Use null instead of 'system' for UUID field
|
user: { userId: 'system', name: 'System' },
|
||||||
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: null as any, name: 'System' }, // Use null instead of 'system' for UUID field
|
user: { userId: 'system', name: 'System' },
|
||||||
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 about final approval (triggers email)
|
// Send notification to initiator (with action required)
|
||||||
const initiatorId = (wf as any).initiatorId;
|
const initiatorId = (wf as any).initiatorId;
|
||||||
await notificationService.sendToUsers([initiatorId], {
|
await notificationService.sendToUsers([initiatorId], {
|
||||||
title: `Request Approved - All Approvals Complete`,
|
title: `Request Approved - Closure Pending`,
|
||||||
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.`,
|
body: `Your request "${(wf as any).title}" has been fully approved. 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',
|
type: 'approval_pending_closure',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true
|
actionRequired: true
|
||||||
});
|
});
|
||||||
@ -425,8 +425,14 @@ 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
|
||||||
// Log approval activity
|
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({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
@ -437,32 +443,6 @@ 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
|
||||||
@ -548,34 +528,12 @@ 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,
|
||||||
requestId: level.requestId,
|
url: `/request/${(wf as any).requestNumber}`
|
||||||
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)
|
||||||
|
|||||||
@ -121,42 +121,24 @@ export class EmailService {
|
|||||||
try {
|
try {
|
||||||
const info = await this.transporter!.sendMail(mailOptions);
|
const info = await this.transporter!.sendMail(mailOptions);
|
||||||
|
|
||||||
if (!info || !info.messageId) {
|
|
||||||
throw new Error('Email sent but no messageId returned');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: { messageId: string; previewUrl?: string } = {
|
const result: { messageId: string; previewUrl?: string } = {
|
||||||
messageId: info.messageId
|
messageId: info.messageId
|
||||||
};
|
};
|
||||||
|
|
||||||
// If using test account, generate preview URL
|
// If using test account, generate preview URL
|
||||||
if (this.useTestAccount) {
|
if (this.useTestAccount) {
|
||||||
try {
|
|
||||||
const previewUrl = nodemailer.getTestMessageUrl(info);
|
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||||
|
result.previewUrl = previewUrl || undefined;
|
||||||
if (previewUrl) {
|
|
||||||
result.previewUrl = previewUrl;
|
|
||||||
|
|
||||||
// Always log to console for visibility
|
// Always log to console for visibility
|
||||||
console.log('\n' + '='.repeat(80));
|
console.log('\n' + '='.repeat(80));
|
||||||
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
||||||
console.log(`To: ${recipients}`);
|
console.log(`To: ${recipients}`);
|
||||||
console.log(`Preview URL: ${previewUrl}`);
|
console.log(`Preview URL: ${previewUrl}`);
|
||||||
console.log(`Message ID: ${info.messageId}`);
|
|
||||||
console.log('='.repeat(80) + '\n');
|
console.log('='.repeat(80) + '\n');
|
||||||
|
|
||||||
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
||||||
logger.info(`📧 Preview URL: ${previewUrl}`);
|
logger.info(`📧 Preview URL: ${previewUrl}`);
|
||||||
} else {
|
|
||||||
logger.warn(`⚠️ Email sent but preview URL not available. Message ID: ${info.messageId}`);
|
|
||||||
logger.warn(`💡 This can happen if the email service is rate-limited or the message hasn't been processed yet.`);
|
|
||||||
}
|
|
||||||
} catch (previewError: any) {
|
|
||||||
logger.error(`❌ Failed to generate preview URL:`, previewError);
|
|
||||||
logger.warn(`⚠️ Email was sent successfully (Message ID: ${info.messageId}) but preview URL generation failed.`);
|
|
||||||
logger.warn(`💡 You can try sending the email again to get a new preview URL.`);
|
|
||||||
// Don't throw - email was sent successfully, just preview URL failed
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
|
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.templateType || requestData.requestType || 'CUSTOM',
|
requestType: requestData.requestType || 'General',
|
||||||
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.templateType || requestData.requestType || 'CUSTOM',
|
requestType: requestData.requestType || 'General',
|
||||||
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.templateType || requestData.requestType || 'CUSTOM',
|
requestType: requestData.requestType || 'General',
|
||||||
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.templateType || requestData.requestType || 'CUSTOM',
|
requestType: requestData.requestType || 'General',
|
||||||
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.templateType || requestData.requestType || 'CUSTOM',
|
requestType: requestData.requestType || 'General',
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
companyName: CompanyInfo.name
|
companyName: CompanyInfo.name
|
||||||
@ -348,27 +348,12 @@ export class EmailNotificationService {
|
|||||||
: tatInfo.thresholdPercentage >= 50 ? 'medium'
|
: tatInfo.thresholdPercentage >= 50 ? 'medium'
|
||||||
: 'low';
|
: 'low';
|
||||||
|
|
||||||
// Get initiator name - try from requestData first, then fetch if needed
|
|
||||||
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
|
||||||
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
|
||||||
try {
|
|
||||||
const { User } = await import('@models/index');
|
|
||||||
const initiator = await User.findByPk(requestData.initiatorId);
|
|
||||||
if (initiator) {
|
|
||||||
const initiatorJson = initiator.toJSON();
|
|
||||||
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to fetch initiator for TAT reminder: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: TATReminderData = {
|
const data: TATReminderData = {
|
||||||
recipientName: approverData.displayName || approverData.email,
|
recipientName: approverData.displayName || approverData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorName,
|
initiatorName: requestData.initiatorName || 'Initiator',
|
||||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||||
timeRemaining: tatInfo.timeRemaining,
|
timeRemaining: tatInfo.timeRemaining,
|
||||||
@ -419,27 +404,12 @@ export class EmailNotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get initiator name - try from requestData first, then fetch if needed
|
|
||||||
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
|
||||||
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
|
||||||
try {
|
|
||||||
const { User } = await import('@models/index');
|
|
||||||
const initiator = await User.findByPk(requestData.initiatorId);
|
|
||||||
if (initiator) {
|
|
||||||
const initiatorJson = initiator.toJSON();
|
|
||||||
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to fetch initiator for TAT breach: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: TATBreachedData = {
|
const data: TATBreachedData = {
|
||||||
recipientName: approverData.displayName || approverData.email,
|
recipientName: approverData.displayName || approverData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorName,
|
initiatorName: requestData.initiatorName || 'Initiator',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||||
@ -476,15 +446,6 @@ export class EmailNotificationService {
|
|||||||
pauseDuration: string
|
pauseDuration: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Validate approver data has email
|
|
||||||
if (!approverData || !approverData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Resumed email: approver email missing`, {
|
|
||||||
approverData: approverData ? { userId: approverData.userId, displayName: approverData.displayName } : null,
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSend = await shouldSendEmail(
|
const canSend = await shouldSendEmail(
|
||||||
approverData.userId,
|
approverData.userId,
|
||||||
EmailNotificationType.WORKFLOW_RESUMED
|
EmailNotificationType.WORKFLOW_RESUMED
|
||||||
@ -534,75 +495,6 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send Workflow Resumed Email to Initiator
|
|
||||||
*/
|
|
||||||
async sendWorkflowResumedToInitiator(
|
|
||||||
requestData: any,
|
|
||||||
initiatorData: any,
|
|
||||||
approverData: any,
|
|
||||||
resumedByData: any,
|
|
||||||
pauseDuration: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Validate initiator data has email
|
|
||||||
if (!initiatorData || !initiatorData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Resumed email to initiator: email missing`, {
|
|
||||||
initiatorData: initiatorData ? { userId: initiatorData.userId, displayName: initiatorData.displayName } : null,
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSend = await shouldSendEmail(
|
|
||||||
initiatorData.userId,
|
|
||||||
EmailNotificationType.WORKFLOW_RESUMED
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canSend) {
|
|
||||||
logger.info(`Email skipped (preferences): Workflow Resumed for initiator ${initiatorData.email}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAutoResumed = !resumedByData || resumedByData.userId === 'system' || !resumedByData.userId;
|
|
||||||
const resumedByText = isAutoResumed
|
|
||||||
? 'automatically'
|
|
||||||
: `by ${resumedByData.displayName || resumedByData.email || resumedByData.name || 'User'}`;
|
|
||||||
|
|
||||||
const data: WorkflowResumedData = {
|
|
||||||
recipientName: initiatorData.displayName || initiatorData.email,
|
|
||||||
requestId: requestData.requestNumber,
|
|
||||||
requestTitle: requestData.title,
|
|
||||||
resumedByText,
|
|
||||||
resumedDate: this.formatDate(new Date()),
|
|
||||||
resumedTime: this.formatTime(new Date()),
|
|
||||||
pausedDuration: pauseDuration,
|
|
||||||
currentApprover: approverData?.displayName || approverData?.email || 'Current Approver',
|
|
||||||
newTATDeadline: requestData.tatDeadline
|
|
||||||
? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline)
|
|
||||||
: 'To be determined',
|
|
||||||
isApprover: false, // This is for initiator
|
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
|
||||||
companyName: CompanyInfo.name
|
|
||||||
};
|
|
||||||
|
|
||||||
const html = getWorkflowResumedEmail(data);
|
|
||||||
const subject = `[${requestData.requestNumber}] Workflow Resumed`;
|
|
||||||
|
|
||||||
const result = await emailService.sendEmail({
|
|
||||||
to: initiatorData.email,
|
|
||||||
subject,
|
|
||||||
html
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.previewUrl) {
|
|
||||||
logger.info(`📧 Workflow Resumed Email Preview (Initiator): ${result.previewUrl}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to send Workflow Resumed email to initiator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 8. Send Request Closed Email
|
* 8. Send Request Closed Email
|
||||||
*/
|
*/
|
||||||
@ -631,26 +523,11 @@ export class EmailNotificationService {
|
|||||||
const duration = closedDate.diff(createdDate, 'day');
|
const duration = closedDate.diff(createdDate, 'day');
|
||||||
const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`;
|
const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
// Get initiator name - try from requestData first, then fetch if needed
|
|
||||||
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
|
||||||
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
|
||||||
try {
|
|
||||||
const { User } = await import('@models/index');
|
|
||||||
const initiator = await User.findByPk(requestData.initiatorId);
|
|
||||||
if (initiator) {
|
|
||||||
const initiatorJson = initiator.toJSON();
|
|
||||||
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to fetch initiator for closed request: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: RequestClosedData = {
|
const data: RequestClosedData = {
|
||||||
recipientName: recipientData.displayName || recipientData.email,
|
recipientName: recipientData.displayName || recipientData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
initiatorName: initiatorName,
|
initiatorName: requestData.initiatorName || 'Initiator',
|
||||||
createdDate: this.formatDate(requestData.createdAt),
|
createdDate: this.formatDate(requestData.createdAt),
|
||||||
closedDate: this.formatDate(requestData.closedAt || new Date()),
|
closedDate: this.formatDate(requestData.closedAt || new Date()),
|
||||||
closedTime: this.formatTime(requestData.closedAt || new Date()),
|
closedTime: this.formatTime(requestData.closedAt || new Date()),
|
||||||
@ -698,118 +575,6 @@ 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 {
|
|
||||||
// Validate recipient data has email
|
|
||||||
if (!recipientData || !recipientData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
|
|
||||||
recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,15 +128,12 @@ class GCSStorageService {
|
|||||||
// Ensure bucket exists before uploading
|
// Ensure bucket exists before uploading
|
||||||
await this.ensureBucketExists();
|
await this.ensureBucketExists();
|
||||||
|
|
||||||
// Generate unique file name with original name first for readability
|
// Generate unique file name
|
||||||
// Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf)
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomHash = Math.random().toString(36).substring(2, 8);
|
const randomHash = Math.random().toString(36).substring(2, 8);
|
||||||
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const extension = path.extname(originalName);
|
const extension = path.extname(originalName);
|
||||||
// Extract name without extension, then add timestamp and hash before extension
|
const fileName = `${timestamp}-${randomHash}-${safeName}`;
|
||||||
const nameWithoutExt = safeName.substring(0, safeName.length - extension.length);
|
|
||||||
const fileName = `${nameWithoutExt}-${timestamp}-${randomHash}${extension}`;
|
|
||||||
|
|
||||||
// Build GCS path: requests/{requestNumber}/{fileType}/{fileName}
|
// Build GCS path: requests/{requestNumber}/{fileType}/{fileName}
|
||||||
// Example: requests/REQ-2025-12-0001/documents/proposal.pdf
|
// Example: requests/REQ-2025-12-0001/documents/proposal.pdf
|
||||||
@ -268,15 +265,11 @@ class GCSStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique file name (same format as GCS) - original name first for readability
|
// Generate unique file name (same format as GCS)
|
||||||
// Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf)
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomHash = Math.random().toString(36).substring(2, 8);
|
const randomHash = Math.random().toString(36).substring(2, 8);
|
||||||
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
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 local path: uploads/requests/{requestNumber}/{fileType}/{fileName}
|
// Build local path: uploads/requests/{requestNumber}/{fileType}/{fileName}
|
||||||
// This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName}
|
// This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName}
|
||||||
|
|||||||
@ -123,12 +123,10 @@ class NotificationService {
|
|||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
// Fetch user preferences and email data
|
// Fetch user preferences
|
||||||
const user = await User.findByPk(userId, {
|
const user = await User.findByPk(userId, {
|
||||||
attributes: [
|
attributes: [
|
||||||
'userId',
|
'userId',
|
||||||
'email',
|
|
||||||
'displayName',
|
|
||||||
'emailNotificationsEnabled',
|
'emailNotificationsEnabled',
|
||||||
'pushNotificationsEnabled',
|
'pushNotificationsEnabled',
|
||||||
'inAppNotificationsEnabled'
|
'inAppNotificationsEnabled'
|
||||||
@ -268,10 +266,6 @@ class NotificationService {
|
|||||||
'rejection': EmailNotificationType.REQUEST_REJECTED,
|
'rejection': EmailNotificationType.REQUEST_REJECTED,
|
||||||
'tat_reminder': EmailNotificationType.TAT_REMINDER,
|
'tat_reminder': EmailNotificationType.TAT_REMINDER,
|
||||||
'tat_breach': EmailNotificationType.TAT_BREACHED,
|
'tat_breach': EmailNotificationType.TAT_BREACHED,
|
||||||
'threshold1': EmailNotificationType.TAT_REMINDER, // 50% TAT reminder
|
|
||||||
'threshold2': EmailNotificationType.TAT_REMINDER, // 75% TAT reminder
|
|
||||||
'breach': EmailNotificationType.TAT_BREACHED, // 100% TAT breach
|
|
||||||
'tat_breach_initiator': EmailNotificationType.TAT_BREACHED, // Breach notification to initiator
|
|
||||||
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
|
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
|
||||||
'closed': EmailNotificationType.REQUEST_CLOSED,
|
'closed': EmailNotificationType.REQUEST_CLOSED,
|
||||||
// These don't get emails (in-app only)
|
// These don't get emails (in-app only)
|
||||||
@ -281,9 +275,7 @@ class NotificationService {
|
|||||||
'status_change': null,
|
'status_change': null,
|
||||||
'ai_conclusion_generated': null,
|
'ai_conclusion_generated': null,
|
||||||
'summary_generated': null,
|
'summary_generated': null,
|
||||||
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
|
'workflow_paused': null, // Conditional - handled separately
|
||||||
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
|
|
||||||
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
|
|
||||||
'pause_retriggered': null
|
'pause_retriggered': null
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -298,11 +290,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if email should be sent (admin + user preferences)
|
// Check if email should be sent (admin + user preferences)
|
||||||
// Critical emails: rejection, tat_breach, breach
|
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
|
||||||
const isCriticalEmail = payload.type === 'rejection' ||
|
|
||||||
payload.type === 'tat_breach' ||
|
|
||||||
payload.type === 'breach';
|
|
||||||
const shouldSend = isCriticalEmail
|
|
||||||
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
||||||
: await shouldSendEmail(userId, emailType); // Regular emails
|
: await shouldSendEmail(userId, emailType); // Regular emails
|
||||||
|
|
||||||
@ -370,17 +358,8 @@ 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(
|
||||||
requestDataWithFirstTat,
|
requestData,
|
||||||
initiatorData,
|
initiatorData,
|
||||||
firstApprover ? firstApprover.toJSON() : null
|
firstApprover ? firstApprover.toJSON() : null
|
||||||
);
|
);
|
||||||
@ -428,7 +407,7 @@ class NotificationService {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
status: 'APPROVED'
|
status: 'APPROVED'
|
||||||
},
|
},
|
||||||
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
|
order: [['approvedAt', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
const allLevels = await ApprovalLevel.findAll({
|
const allLevels = await ApprovalLevel.findAll({
|
||||||
@ -442,21 +421,9 @@ 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,
|
||||||
approverData, // Approver who just approved
|
user, // Approver who just approved
|
||||||
initiatorData,
|
initiatorData,
|
||||||
isFinalApproval,
|
isFinalApproval,
|
||||||
nextApprover ? nextApprover.toJSON() : undefined
|
nextApprover ? nextApprover.toJSON() : undefined
|
||||||
@ -470,34 +437,12 @@ class NotificationService {
|
|||||||
where: {
|
where: {
|
||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
status: 'REJECTED'
|
status: 'REJECTED'
|
||||||
},
|
}
|
||||||
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the approver who rejected from the rejected level
|
|
||||||
let approverData = user; // Fallback to user if we can't find the approver
|
|
||||||
if (rejectedLevel) {
|
|
||||||
const approverUser = await User.findByPk((rejectedLevel as any).approverId);
|
|
||||||
if (approverUser) {
|
|
||||||
approverData = approverUser.toJSON();
|
|
||||||
// Add rejection metadata
|
|
||||||
(approverData as any).rejectedAt = (rejectedLevel as any).actionDate;
|
|
||||||
(approverData as any).comments = (rejectedLevel as any).comments;
|
|
||||||
} else {
|
|
||||||
// If user not found, use approver info from the level itself
|
|
||||||
approverData = {
|
|
||||||
userId: (rejectedLevel as any).approverId,
|
|
||||||
displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
|
|
||||||
email: (rejectedLevel as any).approverEmail || 'unknown@royalenfield.com',
|
|
||||||
rejectedAt: (rejectedLevel as any).actionDate,
|
|
||||||
comments: (rejectedLevel as any).comments
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await emailNotificationService.sendRejectionNotification(
|
await emailNotificationService.sendRejectionNotification(
|
||||||
requestData,
|
requestData,
|
||||||
approverData, // Approver who rejected
|
user, // Approver who rejected
|
||||||
initiatorData,
|
initiatorData,
|
||||||
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
|
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
|
||||||
);
|
);
|
||||||
@ -505,100 +450,38 @@ class NotificationService {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tat_reminder':
|
case 'tat_reminder':
|
||||||
case 'threshold1':
|
|
||||||
case 'threshold2':
|
|
||||||
case 'tat_breach':
|
case 'tat_breach':
|
||||||
case 'breach':
|
|
||||||
case 'tat_breach_initiator':
|
|
||||||
{
|
{
|
||||||
// Get the approver from the current level (the one who needs to take action)
|
|
||||||
const currentLevel = await ApprovalLevel.findOne({
|
|
||||||
where: {
|
|
||||||
requestId: payload.requestId,
|
|
||||||
status: 'PENDING'
|
|
||||||
},
|
|
||||||
order: [['levelNumber', 'ASC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get approver data - prefer from level, fallback to user
|
|
||||||
let approverData = user; // Fallback
|
|
||||||
if (currentLevel) {
|
|
||||||
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
|
||||||
if (approverUser) {
|
|
||||||
approverData = approverUser.toJSON();
|
|
||||||
} else {
|
|
||||||
// If user not found, use approver info from the level itself
|
|
||||||
approverData = {
|
|
||||||
userId: (currentLevel as any).approverId,
|
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine threshold percentage based on notification type
|
|
||||||
let thresholdPercentage = 75; // Default
|
|
||||||
if (notificationType === 'threshold1') {
|
|
||||||
thresholdPercentage = 50;
|
|
||||||
} else if (notificationType === 'threshold2') {
|
|
||||||
thresholdPercentage = 75;
|
|
||||||
} else if (notificationType === 'breach' || notificationType === 'tat_breach' || notificationType === 'tat_breach_initiator') {
|
|
||||||
thresholdPercentage = 100;
|
|
||||||
} else if (payload.metadata?.thresholdPercentage) {
|
|
||||||
thresholdPercentage = payload.metadata.thresholdPercentage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract TAT info from metadata or payload
|
// Extract TAT info from metadata or payload
|
||||||
const tatInfo = payload.metadata?.tatInfo || {
|
const tatInfo = payload.metadata?.tatInfo || {
|
||||||
thresholdPercentage: thresholdPercentage,
|
thresholdPercentage: payload.type === 'tat_breach' ? 100 : 75,
|
||||||
timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
|
timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
|
||||||
tatDeadline: payload.metadata?.tatDeadline || new Date(),
|
tatDeadline: payload.metadata?.tatDeadline || new Date(),
|
||||||
assignedDate: payload.metadata?.assignedDate || requestData.createdAt
|
assignedDate: payload.metadata?.assignedDate || requestData.createdAt
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update threshold percentage if not in tatInfo
|
if (notificationType === 'tat_breach') {
|
||||||
if (!payload.metadata?.tatInfo) {
|
|
||||||
tatInfo.thresholdPercentage = thresholdPercentage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle breach notifications (to approver or initiator)
|
|
||||||
if (notificationType === 'breach' || notificationType === 'tat_breach') {
|
|
||||||
// Breach notification to approver
|
|
||||||
if (approverData && approverData.email) {
|
|
||||||
await emailNotificationService.sendTATBreached(
|
await emailNotificationService.sendTATBreached(
|
||||||
requestData,
|
requestData,
|
||||||
approverData,
|
user,
|
||||||
{
|
{
|
||||||
timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining || 'Exceeded',
|
timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining,
|
||||||
tatDeadline: tatInfo.tatDeadline,
|
tatDeadline: tatInfo.tatDeadline,
|
||||||
assignedDate: tatInfo.assignedDate
|
assignedDate: tatInfo.assignedDate
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} else if (notificationType === 'tat_breach_initiator') {
|
|
||||||
// Breach notification to initiator
|
|
||||||
if (initiatorData && initiatorData.email) {
|
|
||||||
// For initiator, we can use a simpler notification or the same breach template
|
|
||||||
// For now, skip email to initiator on breach (they get in-app notification)
|
|
||||||
// Or we could create a separate initiator breach email template
|
|
||||||
logger.info(`[Email] Breach notification to initiator - in-app only for now`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// TAT reminder (threshold1, threshold2, or tat_reminder)
|
|
||||||
if (approverData && approverData.email) {
|
|
||||||
await emailNotificationService.sendTATReminder(
|
await emailNotificationService.sendTATReminder(
|
||||||
requestData,
|
requestData,
|
||||||
approverData,
|
user,
|
||||||
tatInfo
|
tatInfo
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'workflow_resumed':
|
case 'workflow_resumed':
|
||||||
{
|
{
|
||||||
// Get current level to determine approver
|
|
||||||
const currentLevel = await ApprovalLevel.findOne({
|
const currentLevel = await ApprovalLevel.findOne({
|
||||||
where: {
|
where: {
|
||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
@ -607,71 +490,17 @@ class NotificationService {
|
|||||||
order: [['levelNumber', 'ASC']]
|
order: [['levelNumber', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get approver data from current level
|
const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null;
|
||||||
let approverData = null;
|
|
||||||
if (currentLevel) {
|
|
||||||
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
|
||||||
if (approverUser) {
|
|
||||||
approverData = approverUser.toJSON();
|
|
||||||
} else {
|
|
||||||
// Use approver info from level
|
|
||||||
approverData = {
|
|
||||||
userId: (currentLevel as any).approverId,
|
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumedBy = payload.metadata?.resumedBy;
|
const resumedBy = payload.metadata?.resumedBy;
|
||||||
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
|
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
|
||||||
|
|
||||||
// Convert user to plain object if needed
|
|
||||||
const userData = user.toJSON ? user.toJSON() : user;
|
|
||||||
|
|
||||||
// Determine if the recipient is the approver or initiator
|
|
||||||
const isApprover = approverData && userData.userId === approverData.userId;
|
|
||||||
const isInitiator = userData.userId === initiatorData.userId;
|
|
||||||
|
|
||||||
// Ensure user has email
|
|
||||||
if (!userData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Resumed email: user email missing`, {
|
|
||||||
userId: userData.userId,
|
|
||||||
displayName: userData.displayName,
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send appropriate email based on recipient role
|
|
||||||
if (isApprover) {
|
|
||||||
// Recipient is the approver - send approver email
|
|
||||||
await emailNotificationService.sendWorkflowResumed(
|
await emailNotificationService.sendWorkflowResumed(
|
||||||
requestData,
|
requestData,
|
||||||
userData,
|
currentApprover ? currentApprover.toJSON() : user,
|
||||||
initiatorData,
|
initiatorData,
|
||||||
resumedBy,
|
resumedBy,
|
||||||
pauseDuration
|
pauseDuration
|
||||||
);
|
);
|
||||||
} else if (isInitiator) {
|
|
||||||
// Recipient is the initiator - send initiator email
|
|
||||||
await emailNotificationService.sendWorkflowResumedToInitiator(
|
|
||||||
requestData,
|
|
||||||
userData,
|
|
||||||
approverData,
|
|
||||||
resumedBy,
|
|
||||||
pauseDuration
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Recipient is neither approver nor initiator (spectator) - send initiator-style email
|
|
||||||
await emailNotificationService.sendWorkflowResumedToInitiator(
|
|
||||||
requestData,
|
|
||||||
userData,
|
|
||||||
approverData,
|
|
||||||
resumedBy,
|
|
||||||
pauseDuration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -691,153 +520,6 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'approver_skipped':
|
|
||||||
{
|
|
||||||
const skippedLevel = await ApprovalLevel.findOne({
|
|
||||||
where: {
|
|
||||||
requestId: payload.requestId,
|
|
||||||
status: 'SKIPPED'
|
|
||||||
},
|
|
||||||
order: [['levelEndTime', 'DESC'], ['actionDate', '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 'pause_retrigger_request':
|
|
||||||
{
|
|
||||||
// This is when initiator requests approver to resume a paused workflow
|
|
||||||
// Treat it similar to workflow_paused but with different messaging
|
|
||||||
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
|
|
||||||
const resumeDate = payload.metadata?.resumeDate || new Date();
|
|
||||||
|
|
||||||
// Get recipient data (the approver who paused it)
|
|
||||||
let recipientData = user;
|
|
||||||
if (!recipientData || !recipientData.email) {
|
|
||||||
// Try to get from paused level
|
|
||||||
const pausedLevel = await ApprovalLevel.findOne({
|
|
||||||
where: {
|
|
||||||
requestId: payload.requestId,
|
|
||||||
isPaused: true
|
|
||||||
},
|
|
||||||
order: [['levelNumber', 'ASC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pausedLevel) {
|
|
||||||
const approverUser = await User.findByPk((pausedLevel as any).approverId);
|
|
||||||
if (approverUser) {
|
|
||||||
recipientData = approverUser.toJSON();
|
|
||||||
} else {
|
|
||||||
recipientData = {
|
|
||||||
userId: (pausedLevel as any).approverId,
|
|
||||||
displayName: (pausedLevel as any).approverName || 'Unknown Approver',
|
|
||||||
email: (pausedLevel as any).approverEmail || 'unknown@royalenfield.com'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure email exists before sending
|
|
||||||
if (!recipientData || !recipientData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Pause Retrigger Request email: recipient email missing`, {
|
|
||||||
recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use workflow paused email template but with retrigger context
|
|
||||||
await emailNotificationService.sendWorkflowPaused(
|
|
||||||
requestData,
|
|
||||||
recipientData,
|
|
||||||
pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
|
|
||||||
`Initiator has requested to resume this workflow. Please review and resume if appropriate.`,
|
|
||||||
resumeDate
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_paused':
|
|
||||||
{
|
|
||||||
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
|
|
||||||
const resumeDate = payload.metadata?.resumeDate || new Date();
|
|
||||||
|
|
||||||
// Get recipient data - prefer from user, ensure it has email
|
|
||||||
let recipientData = user;
|
|
||||||
if (!recipientData || !recipientData.email) {
|
|
||||||
// If user object doesn't have email, try to get from current level
|
|
||||||
const currentLevel = await ApprovalLevel.findOne({
|
|
||||||
where: {
|
|
||||||
requestId: payload.requestId,
|
|
||||||
status: 'PENDING'
|
|
||||||
},
|
|
||||||
order: [['levelNumber', 'ASC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentLevel) {
|
|
||||||
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
|
||||||
if (approverUser) {
|
|
||||||
recipientData = approverUser.toJSON();
|
|
||||||
} else {
|
|
||||||
// Use approver info from level
|
|
||||||
recipientData = {
|
|
||||||
userId: (currentLevel as any).approverId,
|
|
||||||
displayName: (currentLevel as any).approverName || 'Unknown User',
|
|
||||||
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no current level, try to get from initiator
|
|
||||||
const initiatorUser = await User.findByPk(requestData.initiatorId);
|
|
||||||
if (initiatorUser) {
|
|
||||||
recipientData = initiatorUser.toJSON();
|
|
||||||
} else {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Paused email: no recipient found for request ${payload.requestId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure email exists before sending
|
|
||||||
if (!recipientData.email) {
|
|
||||||
logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
|
|
||||||
recipientData: { userId: recipientData.userId, displayName: recipientData.displayName },
|
|
||||||
requestNumber: requestData.requestNumber
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await emailNotificationService.sendWorkflowPaused(
|
|
||||||
requestData,
|
|
||||||
recipientData,
|
|
||||||
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,23 +182,18 @@ 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) - no email for self-action
|
// Notify the user who paused (confirmation)
|
||||||
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: 'status_change', // Use status_change to avoid email for self-action
|
type: 'workflow_paused',
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: false
|
actionRequired: false
|
||||||
});
|
});
|
||||||
@ -215,12 +210,7 @@ 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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -458,12 +448,6 @@ export class PauseService {
|
|||||||
const isResumedByInitiator = userId === initiatorId;
|
const isResumedByInitiator = userId === initiatorId;
|
||||||
const isResumedByApprover = userId === approverId;
|
const isResumedByApprover = userId === approverId;
|
||||||
|
|
||||||
// Calculate pause duration
|
|
||||||
const pausedAt = (level as any).pausedAt || (workflow as any).pausedAt;
|
|
||||||
const pauseDurationMs = pausedAt ? now.getTime() - new Date(pausedAt).getTime() : 0;
|
|
||||||
const pauseDurationHours = Math.round((pauseDurationMs / (1000 * 60 * 60)) * 100) / 100; // Round to 2 decimal places
|
|
||||||
const pauseDuration = pauseDurationHours > 0 ? `${pauseDurationHours} hours` : 'less than 1 hour';
|
|
||||||
|
|
||||||
// Notify initiator only if someone else resumed (or auto-resume)
|
// Notify initiator only if someone else resumed (or auto-resume)
|
||||||
// Skip if initiator resumed their own request
|
// Skip if initiator resumed their own request
|
||||||
if (!isResumedByInitiator) {
|
if (!isResumedByInitiator) {
|
||||||
@ -475,11 +459,7 @@ 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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -494,15 +474,11 @@ 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) - no email for self-action
|
// Send confirmation to the user who resumed (if manual resume)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await notificationService.sendToUsers([userId], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'Workflow Resumed Successfully',
|
title: 'Workflow Resumed Successfully',
|
||||||
@ -510,7 +486,7 @@ export class PauseService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'status_change', // Use status_change to avoid email for self-action
|
type: 'workflow_resumed',
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: isResumedByApprover
|
actionRequired: isResumedByApprover
|
||||||
});
|
});
|
||||||
|
|||||||
@ -221,31 +221,13 @@ 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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3092,16 +3074,8 @@ export class WorkflowService {
|
|||||||
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
||||||
return refreshed;
|
return refreshed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
logger.error(`Failed to update workflow ${requestId}:`, error);
|
||||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
throw new Error('Failed to update workflow');
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user