Compare commits

...

3 Commits

19 changed files with 380 additions and 83 deletions

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{a as s}from"./index-yOqi1S1C.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CX5oLBI_.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.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

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

View File

@ -13,15 +13,15 @@
<!-- 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-yOqi1S1C.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<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">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CX5oLBI_.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-3qilyUHW.js">
<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-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-XBJXaMj2.css">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-Bmv9jJki.js">
<link rel="stylesheet" crossorigin href="/assets/index-DCUCLUmo.css">
</head>
<body>

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 || ''];
@ -598,7 +599,7 @@ class NotificationService {
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: payload.requestId,
status: 'PENDING'
status: 'IN_PROGRESS'
},
order: [['levelNumber', 'ASC']]
});
@ -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);