added templete for the additional document and notification enhnced for the aditional documents added to respective users

This commit is contained in:
laxmanhalaki 2026-01-05 19:12:28 +05:30
parent 970e7fec98
commit 87273ae044
23 changed files with 756 additions and 106 deletions

View File

@ -0,0 +1,2 @@
import{a as s}from"./index-C3FB5mWy.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-BABO-alX.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-e5hkQUam.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
{"version":3,"file":"conclusionApi-BABO-alX.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}

View File

@ -1,2 +0,0 @@
import{a as t}from"./index-D6fz6zpd.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-e5hkQUam.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,2 +0,0 @@
import{g as s}from"./index-D6fz6zpd.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
//# sourceMappingURL=requestNavigation-DX8BLchx.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"requestNavigation-DX8BLchx.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}

View File

@ -52,7 +52,7 @@
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-D6fz6zpd.js"></script>
<script type="module" crossorigin src="/assets/index-C3FB5mWy.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
@ -60,7 +60,7 @@
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js">
<link rel="stylesheet" crossorigin href="/assets/index-CxZ05Q0s.css">
<link rel="stylesheet" crossorigin href="/assets/index-DWmoW2h6.css">
</head>
<body>
<div id="root"></div>

View File

@ -79,7 +79,7 @@ export class ConclusionController {
const workNotes = await WorkNote.findAll({
where: { requestId },
order: [['createdAt', 'ASC']],
limit: 20 // Last 20 work notes
limit: 20 // Last 20 work notes - keep full context for better conclusions
});
const documents = await Document.findAll({
@ -90,7 +90,7 @@ export class ConclusionController {
const activities = await Activity.findAll({
where: { requestId },
order: [['createdAt', 'ASC']],
limit: 50 // Last 50 activities
limit: 50 // Last 50 activities - keep full context for better conclusions
});
// Build context object

View File

@ -5,9 +5,14 @@ import fs from 'fs';
import { Document } from '@models/Document';
import { User } from '@models/User';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { Participant } from '@models/Participant';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { Op } from 'sequelize';
import { ResponseHandler } from '@utils/responseHandler';
import { activityService } from '@services/activity.service';
import { gcsStorageService } from '@services/gcsStorage.service';
import { emailNotificationService } from '@services/emailNotification.service';
import { notificationService } from '@services/notification.service';
import type { AuthenticatedRequest } from '../types/express';
import { getRequestMetadata } from '@utils/requestUtils';
import { getConfigNumber, getConfigValue } from '@services/configReader.service';
@ -291,6 +296,205 @@ export class DocumentController {
userAgent: requestMeta.userAgent
});
// Send notifications for additional document added
try {
const initiatorId = (workflowRequest as any).initiatorId || (workflowRequest as any).initiator_id;
const isInitiator = userId === initiatorId;
// Get all participants (spectators)
const spectators = await Participant.findAll({
where: {
requestId,
participantType: 'SPECTATOR'
},
include: [{
model: User,
as: 'user',
attributes: ['userId', 'email', 'displayName']
}]
});
// Get current approver (pending or in-progress approval level)
const currentApprovalLevel = await ApprovalLevel.findOne({
where: {
requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
},
order: [['levelNumber', 'ASC']],
include: [{
model: User,
as: 'approver',
attributes: ['userId', 'email', 'displayName']
}]
});
logWithContext('info', 'Current approver lookup for document notification', {
requestId,
currentApprovalLevelFound: !!currentApprovalLevel,
approverUserId: currentApprovalLevel ? ((currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver)?.userId : null,
isInitiator
});
// Determine who to notify based on who uploaded
const recipientsToNotify: Array<{ userId: string; email: string; displayName: string }> = [];
if (isInitiator) {
// Initiator added → notify spectators and current approver
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Check if uploader is a spectator
const uploaderParticipant = await Participant.findOne({
where: {
requestId,
userId,
participantType: 'SPECTATOR'
}
});
if (uploaderParticipant) {
// Spectator added → notify initiator and current approver
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Approver added → notify initiator and spectators
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
}
}
// Send notifications (email, in-app, and web-push)
const requestData = {
requestNumber: requestNumber,
requestId: requestId,
title: (workflowRequest as any).title || 'Request'
};
// Prepare user IDs for in-app and web-push notifications
const recipientUserIds = recipientsToNotify.map(r => r.userId);
// Send in-app and web-push notifications
if (recipientUserIds.length > 0) {
try {
await notificationService.sendToUsers(
recipientUserIds,
{
title: 'Additional Document Added',
body: `${uploaderName} added "${file.originalname}" to ${requestNumber}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`,
type: 'document_added',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
documentName: file.originalname,
fileSize: file.size,
addedByName: uploaderName,
source: 'Documents Tab'
}
}
);
logWithContext('info', 'In-app and web-push notifications sent for additional document', {
requestId,
documentName: file.originalname,
recipientsCount: recipientUserIds.length
});
} catch (notifyError) {
logWithContext('error', 'Failed to send in-app/web-push notifications for additional document', {
requestId,
error: notifyError instanceof Error ? notifyError.message : 'Unknown error'
});
}
}
// Send email notifications
for (const recipient of recipientsToNotify) {
await emailNotificationService.sendAdditionalDocumentAdded(
requestData,
recipient,
{
documentName: file.originalname,
fileSize: file.size,
addedByName: uploaderName,
source: 'Documents Tab'
}
);
}
logWithContext('info', 'Additional document notifications sent', {
requestId,
documentName: file.originalname,
recipientsCount: recipientsToNotify.length,
isInitiator
});
} catch (notifyError) {
// Don't fail document upload if notifications fail
logWithContext('error', 'Failed to send additional document notifications', {
requestId,
error: notifyError instanceof Error ? notifyError.message : 'Unknown error'
});
}
ResponseHandler.success(res, doc, 'File uploaded', 201);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';

View File

@ -0,0 +1,152 @@
/**
* Additional Document Added Email Template
*
* Sent when a document is added to a request by:
* - Initiator Notifies spectators and current approver
* - Spectator Notifies initiator and current approver
* - Approver Notifies initiator and spectators
*/
import { AdditionalDocumentAddedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getAdditionalDocumentAddedEmail(data: AdditionalDocumentAddedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Additional Document Added</title>
${getResponsiveStyles()}
</head>
<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">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Additional Document Added',
...HeaderStyles.info
}))}
<!-- Content -->
<tr>
<td class="email-content">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong>${data.addedByName}</strong> has added an additional document to the following request:
</p>
<!-- 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">
<tr>
<td class="detail-box" style="padding: 30px;">
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Details</h2>
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.requestNumber || data.requestId}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Title:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.requestTitle || 'N/A'}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Document Name:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.documentName}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>File Size:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.fileSize}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Added By:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.addedByName}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Added On:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.addedDate} at ${data.addedTime}
</td>
</tr>
${data.source ? `
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Source:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.source}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
<!-- Information Box -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What This Means</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
A new document has been added to this request. Please review the document in the request details page to stay updated with the latest information.
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -31,7 +31,8 @@ export enum EmailNotificationType {
ACTIVITY_CREATED = 'activity_created',
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
EINVOICE_GENERATED = 'einvoice_generated',
CREDIT_NOTE_SENT = 'credit_note_sent'
CREDIT_NOTE_SENT = 'credit_note_sent',
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added'
}
/**

View File

@ -35,4 +35,5 @@ export { getActivityCreatedEmail } from './activityCreated.template';
export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmitted.template';
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template';
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';

View File

@ -236,3 +236,13 @@ export interface DealerProposalRequiredData extends BaseEmailData {
dueDate?: string;
}
export interface AdditionalDocumentAddedData extends BaseEmailData {
documentName: string;
fileSize: string;
addedByName: string;
addedDate: string;
addedTime: string;
requestNumber?: string;
source?: string; // 'Documents Tab' or 'Work Notes'
}

View File

@ -99,10 +99,11 @@ class AIService {
try {
// Get the generative model
// Increase maxOutputTokens to handle longer conclusions (up to ~4000 tokens ≈ 3000 words)
const generativeModel = this.vertexAI.getGenerativeModel({
model: this.model,
generationConfig: {
maxOutputTokens: 2048,
maxOutputTokens: 4096, // Increased from 2048 to handle longer conclusions
temperature: 0.3,
},
});
@ -154,6 +155,19 @@ class AIService {
// Extract text from response
const text = candidate.content?.parts?.[0]?.text || '';
// Handle MAX_TOKENS finish reason - accept whatever response we got
// We trust the AI's response - no truncation on our side
if (candidate.finishReason === 'MAX_TOKENS' && text) {
// Accept the response as-is - AI was instructed to stay within limits
// If it hit the limit, we still use what we got (no truncation on our side)
logger.info('[AI Service] Vertex AI response hit token limit, but content received is preserved as-is:', {
textLength: text.length,
finishReason: candidate.finishReason
});
// Return the response without any truncation - trust what AI generated
return text;
}
if (!text) {
// Log detailed response structure for debugging
logger.error('[AI Service] Empty text in Vertex AI response:', {
@ -169,7 +183,7 @@ class AIService {
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.');
throw new Error('Vertex AI response was truncated due to token limit. The prompt may be too long or the response limit was exceeded.');
} else if (candidate.finishReason === 'RECITATION') {
throw new Error('Vertex AI blocked the response due to recitation concerns.');
} else {
@ -254,9 +268,10 @@ class AIService {
const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000');
const maxLength = parseInt(maxLengthStr || '2000', 10);
// Log length (no trimming - preserve complete AI-generated content)
// Trust AI's response - do not truncate anything
// AI is instructed to stay within limit, but we accept whatever it generates
if (remarkText.length > maxLength) {
logger.warn(`[AI Service] ⚠️ AI exceeded suggested limit (${remarkText.length} > ${maxLength}). Content preserved to avoid incomplete information.`);
logger.info(`[AI Service] AI generated ${remarkText.length} characters (suggested limit: ${maxLength}). Full content preserved as-is.`);
}
// Extract key points (look for bullet points or numbered items)
@ -336,8 +351,9 @@ class AIService {
.map((wn: any) => `- ${wn.userName}: "${wn.message.substring(0, 150)}${wn.message.length > 150 ? '...' : ''}"`)
.join('\n');
// Summarize documents
// Summarize documents (limit to reduce token usage)
const documentSummary = documents
.slice(0, 10) // Limit to first 10 documents
.map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`)
.join('\n');
@ -382,15 +398,17 @@ ${isRejected
- Sounds natural and human-written (not AI-generated)`}
**CRITICAL CHARACTER LIMIT - STRICT REQUIREMENT:**
- Your response MUST be EXACTLY within ${maxLength} characters (not words, CHARACTERS including spaces)
- Count your characters carefully before responding
- Your response MUST stay within ${maxLength} characters (not words, CHARACTERS including spaces including HTML tags)
- This is a HARD LIMIT - you must count your characters and ensure your complete response fits within ${maxLength} characters
- Count your characters carefully before responding - include all HTML tags in your count
- If you have too much content, PRIORITIZE the most important information:
1. Final decision (approved/rejected)
2. Key approvers and their decisions
3. Critical TAT breaches (if any)
4. Brief summary of the request
- OMIT less important details to fit within the limit rather than exceeding it
- Better to be concise than to exceed the limit
- Better to be concise and complete within the limit than to exceed it
- IMPORTANT: Generate your complete response within this limit - do not generate partial content that exceeds the limit
**WRITING GUIDELINES:**
- Be concise and direct - every word must add value

View File

@ -2385,29 +2385,30 @@ export class DashboardService {
}
// Calculate aggregated stats using approval_levels directly
// Count ALL approval levels assigned to this approver (like the All Requests pattern)
// IMPORTANT: totalApproved counts DISTINCT requests, not approval levels
// This ensures a single request with multiple actions (e.g., dealer proposal + completion) is counted once
// TAT Compliance includes: completed + pending breached + levels from closed workflows
const statsQuery = `
SELECT
COUNT(DISTINCT al.level_id) as totalApproved,
COUNT(DISTINCT al.request_id) as totalApproved,
SUM(CASE WHEN al.status = 'APPROVED' THEN 1 ELSE 0 END) as approvedCount,
SUM(CASE WHEN al.status = 'REJECTED' THEN 1 ELSE 0 END) as rejectedCount,
SUM(CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') THEN 1 ELSE 0 END) as pendingCount,
SUM(CASE
COUNT(DISTINCT CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') THEN al.request_id END) as pendingCount,
COUNT(DISTINCT CASE
WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED')
AND (al.tat_breached = false
OR (al.tat_breached IS NULL AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours < al.tat_hours))
THEN 1 ELSE 0
THEN al.request_id
END) as withinTatCount,
SUM(CASE
COUNT(DISTINCT CASE
WHEN ((al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED') AND al.tat_breached = true)
OR (al.status IN ('PENDING', 'IN_PROGRESS') AND al.tat_breached = true)
THEN 1 ELSE 0
THEN al.request_id
END) as breachedCount,
SUM(CASE
COUNT(DISTINCT CASE
WHEN al.status IN ('PENDING', 'IN_PROGRESS')
AND al.tat_breached = true
THEN 1 ELSE 0
THEN al.request_id
END) as pendingBreachedCount,
AVG(CASE
WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED')
@ -2416,7 +2417,7 @@ export class DashboardService {
THEN al.elapsed_hours
ELSE NULL
END) as avgResponseHours,
SUM(CASE WHEN wf.status = 'CLOSED' THEN 1 ELSE 0 END) as closedCount
COUNT(DISTINCT CASE WHEN wf.status = 'CLOSED' THEN al.request_id END) as closedCount
FROM approval_levels al
INNER JOIN workflow_requests wf ON al.request_id = wf.request_id
WHERE al.approver_id = :approverId

View File

@ -27,6 +27,7 @@ import {
getCompletionDocumentsSubmittedEmail,
getEInvoiceGeneratedEmail,
getCreditNoteSentEmail,
getAdditionalDocumentAddedEmail,
getViewDetailsLink,
CompanyInfo,
RequestCreatedData,
@ -48,6 +49,7 @@ import {
CompletionDocumentsSubmittedData,
EInvoiceGeneratedData,
CreditNoteSentData,
AdditionalDocumentAddedData,
ApprovalChainItem
} from '../emailtemplates';
import {
@ -1372,6 +1374,71 @@ export class EmailNotificationService {
throw error;
}
}
/**
* 18. Send Additional Document Added Email
*/
async sendAdditionalDocumentAdded(
requestData: any,
recipientData: any,
documentData: {
documentName: string;
fileSize: number;
addedByName: string;
source?: string; // 'Documents Tab' or 'Work Notes'
}
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.ADDITIONAL_DOCUMENT_ADDED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Additional Document Added for ${recipientData.email}`);
return;
}
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const data: AdditionalDocumentAddedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
documentName: documentData.documentName,
fileSize: formatFileSize(documentData.fileSize),
addedByName: documentData.addedByName,
addedDate: this.formatDate(new Date()),
addedTime: this.formatTime(new Date()),
requestNumber: requestData.requestNumber,
source: documentData.source,
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name
};
const html = getAdditionalDocumentAddedEmail(data);
const subject = `[${requestData.requestNumber}] Additional Document Added - ${documentData.documentName}`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Additional Document Added Email Preview: ${result.previewUrl}`);
}
logger.info(`✅ Additional Document Added email sent to ${recipientData.email} for request ${requestData.requestNumber}`);
} catch (error) {
logger.error(`Failed to send Additional Document Added email:`, error);
// Don't throw - email failure shouldn't block document upload
}
}
}
// Singleton instance

View File

@ -3,8 +3,11 @@ import { WorkNote } from '@models/WorkNote';
import { WorkNoteAttachment } from '@models/WorkNoteAttachment';
import { Participant } from '@models/Participant';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { User } from '@models/User';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { activityService } from './activity.service';
import { notificationService } from './notification.service';
import { emailNotificationService } from './emailNotification.service';
import { gcsStorageService } from './gcsStorage.service';
import logger from '@utils/logger';
import fs from 'fs';
@ -149,6 +152,202 @@ export class WorkNoteService {
isDownloadable: (attachment as any).isDownloadable
});
}
// Send notifications for additional document added via work notes
if (attachments.length > 0) {
try {
const workflow = await WorkflowRequest.findOne({ where: { requestId } });
if (workflow) {
const initiatorId = (workflow as any).initiatorId || (workflow as any).initiator_id;
const isInitiator = user.userId === initiatorId;
// Get all participants (spectators)
const spectators = await Participant.findAll({
where: {
requestId,
participantType: 'SPECTATOR'
},
include: [{
model: User,
as: 'user',
attributes: ['userId', 'email', 'displayName']
}]
});
// Get current approver (pending or in-progress approval level)
const currentApprovalLevel = await ApprovalLevel.findOne({
where: {
requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
},
order: [['levelNumber', 'ASC']],
include: [{
model: User,
as: 'approver',
attributes: ['userId', 'email', 'displayName']
}]
});
// Determine who to notify based on who uploaded
const recipientsToNotify: Array<{ userId: string; email: string; displayName: string }> = [];
if (isInitiator) {
// Initiator added → notify spectators and current approver
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== user.userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== user.userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Check if uploader is a spectator
const uploaderParticipant = await Participant.findOne({
where: {
requestId,
userId: user.userId,
participantType: 'SPECTATOR'
}
});
if (uploaderParticipant) {
// Spectator added → notify initiator and current approver
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== user.userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== user.userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Approver added → notify initiator and spectators
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== user.userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== user.userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
}
}
// Send notifications (email, in-app, and web-push)
const requestNumber = (workflow as any).requestNumber || requestId;
const requestData = {
requestNumber: requestNumber,
requestId: requestId,
title: (workflow as any).title || 'Request'
};
// Prepare user IDs for in-app and web-push notifications
const recipientUserIds = recipientsToNotify.map(r => r.userId);
// Send in-app and web-push notifications for each attachment
if (recipientUserIds.length > 0 && attachments.length > 0) {
try {
for (const attachment of attachments) {
await notificationService.sendToUsers(
recipientUserIds,
{
title: 'Additional Document Added',
body: `${user.name || 'User'} added "${attachment.fileName}" to ${requestNumber}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`,
type: 'document_added',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
documentName: attachment.fileName,
fileSize: attachment.fileSize,
addedByName: user.name || 'User',
source: 'Work Notes'
}
}
);
}
logger.info('[WorkNote] In-app and web-push notifications sent for additional documents', {
requestId,
attachmentsCount: attachments.length,
recipientsCount: recipientUserIds.length
});
} catch (notifyError) {
logger.error('[WorkNote] Failed to send in-app/web-push notifications for additional documents:', notifyError);
}
}
// Send email notifications for each attachment
for (const attachment of attachments) {
for (const recipient of recipientsToNotify) {
await emailNotificationService.sendAdditionalDocumentAdded(
requestData,
recipient,
{
documentName: attachment.fileName,
fileSize: attachment.fileSize,
addedByName: user.name || 'User',
source: 'Work Notes'
}
);
}
}
logger.info('[WorkNote] Additional document notifications sent', {
requestId,
attachmentsCount: attachments.length,
recipientsCount: recipientsToNotify.length,
isInitiator
});
}
} catch (notifyError) {
// Don't fail work note creation if notifications fail
logger.error('[WorkNote] Failed to send additional document notifications:', notifyError);
}
}
}
// Log activity for work note