Compare commits
3 Commits
main
...
custom_pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c5187232 | |||
| b5f3513bd1 | |||
| 8753c9477d |
File diff suppressed because one or more lines are too long
@ -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};
|
||||
64
build/assets/index-BmeYOVo5.js
Normal file
64
build/assets/index-BmeYOVo5.js
Normal file
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
2
build/assets/ui-vendor-3qilyUHW.js
Normal file
2
build/assets/ui-vendor-3qilyUHW.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
116
src/emailtemplates/summaryShared.template.ts
Normal file
116
src/emailtemplates/summaryShared.template.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user