shared summary notify and template added for it current approver allowed to add approver

This commit is contained in:
laxmanhalaki 2026-03-17 19:52:06 +05:30
parent b5f3513bd1
commit 02c5187232
12 changed files with 320 additions and 23 deletions

View File

@ -1 +1 @@
import{a as s}from"./index-hYhqmPqT.js";import"./radix-vendor-GwO0o3Qg.js";import"./charts-vendor-waDbLeao.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-3qilyUHW.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-Bmv9jJki.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};
import{a as s}from"./index-BmeYOVo5.js";import"./radix-vendor-GwO0o3Qg.js";import"./charts-vendor-waDbLeao.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-3qilyUHW.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-Bmv9jJki.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};

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,7 @@
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-hYhqmPqT.js"></script>
<script type="module" crossorigin src="/assets/index-BmeYOVo5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-waDbLeao.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-GwO0o3Qg.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">

View File

@ -32,7 +32,8 @@ export enum EmailNotificationType {
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
EINVOICE_GENERATED = 'einvoice_generated',
CREDIT_NOTE_SENT = 'credit_note_sent',
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added'
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added',
SUMMARY_SHARED = 'summary_shared'
}
/**

View File

@ -36,4 +36,5 @@ export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmi
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template';
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';
export { getSummarySharedEmail } from './summaryShared.template';

View File

@ -0,0 +1,116 @@
/**
* Summary Shared Email Template
*/
import { SummarySharedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getSummarySharedEmail(data: SummarySharedData): 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>Request Summary Shared</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">
${getEmailHeader(getBrandedHeader({
title: 'Summary Shared',
...HeaderStyles.info
}))}
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #007bff;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong>${data.sharedByName}</strong> has shared a summary for request <strong>${data.requestNumber}</strong> with you.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestNumber}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle || 'N/A'}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Shared On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.sharedDate} at ${data.sharedTime}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestType}
</td>
</tr>
</table>
</td>
</tr>
</table>
<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 Shared Summary
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
You have been granted access to view the summary and conclusion of this request.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -137,6 +137,15 @@ export interface RequestClosedData extends BaseEmailData {
documentsCount: number;
}
export interface SummarySharedData extends BaseEmailData {
initiatorName: string;
sharedByName: string;
sharedDate: string;
sharedTime: string;
requestNumber: string;
requestType: string;
}
export interface SpectatorAddedData extends BaseEmailData {
spectatorName: string;
addedByName?: string;

View File

@ -256,3 +256,68 @@ export function hasAdminAccess(user: any): boolean {
}
/**
* Middleware: requireApproverAdditionRights
*
* Restricts adding new approvers to:
* 1. The Initiator
* 2. The Current Active Approver (status IN_PROGRESS)
* 3. Only for CUSTOM/NON_TEMPLATIZED workflows
*/
export function requireApproverAdditionRights() {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const userId: string | undefined = (req as any).user?.userId || (req as any).user?.id;
const requestIdentifier: string | undefined = (req.params as any)?.id;
if (!userId || !requestIdentifier) {
return res.status(403).json({ success: false, error: 'Forbidden' });
}
// Resolve requestIdentifier to actual requestId (UUID)
const workflow = await findWorkflowByIdentifier(requestIdentifier);
if (!workflow) {
return res.status(404).json({ success: false, error: 'Workflow not found' });
}
// 1. Scope Check: Exclude Dealer Claims and Template-based workflows
const templateType = (workflow as any).templateType;
const workflowType = (workflow as any).workflowType;
const isCustom = templateType === 'CUSTOM' || workflowType === 'NON_TEMPLATIZED';
if (!isCustom) {
return res.status(403).json({
success: false,
error: 'Dynamic approver addition is not allowed for this workflow type.'
});
}
// 2. Permission Check: Initiator
if ((workflow as any).initiatorId === userId) {
return next();
}
// 3. Permission Check: Current Active Approver
const { ApprovalLevel } = await import('@models/ApprovalLevel');
const currentApprover = await ApprovalLevel.findOne({
where: {
requestId: (workflow as any).requestId,
approverId: userId,
status: 'IN_PROGRESS'
}
});
if (currentApprover) {
return next();
}
return res.status(403).json({
success: false,
error: 'Only the initiator or the current active approver can add new approvers.'
});
} catch (err) {
console.error('❌ Approver addition rights check failed:', err);
return res.status(500).json({ success: false, error: 'Authorization check failed' });
}
};
}

View File

