dealer claim history table recreated and enhanced the approval history versioning

This commit is contained in:
laxmanhalaki 2026-01-19 20:05:48 +05:30
parent e3bda6df15
commit ee56dc8386
15 changed files with 607 additions and 502 deletions

View File

@ -1,2 +1,2 @@
import{a as s}from"./index-r7_wqQ4e.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-KevyU1nl.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}; import{a as s}from"./index-F9w_cZ47.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-sjs6YRoy.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-DG9VB6DM.js.map //# sourceMappingURL=conclusionApi-BIX8LEl5.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-DG9VB6DM.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"} {"version":3,"file":"conclusionApi-BIX8LEl5.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"}

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

File diff suppressed because one or more lines are too long

View File

@ -52,15 +52,15 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-r7_wqQ4e.js"></script> <script type="module" crossorigin src="/assets/index-F9w_cZ47.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js"> <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/radix-vendor-DIkYAdWy.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-KevyU1nl.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-sjs6YRoy.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.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/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js">
<link rel="stylesheet" crossorigin href="/assets/index-DMIvOF3n.css"> <link rel="stylesheet" crossorigin href="/assets/index-CPRbj7YF.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,38 +1,29 @@
import { QueryInterface, DataTypes } from 'sequelize'; import { QueryInterface, DataTypes } from 'sequelize';
export const up = async (queryInterface: QueryInterface) => { export const up = async (queryInterface: QueryInterface) => {
// 1. Drop the old dealer_claim_history table if it exists // 1. Drop and recreate the enum type for snapshot_type to ensure all values are included
const tables = await queryInterface.showAllTables(); // This ensures APPROVE is always present when table is recreated
if (tables.includes('dealer_claim_history')) { // Note: Table should be dropped manually before running this migration
await queryInterface.dropTable('dealer_claim_history');
}
// 2. Create or update the enum type for snapshot_type
// Check if enum exists, if not create it, if yes update it
try { try {
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN -- Drop enum if it exists (cascade will handle any dependencies)
CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'); IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN
ELSE DROP TYPE IF EXISTS enum_dealer_claim_history_snapshot_type CASCADE;
-- Check if APPROVE exists in the enum
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'APPROVE'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type')
) THEN
ALTER TYPE enum_dealer_claim_history_snapshot_type ADD VALUE 'APPROVE';
END IF;
END IF; END IF;
-- Create enum with all values including APPROVE
CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE');
END $$; END $$;
`); `);
} catch (error) { } catch (error) {
// If enum creation fails, try to continue (might already exist) // If enum creation fails, log error but continue
console.warn('Enum creation/update warning:', error); console.error('Enum creation error:', error);
throw error;
} }
// 3. Create new simplified level-based dealer_claim_history table // 2. Create new simplified level-based dealer_claim_history table
await queryInterface.createTable('dealer_claim_history', { await queryInterface.createTable('dealer_claim_history', {
history_id: { history_id: {
type: DataTypes.UUID, type: DataTypes.UUID,
@ -131,6 +122,13 @@ export const up = async (queryInterface: QueryInterface) => {
}; };
export const down = async (queryInterface: QueryInterface) => { export const down = async (queryInterface: QueryInterface) => {
// Drop the new table // Note: Table should be dropped manually
await queryInterface.dropTable('dealer_claim_history'); // Drop the enum type
try {
await queryInterface.sequelize.query(`
DROP TYPE IF EXISTS enum_dealer_claim_history_snapshot_type CASCADE;
`);
} catch (error) {
console.warn('Enum drop warning:', error);
}
}; };

View File

@ -12,6 +12,7 @@ import { ApprovalLevel } from '../models/ApprovalLevel';
import { Participant } from '../models/Participant'; import { Participant } from '../models/Participant';
import { User } from '../models/User'; import { User } from '../models/User';
import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory'; import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory';
import { Document } from '../models/Document';
import { WorkflowService } from './workflow.service'; import { WorkflowService } from './workflow.service';
import { DealerClaimApprovalService } from './dealerClaimApproval.service'; import { DealerClaimApprovalService } from './dealerClaimApproval.service';
import { generateRequestNumber } from '../utils/helpers'; import { generateRequestNumber } from '../utils/helpers';
@ -1194,8 +1195,8 @@ export class DealerClaimService {
if (!actualDealerUserId) { if (!actualDealerUserId) {
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
if (claimDetails?.dealerEmail) { if (claimDetails?.dealerEmail) {
const dealerUser = await User.findOne({ const dealerUser = await User.findOne({
where: { email: claimDetails.dealerEmail } where: { email: claimDetails.dealerEmail }
}); });
actualDealerUserId = dealerUser?.userId || null; actualDealerUserId = dealerUser?.userId || null;
} }
@ -1292,41 +1293,39 @@ export class DealerClaimService {
if (dealerProposalLevel) { if (dealerProposalLevel) {
// Use dealer's comment if provided, otherwise use default message // Use dealer's comment if provided, otherwise use default message
const approvalComment = proposalData.dealerComments?.trim() const approvalComment = proposalData.dealerComments?.trim()
? proposalData.dealerComments.trim() ? proposalData.dealerComments.trim()
: 'Dealer proposal submitted'; : 'Dealer proposal submitted';
// Save proposal history BEFORE approving // Perform the approval action FIRST - only save snapshot if action succeeds
// Use dealer user ID if available, otherwise use initiator ID as fallback
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`);
} else {
await this.saveProposalHistory(
requestId,
dealerProposalLevel.levelId,
dealerProposalLevel.levelNumber,
`Proposal Submitted: ${approvalComment}`,
historyUserId
);
// Save workflow history - dealer submitting document is also an action
await this.saveWorkflowHistory(
requestId,
`Dealer submitted proposal document`,
historyUserId,
dealerProposalLevel.levelId,
dealerProposalLevel.levelNumber,
dealerProposalLevel.levelName || undefined
);
}
await this.approvalService.approveLevel( await this.approvalService.approveLevel(
dealerProposalLevel.levelId, dealerProposalLevel.levelId,
{ action: 'APPROVE', comments: approvalComment }, { action: 'APPROVE', comments: approvalComment },
actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID
{ ipAddress: null, userAgent: null } { ipAddress: null, userAgent: null }
); );
// Save proposal history AFTER approval succeeds (this is the only snapshot needed for dealer submission)
// Use dealer user ID if available, otherwise use initiator ID as fallback
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`);
} else {
try {
await this.saveProposalHistory(
requestId,
dealerProposalLevel.levelId,
dealerProposalLevel.levelNumber,
`Proposal Submitted: ${approvalComment}`,
historyUserId
);
// Note: We don't save workflow history here - proposal history is sufficient
// Workflow history will be saved when the level is approved and moves to next level
} catch (snapshotError) {
// Log error but don't fail the submission - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save proposal history snapshot (non-critical):`, snapshotError);
}
}
} }
logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`); logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`);
@ -1429,53 +1428,51 @@ export class DealerClaimService {
if (dealerCompletionLevel) { if (dealerCompletionLevel) {
// Use dealer's completion description if provided, otherwise use default message // Use dealer's completion description if provided, otherwise use default message
const approvalComment = completionData.completionDescription?.trim() const approvalComment = completionData.completionDescription?.trim()
? completionData.completionDescription.trim() ? completionData.completionDescription.trim()
: 'Completion documents submitted'; : 'Completion documents submitted';
// Get dealer user ID if not provided - try to find by dealer email from claim details // Get dealer user ID if not provided - try to find by dealer email from claim details
let actualDealerUserId: string | null = dealerUserId || null; let actualDealerUserId: string | null = dealerUserId || null;
if (!actualDealerUserId) { if (!actualDealerUserId) {
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
if (claimDetails?.dealerEmail) { if (claimDetails?.dealerEmail) {
const dealerUser = await User.findOne({ const dealerUser = await User.findOne({
where: { email: claimDetails.dealerEmail } where: { email: claimDetails.dealerEmail }
}); });
actualDealerUserId = dealerUser?.userId || null; actualDealerUserId = dealerUser?.userId || null;
} }
} }
// Use dealer user ID if available, otherwise use initiator ID as fallback // Perform the approval action FIRST - only save snapshot if action succeeds
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`);
} else {
// Save completion history BEFORE approving
await this.saveCompletionHistory(
requestId,
dealerCompletionLevel.levelId,
dealerCompletionLevel.levelNumber,
`Completion Submitted: ${approvalComment}`,
historyUserId
);
// Save workflow history - dealer submitting completion document is also an action
await this.saveWorkflowHistory(
requestId,
`Dealer submitted completion document`,
historyUserId,
dealerCompletionLevel.levelId,
dealerCompletionLevel.levelNumber,
dealerCompletionLevel.levelName || undefined
);
}
await this.approvalService.approveLevel( await this.approvalService.approveLevel(
dealerCompletionLevel.levelId, dealerCompletionLevel.levelId,
{ action: 'APPROVE', comments: approvalComment }, { action: 'APPROVE', comments: approvalComment },
actualDealerUserId || (request as any).initiatorId || 'system', actualDealerUserId || (request as any).initiatorId || 'system',
{ ipAddress: null, userAgent: null } { ipAddress: null, userAgent: null }
); );
// Save completion history AFTER approval succeeds (this is the only snapshot needed for dealer completion)
// Use dealer user ID if available, otherwise use initiator ID as fallback
const historyUserId = actualDealerUserId || (request as any).initiatorId || null;
if (!historyUserId) {
logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`);
} else {
try {
await this.saveCompletionHistory(
requestId,
dealerCompletionLevel.levelId,
dealerCompletionLevel.levelNumber,
`Completion Submitted: ${approvalComment}`,
historyUserId
);
// Note: We don't save workflow history here - completion history is sufficient
// Workflow history will be saved when the level is approved and moves to next level
} catch (snapshotError) {
// Log error but don't fail the submission - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save completion history snapshot (non-critical):`, snapshotError);
}
}
} }
logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`); logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`);
@ -1762,15 +1759,15 @@ export class DealerClaimService {
levelName: 'Department Lead IO Approval' levelName: 'Department Lead IO Approval'
} }
}); });
// Fallback: try to find by levelNumber 3 // Fallback: try to find by levelNumber 3
const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({ const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({
where: { requestId, levelNumber: 3 } where: { requestId, levelNumber: 3 }
}); });
// Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user
let ioHistoryUserId: string | null = null;
if (ioLevel) { if (ioLevel) {
// Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user
let ioHistoryUserId: string | null = null;
if (organizedBy) { if (organizedBy) {
// Check if organizedBy is a valid UUID // Check if organizedBy is a valid UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -1784,27 +1781,15 @@ export class DealerClaimService {
ioHistoryUserId = user?.userId || null; ioHistoryUserId = user?.userId || null;
} }
} }
// Fallback to initiator if no user found // Fallback to initiator if no user found
if (!ioHistoryUserId) { if (!ioHistoryUserId) {
const request = await WorkflowRequest.findByPk(requestId); const request = await WorkflowRequest.findByPk(requestId);
ioHistoryUserId = (request as any)?.initiatorId || null; ioHistoryUserId = (request as any)?.initiatorId || null;
} }
if (ioHistoryUserId) {
await this.saveIOHistory(
requestId,
ioLevel.levelId,
ioLevel.levelNumber,
`IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`,
ioHistoryUserId
);
} else {
logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`);
}
} }
// Update budget tracking with blocked amount // Update budget tracking with blocked amount FIRST
await ClaimBudgetTracking.upsert({ await ClaimBudgetTracking.upsert({
requestId, requestId,
ioBlockedAmount: finalBlockedAmount, ioBlockedAmount: finalBlockedAmount,
@ -1813,6 +1798,24 @@ export class DealerClaimService {
currency: 'INR', currency: 'INR',
}); });
// Save IO history AFTER budget tracking update succeeds (only if ioLevel exists)
if (ioLevel && ioHistoryUserId) {
try {
await this.saveIOHistory(
requestId,
ioLevel.levelId,
ioLevel.levelNumber,
`IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`,
ioHistoryUserId
);
} catch (snapshotError) {
// Log error but don't fail the IO blocking - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save IO history snapshot (non-critical):`, snapshotError);
}
} else if (ioLevel && !ioHistoryUserId) {
logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`);
}
logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, { logger.info(`[DealerClaimService] IO blocked for request: ${requestId}`, {
ioNumber: ioData.ioNumber, ioNumber: ioData.ioNumber,
blockedAmount: finalBlockedAmount, blockedAmount: finalBlockedAmount,
@ -2378,6 +2381,16 @@ export class DealerClaimService {
} }
} }
// Fetch supporting documents
const supportingDocs = await Document.findAll({
where: {
requestId,
category: 'SUPPORTING',
isDeleted: false
},
order: [['createdAt', 'DESC']]
});
const snapshotData = { const snapshotData = {
documentUrl: proposalDetails.proposalDocumentUrl, documentUrl: proposalDetails.proposalDocumentUrl,
totalBudget: Number(proposalDetails.totalEstimatedBudget || 0), totalBudget: Number(proposalDetails.totalEstimatedBudget || 0),
@ -2387,6 +2400,13 @@ export class DealerClaimService {
description: i.itemDescription, description: i.itemDescription,
amount: Number(i.amount || 0), amount: Number(i.amount || 0),
order: i.itemOrder order: i.itemOrder
})),
otherDocuments: supportingDocs.map(doc => ({
documentId: doc.documentId,
fileName: doc.fileName,
originalFileName: doc.originalFileName,
storageUrl: doc.storageUrl,
uploadedAt: doc.uploadedAt
})) }))
}; };
@ -2446,6 +2466,16 @@ export class DealerClaimService {
}); });
const nextVersion = lastVersion ? lastVersion.version + 1 : 1; const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Fetch supporting documents for completion
const supportingDocs = await Document.findAll({
where: {
requestId,
category: 'SUPPORTING',
isDeleted: false
},
order: [['createdAt', 'DESC']]
});
// Store all completion data in JSONB // Store all completion data in JSONB
const snapshotData = { const snapshotData = {
documentUrl: (completionDetails as any).completionDocumentUrl || null, documentUrl: (completionDetails as any).completionDocumentUrl || null,
@ -2454,6 +2484,13 @@ export class DealerClaimService {
expenses: expenses.map(e => ({ expenses: expenses.map(e => ({
description: e.description, description: e.description,
amount: Number(e.amount || 0) amount: Number(e.amount || 0)
})),
otherDocuments: supportingDocs.map(doc => ({
documentId: doc.documentId,
fileName: doc.fileName,
originalFileName: doc.originalFileName,
storageUrl: doc.storageUrl,
uploadedAt: doc.uploadedAt
})) }))
}; };
@ -2582,7 +2619,9 @@ export class DealerClaimService {
levelName: level.levelName levelName: level.levelName
}; };
const changeReason = action === 'APPROVE' // Build changeReason - will be updated later if moving to next level
// For now, just include the basic approval/rejection info
const changeReason = action === 'APPROVE'
? `Approved by ${level.approverName || level.approverEmail}` ? `Approved by ${level.approverName || level.approverEmail}`
: `Rejected by ${level.approverName || level.approverEmail}`; : `Rejected by ${level.approverName || level.approverEmail}`;
@ -2613,15 +2652,26 @@ export class DealerClaimService {
userId: string, userId: string,
approvalLevelId?: string, approvalLevelId?: string,
levelNumber?: number, levelNumber?: number,
levelName?: string levelName?: string,
approvalComment?: string
): Promise<void> { ): Promise<void> {
try { try {
const wf = await WorkflowRequest.findByPk(requestId); const wf = await WorkflowRequest.findByPk(requestId);
if (!wf) return; if (!wf) return;
// Get next version for workflow-level snapshots // Get next version for workflow-level snapshots PER LEVEL
// Each level should have its own version numbering starting from 1
// Filter by levelName or levelNumber to get versions for this specific level
const lastVersion = await DealerClaimHistory.findOne({ const lastVersion = await DealerClaimHistory.findOne({
where: { where: levelName ? {
requestId,
levelName,
snapshotType: SnapshotType.WORKFLOW
} : levelNumber !== undefined ? {
requestId,
levelNumber,
snapshotType: SnapshotType.WORKFLOW
} : {
requestId, requestId,
snapshotType: SnapshotType.WORKFLOW snapshotType: SnapshotType.WORKFLOW
}, },
@ -2630,11 +2680,22 @@ export class DealerClaimService {
const nextVersion = lastVersion ? lastVersion.version + 1 : 1; const nextVersion = lastVersion ? lastVersion.version + 1 : 1;
// Store workflow data in JSONB // Store workflow data in JSONB
const snapshotData = { // Include level information for version tracking and comparison
// Include approval comment if provided (for approval actions)
const snapshotData: any = {
status: wf.status, status: wf.status,
currentLevel: wf.currentLevel currentLevel: wf.currentLevel,
// Include level info in snapshotData for completeness and version tracking
approvalLevelId: approvalLevelId || undefined,
levelNumber: levelNumber || undefined,
levelName: levelName || undefined
}; };
// Add approval comment to snapshotData if provided
if (approvalComment) {
snapshotData.comments = approvalComment;
}
await DealerClaimHistory.create({ await DealerClaimHistory.create({
requestId, requestId,
approvalLevelId: approvalLevelId || undefined, approvalLevelId: approvalLevelId || undefined,
@ -3004,24 +3065,15 @@ export class DealerClaimService {
switch (action) { switch (action) {
case 'CANCEL': { case 'CANCEL': {
// Format change reason to include the comment if provided // Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim() const changeReason = data?.reason && data.reason.trim()
? `Request Cancelled: ${data.reason.trim()}` ? `Request Cancelled: ${data.reason.trim()}`
: 'Request Cancelled'; : 'Request Cancelled';
// Find current level for workflow history // Find current level for workflow history
const currentLevel = await ApprovalLevel.findOne({ const currentLevel = await ApprovalLevel.findOne({
where: { requestId, levelNumber: wf.currentLevel || 1 } where: { requestId, levelNumber: wf.currentLevel || 1 }
}); });
await this.saveWorkflowHistory(
requestId,
changeReason,
userId,
currentLevel?.levelId || undefined,
currentLevel?.levelNumber || wf.currentLevel || undefined,
currentLevel?.levelName || undefined
);
await wf.update({ await wf.update({
status: WorkflowStatus.CLOSED, status: WorkflowStatus.CLOSED,
closureDate: now closureDate: now
@ -3042,10 +3094,10 @@ export class DealerClaimService {
case 'REOPEN': { case 'REOPEN': {
// Format change reason to include the comment if provided // Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim() const changeReason = data?.reason && data.reason.trim()
? `Request Reopened: ${data.reason.trim()}` ? `Request Reopened: ${data.reason.trim()}`
: 'Request Reopened'; : 'Request Reopened';
// Find Department Lead level dynamically (handles step shifts) // Find Department Lead level dynamically (handles step shifts)
const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } }); const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } });
const deptLeadLevel = approvalsReopen.find(l => { const deptLeadLevel = approvalsReopen.find(l => {
@ -3059,21 +3111,26 @@ export class DealerClaimService {
const deptLeadLevelNumber = deptLeadLevel.levelNumber; const deptLeadLevelNumber = deptLeadLevel.levelNumber;
// Move back to Department Lead Approval level // Move back to Department Lead Approval level FIRST
await wf.update({ await wf.update({
status: WorkflowStatus.PENDING, status: WorkflowStatus.PENDING,
currentLevel: deptLeadLevelNumber currentLevel: deptLeadLevelNumber
}); });
// Capture workflow snapshot AFTER moving to department lead level // Capture workflow snapshot AFTER workflow update succeeds
await this.saveWorkflowHistory( try {
requestId, await this.saveWorkflowHistory(
`Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`, requestId,
userId, `Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`,
deptLeadLevel.levelId, userId,
deptLeadLevelNumber, deptLeadLevel.levelId,
deptLeadLevel.levelName || undefined deptLeadLevelNumber,
); deptLeadLevel.levelName || undefined
);
} catch (snapshotError) {
// Log error but don't fail the reopen - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
}
// Reset the found level status to IN_PROGRESS so Dept Lead can approve again // Reset the found level status to IN_PROGRESS so Dept Lead can approve again
await deptLeadLevel.update({ await deptLeadLevel.update({
@ -3112,26 +3169,19 @@ export class DealerClaimService {
case 'DISCUSS': { case 'DISCUSS': {
// Format change reason to include the comment if provided // Format change reason to include the comment if provided
const changeReason = data?.reason && data.reason.trim() const changeReason = data?.reason && data.reason.trim()
? `Discussion Requested: ${data.reason.trim()}` ? `Discussion Requested: ${data.reason.trim()}`
: 'Discussion Requested'; : 'Discussion Requested';
// Find Dealer level dynamically // Find Dealer level dynamically
const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } }); const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } });
const dealerLevelDiscuss = approvalsDiscuss.find(l => { const dealerLevelDiscuss = approvalsDiscuss.find(l => {
const name = (l.levelName || '').toLowerCase(); const name = (l.levelName || '').toLowerCase();
return name.includes('dealer proposal') || l.levelNumber === 1; return name.includes('dealer proposal') || l.levelNumber === 1;
}); });
// Save workflow history with dealer level information // Note: DISCUSS action doesn't change workflow state, so no snapshot needed
await this.saveWorkflowHistory( // The action is logged in activity log only
requestId,
changeReason,
userId,
dealerLevelDiscuss?.levelId || undefined,
dealerLevelDiscuss?.levelNumber || undefined,
dealerLevelDiscuss?.levelName || undefined
);
await activityService.log({ await activityService.log({
requestId, requestId,
@ -3160,45 +3210,50 @@ export class DealerClaimService {
case 'REVISE': { case 'REVISE': {
// Format change reason // Format change reason
const changeReason = data?.reason && data.reason.trim() const changeReason = data?.reason && data.reason.trim()
? `Revision Requested: ${data.reason.trim()}` ? `Revision Requested: ${data.reason.trim()}`
: 'Revision Requested'; : 'Revision Requested';
// Find current level and previous level // Find current level and previous level
const allLevels = await ApprovalLevel.findAll({ const allLevels = await ApprovalLevel.findAll({
where: { requestId }, where: { requestId },
order: [['levelNumber', 'ASC']] order: [['levelNumber', 'ASC']]
}); });
const currentLevelNumber = wf.currentLevel || 1; const currentLevelNumber = wf.currentLevel || 1;
const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber); const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber);
if (!currentLevel) { if (!currentLevel) {
throw new Error('Current approval level not found'); throw new Error('Current approval level not found');
} }
// Find previous level (the one before current) // Find previous level (the one before current)
const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber); const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber);
if (!previousLevel) { if (!previousLevel) {
throw new Error('No previous level found to revise to'); throw new Error('No previous level found to revise to');
} }
// Move back to previous level // Move back to previous level FIRST
await wf.update({ await wf.update({
status: WorkflowStatus.PENDING, status: WorkflowStatus.PENDING,
currentLevel: previousLevel.levelNumber currentLevel: previousLevel.levelNumber
}); });
// Capture workflow snapshot when moving back to previous level // Capture workflow snapshot AFTER workflow update succeeds
await this.saveWorkflowHistory( try {
requestId, await this.saveWorkflowHistory(
`Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`, requestId,
userId, `Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`,
previousLevel.levelId, userId,
previousLevel.levelNumber, previousLevel.levelId,
previousLevel.levelName || undefined previousLevel.levelNumber,
); previousLevel.levelName || undefined
);
} catch (snapshotError) {
// Log error but don't fail the revise - snapshot is for audit, not critical
logger.error(`[DealerClaimService] Failed to save workflow history snapshot (non-critical):`, snapshotError);
}
// Reset current level to PENDING // Reset current level to PENDING
await currentLevel.update({ await currentLevel.update({
@ -3273,7 +3328,19 @@ export class DealerClaimService {
} }
] ]
}); });
return history;
// Map to plain objects and sort otherDocuments in snapshots
return history.map(item => {
const plain = item.get({ plain: true });
if (plain.snapshotData && plain.snapshotData.otherDocuments && Array.isArray(plain.snapshotData.otherDocuments)) {
plain.snapshotData.otherDocuments.sort((a: any, b: any) => {
const dateA = a.uploadedAt ? new Date(a.uploadedAt).getTime() : 0;
const dateB = b.uploadedAt ? new Date(b.uploadedAt).getTime() : 0;
return dateB - dateA;
});
}
return plain;
});
} }
} }

View File

@ -106,44 +106,49 @@ export class DealerClaimApprovalService {
return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now); return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now);
} }
// Save approval history BEFORE updating level logger.info(`[DealerClaimApproval] Approving level ${levelId} with action:`, JSON.stringify(action));
await this.getDealerClaimService().saveApprovalHistory(
level.requestId,
level.levelId,
level.levelNumber,
'APPROVE',
action.comments || '',
undefined,
userId
);
// Capture workflow snapshot for approval action (before moving to next level) // Robust comment extraction
// This captures the approval action itself, including initiator evaluation const approvalComment = action.comments || (action as any).comment || '';
const levelName = (level.levelName || '').toLowerCase();
const isInitiatorEvaluation = levelName.includes('requestor') || levelName.includes('evaluation');
const approvalMessage = isInitiatorEvaluation
? `Initiator evaluated and approved (level ${level.levelNumber})`
: `Approved level ${level.levelNumber}`;
await this.getDealerClaimService().saveWorkflowHistory(
level.requestId,
approvalMessage,
userId,
level.levelId,
level.levelNumber,
level.levelName || undefined
);
// Update level status and elapsed time for approval // Update level status and elapsed time for approval FIRST
// Only save snapshot if the update succeeds
await level.update({ await level.update({
status: ApprovalStatus.APPROVED, status: ApprovalStatus.APPROVED,
actionDate: now, actionDate: now,
levelEndTime: now, levelEndTime: now,
elapsedHours: elapsedHours, elapsedHours: elapsedHours,
tatPercentageUsed: tatPercentage, tatPercentageUsed: tatPercentage,
comments: action.comments || undefined comments: approvalComment || undefined
}); });
// Check if this is a dealer submission (proposal or completion) - these have their own snapshot types
const levelName = (level.levelName || '').toLowerCase();
const isDealerSubmission = levelName.includes('dealer proposal') || levelName.includes('dealer completion');
// Only save APPROVE snapshot for actual approver actions (not dealer submissions)
// Dealer submissions use PROPOSAL/COMPLETION snapshot types instead
if (!isDealerSubmission) {
try {
await this.getDealerClaimService().saveApprovalHistory(
level.requestId,
level.levelId,
level.levelNumber,
'APPROVE',
approvalComment,
undefined,
userId
);
} catch (snapshotError) {
// Log error but don't fail the approval - snapshot is for audit, not critical
logger.error(`[DealerClaimApproval] Failed to save approval history snapshot (non-critical):`, snapshotError);
}
}
// Note: We don't save workflow history for approval actions
// The approval history (saveApprovalHistory) is sufficient and includes comments
// Workflow movement information is included in the APPROVE snapshot's changeReason
// Check if this is the final approver // Check if this is the final approver
const allLevels = await ApprovalLevel.findAll({ const allLevels = await ApprovalLevel.findAll({
where: { requestId: level.requestId } where: { requestId: level.requestId }
@ -275,16 +280,37 @@ export class DealerClaimApprovalService {
{ where: { requestId: level.requestId } } { where: { requestId: level.requestId } }
); );
// Capture workflow snapshot when moving to next level // Update the APPROVE snapshot's changeReason to include movement information
// Include both the approved level and the next level in the message // This ensures the approval snapshot shows both the approval and the movement
await this.getDealerClaimService().saveWorkflowHistory( // We don't create a separate WORKFLOW snapshot for approvals - only APPROVE snapshot
level.requestId, try {
`Level ${level.levelNumber} approved, moved to next level (${nextLevelNumber})`, const { DealerClaimHistory } = await import('@models/DealerClaimHistory');
userId, const { SnapshotType } = await import('@models/DealerClaimHistory');
nextLevel?.levelId || undefined, // Store next level's ID since we're moving to it
nextLevelNumber || undefined, const approvalHistory = await DealerClaimHistory.findOne({
(nextLevel as any)?.levelName || undefined where: {
); requestId: level.requestId,
approvalLevelId: level.levelId,
snapshotType: SnapshotType.APPROVE
},
order: [['createdAt', 'DESC']]
});
if (approvalHistory) {
// Use the robust approvalComment from outer scope
const updatedChangeReason = approvalComment
? `Approved by ${level.approverName || level.approverEmail}, moved to next level (${nextLevelNumber}). Comment: ${approvalComment}`
: `Approved by ${level.approverName || level.approverEmail}, moved to next level (${nextLevelNumber})`;
await approvalHistory.update({
changeReason: updatedChangeReason
});
}
} catch (updateError) {
// Log error but don't fail - this is just updating the changeReason for better display
logger.warn(`[DealerClaimApproval] Failed to update approval history changeReason (non-critical):`, updateError);
}
logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`);
} }
@ -641,101 +667,8 @@ export class DealerClaimApprovalService {
// Check if this is the Department Lead approval step (Step 3) // Check if this is the Department Lead approval step (Step 3)
// Robust check: check level name for variations and level number as fallback // Robust check: check level name for variations and level number as fallback
const levelName = (level.levelName || '').toLowerCase(); // Default rejection logic: Return to immediately previous approval step
const isDeptLeadResult = logger.info(`[DealerClaimApproval] Rejection for request ${level.requestId} by level ${level.levelNumber}. Finding previous step to return to.`);
levelName.includes('department lead') ||
levelName.includes('dept lead');
if (isDeptLeadResult) {
logger.info(`[DealerClaimApproval] Department Lead rejected request ${level.requestId}. Circling back to initiator.`);
// Save approval history (rejection) BEFORE updating level
await this.getDealerClaimService().saveApprovalHistory(
level.requestId,
level.levelId,
level.levelNumber,
'REJECT',
action.comments || '',
action.rejectionReason || undefined,
userId
);
// Update level status to REJECTED (but signifies a return at this level)
await level.update({
status: ApprovalStatus.REJECTED,
actionDate: rejectionNow,
levelEndTime: rejectionNow,
elapsedHours: elapsedHours || 0,
tatPercentageUsed: tatPercentage || 0,
comments: action.comments || action.rejectionReason || undefined
});
// Create or activate initiator action level
const initiatorLevel = await this.getDealerClaimService().createOrActivateInitiatorLevel(
level.requestId,
(wf as any).initiatorId
);
// Update workflow status to REJECTED but DO NOT set closureDate
// Set currentLevel to initiator level if created
const newCurrentLevel = initiatorLevel ? initiatorLevel.levelNumber : wf.currentLevel;
await WorkflowRequest.update(
{
status: WorkflowStatus.REJECTED,
currentLevel: newCurrentLevel
},
{ where: { requestId: level.requestId } }
);
// Capture workflow snapshot when moving back to initiator
// Include the rejected level information in the message
await this.getDealerClaimService().saveWorkflowHistory(
level.requestId,
`Department Lead rejected (level ${level.levelNumber}) and moved back to initiator (level ${newCurrentLevel})`,
userId,
level.levelId, // Store the rejected level's ID
level.levelNumber, // Store the rejected level's number
level.levelName || undefined // Store the rejected level's name
);
// Log activity
activityService.log({
requestId: level.requestId,
type: 'rejection',
user: { userId: level.approverId, name: level.approverName },
timestamp: rejectionNow.toISOString(),
action: 'Returned to Initiator',
details: `Request returned to initiator by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// Notify ONLY the initiator
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: `Action Required: Request Returned - ${(wf as any).requestNumber}`,
body: `Your request "${(wf as any).title}" has been returned to you by the Department Lead for revision/discussion. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'HIGH',
actionRequired: true
});
// Emit real-time update
emitToRequestRoom(level.requestId, 'request:updated', {
requestId: level.requestId,
requestNumber: (wf as any)?.requestNumber,
action: 'RETURN',
levelNumber: level.levelNumber,
timestamp: rejectionNow.toISOString()
});
return level;
}
// Default terminal rejection logic for other steps
logger.info(`[DealerClaimApproval] Standard rejection for request ${level.requestId} by level ${level.levelNumber}`);
// Save approval history (rejection) BEFORE updating level // Save approval history (rejection) BEFORE updating level
await this.getDealerClaimService().saveApprovalHistory( await this.getDealerClaimService().saveApprovalHistory(
@ -748,71 +681,171 @@ export class DealerClaimApprovalService {
userId userId
); );
// Capture workflow snapshot for terminal rejection action // Find all levels to determine previous step
await this.getDealerClaimService().saveWorkflowHistory( const allLevels = await ApprovalLevel.findAll({
level.requestId, where: { requestId: level.requestId },
`Level ${level.levelNumber} rejected (terminal rejection)`, order: [['levelNumber', 'ASC']]
userId, });
level.levelId,
level.levelNumber, // Find the immediately previous approval level
level.levelName || undefined const currentLevelNumber = level.levelNumber || 0;
); const previousLevels = allLevels.filter(l => l.levelNumber < currentLevelNumber && l.levelNumber > 0);
const previousLevel = previousLevels[previousLevels.length - 1];
// Update level status - if returning to previous step, set this level to PENDING (reset)
// If no previous step (terminal rejection), set to REJECTED
const newStatus = previousLevel ? ApprovalStatus.PENDING : ApprovalStatus.REJECTED;
// Update level status
await level.update({ await level.update({
status: ApprovalStatus.REJECTED, status: newStatus,
actionDate: rejectionNow, // If resetting to PENDING, clear action details so it can be acted upon again later
levelEndTime: rejectionNow, actionDate: previousLevel ? null : rejectionNow,
elapsedHours: elapsedHours || 0, levelEndTime: previousLevel ? null : rejectionNow,
tatPercentageUsed: tatPercentage || 0, elapsedHours: previousLevel ? 0 : (elapsedHours || 0),
comments: action.comments || action.rejectionReason || undefined tatPercentageUsed: previousLevel ? 0 : (tatPercentage || 0),
}); comments: previousLevel ? null : (action.comments || action.rejectionReason || undefined)
} as any);
// Close workflow // If no previous level found (this is the first step), close the workflow
await WorkflowRequest.update( if (!previousLevel) {
{ logger.info(`[DealerClaimApproval] No previous level found. This is the first step. Closing workflow.`);
status: WorkflowStatus.REJECTED,
closureDate: rejectionNow
},
{ where: { requestId: level.requestId } }
);
// Log rejection activity // Capture workflow snapshot for terminal rejection
activityService.log({ await this.getDealerClaimService().saveWorkflowHistory(
requestId: level.requestId, level.requestId,
type: 'rejection', `Level ${level.levelNumber} rejected (terminal rejection - no previous step)`,
user: { userId: level.approverId, name: level.approverName }, userId,
timestamp: new Date().toISOString(), level.levelId,
action: 'Rejected', level.levelNumber,
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, level.levelName || undefined
ipAddress: requestMetadata?.ipAddress || undefined, );
userAgent: requestMetadata?.userAgent || undefined
});
// Notify initiator and participants // Close workflow FIRST
const participants = await import('@models/Participant').then(m => m.Participant.findAll({ await WorkflowRequest.update(
where: { requestId: level.requestId, isActive: true } {
})); status: WorkflowStatus.REJECTED,
closureDate: rejectionNow
},
{ where: { requestId: level.requestId } }
);
const userIdsToNotify = [(wf as any).initiatorId]; // Capture workflow snapshot AFTER workflow is closed successfully
if (participants && participants.length > 0) { try {
participants.forEach((p: any) => { await this.getDealerClaimService().saveWorkflowHistory(
if (p.userId && p.userId !== (wf as any).initiatorId) { level.requestId,
userIdsToNotify.push(p.userId); `Level ${level.levelNumber} rejected (terminal rejection - no previous step)`,
} userId,
level.levelId,
level.levelNumber,
level.levelName || undefined
);
} catch (snapshotError) {
// Log error but don't fail the rejection - snapshot is for audit, not critical
logger.error(`[DealerClaimApproval] Failed to save workflow history snapshot (non-critical):`, snapshotError);
}
// Log rejection activity (terminal rejection)
activityService.log({
requestId: level.requestId,
type: 'rejection',
user: { userId: level.approverId, name: level.approverName },
timestamp: rejectionNow.toISOString(),
action: 'Rejected',
details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// Notify initiator and participants (workflow is closed)
const participants = await import('@models/Participant').then(m => m.Participant.findAll({
where: { requestId: level.requestId, isActive: true }
}));
const userIdsToNotify = [(wf as any).initiatorId];
if (participants && participants.length > 0) {
participants.forEach((p: any) => {
if (p.userId && p.userId !== (wf as any).initiatorId) {
userIdsToNotify.push(p.userId);
}
});
}
await notificationService.sendToUsers(userIdsToNotify, {
title: `Request Rejected: ${(wf as any).requestNumber}`,
body: `${(wf as any).title} - Rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'HIGH'
});
} else {
// Return to previous step
logger.info(`[DealerClaimApproval] Returning to previous level ${previousLevel.levelNumber} (${previousLevel.levelName || 'unnamed'})`);
// Reset previous level to IN_PROGRESS so it can be acted upon again
await previousLevel.update({
status: ApprovalStatus.IN_PROGRESS,
levelStartTime: rejectionNow,
tatStartTime: rejectionNow,
actionDate: undefined,
levelEndTime: undefined,
comments: undefined,
elapsedHours: 0,
tatPercentageUsed: 0
});
// Update workflow status to IN_PROGRESS (remains active for rework)
// Set currentLevel to previous level
await WorkflowRequest.update(
{
status: WorkflowStatus.PENDING,
currentLevel: previousLevel.levelNumber
},
{ where: { requestId: level.requestId } }
);
// Log rejection activity (returned to previous step)
activityService.log({
requestId: level.requestId,
type: 'rejection',
user: { userId: level.approverId, name: level.approverName },
timestamp: rejectionNow.toISOString(),
action: 'Returned to Previous Step',
details: `Request rejected by ${level.approverName || level.approverEmail} and returned to level ${previousLevel.levelNumber}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined
});
// Notify the approver of the previous level
if (previousLevel.approverId) {
await notificationService.sendToUsers([previousLevel.approverId], {
title: `Request Returned: ${(wf as any).requestNumber}`,
body: `Request "${(wf as any).title}" has been returned to your level for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'assignment',
priority: 'HIGH',
actionRequired: true
});
}
// Notify initiator when request is returned (not closed)
await notificationService.sendToUsers([(wf as any).initiatorId], {
title: `Request Returned: ${(wf as any).requestNumber}`,
body: `Request "${(wf as any).title}" has been returned to level ${previousLevel.levelNumber} for revision. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'HIGH',
actionRequired: true
}); });
} }
await notificationService.sendToUsers(userIdsToNotify, {
title: `Request Rejected: ${(wf as any).requestNumber}`,
body: `${(wf as any).title} - Rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`,
requestNumber: (wf as any).requestNumber,
requestId: level.requestId,
url: `/request/${(wf as any).requestNumber}`,
type: 'rejection',
priority: 'HIGH'
});
// Emit real-time update to all users viewing this request // Emit real-time update to all users viewing this request
emitToRequestRoom(level.requestId, 'request:updated', { emitToRequestRoom(level.requestId, 'request:updated', {
requestId: level.requestId, requestId: level.requestId,

View File

@ -2,11 +2,11 @@ import webpush from 'web-push';
import logger, { logNotificationEvent } from '@utils/logger'; import logger, { logNotificationEvent } from '@utils/logger';
import { Subscription } from '@models/Subscription'; import { Subscription } from '@models/Subscription';
import { Notification } from '@models/Notification'; import { Notification } from '@models/Notification';
import { import {
shouldSendEmail, shouldSendEmail,
shouldSendEmailWithOverride, shouldSendEmailWithOverride,
shouldSendInAppNotification, shouldSendInAppNotification,
EmailNotificationType EmailNotificationType
} from '../emailtemplates/emailPreferences.helper'; } from '../emailtemplates/emailPreferences.helper';
type PushSubscription = any; // Web Push protocol JSON type PushSubscription = any; // Web Push protocol JSON
@ -68,7 +68,7 @@ class NotificationService {
*/ */
async getUserSubscriptions(userId: string) { async getUserSubscriptions(userId: string) {
try { try {
const subscriptions = await Subscription.findAll({ const subscriptions = await Subscription.findAll({
where: { userId }, where: { userId },
attributes: ['subscriptionId', 'endpoint', 'userAgent', 'createdAt'] attributes: ['subscriptionId', 'endpoint', 'userAgent', 'createdAt']
}); });
@ -120,7 +120,7 @@ class NotificationService {
async sendToUsers(userIds: string[], payload: NotificationPayload) { async sendToUsers(userIds: string[], payload: NotificationPayload) {
const message = JSON.stringify(payload); const message = JSON.stringify(payload);
const { User } = require('@models/User'); const { User } = require('@models/User');
for (const userId of userIds) { for (const userId of userIds) {
try { try {
// Fetch user preferences and email data // Fetch user preferences and email data
@ -141,17 +141,17 @@ class NotificationService {
} }
const sentVia: string[] = []; const sentVia: string[] = [];
// 1. Check admin + user preferences for in-app notifications // 1. Check admin + user preferences for in-app notifications
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general'); const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
logger.info(`[Notification] In-app notification check for user ${userId}:`, { logger.info(`[Notification] In-app notification check for user ${userId}:`, {
canSendInApp, canSendInApp,
inAppNotificationsEnabled: user.inAppNotificationsEnabled, inAppNotificationsEnabled: user.inAppNotificationsEnabled,
notificationType: payload.type, notificationType: payload.type,
willCreate: canSendInApp && user.inAppNotificationsEnabled willCreate: canSendInApp && user.inAppNotificationsEnabled
}); });
let notification: any = null; let notification: any = null;
if (canSendInApp && user.inAppNotificationsEnabled) { if (canSendInApp && user.inAppNotificationsEnabled) {
try { try {
@ -206,9 +206,9 @@ class NotificationService {
try { try {
const rows = await Subscription.findAll({ where: { userId } }); const rows = await Subscription.findAll({ where: { userId } });
subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } })); subs = rows.map((r: any) => ({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }));
} catch {} } catch { }
} }
if (subs.length > 0) { if (subs.length > 0) {
for (const sub of subs) { for (const sub of subs) {
try { try {
@ -270,7 +270,7 @@ class NotificationService {
*/ */
private async sendEmailNotification(userId: string, user: any, payload: NotificationPayload): Promise<void> { private async sendEmailNotification(userId: string, user: any, payload: NotificationPayload): Promise<void> {
console.log(`[DEBUG Email] Notification type: ${payload.type}, userId: ${userId}`); console.log(`[DEBUG Email] Notification type: ${payload.type}, userId: ${userId}`);
// Import email service (lazy load to avoid circular dependencies) // Import email service (lazy load to avoid circular dependencies)
const { emailNotificationService } = await import('./emailNotification.service'); const { emailNotificationService } = await import('./emailNotification.service');
const { EmailNotificationType } = await import('../emailtemplates/emailPreferences.helper'); const { EmailNotificationType } = await import('../emailtemplates/emailPreferences.helper');
@ -310,9 +310,9 @@ class NotificationService {
}; };
const emailType = emailTypeMap[payload.type || '']; const emailType = emailTypeMap[payload.type || ''];
console.log(`[DEBUG Email] Email type mapped: ${emailType}`); console.log(`[DEBUG Email] Email type mapped: ${emailType}`);
if (!emailType) { if (!emailType) {
// This notification type doesn't warrant email // This notification type doesn't warrant email
// Note: 'document_added' emails are handled separately via emailNotificationService // Note: 'document_added' emails are handled separately via emailNotificationService
@ -324,14 +324,14 @@ class NotificationService {
// Check if email should be sent (admin + user preferences) // Check if email should be sent (admin + user preferences)
// Critical emails: rejection, tat_breach, breach // Critical emails: rejection, tat_breach, breach
const isCriticalEmail = payload.type === 'rejection' || const isCriticalEmail = payload.type === 'rejection' ||
payload.type === 'tat_breach' || payload.type === 'tat_breach' ||
payload.type === 'breach'; payload.type === 'breach';
const shouldSend = isCriticalEmail const shouldSend = isCriticalEmail
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails ? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
: payload.type === 'assignment' : payload.type === 'assignment'
? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery ? await shouldSendEmailWithOverride(userId, emailType) // Assignment emails - use override to ensure delivery
: await shouldSendEmail(userId, emailType); // Regular emails : await shouldSendEmail(userId, emailType); // Regular emails
console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`); console.log(`[DEBUG Email] Should send email: ${shouldSend} for type: ${payload.type}, userId: ${userId}`);
@ -340,7 +340,7 @@ class NotificationService {
logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`); logger.warn(`[Email] Email skipped for user ${userId}, type: ${payload.type} (preferences or admin disabled)`);
return; return;
} }
logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`); logger.info(`[Email] Sending email notification to user ${userId} for type: ${payload.type}, requestId: ${payload.requestId}`);
// Trigger email based on notification type // Trigger email based on notification type
@ -380,14 +380,14 @@ class NotificationService {
} }
const requestData = request.toJSON(); const requestData = request.toJSON();
// Fetch initiator user // Fetch initiator user
const initiator = await User.findByPk(requestData.initiatorId); const initiator = await User.findByPk(requestData.initiatorId);
if (!initiator) { if (!initiator) {
logger.warn(`[Email] Initiator not found for request ${payload.requestId}`); logger.warn(`[Email] Initiator not found for request ${payload.requestId}`);
return; return;
} }
const initiatorData = initiator.toJSON(); const initiatorData = initiator.toJSON();
switch (notificationType) { switch (notificationType) {
@ -396,18 +396,18 @@ class NotificationService {
const firstLevel = await ApprovalLevel.findOne({ const firstLevel = await ApprovalLevel.findOne({
where: { requestId: payload.requestId, levelNumber: 1 } where: { requestId: payload.requestId, levelNumber: 1 }
}); });
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null; const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
// Get first approver's TAT hours (not total TAT) // Get first approver's TAT hours (not total TAT)
const firstApproverTatHours = firstLevel ? (firstLevel as any).tatHours : null; const firstApproverTatHours = firstLevel ? (firstLevel as any).tatHours : null;
// Add first approver's TAT to requestData for the email // Add first approver's TAT to requestData for the email
const requestDataWithFirstTat = { const requestDataWithFirstTat = {
...requestData, ...requestData,
tatHours: firstApproverTatHours || (requestData as any).totalTatHours || 24 tatHours: firstApproverTatHours || (requestData as any).totalTatHours || 24
}; };
await emailNotificationService.sendRequestCreated( await emailNotificationService.sendRequestCreated(
requestDataWithFirstTat, requestDataWithFirstTat,
initiatorData, initiatorData,
@ -420,33 +420,40 @@ class NotificationService {
{ {
// Fetch the approver user (the one being assigned) // Fetch the approver user (the one being assigned)
const approverUser = await User.findByPk(userId); const approverUser = await User.findByPk(userId);
if (!approverUser) { if (!approverUser) {
logger.warn(`[Email] Approver user ${userId} not found`); logger.warn(`[Email] Approver user ${userId} not found`);
return; return;
} }
const allLevels = await ApprovalLevel.findAll({ const allLevels = await ApprovalLevel.findAll({
where: { requestId: payload.requestId }, where: { requestId: payload.requestId },
order: [['levelNumber', 'ASC']] order: [['levelNumber', 'ASC']]
}); });
// Find the level that matches this approver // Find the level that matches this approver - PRIORITIZE PENDING LEVEL
const matchingLevel = allLevels.find((l: any) => l.approverId === userId); // This ensures that if a user has multiple steps (e.g., Step 1 and Step 2),
// we pick the one that actually needs action (Step 2) rather than the first one (Step 1)
let matchingLevel = allLevels.find((l: any) => l.approverId === userId && l.status === 'PENDING');
// Fallback to any level if no pending level found (though for assignment there should be one)
if (!matchingLevel) {
matchingLevel = allLevels.find((l: any) => l.approverId === userId);
}
// Always reload from DB to ensure we have fresh levelName // Always reload from DB to ensure we have fresh levelName
const currentLevel = matchingLevel const currentLevel = matchingLevel
? (await ApprovalLevel.findByPk((matchingLevel as any).levelId) || matchingLevel as any) ? (await ApprovalLevel.findByPk((matchingLevel as any).levelId) || matchingLevel as any)
: null; : null;
const workflowType = requestData.workflowType || 'CUSTOM'; const workflowType = requestData.workflowType || 'CUSTOM';
logger.info(`[Email] Assignment - workflowType: ${workflowType}, approver: ${approverUser.email}, level: "${(currentLevel as any)?.levelName || 'N/A'}" (${(currentLevel as any)?.levelNumber || 'N/A'})`); logger.info(`[Email] Assignment - workflowType: ${workflowType}, approver: ${approverUser.email}, level: "${(currentLevel as any)?.levelName || 'N/A'}" (${(currentLevel as any)?.levelNumber || 'N/A'})`);
// Use factory to get the appropriate email service // Use factory to get the appropriate email service
const { workflowEmailServiceFactory } = await import('./workflowEmail.factory'); const { workflowEmailServiceFactory } = await import('./workflowEmail.factory');
const workflowEmailService = workflowEmailServiceFactory.getService(workflowType); const workflowEmailService = workflowEmailServiceFactory.getService(workflowType);
if (workflowEmailService && workflowEmailServiceFactory.hasDedicatedService(workflowType)) { if (workflowEmailService && workflowEmailServiceFactory.hasDedicatedService(workflowType)) {
// Use workflow-specific email service // Use workflow-specific email service
await workflowEmailService.sendAssignmentEmail( await workflowEmailService.sendAssignmentEmail(
@ -459,9 +466,9 @@ class NotificationService {
} else { } else {
// Custom workflow or unknown type - use standard logic // Custom workflow or unknown type - use standard logic
const isMultiLevel = allLevels.length > 1; const isMultiLevel = allLevels.length > 1;
const approverData = approverUser.toJSON(); const approverData = approverUser.toJSON();
// Add level number if available // Add level number if available
if (currentLevel) { if (currentLevel) {
(approverData as any).levelNumber = (currentLevel as any).levelNumber; (approverData as any).levelNumber = (currentLevel as any).levelNumber;
@ -481,7 +488,7 @@ class NotificationService {
case 'approval': case 'approval':
{ {
const approvedLevel = await ApprovalLevel.findOne({ const approvedLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'APPROVED' status: 'APPROVED'
}, },
@ -498,7 +505,7 @@ class NotificationService {
// Find next level - get the first PENDING level (handles dynamic approvers) // Find next level - get the first PENDING level (handles dynamic approvers)
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING'); const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
// Get next approver user data // Get next approver user data
let nextApprover = null; let nextApprover = null;
if (nextLevel) { if (nextLevel) {
@ -531,7 +538,7 @@ class NotificationService {
// (they don't need to be notified that they approved their own request) // (they don't need to be notified that they approved their own request)
const approverId = (approverData as any).userId || (approvedLevel as any)?.approverId; const approverId = (approverData as any).userId || (approvedLevel as any)?.approverId;
const isApproverInitiator = approverId && initiatorData.userId && approverId === initiatorData.userId; const isApproverInitiator = approverId && initiatorData.userId && approverId === initiatorData.userId;
if (isApproverInitiator) { if (isApproverInitiator) {
logger.info(`[Email] Skipping approval confirmation email - approver is the initiator (${approverId})`); logger.info(`[Email] Skipping approval confirmation email - approver is the initiator (${approverId})`);
return; return;
@ -550,7 +557,7 @@ class NotificationService {
case 'rejection': case 'rejection':
{ {
const rejectedLevel = await ApprovalLevel.findOne({ const rejectedLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'REJECTED' status: 'REJECTED'
}, },
@ -596,7 +603,7 @@ class NotificationService {
{ {
// Get the approver from the current level (the one who needs to take action) // Get the approver from the current level (the one who needs to take action)
const currentLevel = await ApprovalLevel.findOne({ const currentLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'PENDING' status: 'PENDING'
}, },
@ -683,7 +690,7 @@ class NotificationService {
{ {
// Get current level to determine approver // Get current level to determine approver
const currentLevel = await ApprovalLevel.findOne({ const currentLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'PENDING' status: 'PENDING'
}, },
@ -777,7 +784,7 @@ class NotificationService {
case 'approver_skipped': case 'approver_skipped':
{ {
const skippedLevel = await ApprovalLevel.findOne({ const skippedLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'SKIPPED' status: 'SKIPPED'
}, },
@ -785,7 +792,7 @@ class NotificationService {
}); });
const nextLevel = await ApprovalLevel.findOne({ const nextLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'PENDING' status: 'PENDING'
}, },
@ -814,19 +821,19 @@ class NotificationService {
// Treat it similar to workflow_paused but with different messaging // Treat it similar to workflow_paused but with different messaging
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null; const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
const resumeDate = payload.metadata?.resumeDate || new Date(); const resumeDate = payload.metadata?.resumeDate || new Date();
// Get recipient data (the approver who paused it) // Get recipient data (the approver who paused it)
let recipientData = user; let recipientData = user;
if (!recipientData || !recipientData.email) { if (!recipientData || !recipientData.email) {
// Try to get from paused level // Try to get from paused level
const pausedLevel = await ApprovalLevel.findOne({ const pausedLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
isPaused: true isPaused: true
}, },
order: [['levelNumber', 'ASC']] order: [['levelNumber', 'ASC']]
}); });
if (pausedLevel) { if (pausedLevel) {
const approverUser = await User.findByPk((pausedLevel as any).approverId); const approverUser = await User.findByPk((pausedLevel as any).approverId);
if (approverUser) { if (approverUser) {
@ -871,13 +878,13 @@ class NotificationService {
if (!recipientData || !recipientData.email) { if (!recipientData || !recipientData.email) {
// If user object doesn't have email, try to get from current level // If user object doesn't have email, try to get from current level
const currentLevel = await ApprovalLevel.findOne({ const currentLevel = await ApprovalLevel.findOne({
where: { where: {
requestId: payload.requestId, requestId: payload.requestId,
status: 'PENDING' status: 'PENDING'
}, },
order: [['levelNumber', 'ASC']] order: [['levelNumber', 'ASC']]
}); });
if (currentLevel) { if (currentLevel) {
const approverUser = await User.findByPk((currentLevel as any).approverId); const approverUser = await User.findByPk((currentLevel as any).approverId);
if (approverUser) { if (approverUser) {
@ -925,7 +932,7 @@ class NotificationService {
{ {
// Get the spectator user (the one being added) // Get the spectator user (the one being added)
const spectatorUser = await User.findByPk(userId); const spectatorUser = await User.findByPk(userId);
if (!spectatorUser) { if (!spectatorUser) {
logger.warn(`[Email] Spectator user ${userId} not found`); logger.warn(`[Email] Spectator user ${userId} not found`);
return; return;
@ -949,26 +956,26 @@ class NotificationService {
// Get dealer and proposal data from metadata // Get dealer and proposal data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName }; const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const proposalData = payload.metadata?.proposalData || {}; const proposalData = payload.metadata?.proposalData || {};
// Get activity information from metadata (not from requestData as it doesn't have these fields) // Get activity information from metadata (not from requestData as it doesn't have these fields)
const activityName = payload.metadata?.activityName || requestData.title; const activityName = payload.metadata?.activityName || requestData.title;
const activityType = payload.metadata?.activityType || 'N/A'; const activityType = payload.metadata?.activityType || 'N/A';
// Add activity info to requestData for the email template // Add activity info to requestData for the email template
const requestDataWithActivity = { const requestDataWithActivity = {
...requestData, ...requestData,
activityName: activityName, activityName: activityName,
activityType: activityType activityType: activityType
}; };
// Get next approver if available // Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId; const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null; const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator) // Check if next approver is the recipient (initiator)
const isNextApproverInitiator = proposalData.nextApproverIsInitiator || const isNextApproverInitiator = proposalData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId); (nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendDealerProposalSubmitted( await emailNotificationService.sendDealerProposalSubmitted(
requestDataWithActivity, requestDataWithActivity,
dealerData, dealerData,
@ -997,7 +1004,7 @@ class NotificationService {
ioNumber: payload.metadata?.ioNumber, ioNumber: payload.metadata?.ioNumber,
nextSteps: payload.metadata?.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.' nextSteps: payload.metadata?.nextSteps || 'IO confirmation to be made. Dealer will proceed with activity execution and submit completion documents.'
}; };
await emailNotificationService.sendActivityCreated( await emailNotificationService.sendActivityCreated(
requestData, requestData,
user.toJSON(), user.toJSON(),
@ -1011,15 +1018,15 @@ class NotificationService {
// Get dealer and completion data from metadata // Get dealer and completion data from metadata
const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName }; const dealerData = payload.metadata?.dealerData || { userId: null, email: payload.metadata?.dealerEmail, displayName: payload.metadata?.dealerName };
const completionData = payload.metadata?.completionData || {}; const completionData = payload.metadata?.completionData || {};
// Get next approver if available // Get next approver if available
const nextApproverId = payload.metadata?.nextApproverId; const nextApproverId = payload.metadata?.nextApproverId;
const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null; const nextApprover = nextApproverId ? await User.findByPk(nextApproverId) : null;
// Check if next approver is the recipient (initiator) // Check if next approver is the recipient (initiator)
const isNextApproverInitiator = completionData.nextApproverIsInitiator || const isNextApproverInitiator = completionData.nextApproverIsInitiator ||
(nextApprover && nextApprover.userId === userId); (nextApprover && nextApprover.userId === userId);
await emailNotificationService.sendCompletionDocumentsSubmitted( await emailNotificationService.sendCompletionDocumentsSubmitted(
requestData, requestData,
dealerData, dealerData,
@ -1047,7 +1054,7 @@ class NotificationService {
generatedAt: payload.metadata?.generatedAt, generatedAt: payload.metadata?.generatedAt,
downloadLink: payload.metadata?.downloadLink downloadLink: payload.metadata?.downloadLink
}; };
await emailNotificationService.sendEInvoiceGenerated( await emailNotificationService.sendEInvoiceGenerated(
requestData, requestData,
user.toJSON(), user.toJSON(),
@ -1071,7 +1078,7 @@ class NotificationService {
sentAt: payload.metadata?.sentAt, sentAt: payload.metadata?.sentAt,
downloadLink: payload.metadata?.downloadLink downloadLink: payload.metadata?.downloadLink
}; };
await emailNotificationService.sendCreditNoteSent( await emailNotificationService.sendCreditNoteSent(
requestData, requestData,
user.toJSON(), user.toJSON(),