added new email templates to cover scenerios in detail way

This commit is contained in:
laxman h 2026-04-29 19:49:30 +05:30
parent 8d7805acc9
commit 3c95146f4a
21 changed files with 14147 additions and 1 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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);
});
});

View 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();
});
});
});

View 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');
});
});

View 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');
});
});

View 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']);
});
});

View 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();
});
});

View 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);
});
});

View File

@ -3,11 +3,16 @@
*/ */
export const ALLOWED_EMAIL_TEMPLATE_CODES = [ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED', 'APPLICANT_SHORTLISTED',
'APPLICANT_REJECTED',
'CONSTITUTIONAL_CHANGE_SUBMITTED', 'CONSTITUTIONAL_CHANGE_SUBMITTED',
'CONSTITUTIONAL_CHANGE_UPDATE', 'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY', 'DEALER_CODE_READY',
'EOR_COMPLETED',
'FNF_INITIATED',
'GENERIC_NOTIFICATION', 'GENERIC_NOTIFICATION',
'INTERVIEW_SCHEDULED', 'INTERVIEW_SCHEDULED',
'INTERVIEW_SCHEDULED_APPLICANT',
'INTERVIEW_SCHEDULED_PANELIST',
'LOA_ISSUED', 'LOA_ISSUED',
'LOI_ISSUED', 'LOI_ISSUED',
'NON_OPPORTUNITY', 'NON_OPPORTUNITY',
@ -26,10 +31,13 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'SLA_REMINDER', 'SLA_REMINDER',
'SLA_BREACH', 'SLA_BREACH',
'SLA_ESCALATION', 'SLA_ESCALATION',
'TERMINATION_INITIATED',
'TERMINATION_SCN_ISSUED', 'TERMINATION_SCN_ISSUED',
'TERMINATION_UPDATE', 'TERMINATION_UPDATE',
'USER_ASSIGNED', 'USER_ASSIGNED',
'WORKNOTE_NOTIFICATION' 'WORKNOTE_NOTIFICATION',
'WORKFLOW_ACTION_REQUIRED',
'WORKFLOW_STATUS_UPDATE_DEALER',
] as const; ] as const;
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number]; export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];

View 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}}

View 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}}

View File

@ -0,0 +1,35 @@
{{> email_header}}
<div class="section">
<h2>Full & Final Settlement Initiated — {{requestId}}</h2>
<p>Dear {{recipientName}},</p>
<p>
The Full &amp; Final (F&amp;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&amp;F settlement can only be
initiated on or after the Last Working Day as per Royal Enfield policy.
</p>
</div>
{{> email_footer}}

View 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}}

View 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}}

View 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}}

View 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 &amp; 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 &amp; Final (F&amp;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&amp;F Settlement
</a>
</div>
<p style="color:#888;font-size:12px;">
Per Royal Enfield policy, F&amp;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();