@ -7,7 +7,7 @@ import { validateBody, validateParams } from '../middlewares/validate.middleware
import { createWorkflowSchema, updateWorkflowSchema, workflowParamsSchema } from '../validators/workflow.validator';
import { approvalActionSchema, approvalParamsSchema } from '../validators/approval.validator';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { requireParticipantTypes } from '../middlewares/authorization.middleware';
import { requireParticipantTypes, requireApproverAdditionRights } from '../middlewares/authorization.middleware';
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
@ -737,6 +737,7 @@ router.get('/work-notes/attachments/:attachmentId/download',
router.post('/:id/participants/approver',
authenticateToken,
validateParams(workflowParamsSchema),
requireApproverAdditionRights(),
asyncHandler(async (req: any, res: Response) => {
const workflowService = new WorkflowService();
const wf = await (workflowService as any).findWorkflowByIdentifier(req.params.id);
@ -814,7 +815,7 @@ router.post('/:id/approvals/:levelId/skip',
// Add approver at specific level with level shifting
router.post('/:id/approvers/at-level',
authenticateToken,
requireParticipantTypes(['INITIATOR', 'APPROVER']), // Only initiator or approvers can add new approvers
requireApproverAdditionRights(),
validateParams(workflowParamsSchema),
asyncHandler(async (req: any, res: Response) => {
const workflowService = new WorkflowService();

View File

@ -20,6 +20,7 @@ import {
getSpectatorAddedEmail,
getApproverSkippedEmail,
getRequestClosedEmail,
getSummarySharedEmail,
getDealerProposalSubmittedEmail,
getDealerProposalRequiredEmail,
getDealerCompletionRequiredEmail,
@ -44,6 +45,7 @@ import {
SpectatorAddedData,
ApproverSkippedData,
RequestClosedData,
SummarySharedData,
DealerProposalSubmittedData,
DealerProposalRequiredData,
ActivityCreatedData,
@ -1440,6 +1442,60 @@ export class EmailNotificationService {
// Don't throw - email failure shouldn't block document upload
}
}
/**
* 11. Send Summary Shared Email
*/
async sendSummaryShared(
requestData: any,
recipientData: any,
initiatorData: any,
sharedByData: any,
sharedSummaryId?: string
): Promise<void> {
try {
const canSend = await shouldSendEmail(
recipientData.userId,
EmailNotificationType.SUMMARY_SHARED
);
if (!canSend) {
logger.info(`Email skipped (preferences): Summary Shared for ${recipientData.email}`);
return;
}
const data: SummarySharedData = {
recipientName: recipientData.displayName || recipientData.email,
requestId: requestData.requestNumber,
requestTitle: requestData.title,
requestNumber: requestData.requestNumber,
initiatorName: initiatorData.displayName || initiatorData.email,
sharedByName: sharedByData.displayName || sharedByData.email,
sharedDate: this.formatDate(new Date()),
sharedTime: this.formatTime(new Date()),
requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType),
viewDetailsLink: sharedSummaryId
? `${this.frontendUrl}/shared-summaries/${sharedSummaryId}`
: `${this.frontendUrl}/request/${requestData.requestNumber}/summary`,
companyName: CompanyInfo.name
};
const html = getSummarySharedEmail(data);
const subject = `${requestData.requestNumber} - ${requestData.title} - Request Summary Shared with You`;
const result = await emailService.sendEmail({
to: recipientData.email,
subject,
html
});
if (result.previewUrl) {
logger.info(`📧 Summary Shared Email Preview: ${result.previewUrl}`);
}
} catch (error) {
logger.error('Failed to send Summary Shared email:', error);
}
}
}
// Singleton instance

View File

@ -25,7 +25,7 @@ interface NotificationPayload {
const appDomain = process.env.APP_DOMAIN || 'royalenfield.com';
class NotificationService {
export class NotificationService {
private userIdToSubscriptions: Map<string, PushSubscription[]> = new Map();
configure(vapidPublicKey?: string, vapidPrivateKey?: string, mailto?: string) {
@ -307,7 +307,8 @@ class NotificationService {
'einvoice_generated': EmailNotificationType.EINVOICE_GENERATED,
'credit_note_sent': EmailNotificationType.CREDIT_NOTE_SENT,
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
'pause_retriggered': null
'pause_retriggered': null,
'summary_shared': EmailNotificationType.SUMMARY_SHARED
};
const emailType = emailTypeMap[payload.type || ''];
@ -808,6 +809,22 @@ class NotificationService {
}
break;
case 'summary_shared':
{
const sharedByUserId = payload.metadata?.sharedBy;
const sharedSummaryId = payload.metadata?.sharedSummaryId;
const sharedByUser = sharedByUserId ? await User.findByPk(sharedByUserId) : null;
await emailNotificationService.sendSummaryShared(
requestData,
user, // Recipient
initiatorData, // Original request initiator
sharedByUser ? sharedByUser.toJSON() : { displayName: 'System' },
sharedSummaryId
);
}
break;
case 'pause_retrigger_request':
{
// This is when initiator requests approver to resume a paused workflow

View File

@ -3,6 +3,7 @@ import '@models/index'; // Ensure associations are loaded
import { Op } from 'sequelize';
import logger from '@utils/logger';
import dayjs from 'dayjs';
import { NotificationService } from './notification.service';
export class SummaryService {
/**
@ -674,6 +675,36 @@ export class SummaryService {
}
logger.info(`[Summary] Shared summary ${summaryId} with ${sharedSummaries.length} users`);
// 🔔 TRIGGER NOTIFICATIONS
try {
const notificationService = new NotificationService();
const request = await WorkflowRequest.findByPk((summary as any).requestId);
const sharedByUser = await User.findByPk(sharedBy);
if (request && sharedSummaries.length > 0) {
for (const shared of sharedSummaries) {
await notificationService.sendToUsers([shared.sharedWith], {
title: 'Summary Shared',
body: `${sharedByUser?.displayName || 'Someone'} shared a summary for request ${request.requestNumber} with you.`,
requestId: (request as any).requestId,
requestNumber: (request as any).requestNumber,
type: 'summary_shared',
url: `/shared-summaries/${shared.sharedSummaryId}`,
metadata: {
sharedBy,
summaryId,
sharedSummaryId: shared.sharedSummaryId
}
});
}
logger.info(`[Summary] Triggered individual notifications for ${sharedSummaries.length} users`);
}
} catch (notifyError) {
logger.error('[Summary] Failed to trigger notifications for shared summary:', notifyError);
// Don't throw - sharing was successful
}
return sharedSummaries;
} catch (error) {
logger.error(`[Summary] Failed to share summary ${summaryId}:`, error);