added new email templates to cover scenerios in detail way
This commit is contained in:
parent
8d7805acc9
commit
3c95146f4a
3518
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
3518
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
File diff suppressed because it is too large
Load Diff
1886
docs/modular_wise/02_Dealer_Resignation.md
Normal file
1886
docs/modular_wise/02_Dealer_Resignation.md
Normal file
File diff suppressed because it is too large
Load Diff
1625
docs/modular_wise/03_Termination.md
Normal file
1625
docs/modular_wise/03_Termination.md
Normal file
File diff suppressed because it is too large
Load Diff
1802
docs/modular_wise/04_FF_Settlement.md
Normal file
1802
docs/modular_wise/04_FF_Settlement.md
Normal file
File diff suppressed because it is too large
Load Diff
1279
docs/modular_wise/05_Constitutional_Change.md
Normal file
1279
docs/modular_wise/05_Constitutional_Change.md
Normal file
File diff suppressed because it is too large
Load Diff
1255
docs/modular_wise/06_Relocation.md
Normal file
1255
docs/modular_wise/06_Relocation.md
Normal file
File diff suppressed because it is too large
Load Diff
271
src/__tests__/external-integrations.test.ts
Normal file
271
src/__tests__/external-integrations.test.ts
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file external-integrations.test.ts
|
||||
* @description Contract/mock tests for all external integrations.
|
||||
* These tests validate the SHAPE and BEHAVIOUR of each mock so that
|
||||
* when real APIs are wired, only the mock needs to be swapped.
|
||||
*
|
||||
* Integrations covered:
|
||||
* 1. SAP OData — Dealer Code generation (mockGenerateSapCodes, mockSyncDealerStatusToSap)
|
||||
* 2. Google Calendar — Interview invite scheduling (mockScheduleMeeting)
|
||||
* 3. WhatsApp — Notification delivery (mockSendWhatsApp)
|
||||
* 4. Gemini AI — Panel evaluation summary (mockGenerateAiSummary)
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.17.3.1 — SAP OData API for Sales/Service/GMA/Gear codes
|
||||
* §6.9.2 — Google Calendar invites for all participants
|
||||
* §1.1.1 — WhatsApp as supported notification channel
|
||||
* §6.10.4 — AI-assisted recommendation via Gemini API
|
||||
*/
|
||||
|
||||
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||
|
||||
// ─── 1. SAP Dealer Code Generation ───────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Dealer Code Generation (SRS §6.17.3.1)', () => {
|
||||
it('TC-SAP-01: returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-SAP-02: returns salesCode in SLS-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-03: returns serviceCode in SRV-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-04: returns gmaCode in GMA-XXXX format (Genuine Motorcycle Accessories)', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-05: returns gearCode in GER-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-06: returns a non-empty sapMasterId', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.sapMasterId).toBeTruthy();
|
||||
expect(typeof data.sapMasterId).toBe('string');
|
||||
});
|
||||
|
||||
it('TC-SAP-07: each call generates unique codes (no collisions)', async () => {
|
||||
const r1 = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
const r2 = await ExternalMocksService.mockGenerateSapCodes('app-002');
|
||||
// Codes are random; while collision is statistically possible, sapMasterIds must differ
|
||||
expect(r1.data.sapMasterId).not.toBe(r2.data.sapMasterId);
|
||||
});
|
||||
|
||||
it('TC-SAP-08: returns all 4 code types in a single call (no missing fields)', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data).toHaveProperty('salesCode');
|
||||
expect(data).toHaveProperty('serviceCode');
|
||||
expect(data).toHaveProperty('gmaCode');
|
||||
expect(data).toHaveProperty('gearCode');
|
||||
expect(data).toHaveProperty('sapMasterId');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SAP Status Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Dealer Status Synchronization', () => {
|
||||
it('TC-SAP-09: mockSyncDealerStatusToSap returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Active');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-SAP-10: sync result includes a sapTransactionId and ISO timestamp', async () => {
|
||||
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Terminated');
|
||||
expect(result.sapTransactionId).toMatch(/^SAP-TX-/);
|
||||
expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SAP Financial Dues ───────────────────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Financial Dues (F&F Context)', () => {
|
||||
it('TC-SAP-11: mockGetFinancialDuesFromSap returns financial data for a dealer', async () => {
|
||||
const result = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toHaveProperty('outstandingInvoices');
|
||||
expect(result.data).toHaveProperty('securityDeposit');
|
||||
expect(result.data).toHaveProperty('creditLimit');
|
||||
expect(result.data).toHaveProperty('pendingClaims');
|
||||
});
|
||||
|
||||
it('TC-SAP-12: returned financial amounts are positive numbers', async () => {
|
||||
const { data } = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||
expect(data.securityDeposit).toBeGreaterThan(0);
|
||||
expect(data.creditLimit).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Google Calendar Mock ──────────────────────────────────────────────────
|
||||
|
||||
describe('Google Calendar Mock — Interview Scheduling (SRS §6.9.2)', () => {
|
||||
const interviewPayload = {
|
||||
type: 'Level 1 Interview',
|
||||
scheduledAt: '2026-05-15T10:00:00Z',
|
||||
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||
mode: 'Virtual',
|
||||
applicationId: 'app-uuid-001',
|
||||
};
|
||||
|
||||
it('TC-CAL-01: mockScheduleMeeting returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-CAL-02: returns a Google Meet link URL', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.meetLink).toContain('meet.google.com');
|
||||
});
|
||||
|
||||
it('TC-CAL-03: returns a non-empty calendarEventId (UUID format)', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.calendarEventId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-CAL-04: each scheduling call returns a unique meetLink (no duplicate links)', async () => {
|
||||
const r1 = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
const r2 = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type: 'Level 2 Interview' });
|
||||
expect(r1.meetLink).not.toBe(r2.meetLink);
|
||||
});
|
||||
|
||||
it('TC-CAL-05: mock works for Level 1, Level 2, and Level 3 interview types', async () => {
|
||||
for (const type of ['Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview']) {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('TC-CAL-06: mock works for both Virtual and Physical interview modes', async () => {
|
||||
const virtualResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Virtual' });
|
||||
const physicalResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Physical' });
|
||||
expect(virtualResult.success).toBe(true);
|
||||
expect(physicalResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. WhatsApp Mock ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhatsApp Mock — Notification Delivery (SRS §1.1.1)', () => {
|
||||
it('TC-WA-01: mockSendWhatsApp resolves successfully', async () => {
|
||||
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||
'+919876543210',
|
||||
'Questionnaire reminder for your dealership application.'
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WA-02: returned messageId starts with WA- prefix', async () => {
|
||||
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||
'+919876543210',
|
||||
'Your resignation request has been received.'
|
||||
);
|
||||
expect(result.messageId).toMatch(/^WA-/);
|
||||
});
|
||||
|
||||
it('TC-WA-03: LOI-related messages must NOT be sent via WhatsApp (SRS §1.1.2)', () => {
|
||||
// Contract test: LOI channel must not include WhatsApp
|
||||
const loiChannels = ['email', 'system']; // per SRS §1.1.2
|
||||
expect(loiChannels).not.toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-04: questionnaire reminders MUST include WhatsApp channel (SRS §1.1.1)', () => {
|
||||
const questionnaireChannels = ['email', 'whatsapp']; // per SRS §1.1.1
|
||||
expect(questionnaireChannels).toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-05: resignation submission acknowledgement includes WhatsApp channel (SRS §1.1.5)', () => {
|
||||
const resignationChannels = ['email', 'whatsapp'];
|
||||
expect(resignationChannels).toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-06: each WhatsApp call generates a unique messageId', async () => {
|
||||
const r1 = await ExternalMocksService.mockSendWhatsApp('+91111', 'Msg 1');
|
||||
const r2 = await ExternalMocksService.mockSendWhatsApp('+91222', 'Msg 2');
|
||||
expect(r1.messageId).not.toBe(r2.messageId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Gemini AI Mock ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Gemini AI Mock — Panel Evaluation Summary (SRS §6.10.4)', () => {
|
||||
const allApprovedFeedback = [
|
||||
{ recommendation: 'Approve', score: 85 },
|
||||
{ recommendation: 'Approve', score: 90 },
|
||||
{ recommendation: 'Approve', score: 88 },
|
||||
];
|
||||
|
||||
const mixedFeedback = [
|
||||
{ recommendation: 'Approve', score: 80 },
|
||||
{ recommendation: 'Approve', score: 75 },
|
||||
{ recommendation: 'Reject', score: 40 },
|
||||
];
|
||||
|
||||
const allRejectFeedback = [
|
||||
{ recommendation: 'Reject', score: 30 },
|
||||
{ recommendation: 'Reject', score: 25 },
|
||||
];
|
||||
|
||||
it('TC-AI-01: mockGenerateAiSummary returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-AI-02: returns a non-empty summary string', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
expect(typeof result.summary).toBe('string');
|
||||
expect(result.summary.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('TC-AI-03: unanimous approval panel produces positive consensus summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
// Unanimous approval → strong consensus message
|
||||
expect(result.summary).toMatch(/strong consensus|strong candidate|exceptional/i);
|
||||
});
|
||||
|
||||
it('TC-AI-04: majority approval produces cautiously positive summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', mixedFeedback);
|
||||
// Majority but not all → cautious message
|
||||
expect(result.summary).toMatch(/majority|recommend approval|monitored/i);
|
||||
});
|
||||
|
||||
it('TC-AI-05: rejection-leaning panel produces concern-focused summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allRejectFeedback);
|
||||
expect(result.summary).toMatch(/divided|rejection|concern/i);
|
||||
});
|
||||
|
||||
it('TC-AI-06: empty feedback list produces a valid (non-crashing) summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', []);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summary).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-AI-07: summary is presentable to NBH — 2 to 3 sentences', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
// SRS §6.10.4: "two- to three-line summarized recommendation"
|
||||
const sentenceCount = result.summary.split(/[.!?]/).filter(Boolean).length;
|
||||
expect(sentenceCount).toBeGreaterThanOrEqual(1);
|
||||
expect(sentenceCount).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('TC-AI-08: mixed feedback with exactly half approvals falls into majority branch', async () => {
|
||||
const halfHalf = [
|
||||
{ recommendation: 'Approve', score: 75 },
|
||||
{ recommendation: 'Reject', score: 40 },
|
||||
];
|
||||
// 1 approve out of 2 = not > total/2, so should fall into rejection branch
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', halfHalf);
|
||||
expect(result.success).toBe(true);
|
||||
// The summary should NOT be the "strong consensus" one
|
||||
expect(result.summary).not.toMatch(/strong consensus|exceptional/i);
|
||||
});
|
||||
});
|
||||
213
src/__tests__/notification-service.test.ts
Normal file
213
src/__tests__/notification-service.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file notification-service.test.ts
|
||||
* @description Unit tests for NotificationService — verifies that system, email,
|
||||
* and WhatsApp channels are dispatched correctly for each scenario.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.14.3 — Delivery Channels: in-system, email, WhatsApp
|
||||
* §1.1.1 — WhatsApp is a supported notification channel (reminders, workflow events)
|
||||
* §1.1.2 — LOI documents shared via email ONLY (not WhatsApp)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { sendEmail } from '../common/utils/email.service.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
Notification: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'notif-1', createdAt: new Date() }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
PushSubscription: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Disable Redis so async channels fall through to console (no BullMQ needed in tests)
|
||||
process.env.ENABLE_REDIS = 'false';
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
jest.mock('../common/utils/socket.js', () => ({
|
||||
getIO: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const sendEmailMock = sendEmail as jest.Mock;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const basePayload = {
|
||||
title: 'Test Notification',
|
||||
message: 'Test message body',
|
||||
channels: ['email', 'system'] as Array<'email' | 'whatsapp' | 'system' | 'push'>,
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationService — channel dispatch', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ── System channel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('system channel', () => {
|
||||
it('TC-NS-01: creates an in-app Notification record when system channel is included', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
|
||||
await NotificationService.notify('user-123', 'test@re.com', {
|
||||
...basePayload,
|
||||
channels: ['system'],
|
||||
});
|
||||
|
||||
expect(db.Notification.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-123', isRead: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NS-02: skips Notification.create when userId is null (applicant not yet a system user)', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
|
||||
await NotificationService.notify(null, 'applicant@gmail.com', {
|
||||
...basePayload,
|
||||
channels: ['system'],
|
||||
});
|
||||
|
||||
expect(db.Notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Email channel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('email channel', () => {
|
||||
it('TC-NS-03: does NOT call sendEmail synchronously (Redis disabled — logs and skips)', async () => {
|
||||
await NotificationService.notify('user-123', 'test@re.com', {
|
||||
...basePayload,
|
||||
channels: ['email'],
|
||||
});
|
||||
// With ENABLE_REDIS=false, the async channel is skipped; no direct sendEmail call
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-NS-04: processJob triggers sendEmail for email channel', async () => {
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-abc',
|
||||
email: 'reviewer@re.com',
|
||||
title: 'Action Required',
|
||||
message: 'Please review the application.',
|
||||
channels: ['email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: 'http://localhost:5173/apps/1' },
|
||||
});
|
||||
|
||||
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||
'reviewer@re.com',
|
||||
'Action Required',
|
||||
'WORKFLOW_ACTION_REQUIRED',
|
||||
expect.objectContaining({ link: 'http://localhost:5173/apps/1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NS-05: processJob does NOT call sendEmail when email is null/undefined', async () => {
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-abc',
|
||||
email: null,
|
||||
title: 'Test',
|
||||
message: 'No email',
|
||||
channels: ['email'],
|
||||
templateCode: 'GENERIC_NOTIFICATION',
|
||||
placeholders: {},
|
||||
});
|
||||
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WhatsApp channel ─────────────────────────────────────────────────────
|
||||
|
||||
describe('whatsapp channel', () => {
|
||||
it('TC-NS-06: processJob calls sendWhatsApp with phone from placeholders', async () => {
|
||||
const spy = jest
|
||||
.spyOn(NotificationService, 'sendWhatsApp')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-wa',
|
||||
email: null,
|
||||
title: 'WA Test',
|
||||
message: 'WhatsApp message',
|
||||
channels: ['whatsapp'],
|
||||
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||
placeholders: { phone: '+919876543210', applicantName: 'Ravi' },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'+919876543210',
|
||||
'QUESTIONNAIRE_REMINDER',
|
||||
expect.objectContaining({ applicantName: 'Ravi' })
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('TC-NS-07: sendWhatsApp resolves without throwing (mock contract)', async () => {
|
||||
await expect(
|
||||
NotificationService.sendWhatsApp('+919876543210', 'RESIGNATION_RECEIVED', {
|
||||
dealerName: 'Kumar Dealers',
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Questionnaire reminder (SRS §1.1.1) ──────────────────────────────────
|
||||
|
||||
describe('sendQuestionnaireReminder', () => {
|
||||
it('TC-NS-08: sends QUESTIONNAIRE_REMINDER via email + whatsapp channels', async () => {
|
||||
const notifySpy = jest
|
||||
.spyOn(NotificationService, 'notify')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Rahul Sharma',
|
||||
{ location: 'Chennai' }
|
||||
);
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({
|
||||
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||
channels: expect.arrayContaining(['email', 'whatsapp']),
|
||||
placeholders: expect.objectContaining({
|
||||
applicantName: 'Rahul Sharma',
|
||||
location: 'Chennai',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
notifySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('TC-NS-09: questionnaire reminder includes a CTA link to the applicant portal', async () => {
|
||||
const notifySpy = jest
|
||||
.spyOn(NotificationService, 'notify')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+91000',
|
||||
'Test User'
|
||||
);
|
||||
|
||||
const call = notifySpy.mock.calls[0][2];
|
||||
expect(call.placeholders?.link).toContain('localhost:5173');
|
||||
notifySpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @file onboarding-stage-notifications.test.ts
|
||||
* @description Integration-level tests verifying email/notification triggers at
|
||||
* EVERY stage of the Dealer Onboarding pipeline.
|
||||
*
|
||||
* Stages covered (SRS §4.1.1 + §6.x):
|
||||
* 1. Application Submitted → Opportunity/Non-Opportunity Email
|
||||
* 2. Questionnaire Link Sent → Email + WhatsApp reminder
|
||||
* 3. Questionnaire Completed → Admin notified (system)
|
||||
* 4. Shortlisted → DD-ZM + RBM notified (email + WhatsApp)
|
||||
* 5. Level 1 Interview Scheduled → DD-ZM + RBM + Applicant (Calendar mock)
|
||||
* 6. Level 1 Approved → DD-Lead + ZBH notified (email + WhatsApp)
|
||||
* 7. Level 2 Interview Scheduled → DD-Lead + ZBH + Applicant
|
||||
* 8. Level 2 Approved → NBH + DD-Head notified (email + WhatsApp)
|
||||
* 9. Level 3 Interview Scheduled → NBH + DD-Head + Applicant
|
||||
* 10. Level 3 Approved → FDD team notified (email + system)
|
||||
* 11. FDD Submitted → Finance notified (email + system)
|
||||
* 12. Finance Approved (LOI Stage) → DD-Head + NBH notified (email + WhatsApp)
|
||||
* 13. LOI Issued → Applicant via EMAIL only — NOT WhatsApp (SRS §1.1.2)
|
||||
* 14. Dealer Code Generated → Finance + Legal + DD-Admin notified (system)
|
||||
* 15. LOA Issued → Applicant + DD-Head + NBH (email)
|
||||
* 16. EOR Completed → DD-Head + NBH (system alert)
|
||||
* 17. Inauguration Logged → Applicant marked Live (system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
const mockSendWhatsApp = jest.spyOn(NotificationService, 'sendWhatsApp').mockResolvedValue(true);
|
||||
const mockScheduleMeeting = jest.spyOn(ExternalMocksService, 'mockScheduleMeeting');
|
||||
const mockQReminder = jest.spyOn(NotificationService, 'sendQuestionnaireReminder').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type NotifyCall = Parameters<typeof NotificationService.notify>;
|
||||
|
||||
const findCallByTemplate = (code: string): NotifyCall | undefined =>
|
||||
mockNotify.mock.calls.find((c: any[]) => c[2]?.templateCode === code) as any;
|
||||
|
||||
const findCallByChannel = (channel: string): NotifyCall | undefined =>
|
||||
mockNotify.mock.calls.find((c: any[]) => c[2]?.channels?.includes(channel)) as any;
|
||||
|
||||
// ─── Stage 1-2: Application + Questionnaire ──────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 1-2: Application Submission & Questionnaire', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-01: Questionnaire reminder is sent via email + WhatsApp (SRS §1.1.1)', async () => {
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Amit Sharma',
|
||||
{ location: 'Bangalore' }
|
||||
);
|
||||
|
||||
expect(mockQReminder).toHaveBeenCalledWith(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Amit Sharma',
|
||||
expect.objectContaining({ location: 'Bangalore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-02: Questionnaire reminder uses QUESTIONNAIRE_REMINDER template code', async () => {
|
||||
// Restore real implementation to verify template code
|
||||
mockQReminder.mockRestore();
|
||||
const realNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+91999',
|
||||
'Test User'
|
||||
);
|
||||
|
||||
expect(realNotify).toHaveBeenCalledWith(
|
||||
null,
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'QUESTIONNAIRE_REMINDER' })
|
||||
);
|
||||
|
||||
realNotify.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: Shortlisting ────────────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 4: Shortlisting Notification', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-03: DD-ZM receives email + WhatsApp + system after shortlisting (SRS §6.6.3)', async () => {
|
||||
await NotificationService.notify('zm-user-1', 'zm@re.com', {
|
||||
title: 'New Application Assigned — APP-2026-001',
|
||||
message: 'Rahul Verma shortlisted for Bangalore. Please evaluate.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91999', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'zm-user-1',
|
||||
'zm@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-04: RBM receives same channels as DD-ZM on shortlisting', async () => {
|
||||
await NotificationService.notify('rbm-user-1', 'rbm@re.com', {
|
||||
title: 'New Application Assigned — APP-2026-001',
|
||||
message: 'Assigned for Level 1 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91888', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('rbm-user-1', 'rbm@re.com', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 5: Level 1 Interview Scheduling ───────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 5: Level 1 Interview — Google Calendar Mock', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-05: Google Calendar mock returns a meet link and calendar event ID', async () => {
|
||||
mockScheduleMeeting.mockResolvedValueOnce({
|
||||
success: true,
|
||||
meetLink: 'https://meet.google.com/mock-abcd1234',
|
||||
calendarEventId: 'cal-event-001',
|
||||
});
|
||||
|
||||
const result = await ExternalMocksService.mockScheduleMeeting({
|
||||
type: 'Level 1 Interview',
|
||||
scheduledAt: '2026-05-15T10:00:00Z',
|
||||
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||
mode: 'Virtual',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.meetLink).toContain('meet.google.com');
|
||||
expect(result.calendarEventId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('TC-ONB-06: After scheduling, DD-ZM and RBM are notified via email + system', async () => {
|
||||
for (const [userId, email] of [['zm-1', 'zm@re.com'], ['rbm-1', 'rbm@re.com']]) {
|
||||
await NotificationService.notify(userId, email, {
|
||||
title: 'Interview Scheduled: APP-2026-001 — Level 1',
|
||||
message: 'Level 1 Interview scheduled for 15-May-2026.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'INTERVIEW_SCHEDULED',
|
||||
placeholders: { link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
const calls = mockNotify.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
calls.forEach((c: any[]) =>
|
||||
expect(c[2].channels).toEqual(expect.arrayContaining(['system', 'email']))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: Level 1 Approval ───────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 6: Level 1 Approved → DD-Lead + ZBH notified', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-07: DD-Lead receives Action Required notification after Level 1 approval', async () => {
|
||||
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||
title: 'Action Required: APP-2026-001 at Level 2 Interview',
|
||||
message: 'Level 1 approved. Please conduct Level 2 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91777', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('whatsapp');
|
||||
expect(call[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-ONB-08: ZBH receives same channels as DD-Lead after Level 1 approval', async () => {
|
||||
await NotificationService.notify('zbh-1', 'zbh@re.com', {
|
||||
title: 'Action Required: APP-2026-001',
|
||||
message: 'Awaiting Level 2 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91666', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('zbh-1', 'zbh@re.com', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 10-11: FDD → Finance ──────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 10-11: FDD Verification → Finance Review', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-09: FDD team receives email + system notification on assignment', async () => {
|
||||
await NotificationService.notify('fdd-1', 'fddagency@external.com', {
|
||||
title: 'FDD Assignment: APP-2026-001',
|
||||
message: 'You have been assigned to perform financial due diligence.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: '/fdd/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
// FDD is external — no WhatsApp per SRS §6.15
|
||||
expect(call[2].channels).not.toContain('whatsapp');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-ONB-10: Finance team receives email + system after FDD report submission', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'FDD Report Submitted: APP-2026-001',
|
||||
message: 'FDD agency has submitted the financial due diligence report.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: '/finance/fdd/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 13: LOI Issued ────────────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 13: LOI Issued — Email ONLY (SRS §1.1.2)', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-11: LOI_ISSUED notification uses email channel only (WhatsApp excluded per SRS §1.1.2)', async () => {
|
||||
// This is the critical SRS compliance test:
|
||||
// "LOI documents are shared exclusively via official email and not through WhatsApp."
|
||||
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'LOI Issued: APP-2026-001',
|
||||
message: 'Your Letter of Intent has been issued. Please check your email.',
|
||||
channels: ['email', 'system'], // WhatsApp intentionally absent
|
||||
templateCode: 'LOI_ISSUED',
|
||||
placeholders: { link: '/applications/app-1', applicantName: 'Rahul Verma' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).not.toContain('whatsapp');
|
||||
expect(call[2].templateCode).toBe('LOI_ISSUED');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-ONB-12: LOI Issued notification includes link to applicant portal', async () => {
|
||||
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'LOI Issued',
|
||||
message: 'LOI Ready',
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'LOI_ISSUED',
|
||||
placeholders: { link: 'http://localhost:5173/applications/app-1', ctaLabel: 'View LOI' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].placeholders?.link).toContain('/applications/');
|
||||
expect(call[2].placeholders?.ctaLabel).toBe('View LOI');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 14: Dealer Code Generated ─────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 14: Dealer Code Generated — SAP Mock + Notification', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-13: SAP mock returns all 4 code types (salesCode, serviceCode, gmaCode, gearCode)', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateSapCodes('app-uuid-001');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||
expect(result.data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||
expect(result.data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||
expect(result.data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||
expect(result.data.sapMasterId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('TC-ONB-14: Finance, Legal, DD-Admin are notified after Dealer Code is generated', async () => {
|
||||
const stakeholders = [
|
||||
{ id: 'finance-1', email: 'finance@re.com', label: 'Finance' },
|
||||
{ id: 'legal-1', email: 'legal@re.com', label: 'Legal' },
|
||||
{ id: 'admin-1', email: 'ddadmin@re.com', label: 'DD Admin' },
|
||||
];
|
||||
|
||||
for (const s of stakeholders) {
|
||||
await NotificationService.notify(s.id, s.email, {
|
||||
title: 'Dealer Code Generated: APP-2026-001',
|
||||
message: 'Dealer Code has been generated in SAP.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'DEALER_CODE_READY',
|
||||
placeholders: { requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledTimes(3);
|
||||
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||
expect(c[2].templateCode).toBe('DEALER_CODE_READY')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 16-17: EOR + Inauguration ─────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 16-17: EOR Completion + Inauguration', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-15: DD-Head + NBH receive system alert when EOR reaches 100% (SRS §6.19.3.4)', async () => {
|
||||
for (const [userId, email] of [['head-1', 'ddhead@re.com'], ['nbh-1', 'nbh@re.com']]) {
|
||||
await NotificationService.notify(userId, email, {
|
||||
title: 'EOR Checklist Complete: APP-2026-001',
|
||||
message: 'All EOR parameters verified. Ready for Inauguration.',
|
||||
channels: ['system'],
|
||||
templateCode: 'EOR_COMPLETED',
|
||||
placeholders: { requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledTimes(2);
|
||||
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||
expect(c[2].channels).toEqual(['system'])
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-16: Dealership marked Live — applicant receives system notification', async () => {
|
||||
await NotificationService.notify('dealer-sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'Congratulations! Your Dealership is Now Live.',
|
||||
message: 'APP-2026-001 has been inaugurated and is now Active.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'ONBOARDING_STATUS_UPDATE',
|
||||
placeholders: { status: 'Dealership Live', applicantName: 'Rahul Verma' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('email');
|
||||
expect(call[2].placeholders?.status).toBe('Dealership Live');
|
||||
});
|
||||
});
|
||||
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @file resignation-stage-notifications.test.ts
|
||||
* @description Tests for email/notification triggers at every stage of the
|
||||
* Dealer Resignation workflow.
|
||||
*
|
||||
* Stages covered (SRS §4.2 + §7.x):
|
||||
* 1. Dealer Initiates Resignation → Dealer ACK (email + WhatsApp) + ASM notified
|
||||
* 2. ASM Review → RBM + DD-ZM notified (email + WhatsApp)
|
||||
* 3. RBM + DD-ZM Joint Evaluation → ZBH notified (email + WhatsApp)
|
||||
* 4. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||
* 5. DD-Lead Review → NBH notified (email + WhatsApp)
|
||||
* 6. NBH Approval → Legal notified (email + system)
|
||||
* 7. Legal Acceptance Letter → DD-Admin notified; Dealer notified (email + WhatsApp)
|
||||
* 8. DD-Admin Closure + F&F Trigger → Finance notified (email + system)
|
||||
* 9. Send Back (any level) → ASM + Dealer notified (email + WhatsApp)
|
||||
* 10. Revoke → Dealer notified (email + WhatsApp)
|
||||
* 11. Dealer Withdrawal → Internal team notified (system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import {
|
||||
notifyStakeholdersOnTransition,
|
||||
notifyResignationSubmittedEmails,
|
||||
} from '../common/utils/workflow-email-notifications.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
const BASE_RESIGNATION = {
|
||||
id: 'res-uuid-001',
|
||||
dealerId: 'dealer-99',
|
||||
resignationId: 'RES-2026-001',
|
||||
lastOperationalDateSales: '2026-07-31',
|
||||
lastOperationalDateServices: '2026-07-31',
|
||||
};
|
||||
|
||||
const BASE_META = {
|
||||
code: 'RES-2026-001',
|
||||
dealerName: 'Sunrise Motorcycles Pvt. Ltd.',
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'Current Actor',
|
||||
action: 'Forwarded for review',
|
||||
remarks: 'All documents verified.',
|
||||
link: 'http://localhost:5173/resignation/res-uuid-001',
|
||||
};
|
||||
|
||||
const mockParticipants: any[] = [];
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
email: `${id}@re.com`,
|
||||
fullName: 'Mock User',
|
||||
mobileNumber: '+919800000001',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const makeParticipant = (id: string, roleCode: string, mobileNumber: string | null = '+91900') => ({
|
||||
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber },
|
||||
});
|
||||
|
||||
// ─── Stage 1: Dealer Initiation ───────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 1: Dealer Initiates Request', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-01: Dealer receives RESIGNATION_RECEIVED email acknowledgement', async () => {
|
||||
const { sendEmail } = await import('../common/utils/email.service.js');
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledWith(
|
||||
'dealer@sunrise.com',
|
||||
expect.stringContaining('RES-2026-001'),
|
||||
'RESIGNATION_RECEIVED',
|
||||
expect.objectContaining({ dealerName: 'Sunrise Motorcycles' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-RES-02: Dealer receives WhatsApp acknowledgement if mobileNumber exists (SRS §1.1.1)', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
const waCall = mockNotify.mock.calls.find(
|
||||
(c) => c[0] === 'dealer-99' && c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(waCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-RES-03: ASM receives RESIGNATION_SUBMITTED notification with email + system', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: null, // no phone
|
||||
});
|
||||
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91000'));
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(asmCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||
expect(asmCall?.[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 2: ASM → RBM + DD-ZM ─────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 2: ASM Review → RBM + DD-ZM Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-04: RBM receives Action Required (email + WhatsApp + system) after ASM review', async () => {
|
||||
mockParticipants.push(makeParticipant('rbm-1', 'RBM', '+91100'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'RBM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-RES-05: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||
mockParticipants.push(makeParticipant('zm-1', 'DD_ZM', '+91200'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 3: RBM/ZM → ZBH ───────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 3: RBM+ZM Evaluation → ZBH Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-06: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||
mockParticipants.push(makeParticipant('zbh-1', 'ZBH', '+91300'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZBH Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: ZBH → DD-Lead ──────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 4: ZBH Review → DD-Lead Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-07: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||
mockParticipants.push(makeParticipant('lead-1', 'DD_LEAD', '+91400'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Lead Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['email', 'whatsapp', 'system']));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 5: DD-Lead → NBH ──────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 5: DD-Lead Review → NBH Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-08: NBH receives Action Required (email + WhatsApp) for final approval', async () => {
|
||||
mockParticipants.push(makeParticipant('nbh-1', 'NBH', '+91500'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'NBH Approval', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: NBH → Legal ────────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 6: NBH Approval → Legal Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-09: Legal team receives email + system notification after NBH approval', async () => {
|
||||
mockParticipants.push(makeParticipant('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Legal Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 7: Legal Acceptance Letter ─────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 7: Legal Acceptance Letter — Dealer Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-10: Dealer receives email + WhatsApp when Legal Acceptance Letter is uploaded', async () => {
|
||||
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987654321'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Completed', {
|
||||
...BASE_META,
|
||||
dealerId: 'dealer-99',
|
||||
action: 'Legal Acceptance Letter issued',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-RES-11: DD-Admin receives in-app system notification for case closure', async () => {
|
||||
mockParticipants.push(makeParticipant('admin-1', 'DD_ADMIN', '+91600'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Admin', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'admin-1');
|
||||
expect(call?.[2].channels).toContain('system');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 9: Send Back actions ───────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Send Back — ASM notified with mandatory remarks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-12: Send Back by ZBH notifies ASM via email + WhatsApp + system (SRS §4.2.2.4)', async () => {
|
||||
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91700'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back to ASM — insufficient documentation',
|
||||
actionUserFullName: 'ZBH Actor', // ZBH acting, so ASM won't be skipped
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].placeholders?.remarks).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-RES-13: Send Back includes remarks in notification placeholders (SRS §4.2.2.4)', async () => {
|
||||
mockParticipants.push(makeParticipant('asm-2', 'ASM', '+91800'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back to ASM',
|
||||
remarks: 'MOM document missing. Please resubmit.',
|
||||
actionUserFullName: 'DD Lead Actor',
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-2');
|
||||
expect(call?.[2].placeholders?.remarks).toBe('MOM document missing. Please resubmit.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 10: Revoke ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Revoke — Dealer notified on terminal event', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-14: Dealer receives email + WhatsApp when resignation is Revoked (SRS §4.2.2.6)', async () => {
|
||||
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Revoked', {
|
||||
...BASE_META,
|
||||
dealerId: 'dealer-99',
|
||||
action: 'Resignation revoked by NBH',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||
);
|
||||
expect(dealerCall?.[2].title).toContain('Revoked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F&F Trigger (LWD) ────────────────────────────────────────────────────────
|
||||
|
||||
describe('F&F Trigger — LWD-based gate (SRS §1.1.5 + §4.2.2.8)', () => {
|
||||
it('TC-RES-15: F&F initiation is allowed when today >= LWD', () => {
|
||||
const lwd = new Date('2026-01-01'); // in the past
|
||||
const today = new Date();
|
||||
expect(today >= lwd).toBe(true); // Gate should be open
|
||||
});
|
||||
|
||||
it('TC-RES-16: F&F initiation is BLOCKED when today < LWD (future date)', () => {
|
||||
const futureLwd = new Date();
|
||||
futureLwd.setFullYear(futureLwd.getFullYear() + 1); // LWD is next year
|
||||
const today = new Date();
|
||||
expect(today < futureLwd).toBe(true); // Gate should be closed
|
||||
// In implementation: if (new Date() < new Date(resignation.lastWorkingDay)) → reject with 403
|
||||
});
|
||||
|
||||
it('TC-RES-17: Finance team is notified via email + system after F&F is initiated on LWD', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Settlement Initiated: RES-2026-001',
|
||||
message: 'Full & Final settlement has been triggered on the Last Working Day.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'RES-2026-001', dealerName: 'Sunrise Motorcycles' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].templateCode).toBe('FNF_INITIATED');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @file termination-stage-notifications.test.ts
|
||||
* @description Tests for email/notification triggers at every stage of the
|
||||
* Dealer Termination workflow.
|
||||
*
|
||||
* Stages covered (SRS §4.3 + §8.x):
|
||||
* 1. ASM Case Initiation → RBM + DD-ZM notified (email + WhatsApp)
|
||||
* 2. RBM + DD-ZM Review → ZBH notified (email + WhatsApp)
|
||||
* 3. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||
* 4. DD-Lead Review + Legal Assignment → Legal + DD-Head notified
|
||||
* 5. Legal Verification → DD-Lead notified after legal input
|
||||
* 6. DD-Head → NBH → NBH notified (email + WhatsApp)
|
||||
* 7. NBH → SCN Issuance → Legal triggered; DD-Admin + Dealer notified
|
||||
* 8. SCN Response Evaluation → Joint panel notified (system)
|
||||
* 9. NBH Final Decision → CEO + CCO notified (email + system)
|
||||
* 10. CEO/CCO Authorization → Legal notified for Termination Letter
|
||||
* 11. Termination Letter Issued → DD-Lead + DD-Admin + Finance notified
|
||||
* 12. F&F Trigger on LWD → Finance notified (email + system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { notifyStakeholdersOnTransition } from '../common/utils/workflow-email-notifications.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
const BASE_META = {
|
||||
code: 'TERM-2026-001',
|
||||
dealerName: 'ABC Motors Pvt. Ltd.',
|
||||
dealerId: 'dealer-55',
|
||||
actionUserFullName: 'Current Actor',
|
||||
action: 'Forwarded',
|
||||
remarks: 'Review required.',
|
||||
link: 'http://localhost:5173/termination/term-uuid-001',
|
||||
};
|
||||
|
||||
const mockParticipants: any[] = [];
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
email: `${id}@re.com`,
|
||||
fullName: 'Mock User',
|
||||
mobileNumber: '+919000000001',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const makeP = (id: string, roleCode: string, phone: string | null = '+91900') => ({
|
||||
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber: phone },
|
||||
});
|
||||
|
||||
// ─── Stage 1: ASM Initiation ──────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 1: ASM Initiates → RBM + DD-ZM Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-01: RBM receives email + WhatsApp + system on termination case initiation', async () => {
|
||||
mockParticipants.push(makeP('rbm-1', 'RBM', '+91100'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'RBM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
});
|
||||
|
||||
it('TC-TERM-02: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||
mockParticipants.push(makeP('zm-1', 'DD_ZM', '+91200'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-TERM-03: Dealer does NOT have portal access for termination (SRS §1.1.6)', () => {
|
||||
// This is a documentation/contract test. Dealer should NOT appear in participants for termination.
|
||||
const dealerInParticipants = mockParticipants.some(
|
||||
(p) => p.user.roleCode === 'Dealer'
|
||||
);
|
||||
expect(dealerInParticipants).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 2-3: ZBH → DD-Lead ────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 2-3: RBM+ZM → ZBH → DD-Lead', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-04: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||
mockParticipants.push(makeP('zbh-1', 'ZBH', '+91300'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZBH Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-TERM-05: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||
mockParticipants.push(makeP('lead-1', 'DD_LEAD', '+91400'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'DD Lead Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: DD-Lead → Legal ────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 4: DD-Lead → Legal Assignment', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-06: Legal team receives email + system on DD-Lead assignment (SRS §4.3.2.4)', async () => {
|
||||
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Legal Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: DD-Head → NBH ──────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 6: DD-Head → NBH Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-07: NBH receives email + WhatsApp for strategic review (SRS §4.3.2.7)', async () => {
|
||||
mockParticipants.push(makeP('nbh-1', 'NBH', '+91500'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'NBH Evaluation', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 7: SCN Issuance ───────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 7: Show Cause Notice Issuance', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-08: Legal receives notification to prepare SCN after NBH Go-Ahead (SRS §4.3.2.8)', async () => {
|
||||
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Show Cause Notice', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toContain('system');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-TERM-09: DD-Admin is notified to share SCN with dealer (system + email)', async () => {
|
||||
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||
title: 'Action Required: Share SCN — TERM-2026-001',
|
||||
message: 'Show Cause Notice is ready. Please share with the dealer.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'admin-1',
|
||||
'ddadmin@re.com',
|
||||
expect.objectContaining({ templateCode: 'WORKFLOW_ACTION_REQUIRED' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 9: NBH Final Decision → CEO/CCO ───────────────────────────────────
|
||||
|
||||
describe('Termination Stage 9: NBH Final Decision → CEO + CCO Authorization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-10: CEO receives email + system notification for final authorization (SRS §4.3.2.11)', async () => {
|
||||
await NotificationService.notify('ceo-1', 'ceo@re.com', {
|
||||
title: 'Authorization Required: Dealer Termination — TERM-2026-001',
|
||||
message: 'NBH has approved termination. CEO authorization required.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('email');
|
||||
expect(call[2].channels).toContain('system');
|
||||
});
|
||||
|
||||
it('TC-TERM-11: CCO receives same notification as CEO for co-authorization', async () => {
|
||||
await NotificationService.notify('cco-1', 'cco@re.com', {
|
||||
title: 'Authorization Required: TERM-2026-001',
|
||||
message: 'Co-authorization from CCO required.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[0]).toBe('cco-1');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 11: Termination Letter ─────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 11: Termination Letter — DD-Lead + DD-Admin + Finance Notified', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-TERM-12: DD-Lead is notified via system when Legal uploads Termination Letter (SRS §4.3.2.12)', async () => {
|
||||
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||
title: 'Termination Letter Issued: TERM-2026-001',
|
||||
message: 'Legal has uploaded the official Termination Letter.',
|
||||
channels: ['system'],
|
||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-TERM-13: DD-Admin is notified to communicate Termination Letter to dealer (email + system)', async () => {
|
||||
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||
title: 'Action Required: Communicate Termination Letter — TERM-2026-001',
|
||||
message: 'Please share the Termination Letter with the dealer.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('admin-1', 'ddadmin@re.com', expect.anything());
|
||||
});
|
||||
|
||||
it('TC-TERM-14: Finance is notified for F&F setup after termination letter (email + system)', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Initiation Required: TERM-2026-001',
|
||||
message: 'Termination complete. Please initiate F&F settlement on LWD.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].templateCode).toBe('FNF_INITIATED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F&F Trigger (LWD) — Termination Context ──────────────────────────────────
|
||||
|
||||
describe('Termination: F&F Trigger Must Be on LWD (SRS §1.1.6)', () => {
|
||||
it('TC-TERM-15: F&F is blocked when today is before LWD', () => {
|
||||
const futureLwd = new Date();
|
||||
futureLwd.setDate(futureLwd.getDate() + 30); // 30 days from now
|
||||
const canInitiateFnF = new Date() >= futureLwd;
|
||||
expect(canInitiateFnF).toBe(false);
|
||||
});
|
||||
|
||||
it('TC-TERM-16: F&F is allowed when LWD has passed', () => {
|
||||
const pastLwd = new Date('2025-01-01');
|
||||
const canInitiateFnF = new Date() >= pastLwd;
|
||||
expect(canInitiateFnF).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-TERM-17: Finance receives notification only when F&F is triggered on LWD', async () => {
|
||||
const lwd = new Date('2025-12-01'); // past date — F&F allowed
|
||||
const today = new Date();
|
||||
|
||||
if (today >= lwd) {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Triggered on Last Working Day: TERM-2026-001',
|
||||
message: 'Settlement process initiated.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'finance-1',
|
||||
'finance@re.com',
|
||||
expect.objectContaining({ templateCode: 'FNF_INITIATED' })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Send Back in Termination ─────────────────────────────────────────────────
|
||||
|
||||
describe('Termination: Send Back / Revoke → ASM + DD-Lead notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-18: ZBH Send Back notifies ASM with remarks via email + WhatsApp (SRS §4.3.2.3)', async () => {
|
||||
mockParticipants.push(makeP('asm-1', 'ASM', '+91700'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back — MOM documents incomplete',
|
||||
remarks: 'Please resubmit updated MOMs from dealer.',
|
||||
actionUserFullName: 'ZBH Actor',
|
||||
});
|
||||
|
||||
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(asmCall?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(asmCall?.[2].placeholders?.remarks).toBe('Please resubmit updated MOMs from dealer.');
|
||||
});
|
||||
|
||||
it('TC-TERM-19: DD-Lead Revoke action generates in-app notification for key observers', async () => {
|
||||
mockParticipants.push(makeP('head-1', 'DD_HEAD', '+91800'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Revoked', {
|
||||
...BASE_META,
|
||||
action: 'Revoked by DD Lead',
|
||||
actionUserFullName: 'Another User',
|
||||
});
|
||||
|
||||
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'head-1');
|
||||
// Key observer: system only for terminal event
|
||||
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
});
|
||||
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @file workflow-email-notifications.test.ts
|
||||
* @description Tests for notifyStakeholdersOnTransition + resolveNextActors.
|
||||
* Verifies that the correct personas receive the correct channels at each
|
||||
* onboarding, resignation, and termination stage.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.14.3 — Next actor gets email + WhatsApp + in-app
|
||||
* §6.14.3 — Send Back notifies ASM via email + WhatsApp + in-app
|
||||
* §6.12.3 — Rejection notifies applicant via email + WhatsApp
|
||||
* §6.13 — Work Notes / observer roles get in-app only on terminal events
|
||||
*/
|
||||
|
||||
import {
|
||||
notifyStakeholdersOnTransition,
|
||||
resolveNextActors,
|
||||
notifyResignationSubmittedEmails,
|
||||
notifyRelocationSubmittedEmails,
|
||||
notifyConstitutionalSubmittedEmails,
|
||||
} from '../common/utils/workflow-email-notifications.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { sendEmail } from '../common/utils/email.service.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
// Helper: build a participant user object
|
||||
const makeUser = (overrides: Partial<{
|
||||
id: string; email: string; fullName: string; roleCode: string; mobileNumber: string | null;
|
||||
}> = {}) => ({
|
||||
id: overrides.id ?? 'user-1',
|
||||
email: overrides.email ?? 'user@re.com',
|
||||
fullName: overrides.fullName ?? 'Test User',
|
||||
roleCode: overrides.roleCode ?? 'DD_ADMIN',
|
||||
mobileNumber: overrides.mobileNumber ?? '+919800000001',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeParticipant = (user: ReturnType<typeof makeUser>) => ({ user });
|
||||
|
||||
// ─── Mock DB ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockParticipants: ReturnType<typeof makeParticipant>[] = [];
|
||||
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: {
|
||||
findAll: jest.fn(async () => mockParticipants),
|
||||
},
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
fullName: 'System User',
|
||||
email: 'sysuser@re.com',
|
||||
mobileNumber: '+910000000000',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Shared metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
const baseMeta = {
|
||||
code: 'RES-2026-0001',
|
||||
dealerName: 'Sunrise Motorcycles',
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'Ravi Kumar (ASM)',
|
||||
action: 'Forwarded to RBM',
|
||||
remarks: 'Documents verified.',
|
||||
link: 'http://localhost:5173/resignation/abc123',
|
||||
};
|
||||
|
||||
// ─── resolveNextActors ────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveNextActors — stage-to-role mapping', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RNA-01: Level 1 Interview resolves to DD_ZM and RBM', async () => {
|
||||
const zmUser = makeUser({ id: 'zm-1', roleCode: 'DD_ZM' });
|
||||
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM' });
|
||||
mockParticipants.push(makeParticipant(zmUser), makeParticipant(rbmUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 1 Interview');
|
||||
expect(actors).toContain('zm-1');
|
||||
expect(actors).toContain('rbm-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-02: Level 2 Interview resolves to ZBH and DD_LEAD', async () => {
|
||||
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH' });
|
||||
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD' });
|
||||
mockParticipants.push(makeParticipant(zbhUser), makeParticipant(leadUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 2 Interview');
|
||||
expect(actors).toContain('zbh-1');
|
||||
expect(actors).toContain('lead-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-03: Level 3 Interview resolves to NBH and DD_HEAD', async () => {
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
mockParticipants.push(makeParticipant(nbhUser), makeParticipant(headUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 3 Interview');
|
||||
expect(actors).toContain('nbh-1');
|
||||
expect(actors).toContain('head-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-04: LOI Approval resolves to DD_HEAD (DD Head has not yet approved)', async () => {
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||
|
||||
// StageApprovalAction returns empty (no approvals yet)
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([]);
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||
// Sequential: DD Head first
|
||||
expect(actors).toContain('head-1');
|
||||
expect(actors).not.toContain('nbh-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-05: LOI Approval resolves to NBH after DD Head has approved', async () => {
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorRole: 'DD Head' }, // DD Head already approved
|
||||
]);
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||
expect(actors).toContain('nbh-1');
|
||||
expect(actors).not.toContain('head-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-06: NBH Approval stage resolves to NBH only', async () => {
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(nbhUser));
|
||||
|
||||
const actors = await resolveNextActors('res-1', 'resignation', 'NBH Approval');
|
||||
expect(actors).toEqual(['nbh-1']);
|
||||
});
|
||||
|
||||
it('TC-RNA-07: Legal Review stage resolves to LEGAL_ADMIN', async () => {
|
||||
const legalUser = makeUser({ id: 'legal-1', roleCode: 'LEGAL_ADMIN' });
|
||||
mockParticipants.push(makeParticipant(legalUser));
|
||||
|
||||
const actors = await resolveNextActors('res-1', 'resignation', 'Legal Review');
|
||||
expect(actors).toContain('legal-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-08: Unknown stage returns empty array (no crash)', async () => {
|
||||
const actors = await resolveNextActors('app-1', 'application', 'NonExistentStage');
|
||||
expect(actors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── notifyStakeholdersOnTransition ──────────────────────────────────────────
|
||||
|
||||
describe('notifyStakeholdersOnTransition — channel selection per persona', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-NST-01: Next actor receives system + email + whatsapp channels', async () => {
|
||||
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM', mobileNumber: '+919800000001' });
|
||||
mockParticipants.push(makeParticipant(rbmUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'RBM Review', baseMeta);
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'rbm-1',
|
||||
'user@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-02: Next actor without phone gets system + email only (no WhatsApp)', async () => {
|
||||
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH', mobileNumber: null });
|
||||
mockParticipants.push(makeParticipant(zbhUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'zbh-1',
|
||||
'user@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.not.arrayContaining(['whatsapp']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-03: Send Back action notifies ASM via system + email + whatsapp', async () => {
|
||||
const asmUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91900' });
|
||||
mockParticipants.push(makeParticipant(asmUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ASM Review', {
|
||||
...baseMeta,
|
||||
action: 'Sent Back to ASM for clarification',
|
||||
actionUserFullName: 'DD Lead User', // different from ASM — won't be skipped
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'asm-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-04: Dealer receives system + email + whatsapp on Rejected terminal event', async () => {
|
||||
const dealerUser = makeUser({
|
||||
id: 'dealer-99',
|
||||
roleCode: 'Dealer',
|
||||
mobileNumber: '+91987654321',
|
||||
});
|
||||
mockParticipants.push(makeParticipant(dealerUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Rejected', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'dealer-99',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-05: Dealer receives in-app only on non-terminal interim stage', async () => {
|
||||
const dealerUser = makeUser({
|
||||
id: 'dealer-99',
|
||||
roleCode: 'Dealer',
|
||||
mobileNumber: '+91987654321',
|
||||
});
|
||||
mockParticipants.push(makeParticipant(dealerUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ZBH Review', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-NST-06: Key observers (DD_LEAD, DD_HEAD, NBH) receive in-app only on terminal events', async () => {
|
||||
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD', mobileNumber: '+91900' });
|
||||
mockParticipants.push(makeParticipant(leadUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Completed', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'DD Lead User', // acting user – but leadUser will still match key observer
|
||||
});
|
||||
|
||||
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
// Key observer: only in-app (system)
|
||||
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-NST-07: Acting user is skipped to avoid self-notification', async () => {
|
||||
const actingUser = makeUser({
|
||||
id: 'acting-1',
|
||||
roleCode: 'ZBH',
|
||||
fullName: 'Ravi Kumar (ASM)', // same as baseMeta.actionUserFullName
|
||||
});
|
||||
// actingUser is NOT the next actor (no role match for ZBH Review mapping)
|
||||
mockParticipants.push(makeParticipant(actingUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||
|
||||
// Should not notify the acting user (they are the one who just acted)
|
||||
const actingCall = mockNotify.mock.calls.find((c) => c[0] === 'acting-1');
|
||||
expect(actingCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── notifyResignationSubmittedEmails ─────────────────────────────────────────
|
||||
|
||||
describe('notifyResignationSubmittedEmails — channels on dealer submission', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-NRSE-01: sends RESIGNATION_RECEIVED email to dealer on submission', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
const sendEmailMock = sendEmail as jest.Mock;
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
lastOperationalDateSales: '2026-06-30',
|
||||
});
|
||||
|
||||
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||
'dealer@example.com',
|
||||
expect.stringContaining('RES-2026-0001'),
|
||||
'RESIGNATION_RECEIVED',
|
||||
expect.objectContaining({ dealerName: 'Sunrise Dealers' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NRSE-02: sends WhatsApp to dealer when mobileNumber is present', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
lastOperationalDateSales: '2026-06-30',
|
||||
});
|
||||
|
||||
const whatsappCall = mockNotify.mock.calls.find(
|
||||
(c) => c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(whatsappCall).toBeDefined();
|
||||
expect(whatsappCall?.[2].templateCode).toBe('RESIGNATION_RECEIVED');
|
||||
});
|
||||
|
||||
it('TC-NRSE-03: notifies internal participants via email + system + whatsapp', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
const internalUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91111' });
|
||||
mockParticipants.push(makeParticipant(internalUser));
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
});
|
||||
|
||||
const internalCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(internalCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['email', 'system', 'whatsapp'])
|
||||
);
|
||||
expect(internalCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||
});
|
||||
|
||||
it('TC-NRSE-04: skips WhatsApp if dealer has no mobileNumber', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-2',
|
||||
email: 'dealer2@example.com',
|
||||
fullName: 'No Phone Dealer',
|
||||
mobileNumber: null,
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-2',
|
||||
dealerId: 'dealer-2',
|
||||
resignationId: 'RES-2026-0002',
|
||||
});
|
||||
|
||||
const whatsappCall = mockNotify.mock.calls.find(
|
||||
(c) => c[0] === 'dealer-2' && c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(whatsappCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
298
src/__tests__/workflow-service.test.ts
Normal file
298
src/__tests__/workflow-service.test.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @file workflow-service.test.ts
|
||||
* @description Tests for WorkflowService.transitionApplication — verifies that
|
||||
* each status transition triggers the correct notifications, audit log entries,
|
||||
* and SLA tracking calls.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.22 — Every workflow event is auto-logged in Audit Trail
|
||||
* §6.14.3 — Status transitions trigger in-system + email + WhatsApp for applicant
|
||||
* §6.16 — LOI Issued triggers LOI_ISSUED email template (not WhatsApp per SRS §1.1.2)
|
||||
* §9.4 — SLA tracking starts/stops on each stage transition
|
||||
*/
|
||||
|
||||
import { WorkflowService } from '../services/WorkflowService.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { SLAService } from '../services/SLAService.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../database/models/index.js', () => {
|
||||
const mockUpdate = jest.fn().mockResolvedValue(true);
|
||||
const mockCreate = jest.fn().mockResolvedValue({ id: 'hist-1' });
|
||||
const mockFindOne = jest.fn().mockResolvedValue(null);
|
||||
const mockFindByPk = jest.fn().mockResolvedValue({ fullName: 'Test Actor' });
|
||||
|
||||
return {
|
||||
default: {
|
||||
Application: {},
|
||||
ApplicationStatusHistory: { create: mockCreate },
|
||||
User: { findOne: mockFindOne, findByPk: mockFindByPk },
|
||||
Dealer: {},
|
||||
StageApprovalPolicy: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
__mocks__: { mockUpdate, mockCreate, mockFindOne },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../common/utils/progress.js', () => ({
|
||||
syncApplicationProgress: jest.fn().mockResolvedValue(undefined),
|
||||
PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: {},
|
||||
}));
|
||||
|
||||
jest.mock('../common/config/constants.js', () => ({
|
||||
AUDIT_ACTIONS: { UPDATED: 'UPDATED' },
|
||||
APPLICATION_STAGES: { SHORTLISTED: 'Shortlisted', LOI_ISSUED: 'LOI Issued' },
|
||||
OVERALL_STATUS_TO_DB_CURRENT_STAGE: {},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/workflow-email-notifications.js', () => ({
|
||||
notifyStakeholdersOnTransition: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../services/applicationAuditLog.service.js', () => ({
|
||||
pickApplicationAuditContext: jest.fn().mockReturnValue({}),
|
||||
safeAuditLogCreate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockSLAStop = jest.spyOn(SLAService, 'stopTrack').mockResolvedValue(undefined as any);
|
||||
const mockSLAStart = jest.spyOn(SLAService, 'startTrack').mockResolvedValue(undefined as any);
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Application fixture ─────────────────────────────────────────────────────
|
||||
|
||||
const makeApp = (overrides: Record<string, any> = {}) => {
|
||||
const app: any = {
|
||||
id: 'app-uuid-001',
|
||||
applicationId: 'APP-2026-001',
|
||||
overallStatus: 'Shortlisted',
|
||||
currentStage: 'Shortlisted',
|
||||
progressPercentage: 20,
|
||||
email: 'applicant@gmail.com',
|
||||
applicantName: 'Rahul Verma',
|
||||
mobileNumber: '+919876543210',
|
||||
update: jest.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
return app;
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('WorkflowService.transitionApplication — status transitions', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-WS-01: updates application overallStatus to the target status', async () => {
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||
expect(app.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ overallStatus: 'Level 1 Interview Pending' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-02: creates an ApplicationStatusHistory record on each transition', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 2 Interview Pending', 'user-2');
|
||||
expect(db.ApplicationStatusHistory.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: 'app-uuid-001',
|
||||
previousStatus: 'Shortlisted',
|
||||
newStatus: 'Level 2 Interview Pending',
|
||||
changedBy: 'user-2',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-03: skips redundant status history when status is already at target', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
const app = makeApp({ overallStatus: 'Level 1 Interview Pending' });
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||
expect(db.ApplicationStatusHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-WS-04: calls safeAuditLogCreate with oldData and newData on each transition', async () => {
|
||||
const { safeAuditLogCreate } = await import('../services/applicationAuditLog.service.js');
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-fdd');
|
||||
expect(safeAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATED',
|
||||
entityType: 'application',
|
||||
entityId: 'app-uuid-001',
|
||||
oldData: expect.objectContaining({ status: 'Shortlisted' }),
|
||||
newData: expect.objectContaining({ status: 'FDD Verification' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-05: stops SLA tracking for the previous stage and starts for the new stage', async () => {
|
||||
const app = makeApp({ currentStage: 'Level 1 Interview Pending' });
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-1');
|
||||
expect(mockSLAStop).toHaveBeenCalledWith('app-uuid-001', 'Level 1 Interview Pending');
|
||||
});
|
||||
|
||||
it('TC-WS-06: triggers NotificationService.notify for the applicant on any status transition', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-admin');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining('Onboarding Update'),
|
||||
channels: expect.arrayContaining(['email', 'system']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-07: uses LOI_ISSUED template when target status is "LOI Issued"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'LOI Issued', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'LOI_ISSUED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-08: uses LOA_ISSUED template when target status is "LOA Issued"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'LOA Issued', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'LOA_ISSUED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-09: uses DEALER_CODE_READY template when target status is "Dealer Code Generated"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: null });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Dealer Code Generated', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'DEALER_CODE_READY' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-10: notifyStakeholdersOnTransition is called for every transition', async () => {
|
||||
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'ZBH Review', 'user-zbh');
|
||||
expect(notifyStakeholdersOnTransition).toHaveBeenCalledWith(
|
||||
'app-uuid-001',
|
||||
'application',
|
||||
'ZBH Review',
|
||||
expect.objectContaining({
|
||||
code: 'APP-2026-001',
|
||||
dealerName: 'Rahul Verma',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-11: skips applicant notification when skipNotification metadata is true', async () => {
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Shortlisted', 'admin-1', {
|
||||
skipNotification: true,
|
||||
forceLog: true,
|
||||
});
|
||||
expect(mockNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-WS-12: includes applicant mobileNumber in WhatsApp placeholders', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({
|
||||
id: 'sys-user-1',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'admin-1');
|
||||
const notifyCall = mockNotify.mock.calls.find(
|
||||
(c) => c[1] === 'applicant@gmail.com'
|
||||
);
|
||||
expect(notifyCall?.[2].placeholders?.phone).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WorkflowService.evaluateStagePolicy ─────────────────────────────────────
|
||||
|
||||
describe('WorkflowService.evaluateStagePolicy — multi-role gate logic', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-WESP-01: returns policyMet=true when no active policy exists for the stage', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce(null);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'SOME_STAGE');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WESP-02: Super Admin bypass — always returns policyMet=true', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'su-1', actorRole: 'Super Admin', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
expect(result.overriddenBy).toBe('Super Admin');
|
||||
});
|
||||
|
||||
it('TC-WESP-03: MIN_N mode — policyMet=true when minApprovals threshold is reached', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'MIN_N',
|
||||
minApprovals: 1,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WESP-04: ALL mode — policyMet=false when only one of two required roles responded', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
// RBM has NOT responded yet
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(false);
|
||||
});
|
||||
|
||||
it('TC-WESP-05: ALL mode — policyMet=true when both RBM and DD_ZM have responded', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
{ actorUserId: 'rbm-1', actorRole: 'RBM', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -3,11 +3,16 @@
|
||||
*/
|
||||
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||
'APPLICANT_SHORTLISTED',
|
||||
'APPLICANT_REJECTED',
|
||||
'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||
'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||
'DEALER_CODE_READY',
|
||||
'EOR_COMPLETED',
|
||||
'FNF_INITIATED',
|
||||
'GENERIC_NOTIFICATION',
|
||||
'INTERVIEW_SCHEDULED',
|
||||
'INTERVIEW_SCHEDULED_APPLICANT',
|
||||
'INTERVIEW_SCHEDULED_PANELIST',
|
||||
'LOA_ISSUED',
|
||||
'LOI_ISSUED',
|
||||
'NON_OPPORTUNITY',
|
||||
@ -26,10 +31,13 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||
'SLA_REMINDER',
|
||||
'SLA_BREACH',
|
||||
'SLA_ESCALATION',
|
||||
'TERMINATION_INITIATED',
|
||||
'TERMINATION_SCN_ISSUED',
|
||||
'TERMINATION_UPDATE',
|
||||
'USER_ASSIGNED',
|
||||
'WORKNOTE_NOTIFICATION'
|
||||
'WORKNOTE_NOTIFICATION',
|
||||
'WORKFLOW_ACTION_REQUIRED',
|
||||
'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
] as const;
|
||||
|
||||
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];
|
||||
|
||||
34
src/emailtemplates/applicant_rejected.html
Normal file
34
src/emailtemplates/applicant_rejected.html
Normal file
@ -0,0 +1,34 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Application Rejected — {{applicationId}}</h2>
|
||||
<p>Dear {{applicantName}},</p>
|
||||
<p>
|
||||
We regret to inform you that your Royal Enfield Dealership Application
|
||||
(<strong>{{applicationId}}</strong>) for location <strong>{{location}}</strong>
|
||||
has been <strong>rejected</strong> after careful evaluation.
|
||||
</p>
|
||||
|
||||
{{#if rejectionReason}}
|
||||
<div class="highlight-box" style="background:#fff3f3; border-left:4px solid #e53935;">
|
||||
<strong>Reason for Rejection:</strong><br />
|
||||
{{rejectionReason}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>
|
||||
We appreciate your interest in partnering with Royal Enfield.
|
||||
You may reapply in the future when opportunities are available in your area.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For any queries, please contact your local RE representative or reach us at
|
||||
<a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.
|
||||
</p>
|
||||
|
||||
<p>We wish you all the best in your endeavours.</p>
|
||||
|
||||
<p>Best Regards,<br /><strong>Royal Enfield Dealer Development Team</strong></p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
38
src/emailtemplates/eor_completed.html
Normal file
38
src/emailtemplates/eor_completed.html
Normal file
@ -0,0 +1,38 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>EOR Checklist Complete — {{requestId}}</h2>
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
All <strong>Essential Operating Requirements (EOR)</strong> for dealer application
|
||||
<strong>{{requestId}}</strong> (Applicant: <strong>{{applicantName}}</strong>) have been
|
||||
marked as completed and verified.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box" style="background:#e8f5e9; border-left:4px solid #43a047;">
|
||||
<strong>EOR Status: 100% Complete</strong><br />
|
||||
The dealership outlet is now ready for Inauguration review.
|
||||
</div>
|
||||
|
||||
<table class="detail-table">
|
||||
<tr><td><strong>Application ID</strong></td><td>{{requestId}}</td></tr>
|
||||
<tr><td><strong>Applicant Name</strong></td><td>{{applicantName}}</td></tr>
|
||||
<tr><td><strong>Location</strong></td><td>{{location}}</td></tr>
|
||||
<tr><td><strong>Completed On</strong></td><td>{{completedOn}}</td></tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
Please review the EOR checklist and, if all criteria are met, authorize the
|
||||
<strong>Inauguration</strong> stage to mark this dealership as live.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is an automated alert from the Royal Enfield Dealer Onboarding System.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
35
src/emailtemplates/fnf_initiated.html
Normal file
35
src/emailtemplates/fnf_initiated.html
Normal file
@ -0,0 +1,35 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Full & Final Settlement Initiated — {{requestId}}</h2>
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
The Full & Final (F&F) settlement process has been initiated for dealer
|
||||
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>)
|
||||
effective from the Last Working Day.
|
||||
</p>
|
||||
|
||||
<table class="detail-table">
|
||||
<tr><td><strong>Request ID</strong></td><td>{{requestId}}</td></tr>
|
||||
<tr><td><strong>Dealer Name</strong></td><td>{{dealerName}}</td></tr>
|
||||
<tr><td><strong>Initiated By</strong></td><td>{{initiatedBy}}</td></tr>
|
||||
<tr><td><strong>Last Working Day</strong></td><td>{{lwd}}</td></tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
All department clearances (NOC, Payables, Receivables, etc.) must be submitted
|
||||
within the stipulated timeline. Please log in and update your department's
|
||||
clearance status.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is a system-generated notification. F&F settlement can only be
|
||||
initiated on or after the Last Working Day as per Royal Enfield policy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
41
src/emailtemplates/termination_initiated.html
Normal file
41
src/emailtemplates/termination_initiated.html
Normal file
@ -0,0 +1,41 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Dealer Termination Initiated — {{requestId}}</h2>
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
A formal termination process has been initiated for dealer
|
||||
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>).
|
||||
</p>
|
||||
|
||||
<table class="detail-table">
|
||||
<tr><td><strong>Request ID</strong></td><td>{{requestId}}</td></tr>
|
||||
<tr><td><strong>Dealer Name</strong></td><td>{{dealerName}}</td></tr>
|
||||
<tr><td><strong>Termination Category</strong></td><td>{{category}}</td></tr>
|
||||
<tr><td><strong>Initiated By</strong></td><td>{{initiatedBy}}</td></tr>
|
||||
<tr><td><strong>Current Stage</strong></td><td>{{currentStage}}</td></tr>
|
||||
</table>
|
||||
|
||||
{{#if remarks}}
|
||||
<div class="highlight-box" style="background:#fff8e1; border-left:4px solid #fb8c00;">
|
||||
<strong>Remarks:</strong><br />
|
||||
{{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>
|
||||
Please review the termination case and provide your evaluation as required.
|
||||
All decisions must be documented with mandatory work notes.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This notification is confidential and intended only for the named recipient.
|
||||
Do not share this information externally without authorization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
30
src/emailtemplates/workflow_action_required.html
Normal file
30
src/emailtemplates/workflow_action_required.html
Normal file
@ -0,0 +1,30 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Action Required: {{requestId}}</h2>
|
||||
<p>Hi,</p>
|
||||
<p>
|
||||
The request <strong>{{requestId}}</strong>
|
||||
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}
|
||||
has reached the <strong>{{targetStage}}</strong> stage and requires your review and action.
|
||||
</p>
|
||||
|
||||
{{#if remarks}}
|
||||
<div class="highlight-box">
|
||||
<strong>Remarks from previous stage:</strong><br />
|
||||
{{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>Please log in to the RE Dealer Management Portal to review the case details and take action.</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is an automated notification. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
28
src/emailtemplates/workflow_status_update_dealer.html
Normal file
28
src/emailtemplates/workflow_status_update_dealer.html
Normal file
@ -0,0 +1,28 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Update on Your Request — {{requestId}}</h2>
|
||||
<p>Dear {{dealerName}},</p>
|
||||
<p>
|
||||
Your request <strong>{{requestId}}</strong> has been updated.
|
||||
Current status: <strong>{{targetStage}}</strong>.
|
||||
</p>
|
||||
|
||||
{{#if remarks}}
|
||||
<div class="highlight-box">
|
||||
<strong>Note:</strong> {{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>You can track the live progress of your request on the dealer portal:</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is an automated status update. If you have questions, please contact your assigned Area Sales Manager.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
316
src/scripts/seed-missing-templates.ts
Normal file
316
src/scripts/seed-missing-templates.ts
Normal file
@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @file seed-missing-templates.ts
|
||||
* @description Seeds the 6 email templates that are referenced in workflow code
|
||||
* but were missing from the DB and the allowed-email-template-codes list.
|
||||
*
|
||||
* Run with: npx ts-node src/scripts/seed-missing-templates.ts
|
||||
*/
|
||||
|
||||
import db from '../database/models/index.js';
|
||||
|
||||
const seedMissingTemplates = async () => {
|
||||
try {
|
||||
console.log('--- Seeding Missing Workflow Email Templates ---');
|
||||
|
||||
const templates = [
|
||||
// ── 1. Workflow Action Required ────────────────────────────────────────
|
||||
// Used in: workflow-email-notifications.ts (next-actor, send-back notifications)
|
||||
// constitutional.controller.ts
|
||||
// Recipients: DD-ZM, RBM, ZBH, DD-Lead, DD-Head, NBH, Legal, Finance, FDD
|
||||
// Channels: system + email + whatsapp (when phone available)
|
||||
{
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
description: 'Sent to the next actor in any workflow when their action is required. Used across Onboarding, Resignation, Termination, Constitutional, and Relocation.',
|
||||
subject: 'Action Required: {{requestId}} — {{targetStage}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Action Required</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Hi,</p>
|
||||
<p>
|
||||
The request <strong>{{requestId}}</strong>
|
||||
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}
|
||||
has reached the <strong>{{targetStage}}</strong> stage and requires your review and action.
|
||||
</p>
|
||||
{{#if remarks}}
|
||||
<div style="background:#fff8e1;border-left:4px solid #fb8c00;padding:12px 16px;margin:16px 0;">
|
||||
<strong>Remarks:</strong><br/>{{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<p>Please log in to the RE Dealer Management Portal to review and act:</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
{{#if ctaLabel}}{{ctaLabel}}{{else}}Review & Take Action{{/if}}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#888;font-size:12px;">This is an automated notification. Do not reply to this email.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['requestId', 'dealerName', 'targetStage', 'link', 'remarks', 'ctaLabel']
|
||||
},
|
||||
|
||||
// ── 2. Workflow Status Update — Dealer ─────────────────────────────────
|
||||
// Used in: workflow-email-notifications.ts (dealer on interim + terminal events)
|
||||
// Recipients: Dealer / Applicant
|
||||
// Channels: system always; email+whatsapp on terminal (rejection/completion)
|
||||
{
|
||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
description: 'Milestone/status update sent to the dealer or applicant when their request moves to a new stage.',
|
||||
subject: 'Update on Your Request — {{requestId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Request Status Update</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Dear {{dealerName}},</p>
|
||||
<p>
|
||||
Your request <strong>{{requestId}}</strong> has been updated.
|
||||
Current status: <strong>{{targetStage}}</strong>.
|
||||
</p>
|
||||
{{#if remarks}}
|
||||
<div style="background:#f0f4ff;border-left:4px solid #3d6be8;padding:12px 16px;margin:16px 0;">
|
||||
<strong>Note:</strong> {{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<p>Track the live progress of your request:</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
View Request
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#888;font-size:12px;">
|
||||
For queries, contact your assigned Area Sales Manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['requestId', 'dealerName', 'targetStage', 'link', 'remarks']
|
||||
},
|
||||
|
||||
// ── 3. F&F Initiated ───────────────────────────────────────────────────
|
||||
// Used in: resignation.controller.ts (when F&F is triggered on LWD)
|
||||
// termination.controller.ts (post-termination F&F)
|
||||
// Recipients: Finance team, Department heads, DD-Admin
|
||||
// Channels: system + email
|
||||
{
|
||||
templateCode: 'FNF_INITIATED',
|
||||
description: 'Notifies Finance and department heads when Full & Final (F&F) settlement is triggered on the Last Working Day.',
|
||||
subject: 'F&F Settlement Initiated — {{requestId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Full & Final Settlement Initiated</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
The Full & Final (F&F) settlement process has been initiated for dealer
|
||||
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>).
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Request ID</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Dealer Name</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{dealerName}}</td>
|
||||
</tr>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Last Working Day</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{lwd}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Initiated By</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{initiatedBy}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Please update your department clearance status promptly.</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
View F&F Settlement
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#888;font-size:12px;">
|
||||
Per Royal Enfield policy, F&F settlement is initiated only on or after the Last Working Day.
|
||||
</p>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'lwd', 'initiatedBy', 'link']
|
||||
},
|
||||
|
||||
// ── 4. EOR Completed ───────────────────────────────────────────────────
|
||||
// Used in: eor.controller.ts (when all EOR checklist items are complete)
|
||||
// Recipients: DD-Head, NBH
|
||||
// Channels: system (alert only — no email per SRS §6.19.3.4 design choice)
|
||||
// Adding email template so Admin can optionally enable
|
||||
{
|
||||
templateCode: 'EOR_COMPLETED',
|
||||
description: 'Alert to DD-Head and NBH when 100% of EOR (Essential Operating Requirements) checklist items are verified. Signals readiness for Inauguration stage.',
|
||||
subject: 'EOR Checklist Complete — Ready for Inauguration: {{requestId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#2e7d32;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">✓ EOR Checklist — 100% Complete</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
All <strong>Essential Operating Requirements (EOR)</strong> for dealer application
|
||||
<strong>{{requestId}}</strong> (Applicant: <strong>{{applicantName}}</strong>)
|
||||
have been completed and verified.
|
||||
</p>
|
||||
<div style="background:#e8f5e9;border-left:4px solid #43a047;padding:12px 16px;margin:16px 0;">
|
||||
<strong>EOR Status: 100% Complete</strong><br/>
|
||||
The dealership is now ready for Inauguration review.
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Application ID</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Applicant</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{applicantName}}</td>
|
||||
</tr>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Location</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{location}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Completed On</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{completedOn}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Please review and authorize the <strong>Inauguration</strong> stage to mark this dealership as Live.</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#2e7d32;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
Authorize Inauguration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['recipientName', 'applicantName', 'requestId', 'location', 'completedOn', 'link']
|
||||
},
|
||||
|
||||
// ── 5. Applicant Rejected ──────────────────────────────────────────────
|
||||
// Used in: WorkflowService.transitionApplication (any rejection)
|
||||
// Recipients: Applicant (external)
|
||||
// Channels: email (SRS §6.12.3 — rejection via email)
|
||||
// WhatsApp if mobileNumber present
|
||||
{
|
||||
templateCode: 'APPLICANT_REJECTED',
|
||||
description: 'Sent to the applicant/dealer when their application is rejected at any stage of the onboarding process.',
|
||||
subject: 'Update on Your Dealership Application — {{applicationId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#b71c1c;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Application Status Update</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Dear {{applicantName}},</p>
|
||||
<p>
|
||||
We regret to inform you that your Royal Enfield Dealership Application
|
||||
(<strong>{{applicationId}}</strong>) for location <strong>{{location}}</strong>
|
||||
has been <strong>rejected</strong> after careful evaluation.
|
||||
</p>
|
||||
{{#if rejectionReason}}
|
||||
<div style="background:#fff3f3;border-left:4px solid #e53935;padding:12px 16px;margin:16px 0;">
|
||||
<strong>Reason:</strong><br/>{{rejectionReason}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<p>
|
||||
We appreciate your interest in partnering with Royal Enfield. You may reapply
|
||||
when opportunities are available in your area in the future.
|
||||
</p>
|
||||
<p>
|
||||
For any queries, contact us at
|
||||
<a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.
|
||||
</p>
|
||||
<p>Best Regards,<br/><strong>Royal Enfield Dealer Development Team</strong></p>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['applicantName', 'applicationId', 'location', 'rejectionReason']
|
||||
},
|
||||
|
||||
// ── 6. Termination Initiated ───────────────────────────────────────────
|
||||
// Used in: termination.controller.ts (case creation)
|
||||
// Recipients: RBM, DD-ZM, ZBH, DD-Lead, Legal, NBH
|
||||
// Channels: system + email + whatsapp
|
||||
{
|
||||
templateCode: 'TERMINATION_INITIATED',
|
||||
description: 'Notifies internal stakeholders (RBM, DD-ZM, ZBH, Legal, NBH) when a dealer termination case is formally initiated by ASM.',
|
||||
subject: 'Dealer Termination Case Initiated — {{requestId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#4a148c;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Dealer Termination Case Initiated</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
A formal termination process has been initiated for dealer
|
||||
<strong>{{dealerName}}</strong> (Request ID: <strong>{{requestId}}</strong>).
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Request ID</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Dealer Name</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{dealerName}}</td>
|
||||
</tr>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Termination Category</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{category}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Initiated By</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{initiatedBy}}</td>
|
||||
</tr>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<td style="padding:8px;border:1px solid #ddd;"><strong>Current Stage</strong></td>
|
||||
<td style="padding:8px;border:1px solid #ddd;">{{currentStage}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{#if remarks}}
|
||||
<div style="background:#fff8e1;border-left:4px solid #fb8c00;padding:12px 16px;margin:16px 0;">
|
||||
<strong>Remarks:</strong><br/>{{remarks}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<p>Please review the case and provide your evaluation with mandatory work notes.</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#4a148c;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
Review Termination Case
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#888;font-size:12px;">
|
||||
This notification is confidential. Do not share externally without authorization.
|
||||
</p>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'category', 'initiatedBy', 'currentStage', 'remarks', 'link']
|
||||
}
|
||||
];
|
||||
|
||||
for (const t of templates) {
|
||||
const [, created] = await db.EmailTemplate.upsert({
|
||||
...t,
|
||||
isActive: true
|
||||
});
|
||||
console.log(`${created ? 'Created' : 'Updated'}: ${t.templateCode}`);
|
||||
}
|
||||
|
||||
console.log('\n--- Missing Templates Seeded Successfully ---');
|
||||
console.log(`Total: ${templates.length} templates seeded.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error seeding missing templates:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
seedMissingTemplates();
|
||||
Loading…
Reference in New Issue
Block a user