Compare commits

..

18 Commits

Author SHA1 Message Date
80495a78a6 typo chnge errors and Loi Document request mail templatd added 2026-05-26 12:41:14 +05:30
29d67f6ca6 reported bugs around 15 were coverd from even new bug list also coverd from bangalore meet 2026-05-25 22:51:47 +05:30
ac31b9ba57 i male base url issue resolved 2026-05-22 20:40:24 +05:30
061dc8e260 build issues resolved 2026-05-21 22:40:15 +05:30
c9c03f8761 smtp implemented and sla tracker enhancement done 2026-05-20 20:25:06 +05:30
9c6c585073 started removing document type dependecy and f&F screeen bug changes fixed 2026-05-19 21:25:24 +05:30
f5d7ccc1ab few de demo bugs and sla tracker implemeted alog with sla moitor screen 2026-05-18 21:08:49 +05:30
f5022b613d bugs were covered 2026-05-15 20:17:06 +05:30
e99a28b7f7 few more bugs fixed and dealer ide ui alignmen and visibibity chabges done F& F made manuall trigger 2026-05-14 14:38:35 +05:30
fb07f7ab61 system log table added and feew bugs coverd from the tracker 2026-05-13 20:43:59 +05:30
eeae163782 new mail templates added for edge scenerios 2026-05-12 20:00:29 +05:30
0ab90ee356 contitutional and relocation changes done based on document alignment 2026-05-06 10:45:14 +05:30
5ddbe525e6 stage names modified and calendar added in opportunity requests added and checked resignation and termination flow end to end from chennai 2026-05-04 13:28:52 +05:30
2b73036bb9 notification service enhanced even more detailed way added more templates documentented i splitted based on modulewise 2026-04-30 18:49:11 +05:30
3c95146f4a added new email templates to cover scenerios in detail way 2026-04-29 19:49:30 +05:30
8d7805acc9 added joint approval in resignation for RBM DD-ZM approval stage 2026-04-29 16:37:45 +05:30
ede68caefc stage transition issue resolved 2026-04-28 13:12:20 +05:30
b6938abc7c progress track isue for interview level 1 fixed 2026-04-28 08:32:28 +05:30
172 changed files with 24187 additions and 2320 deletions

View File

@ -19,7 +19,8 @@ DB_HOST=localhost
DB_PORT=5432
DB_SSL=false
# Email Configuration
# Email: ENABLE_SMTP=true → real SMTP (GSM / EMAIL_*); false → Ethereal test inbox
ENABLE_SMTP=false
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_SECURE=true
@ -27,6 +28,14 @@ EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-app-password
EMAIL_FROM="Royal Enfield <noreply@royalenfield.com>"
# Google Secret Manager (optional — fetches SMTP_* secrets into EMAIL_* env vars)
USE_GOOGLE_SECRET_MANAGER=false
# Optional — defaults to project_id inside the credentials JSON
# GCP_PROJECT_ID=your-gcp-project-id
GCP_KEY_FILE=./credentials/your-service-account.json
GCP_SECRET_PREFIX=
GCP_SECRET_MAP_FILE=./config/gcp-secret-map.smtp.example.json
# Web Push Notifications (VAPID)
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
@ -35,3 +44,19 @@ VAPID_EMAIL=admin@royalenfield.com
# File Uploads
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# Redis / BullMQ (required for scheduled jobs: SLA, LWD, questionnaire reminders, notifications)
ENABLE_REDIS=false
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# DEBUG_SLA_FAST_MODE=true # Internal SLA checks every minute (dev only)
# DEBUG_SLA_REPEAT_IN_FAST_MODE=true # Also send repeat-overdue every minute in fast mode (usually OFF)
# SLA_BUSINESS_HOURS=false # Disable MonFri 918h TAT counting (default: on unless fast mode)
# DEBUG_OFFBOARDING_LWD_FAST_MODE=true # LWD reminder sweep every 15 min (dev only)
# Prospect questionnaire reminders (scheduled — NOT internal SLA)
QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED=true
QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS=1
QUESTIONNAIRE_REMINDER_INTERVAL_DAYS=2
QUESTIONNAIRE_REMINDER_MAX_COUNT=5
# DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true # Sweep every 15 min (dev only)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Royal Enfield Onboarding</title>
<script type="module" crossorigin src="/assets/index-CAe70IkM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DkEVuJwH.css">
<script type="module" crossorigin src="/assets/index-XdyJ-8da.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DqVo88us.css">
</head>
<body>
<div id="root"></div>

View File

@ -0,0 +1,8 @@
{
"SMTP_HOST": "EMAIL_HOST",
"SMTP_PORT": "EMAIL_PORT",
"SMTP_USER": "EMAIL_USER",
"SMTP_PASSWORD": "EMAIL_PASSWORD",
"SMTP_SECURE": "EMAIL_SECURE",
"EMAIL_FROM": "EMAIL_FROM"
}

View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "re-platform-workflow-dealer",
"private_key_id": "89a922d4d0ea7743669856c4c56908e76d35a46b",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC+LBc4luOn56vz\n+lOarDmT9W9SaSpCysLbGFELAebwwEbzV/0AdbSSZCPezJgMeIjbgtbKMVoVXA79\nqFbeT4tNWrg+4+kSxHaUyaDnIStpc/x1WRAxpfzYDnmDrTuHuvJXJxs7zOlZO3Tz\nVfRFWLwiuwbOqj/sggmusMd9n8b2oJrtWAwGGnHLePo63kU+rz/DtDlhP9da7LG8\nTEgu7yRJ+CHI1OQnEz+HcjSxa2S2qhYIZkwjTy6DwyMBBO4vamou//RDUW+26Dxb\nDMstqS9KRtxfr4VENChn1TwOAQ1doCulzkmaptMwgYiJryO6XFx3T2KO4yDCUWTk\nuyA4gqQxAgMBAAECggEABmHxUlLVn5G15qeN2WbK/KtXzfO1wXW/zBXNCAYbgbRp\nWTLYfOTGb4SdA9X46y+7M6dYtULqZaXAIs0s47OWnr0zCnjQnnKxsmkRPXqic+zK\nsgdQBOOP2LJFK6lDcojwp+JKDqPwJ8CYKm/VxcNOdqmxZvulrzbW03ovStkDRu0S\ndM8Iw1NqpymbPDV57YPiXszx7mHnEIptpM1ODruulNnFMDXWImkmcWdRtJP0abHk\nz9hOQEC/KXCuoHXZl7EYdDhvrHssfzGTBajb0W2X3e5S25pmIyc9k1d8Jamvfyr0\nZYiOF1MaiGaUH39qI7rtWHic9vbxVC2BN/mUIXgYYQKBgQD493/C5fsFI40V1n9P\nM9RRTdmXtz20Tu/zOC/d8ohnqmc5Eyi2NwGIz99UqsVIIt/droPouz+pAJgrF6/8\nWv2CJsYh8d9beN4L8HCd8AEWbKzCZxcb2KQIVbVPJ7c0O8RmcNTqow2l8qqSRI+1\naX7kiQxD0Dbmmf43+Laf2INuUQKBgQDDi2VNPLQyxCEuG7TYjcwzN1bl+V/XeYmD\nrXTC+feuh4juluZiNdbLn8OR/ktF10BUgOOqJ9KaXDffTQbuJOoBVNBZ3+2VImHj\n+zZzMxceG4T1S8KcQrQgrBjtzmIYo2jux7x24HkQM9qU7sRFAOgq0IIi7tasmGkC\nkvYuTVz/4QKBgFgzxs2TkJTHfYpJDZ1PrV6IiBgZ0QB6HsQ6Gas162Fem2c7BGdZ\noW+IxYRHY9Ekmc79rrna7LjA/yf1ImHzEnDzr6oC+LB9Z50vN5acmqYJJkNRJny1\nCZfyVWOPnHYi3ne0bZoa3hD2obtkEs2gbFYmv3Oe5nRYBhpqQLjsidOhAoGAes6l\n3V8dcLCaggGmj0Zmk1fS/HWkSogq5AbgyL8CXZsDVYxxvgZAEvwQcDT7gy5PWYLk\n+G0wJ/94m4YdrxyB1jo06+zlof7I6cxQgwL4JtFzrDZbT5XY2Jgcw+UU2JJwCV5p\nr2MExTc7tMNLgmayaIkw4c2MBzNk59fyQlwV5yECgYBMOzR9dxY+8ExjK4OHCXqo\nu/ZM0VKz5dorS8QA2wq2xfl27wTe+4Kl4veMGQ74iO6Pb1z6buORXjTD2S4/SvPN\nl0LtrkftjHZVN9/X2NfOh4YmUUg5U6lXDFQJ6F1NLcS+ZRaIG5zKJNX3zLH0HHmb\nL+V24teGBIY4sN3OzJ2s9A==\n-----END PRIVATE KEY-----\n",
"client_email": "re-bridge-workflow@re-platform-workflow-dealer.iam.gserviceaccount.com",
"client_id": "108776059196607325512",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/re-bridge-workflow%40re-platform-workflow-dealer.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@ -1,28 +1,30 @@
version: '3.8'
services:
# Redis - Required for BullMQ background jobs (notifications, SLAs)
# Uses host networking so Compose does not create a bridge (avoids DOCKER-FORWARD / iptables failures on some hosts).
# Redis listens on the host on port 6379 (same as REDIS_PORT in .env).
redis:
image: redis:7.2-alpine
container_name: re-onboarding-redis
restart: always
ports:
- "6379:6379"
network_mode: host
volumes:
- redis_data:/data
command: redis-server --save 60 1 --loglevel warning
# RedisInsight - GUI for monitoring Redis/BullMQ (Optional)
# Connect in the UI to host.docker.internal:6379 (extra_hosts maps that to the host where Redis runs).
redis-insight:
image: redislabs/redisinsight:latest
container_name: re-onboarding-redis-insight
restart: always
ports:
- "8001:8001"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- redis
# PostgreSQL - Application Database
# PostgreSQL - Uses the default bridge; `docker compose up` (full stack) still needs iptables/Docker networking working.
postgres:
image: postgres:15-alpine
container_name: re-onboarding-db

View File

@ -32,7 +32,7 @@ A major addition allowing onboarded dealers to manage their own lifecycle via th
- **Manual Trigger:** Dealer codes are no longer auto-generated; they require a manual trigger by **DD Admin** for creation in SAP Master.
### 3.2 Sequence Corrections
- **LOA before EOR:** The workflow now ensures the **Letter of Authorization (LOA)** is issued *before* starting the **Essential Operating Requirements (EOR)** checklist.
- **LOA before EOR:** The workflow now ensures the **Letter of Agreement (LOA)** is issued *before* starting the **Essential Operating Requirements (EOR)** checklist.
### 3.3 Settlement Logic
- **LWD-Based Trigger:** F&F (Full & Final) settlements are strictly triggered on the **Last Working Day (LWD)**, ensuring accuracy regardless of when the resignation was approved.

View File

@ -40,6 +40,8 @@ These constants are the primary drivers of application logic and stage-gate cont
| `LOA Issued` | Final Appointment Letter issued |
| `Onboarded` | System provisioning complete (Active Dealer) |
> **Full status list (sequential order, UI mapping, parallel statutory/architecture):** see [ONBOARDING_APPLICATION_STATUSES.md](./ONBOARDING_APPLICATION_STATUSES.md).
### 📦 Request Types (`REQUEST_TYPES`)
- `application`: New Onboarding
- `resignation`: Voluntary Exit

View File

@ -0,0 +1,237 @@
# Onboarding — Application Status Reference
This document lists every **`overallStatus`** value used in dealer onboarding: what the UI shows, how statuses map to pipeline progress, and the typical lifecycle order from application submission through inauguration and onboarding.
**Source of truth (code):**
| Area | Path |
|------|------|
| Status constants | `backend/src/common/config/constants.ts``APPLICATION_STATUS` |
| Status → pipeline stage | `backend/src/common/utils/progress.ts``PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS` |
| Progress milestones | `backend/src/common/utils/progress.ts``ONBOARDING_STAGES` |
| Status → DB `currentStage` | `backend/src/common/config/constants.ts``OVERALL_STATUS_TO_DB_CURRENT_STAGE` |
| UI type / badges | `frontend/src/lib/mock-data.ts``ApplicationStatus` |
---
## Three layers (do not confuse)
| Layer | Field / table | Shown in UI as | Purpose |
|-------|----------------|----------------|---------|
| **Overall status** | `Application.overallStatus` | Status badge on lists, cards, Application Details sidebar | Fine-grained lifecycle label (this document) |
| **Current stage** | `Application.currentStage` | Sometimes in filters / audit; coarser enum | Role-based workflow stage (`DD`, `FDD`, `LOI`, `LOA`, `EOR`, etc.) |
| **Progress milestones** | `ApplicationProgress.stageName` | Application Details → **Progress** tab (“Application Journey”) | Ordered pipeline steps (`Submitted`, `Questionnaire`, `Shortlist`, …) |
`overallStatus` is what users most often see as **Status**. Progress tab labels can differ (e.g. badge **Level 1 Interview Pending** maps to milestone **1st Level Interview**).
---
## Happy path — sequential `overallStatus` (submission → inauguration)
Typical forward flow. Not every application visits every row (policy gates, parallel work, and admin shortcuts can skip or reorder steps).
| # | `overallStatus` (UI label) | Phase | Notes |
|---|---------------------------|-------|-------|
| 1 | **Submitted** | Intake | Default when no active opportunity; non-opportunity applications stay here until questionnaire is sent |
| 2 | **Questionnaire Pending** | Questionnaire | Default when application is tied to an active opportunity |
| 3 | **Questionnaire Completed** | Questionnaire | Set when prospect submits public questionnaire |
| 4 | **Shortlisted** | Evaluation | DD shortlists; interviews can be scheduled |
| 5 | **Level 1 Interview Pending** | Interview L1 | Set when Level 1 interview is scheduled |
| 6 | **Level 1 Approved** | Interview L1 | Level 1 panel decision (policy-managed) |
| 7 | **Level 2 Interview Pending** | Interview L2 | Level 2 scheduled |
| 8 | **Level 2 Approved** | Interview L2 | Level 2 passed |
| 9 | **Level 3 Interview Pending** | Interview L3 | Level 3 scheduled |
| 10 | **Level 3 Approved** | Interview L3 | Optional; admin quick-approve path before FDD |
| 11 | **FDD Verification** | FDD | Financial due diligence; interview L3 approval may jump here directly |
| 12 | **LOI In Progress** | LOI | LOI approval workflow (DD Head / NBH policy) |
| 13 | **Payment Pending** | Security deposit | Finance verification of security deposit |
| 14 | **Security Deposit** | Security deposit | LOI approved; deposit verified; gate before LOI issue (`SECURITY_DETAILS` constant) |
| 15 | **LOI Issued** | LOI | Letter of Intent issued to applicant |
| 16 | **Dealer Code Generation** | SAP / codes | Dealer codes generated (DD Admin trigger) |
| 17 | **Architecture Team Assigned** | Architecture (parallel) | Architecture team assigned |
| 18 | **Architecture Document Upload** | Architecture (parallel) | Site / blueprint uploads |
| 19 | **Architecture Team Completion** | Architecture (parallel) | Architecture track complete |
| 20 | **LOA Pending** | LOA | LOA approval; may require First Fill verified by Finance |
| 21 | **LOA Issued** | LOA | LOA document generated (policy path) |
| 22 | **EOR In Progress** | EOR | EOR checklist started |
| 23 | **EOR Complete** | EOR | All EOR items compliant |
| 24 | **Inauguration** | Go-live | EOR audit submitted as Completed; dealership ready for inauguration |
| 25 | **Approved** | Go-live | Treated like inauguration in progress / progress sync |
| 26 | **Onboarded** | Active dealer | Final onboarding after inauguration action; dealer profile active |
**Short path (badge only):**
`Submitted``Questionnaire Pending``Questionnaire Completed``Shortlisted` → … → `Inauguration``Onboarded`
---
## Statutory sub-statuses (parallel with architecture)
After **Dealer Code Generation**, statutory compliance can surface granular statuses (all map to pipeline **Statutory Work**):
| `overallStatus` | Meaning |
|-----------------|---------|
| **Statutory GST** | GST certificate step |
| **Statutory PAN** | PAN step |
| **Statutory Nodal** | Nodal agreement |
| **Statutory Check** | Cancelled check / bank proof |
| **Statutory Partnership** | Partnership / LLP / MOA documents |
| **Statutory Firm Reg** | Firm registration |
| **Statutory Rental** | Property / rental agreement |
| **Statutory Virtual Code** | Virtual code |
| **Statutory Domain** | Domain setup |
| **Statutory MSD** | MSD configuration |
| **Statutory LOI Ack** | LOI acknowledgement copy |
| **Statutory Work** | Generic statutory milestone |
Generic architecture labels: **Architecture Work**, plus team assignment statuses in the happy-path table above.
---
## Alternate / branch statuses
| `overallStatus` | When used |
|-----------------|-----------|
| **Pending** | Legacy / initial; progress sync maps to **Submitted** |
| **In Review** | Legacy; maps to pipeline **Shortlist** |
| **Level 2 Recommended** | Alternate Level 2 outcome (still 2nd Level Interview pipeline) |
| **Returned to FDD** | Application sent back to FDD from a later stage |
| **Security Details** | **Deprecated** DB label; same stage as **Security Deposit** — migrate to **Security Deposit** |
Frontend also styles optional labels that may appear from older APIs or composite UI (see `frontend/src/lib/mock-data.ts`): e.g. **LOI Approved**, **Security Deposit In Progress**, **Architecture Work In Progress**, **Dealer Code Generated**, etc.
---
## Terminal (end-state) statuses
| `overallStatus` | Outcome |
|-----------------|---------|
| **Rejected** | Application rejected (any stage) |
| **Disqualified** | Disqualified |
| **LOI Rejected** | LOI approval rejected |
| **LOA Rejected** | LOA approval rejected |
---
## Progress tab milestones (`ONBOARDING_STAGES`)
Shown on Application Details → **Progress** (not always identical to the status badge text):
| Order | Milestone name | Parallel |
|------:|----------------|----------|
| 1 | Submitted | |
| 2 | Questionnaire | |
| 3 | Shortlist | |
| 4 | 1st Level Interview | |
| 5 | 2nd Level Interview | |
| 6 | 3rd Level Interview | |
| 7 | FDD | |
| 8 | LOI Approval | |
| 9 | Security Deposit | |
| 10 | LOI Issue | |
| 11 | Dealer Code Generation | |
| 12 | Architecture Work | Yes (order 12) |
| 12 | Statutory Work | Yes (order 12) |
| 13 | LOA | |
| 14 | EOR Complete | |
| 15 | Inauguration | |
| 16 | Onboarded | |
UI journey definition: `frontend/src/features/onboarding/hooks/useApplicationDetailsStageData.ts` (`processStages`).
---
## Mapping: `overallStatus` → progress milestone
Used by `syncApplicationProgress()` and SLA stage naming:
| `overallStatus` | Pipeline milestone (`ApplicationProgress.stageName`) |
|-----------------|-----------------------------------------------------|
| Submitted | Submitted |
| Questionnaire Pending / Completed | Questionnaire |
| Shortlisted, In Review | Shortlist |
| Level 1 Interview Pending / Approved | 1st Level Interview |
| Level 2 Interview Pending / Approved / Recommended | 2nd Level Interview |
| Level 3 Interview Pending / Approved | 3rd Level Interview |
| FDD Verification, Returned to FDD | FDD |
| LOI In Progress | LOI Approval |
| Payment Pending, Security Deposit, Security Details | Security Deposit |
| LOI Issued, Statutory LOI Ack, LOI Rejected | LOI Issue |
| Dealer Code Generation | Dealer Code Generation |
| Architecture Team * / Architecture Work | Architecture Work |
| Statutory * / Statutory Work | Statutory Work |
| LOA Pending / LOA Issued / LOA Rejected | LOA |
| EOR In Progress / EOR Complete | EOR Complete |
| Inauguration / Approved | Inauguration |
| Onboarded | Onboarded |
| Rejected / Disqualified | Rejected |
Full map: `PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS` in `progress.ts`.
---
## Mapping: `overallStatus``currentStage` (DB enum)
Coarser internal stage on `Application.currentStage` (see `OVERALL_STATUS_TO_DB_CURRENT_STAGE`):
| `currentStage` | Example `overallStatus` values |
|----------------|--------------------------------|
| `DD` | Submitted, Questionnaire *, Shortlisted, Level * Pending |
| `Level 1 Approved` | Level 1 Approved |
| `Level 2 Approved` | Level 2 Approved |
| `Level 2 Recommended` | Level 2 Recommended |
| `Level 3 Approved` | Level 3 Approved |
| `FDD` | FDD Verification, Returned to FDD |
| `LOI` | LOI In Progress, Security Deposit, Payment Pending, LOI Issued, Dealer Code Generation, Statutory LOI Ack |
| `Architecture Work` | Architecture Team *, Architecture Work |
| `Statutory Work` | Statutory * |
| `LOA` | LOA Pending, LOA Issued, LOA Rejected |
| `EOR` | EOR In Progress, EOR Complete |
| `Approved` | Inauguration, Onboarded, Approved |
| `Rejected` | Rejected, Disqualified, LOI Rejected |
---
## Key transitions (implementation notes)
| Trigger | Typical transition |
|---------|-------------------|
| Application create (opportunity) | → **Questionnaire Pending** |
| Application create (no opportunity) | → **Submitted** |
| Public questionnaire submit | → **Questionnaire Completed** |
| Bulk / single shortlist | → **Shortlisted** |
| Schedule interview level N | → **Level N Interview Pending** |
| Interview L1/L2 approve (policy) | → **Level 1 Approved** / **Level 2 Approved** |
| Interview L3 approve (policy) | → **FDD Verification** (may skip **Level 3 Approved**) |
| FDD complete / admin approve | → **LOI In Progress** |
| LOI policy + deposit verified | → **Security Deposit** |
| LOI issued | → **LOI Issued****Dealer Code Generation** |
| Dealer codes created (integrity service) | → **LOA Pending** (architecture/statutory may continue in parallel) |
| LOA policy met | → **EOR In Progress** (or **LOA Issued** on policy path) |
| EOR checklist Completed | → **Inauguration** |
| Dealer onboard action | → **Onboarded** (requires **Inauguration** / **Approved** and EOR complete) |
Admin sequential override (frontend): `useApplicationDetailsAdminActions.ts` — manual **Approve** button steps through statuses when policy does not apply.
---
## UI surfaces by page
| Page / component | Statuses typically shown |
|------------------|-------------------------|
| **Opportunity Requests** | Questionnaire Pending, Questionnaire Completed, Shortlisted |
| **All Applications (DD)** | Early statuses (Submitted, Questionnaire *, Shortlisted, interview pending) |
| **Applications / cards** | All `overallStatus` values from API |
| **Application Details** | Current `overallStatus` badge + Progress tab milestones |
| **Non-Opportunities** | **Submitted** (not yet in opportunity flow) |
---
## Related documentation
- Module overview: `modular_wise/01_Dealer_Onboarding.md`
- SLA vs applicant reminders: `sla/ONBOARDING_SLA_RULES.md`
- Data flows summary: `Detailed_Module_Data_Flows.md`
---
*Last aligned with codebase: `APPLICATION_STATUS` and `progress.ts` as of document creation.*

View File

@ -0,0 +1,118 @@
# Royal Enfield Dealer Onboarding — Email Content Brief (Client & Stakeholders)
**Audience:** Marketing, Legal, Dealer Development leadership, and anyone preparing or approving customer-facing copy.
**Not a technical document:** This brief explains *what* each communication is for, *who* sees it, and *what your team should supply*. Engineering implements delivery; you own the words and policy.
**Where to edit live copy:** After go-live, authorised users can change subject lines and HTML bodies in the application under **Master Configuration → Email Templates** without developer involvement. Run the seed script only when adding *new* template types.
**Companion (technical) catalogue:** For merge-field names, file paths, and automation triggers tied to code, see `RE_Dealer_Email_Templates.md`.
---
## How to use this document
1. **Review each row** in the tables below. Decide whether the default copy fits your brand and regulatory needs.
2. **Fill the “Your team should confirm” column** with decisions (e.g. exact deposit amounts, bank account text, legal disclaimers, contact paths).
3. **Send your marked-up copy** back to the implementation team *or* enter it directly in **Email Templates** in the admin portal.
4. **Placeholders** shown in curly braces (e.g. `{{applicantName}}`) are replaced automatically by the system—do not remove them unless Product agrees to drop that personalisation.
**Tone guidance (default):** Professional, warm, concise; Royal Enfield voice; avoid jargon where a plain “next step” works; always include a clear call-to-action and support path.
---
## A. Onboarding — Applicant (prospect) journey
| # | Working name | Purpose (plain English) | Who receives it | When it is sent (business event) | Your team should confirm |
|---|--------------|-------------------------|-----------------|----------------------------------|---------------------------|
| A1 | Opportunity available | Invite applicant to complete the assessment questionnaire | Applicant email | Location is in an open opportunity window | Questionnaire link text, support contact |
| A2 | Non-opportunity regret | Politely close the lead when no window exists | Applicant | Application submitted, no opportunity | Regret wording, future contact policy |
| A3 | Questionnaire submitted | Acknowledge receipt of assessment | Applicant | Questionnaire submitted | Optional next-step expectation |
| A4 | Questionnaire reminder | Nudge to complete pending questionnaire | Applicant (+ WhatsApp if mobile on file) | Bulk reminder from DD-Admin | Reminder frequency policy |
| A5 | Shortlisted | Congratulate and point to portal | Applicant | Bulk or single shortlist | Portal URL / “what happens next” |
| A6 | **Prospect document request** | Ask for KYC / business documents after shortlist | Applicant (+ WhatsApp if available) | Immediately after shortlist | **Full checklist** (PAN, net worth, bank statements, etc.), deadlines, file rules |
| A7 | Application rejected | Formal rejection with optional reason | Applicant | Rejection at any evaluation stage | Standard reason codes, escalation contact |
| A8 | Interview (applicant / panelist) | Schedule, reschedule, or cancel interviews | Applicant and/or panelists | Interview actions in assessment module | Meeting etiquette, virtual link policy |
| A9 | LOI issued | LOI available on portal | Applicant (email only per policy) | LOI document generated | LOI legal disclaimer, portal path |
| A10 | **Statutory document request** | Collect licences, NOCs, deeds, GST, etc. post-LOI | Applicant | After LOI issuance email | **Authoritative statutory list** for your state/entity types |
| A11 | **LOI acknowledgement reminder** | Chase if LOI not acknowledged | Applicant (+ WhatsApp if available) | **Manual:** DD-Admin sends bulk “LOI ack reminders” for selected applications | Deadline text, consequences of non-ack |
| A12 | **Security deposit request** | Request deposit and proof upload | Applicant (+ WhatsApp if available) | After applicant acknowledges LOI | **Amount**, bank vs online flow, timeline |
| A13 | Payment verified (internal) | Finance verified deposit | DD-Admin / DD-Lead | Finance marks payment paid | Internal wording only |
| A14 | LOA issued | Appointment letter ready | Applicant + internal teams | LOA fully approved | Celebration tone, dealer code display rules |
| A15 | **Dealership agreement signature** | E-sign request for agreement | Applicant | Same event as LOA issuance (second email) | **Agreement** legal intro, signing deadline |
| A16 | Dealer codes ready | SAP sales/service codes | Applicant | Dealer code generation stage | Code formatting, next operational steps |
| A17 | **FDD document request** | Financial due diligence checklist | Applicant | FDD agency assigned | **FDD checklist**, partner naming, confidentiality line |
| A18 | **Architecture / site inputs** | Request drawings, photos, civic approvals | Applicant | Architecture lead assigned on application | **Input list** aligned with your architecture SOP |
| A19 | **Document received acknowledgement** | Confirm each upload | Applicant | Each document upload to the application | Whether per-file email is acceptable volume-wise |
| A20 | **Document submission reminder** | Remind pending uploads | Applicant (+ WhatsApp if available) | **Manual:** bulk “document reminders” from DD-Admin | Default pending text vs customised list per campaign |
| A21 | **Document rejected — resubmit** | Explain rejection and ask for corrected file | Applicant (+ WhatsApp if available) | DD-Admin / DD-Lead / Legal rejects a document on portal | Standard rejection categories, appeal path |
| A22 | Onboarding status update | Generic status change | Applicant | Many workflow transitions | When to use vs specific templates |
| A23 | EOR complete | All EOR items verified | DD-Head / NBH (configurable) | EOR checklist 100% | Internal handoff language |
| A24 | Inauguration logged | Dealership live | Internal teams | Inauguration recorded | Distribution list |
---
## B. Collaboration & workflow (internal and dealer)
| # | Working name | Purpose | Who receives it | When | Your team should confirm |
|---|--------------|---------|-----------------|------|---------------------------|
| B1 | User assigned | Someone joined the case as participant | Assigned user | Participant added | Role titles |
| B2 | Worknote mention | @mention in worknote | Mentioned user | Mention in collaboration | — |
| B3 | Action required | Next approvers turn | Next actor (+ WhatsApp if phone) | Workflow advance / send-back | CTA labels |
| B4 | Dealer status update | Milestone to dealer | Dealer / applicant | Interim or terminal workflow events | Terminal vs interim policy |
---
## C. Resignation, termination, relocation, constitutional change, F&F, SLA
Use the same pattern: confirm legal wording for **termination**, **show-cause**, **F&F amounts**, and **SLA** escalation text with Legal and Finance. Detailed rows can mirror Section A format in your next revision; the technical catalogue lists every code already live.
---
## D. Operational notes for your team
1. **Bulk actions (DD-Admin):**
- **Questionnaire reminders** — existing endpoint.
- **Document submission reminders** — new; send to selected `applicationIds`; optional custom “pending” paragraph.
- **LOI acknowledgement reminders** — new; only sends if LOI is approved, a generated LOI document exists, and **no** acknowledgement record is present yet.
2. **Document rejection:** DD-Admin, DD-Lead, Legal Admin, or Super Admin can call the reject API (or future UI button) with a **mandatory reason**; the applicant receives the resubmission email.
3. **WhatsApp:** Where a template supports WhatsApp, the system uses the mobile number on the applicant or user record. If no number is stored, email is still sent.
4. **Default checklists in code:** Until you replace them in **Email Templates**, the system uses sensible default bullet lists for prospect documents, statutory documents, FDD, and architecture inputs. **Legal should replace these** with your official lists.
---
## E. Sign-off sheet (optional)
| Template (working name) | Owner | Copy approved (Y/N) | Legal approved (Y/N) | Effective date |
|-------------------------|-------|---------------------|------------------------|------------------|
| Prospect document request | | | | |
| Statutory document request | | | | |
| LOI acknowledgement reminder | | | | |
| Security deposit request | | | | |
| Dealership agreement signature | | | | |
| FDD document request | | | | |
| Architecture / site inputs | | | | |
| Document received acknowledgement | | | | |
| Document submission reminder | | | | |
| Document rejected — resubmit | | | | |
---
## F. Reference — API endpoints (for IT / integration; not for dealers)
All routes require an authenticated staff token (same as other onboarding APIs).
| Action | Method & path | JSON body |
|--------|----------------|-----------|
| Bulk document upload reminder | `POST /api/onboarding/applications/document-reminders` | `{ "applicationIds": ["<uuid>", ...], "pendingDocuments": "<optional paragraph>", "dueDate": "<optional display string>" }` |
| Bulk LOI acknowledgement reminder | `POST /api/onboarding/applications/loi-ack-reminders` | `{ "applicationIds": ["<uuid>", ...], "dueDate": "<optional>" }` — skips applications that already have an LOI acknowledgement on the latest generated LOI document |
| Reject an uploaded document | `POST /api/onboarding/applications/:id/documents/:documentId/reject` | `{ "rejectionReason": "<required>", "dueDate": "<optional>" }` — roles: DD Admin, DD Lead, Legal Admin, Super Admin |
---
**Document version:** 1.0
**Aligned with application build:** May 2026
When you update copy in production, record the change owner and date in your own change log; the portal stores template versions in the database.

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,163 @@
# RE Dealer System - Test Coverage Tracker
This tracker is maintained separately from the test stories document.
Update module by module as execution progresses.
---
## Status Legend
- `Not Started` - Test story not executed yet
- `In Progress` - Execution started but not finalized
- `Executed` - Execution completed
- `Blocked` - Cannot execute due to dependency/environment issue
Result values:
- `Pass`
- `Fail`
- `NA` (Not applicable or not executed yet)
---
## Overall Coverage
| Metric | Count |
|---|---:|
| Total Test Stories | 61 |
| Executed | 0 |
| Passed | 0 |
| Failed | 0 |
| Blocked | 0 |
| Remaining | 61 |
---
## Module Summary
| Module | Total | Executed | Passed | Failed | Blocked | Remaining |
|---|---:|---:|---:|---:|---:|---:|
| Module 1 - Dealer Onboarding | 22 | 0 | 0 | 0 | 0 | 22 |
| Module 2 - Dealer Resignation | 9 | 0 | 0 | 0 | 0 | 9 |
| Module 3 - Dealer Termination | 12 | 0 | 0 | 0 | 0 | 12 |
| Module 4 - Constitutional Change | 3 | 0 | 0 | 0 | 0 | 3 |
| Module 5 - Dealer Relocation | 3 | 0 | 0 | 0 | 0 | 3 |
| Module 6 - Full and Final (F&F) Settlement | 6 | 0 | 0 | 0 | 0 | 6 |
| Module 7 - Finance Dashboard | 2 | 0 | 0 | 0 | 0 | 2 |
| Module 8 - Admin and System Configuration | 4 | 0 | 0 | 0 | 0 | 4 |
---
## Module 1 - Dealer Onboarding
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 1.1.1 | Applicant Submits Application (Opportunity Location) | Not Started | NA | | | |
| 1.1.2 | Applicant Submits Application (Non-Opportunity Location) | Not Started | NA | | | |
| 1.1.3 | Application Submission Validation Failure | Not Started | NA | | | |
| 1.2.1 | Applicant Completes Questionnaire | Not Started | NA | | | |
| 1.3.1 | DD-Admin Shortlists Application | Not Started | NA | | | |
| 1.3.2 | DD-Admin Archives Application | Not Started | NA | | | |
| 1.4.1 | DD-Admin Schedules Interview (Level 1) | Not Started | NA | | | |
| 1.4.2 | DD-ZM + RBM Fill KT Matrix and Feedback (Level 1) | Not Started | NA | | | |
| 1.4.3 | Level 2 Evaluation (DD-Lead + ZBH) | Not Started | NA | | | |
| 1.4.4 | Level 3 Final Evaluation and AI Summary (NBH + DD-Head) | Not Started | NA | | | |
| 1.5.1 | FDD Partner Submits Due Diligence Report | Not Started | NA | | | |
| 1.5.2 | Finance Team Reviews FDD Report | Not Started | NA | | | |
| 1.6.1 | DD-Admin Triggers LOI Document Request | Not Started | NA | | | |
| 1.6.2 | Security Deposit Validation | Not Started | NA | | | |
| 1.6.3 | LOI Approval Chain (Finance -> DD-Head -> NBH) | Not Started | NA | | | |
| 1.6.4 | LOI Issuance to Applicant | Not Started | NA | | | |
| 1.7.1 | DD-Admin Triggers Dealer Code Creation | Not Started | NA | | | |
| 1.8.1 | Architectural Work Assignment and Completion | Not Started | NA | | | |
| 1.8.2 | Statutory Document Collection and Verification | Not Started | NA | | | |
| 1.9.1 | LOA Approval and Issuance | Not Started | NA | | | |
| 1.10.1 | EOR Completion by Functional Teams | Not Started | NA | | | |
| 1.11.1 | Dealership Inauguration and Closure | Not Started | NA | | | |
---
## Module 2 - Dealer Resignation
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 2.1 | Dealer Initiates Resignation via Portal | Not Started | NA | | | |
| 2.2 | DD-ASM Reviews and Forwards Resignation | Not Started | NA | | | |
| 2.3 | RBM + DD-ZM Joint Evaluation | Not Started | NA | | | |
| 2.4 | ZBH Review | Not Started | NA | | | |
| 2.5 | DD-Lead Review and Presentation | Not Started | NA | | | |
| 2.6 | NBH Final Approval | Not Started | NA | | | |
| 2.7 | Legal Issues Resignation Acceptance Letter | Not Started | NA | | | |
| 2.8 | DD-Admin Closure and F&F Trigger | Not Started | NA | | | |
| 2.9 | Dealer Withdraws Resignation Request | Not Started | NA | | | |
---
## Module 3 - Dealer Termination
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 3.1 | ASM Creates Termination Request | Not Started | NA | | | |
| 3.2 | RBM + DD-ZM Review | Not Started | NA | | | |
| 3.3 | ZBH Review | Not Started | NA | | | |
| 3.4 | DD-Lead Review and Legal Assignment | Not Started | NA | | | |
| 3.5 | Legal Verification | Not Started | NA | | | |
| 3.6 | DD-Head Review -> NBH Evaluation | Not Started | NA | | | |
| 3.7 | Show Cause Notice (SCN) Issuance | Not Started | NA | | | |
| 3.8 | Joint Review of Dealer Response -> NBH Final Decision | Not Started | NA | | | |
| 3.9 | CEO and CCO Final Authorization | Not Started | NA | | | |
| 3.10 | Legal Issues Termination Letter | Not Started | NA | | | |
| 3.11 | DD-Admin Communication and F&F Trigger | Not Started | NA | | | |
| 3.12 | Immediate Termination (Unethical Practice) | Not Started | NA | | | |
---
## Module 4 - Constitutional Change
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 4.1 | Dealer Initiates Constitutional Change Request | Not Started | NA | | | |
| 4.2 | Multi-Level Review and Approval | Not Started | NA | | | |
| 4.3 | Legal Validation and Master Data Update | Not Started | NA | | | |
---
## Module 5 - Dealer Relocation
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 5.1 | Dealer Initiates Relocation Request | Not Started | NA | | | |
| 5.2 | Multi-Level Review and Approval | Not Started | NA | | | |
| 5.3 | Final Approval and Master Data Update | Not Started | NA | | | |
---
## Module 6 - Full and Final (F&F) Settlement
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 6.1 | F&F Case Initiation | Not Started | NA | | | |
| 6.2 | Department Clearance Submission (All 16 Departments) | Not Started | NA | | | |
| 6.3 | Finance Consolidates F&F Summary | Not Started | NA | | | |
| 6.4 | Dealer Discussion and Acknowledgment | Not Started | NA | | | |
| 6.5 | Final Finance Approval and Payment Processing | Not Started | NA | | | |
| 6.6 | F&F Closure | Not Started | NA | | | |
---
## Module 7 - Finance Dashboard
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 7.1 | Security Deposit Verification (Onboarding) | Not Started | NA | | | |
| 7.2 | Finance Flags Payment Discrepancy | Not Started | NA | | | |
---
## Module 8 - Admin and System Configuration
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|---|---|---|---|---|---|---|
| 8.1.1 | Admin Configures SLA for a Workflow Activity | Not Started | NA | | | |
| 8.1.2 | SLA Breach and Escalation Flow | Not Started | NA | | | |
| 8.2.1 | Admin Creates Email Template | Not Started | NA | | | |
| 8.3.1 | Admin Creates New Opportunity Window | Not Started | NA | | | |

View File

@ -518,9 +518,9 @@ application progresses — from initial registration to final inauguration and o
achieved.
```
4.1.1.11 LOA (Letter of Authorization) & Final Go-Live
4.1.1.11 LOA (Letter of Agreement) & Final Go-Live
```
- After LOI issuance and Dealer Code generation, the **Letter of Authorization (LOA) is**
- After LOI issuance and Dealer Code generation, the **Letter of Agreement (LOA) is**
**generated and approved by NBH and DD-Head**. Upon successful LOA issuance, the
@ -2823,11 +2823,11 @@ The **LOA Issuance, Essential Operating Requirements (EOR) & Inauguration** modu
the final execution phase of the dealer onboarding lifecycle. It ensures that only those dealerships
which have fulfilled all architectural, statutory, and financial prerequisites are authorized to
commence operations under Royal Enfields network.
This module manages the formal **Letter of Authorization (LOA)** release, verification of **EOR
This module manages the formal **Letter of Agreement (LOA)** release, verification of **EOR
compliance** , and the **dealership inauguration process** , providing complete visibility, audit
control, and cross-departmental coordination before official go-live.
The **Letter of Authorization (LOA) is a parallel statutory activity** and is **not dependent on
The **Letter of Agreement (LOA) is a parallel statutory activity** and is **not dependent on
infrastructure readiness** for issuance. LOA processing and issuance proceed **in parallel with**
@ -2845,7 +2845,7 @@ go-live.
**6.18.3 Depth**
```
6.18.3.1 LOA (Letter of Authorization) Issuance
6.18.3.1 LOA (Letter of Agreement) Issuance
```
- Once statutory verification and site readiness are complete, **DD-Admin** initiates the **LOA**
**document preparation**.
@ -3160,7 +3160,7 @@ o Statutory Documents: Eleven-step checklist for compliance uploads including:
▪ Domain ID
▪ MSD Configuration
▪ LOI Acknowledgement Copy
o LOA (Letter of Authorization): Issued after LOI acceptance.
o LOA (Letter of Agreement): Issued after LOI acceptance.
o EOR (Essential Operating Requirements): Verification of pre-opening operational
criteria.
o Inauguration: Final dealership launch milestone.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,712 @@
# RE Offboarding System
# Requirements
**1.1.5 Dealer Resignation Access & Workflow Enhancements**
- Enabled **dealer portal access** for initiating resignation requests and uploading required
information.
- Clarified that the **Legal team issues the Resignation Acceptance Letter** in all cases.
- Expanded review authority to allow **ZBH, DD Lead, DD Head, and NBH** to **Send Back or**
**Revoke resignation requests** , with communication routed through **Work Notes**.
- Confirmed that **Full & Final (F&F) settlement is triggered strictly on the Last Working**
**Day (LWD)** and not based on approval date.
**1.1.7 Role & Persona Alignment**
- Added **NBH** to the personas section.
- Added **RBM** to applicable review and approval tables.
- Clarified that **DD ASM is responsible for interview scheduling and coordination** , with
no Admin involvement.
**1.1.8 Access Control & Visibility Refinements**
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
- Granted **approval visibility** to DD Lead where applicable.
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
with **DD Lead, RBM, and ZBH** having view access.
- Limited applicant and dealer portal access to **stage-specific and context-specific**
**scenarios only**.
- Confirmed that **dealer portal access is revoked after resignation or termination**.
**1.1.10 Super Admin Role Introduction**
- Introduced a **Super Admin (Master Role)** with end-to-end access and workflow control
across modules.
- Defined segregation of duties by splitting Super Admin into **two DD Admin roles** with
clearly scoped responsibilities.
**1.2.2 Dealer Resignation Enablement**
- Enabled **dealer-initiated resignation requests** at outlet level via the portal.
- Added structured resignation submission with:
o Last Operational Date (Sales & Services)
o Reason for resignation
o Mandatory document readiness guidance
- Enabled **dealer withdrawal option** for resignation requests **only until the case is**
**pending with NBH**.
- Clarified that **Legal team issues the Resignation Acceptance Letter** post approvals.
- Ensured **F&F settlement is triggered based on Last Working Day (LWD)** and not
approval date.
- Restricted dealer portal access **post resignation closure**.
**1.2.5 Post-Exit Access Control**
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
completed.
### 2.1 Business & Functional Users
**2.1.1 Dealer Development (DD) Team**
- **Super Admin (Master Role):**
The **Super Admin has unrestricted access** across all modules and workflows, with
authority to **configure, override, and influence workflow behavior** at every level.
```
The Super Admin role is segregated into two DD Admin roles , each with clearly defined
scopes to ensure segregation of duties and governance control.
```
- **DD-Admin:** System administrator responsible for user setup, role mapping, hierarchy
configuration, and workflow management.
- **DD-AM (Area Manager):** Reviews and manages applications within assigned regions;
performs preliminary screening.
- **DD-ZM (Zonal Manager):** Conducts the first level of dealer evaluation along with RBM;
prepares presentation decks for final interviews.
- **DD-Lead:** Reviews zonal evaluations, validates recommendations, and forwards
shortlisted applicants for senior-level approval.
- **DD-Head: DD Head** is engaged in the **final review and approval** of shortlisted dealer
applications before the **NBH interview** , and later **oversees final verification and LOI**
**issuance** after all evaluations are complete.
**2.1.2 Regional Sales & Business Team**
- **RBM (Regional Business Manager):** Participates in early-stage evaluations, provides
ground-level business insights, and recommends suitable candidates.
- **ZBH (Zonal Business Head):** Conducts the second-level review along with DD-Lead;
provides strategic feedback on market and location viability.
- **NBH (National Business Head):** Holds final authority for approval or rejection of dealer
onboarding; reviews consolidated feedback from all levels.
**2.1.3 Supporting Departments**
- **Finance Team:** Reviews financial due diligence reports, validates F&F (Full and Final)
settlements, and manages monetary closure during offboarding.
- **Legal Team:** Reviews agreements, issues **Letters of Intent (LOI)** or **Termination Letters** ,
and ensures all documentation aligns with company policy.
- **Brand Experience / Architecture Team:** Manages **EOR (Essential Operating**
**Requirements)** and ensures adherence to brand and infrastructure standards.
**2.1.4 Dealers**
Once a dealer is **successfully onboarded and activated in the system** , the Dealer role is enabled
with controlled, role-based access to initiate and track select lifecycle requests. This
enhancement introduces **structured self-service capabilities for dealers** , while ensuring all
actions remain governed by defined validations, internal reviews, and approval workflows as per
RE standards.
The Dealer role is enabled to perform the following activities:
- **Resignation Initiation**
```
The dealer can initiate the resignation process directly through the portal , submit the
reason for exit, and track the status of the request across the defined review, clearance,
and closure stages.
```
Acronym Full Form / Description
RE Royal Enfield
DD Dealer Development
DD-AM Dealer Development Area Manager
DD-ZM Dealer Development Zonal Manager
DD-Lead Dealer Development Lead
DD-Head Dealer Development Head
RBM Regional Business Manager
ZBH Zonal Business Head
NBH National Business Head
ASM Area Sales Manager
FDD Financial Due Diligence (External Partner/Agency)
LOI Letter of Intent
EOR Essential Operating Requirements
LOA Letter of Appointment
F&F Full and Final (Dealer Settlement)
KT Matrix Evaluation Matrix used for scoring applicants
```
4.1.1.13 System-Driven Governance & Audit
```
- Each stage automatically logs:
o User action, timestamp, and remarks
o Uploaded artefacts and version control
o Notifications sent and approvals received
- The entire lifecycle remains accessible under **Audit Trail** for future reference, compliance,
or offboarding workflows.
### 4.2 Dealer Resignation Process Flow Overview
**4.2.1.1 Overview**
```
The Dealer Resignation Process manages the structured offboarding of a dealership initiated
by the dealer. The process begins when a dealer formally submits their resignation via
email to the Area Sales Manager (ASM) , after which the workflow transitions into the
system-managed approval sequence.
```
```
Dealer resignation requests are initiated by the dealer through the portal and subsequently
reviewed and processed by Admin, Finance, Legal, and relevant business stakeholders.
```
```
This flow ensures that each resignation is verified, discussed, and approved across all
required levels — maintaining proper documentation, compliance, and traceability until the
final Legal Acceptance Letter is issued.
```
**4.2.2 Step-by-Step Process Flow**
```
4.2.2.1 Dealer Initiation
```
- The dealer submits a **formal resignation email** on the dealerships official letterhead to
the **ASM**.
- The resignation reason must be clearly stated (e.g., personal, financial, business
restructuring).
- The **dealer is provided portal access** to initiate the resignation request directly through
the system. The dealer submits resignation details, reason for exit, and proposed
timeline via the portal, after which the request enters the internal review and clearance
workflow.
```
4.2.2.2 ASM Review
```
- The **ASM** reviews the dealers resignation request and supporting letter.
- Uploads the **resignation email** and **dealers letterhead document** onto the portal.
- Adds remarks summarizing the discussion and reason for resignation.
- Forwards the request to **RBM + DD-ZM** for evaluation.
```
4.2.2.3 RBM + DD-ZM Joint Evaluation
```
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
**ZM)** review the uploaded documents.
- Conduct a joint discussion with the dealer to confirm the intent and understand any
issues.
- Uploads the **Minutes of Meeting (MOM)** or discussion summary.
- Adds comments and recommendations before forwarding to **Zonal Business Head**
**(ZBH)**.
- Actions available at this stage:
o **Approve** → Send forward for next-level review
o **Send Back for Clarification** → Returns to ASM
o **Withdraw** → Cancels the request (with remarks logged)
```
4.2.2.4 ZBH Review
```
- The **Zonal Business Head (ZBH)** reviews the resignation summary and all remarks.
- Adds their comments and recommendations.
- Forwards the request to **DD-Lead** through the system.
- Worknote is updated automatically to reflect action and timestamp.
- The resignation request is reviewed by authorized business stakeholders,
including **RBM, ZBH, and DD-Head**. During the review stage, the **ZBH is authorized to**
```
Send Back or Revoke the resignation request for clarification or correction. Send Back
actions are communicated to the dealer and internal teams through Work Notes , with
mandatory remarks captured for traceability.
```
```
4.2.2.5 DD-Lead Review
```
- The **DD-Lead** consolidates all discussions, documents, and feedback.
- Prepares a **Resignation Presentation** with recommendations and supporting data.
- Uploads the presentation to the portal.
- Forwards the case to **DD-Head** for the next-level Dealer Development review.
- At this stage, the DD-Lead is authorized to **Send Back or Revoke** the resignation
request for clarification, correction, or reconsideration. **Send Back actions are**
**communicated through Work Notes** , with **mandatory remarks** recorded for audit and
traceability.
```
4.2.2.6 DD-Head Review
```
- The **DD-Head** reviews the DD-Lead's consolidated dossier, presentation, and the
cumulative feedback captured by ASM, RBM/DD-ZM, ZBH, and DD-Lead.
- Validates that all Dealer Development checks (asset recovery readiness, transition
plan, territory impact) have been adequately addressed at the DD layer.
- Adds final Dealer Development remarks and either:
o **Approve** → Forwards the case to **NBH** for the National-level decision.
o **Send Back for Clarification** → Returns the case to **DD-Lead** with mandatory
remarks logged through Work Notes.
o **Revoke** → Closes the request with a documented rationale captured in Work Notes.
- The transition from **DD-Lead → DD-Head → NBH** is fully inline; on DD-Head approval
the workflow moves to NBH **without manual intervention** , and the SLA timer for the
next stage starts automatically.
```
4.2.2.7 NBH Final Approval
```
- The **National Business Head (NBH)** reviews the entire resignation dossier.
- Adds final remarks with one of the following outcomes:
o **Approve** → Case moves automatically to Legal for letter issuance.
o **Send Back for Clarification** → Returns to DD-Lead or ZBH for revalidation.
o **Hold** → Temporarily pauses the process pending further discussion.
- Upon approval, the system triggers a **Worknote Notification** to DD-Lead, RBM, ZBH, and
Finance teams.
- The resignation request is reviewed by the **NBH** , who may **Approve, Send Back, or**
**Revoke** the request based on business considerations. Any **Send Back or Revoke action**
**must be accompanied by remarks recorded in Work Notes** , ensuring transparent
communication and governance.
```
4.2.2.8 Legal Acceptance Letter
```
- Once approved by **NBH** , the request is **auto-assigned to the Legal team**.
- Legal verifies the uploaded resignation and issues a **Resignation Acceptance Letter**.
- The letter is uploaded to the portal, visible to all relevant personas including **DD-**
**Admin** and **DD-AM**.
- Legal can also raise clarifications through worknotes if required.
- Upon completion of all approvals, the **Legal team issues the official Resignation**
**Acceptance Letter** and shares it with the dealer through authorized communication
channels.
```
4.2.2.9 DD-Admin Closure
```
- The **DD-Admin** downloads and shares the final **Resignation Acceptance Letter** with the
dealer.
- Marks the resignation as completed and triggers the **F&F (Full and Final) process** by
forwarding the case to the Finance team.
- The **Full & Final (F&F) settlement process is initiated only on the Last Working Day**
**(LWD) of the dealership**. The system shall **enable and trigger the F&F workflow strictly**
**based on the LWD date** , and **not based on the resignation approval date**.
```
## 7 Dealer Resignation
The **Dealer Resignation** process enables an existing Royal Enfield dealer to formally
initiate their intent to discontinue the dealership through a structured and transparent
workflow. This process captures the dealers resignation details, reasons for exit, and
proposed timeline, ensuring all associated departments — including **DD-Admin, DD-**
**Head, Finance, Legal, and Regional Teams** — are informed and involved in the validation
and clearance stages. Each resignation request undergoes systematic review, covering
asset recovery, financial reconciliation, documentation verification, and contractual
obligations before final approval and closure.
### 7.1 Dealer Resignation Request (Initiation)
**7.1.1 Functionality Scope**
The **Dealer Resignation Request** process begins when a dealer formally communicates their
intent to resign via an **official email** to ASM. Once received, the **DD-ASM** initiates the resignation
process in the system by creating a digital record using the _Create Resignation Request_ form. The
form captures critical dealership, operational, and contextual information — such as business
constitution, sales data, and closure type — ensuring that the request is documented in a
structured, traceable, and standardized manner. This process establishes a single source of truth
for all resignation-related data, facilitating transparent coordination among **DD-Head, Finance,
Legal, and Regional Teams** for subsequent review and action. Dealer can login exclusively and
can only initiate the Resignation request.
The **Dealer Resignation Request is initiated by the dealer through the portal** , providing a
structured mechanism to formally submit the intent to discontinue the dealership. The dealer
captures resignation details, reason for exit, and the proposed effective date. Upon submission,
the request is routed to the internal stakeholders for review, validation, and subsequent
clearance processes. The **dealer logs into the portal and initiates the resignation request** by
submitting the required details and supporting information.
**7.1.2 Width**
- Accessible exclusively to **DD-ASM** through the **“Create Resignation Request”** interface.
- Includes the following mandatory and optional input fields:
o **Dealer Code** (it will be fed to SAP API to pull details.)
o **Inauguration** , **LOA** , and **LOI Dates** (Will be fetched from system DB, if available)
o **Last 6 Months Sales**
o **Number of Dealerships / Studios**
o **Constitution** (Proprietorship, Partnership, LLP, Pvt. Ltd., etc.)
o **Dealership Type** (Main, Satellite, Studio, etc.)
o **Type of Closure** (Voluntary, Business Transfer, Termination, etc.)
o **Format Category** (Urban, Rural, etc.)
o **Dealer Scorecard Band**
o **Resignation Reason** (brief summary)
o **Dealer Voice** (detailed justification or remarks from dealers email)
o **Upload Document** (resignation email copy or supporting documents)
- **Buttons:**
o **Submit Request:** validates data and triggers routing to the next stage of review.
o **Cancel:** exits without saving.
**7.1.3 Depth**
- Upon submission by **DD-Admin** , the system performs the following
o Validates the **Dealer Code** against the dealership master from SAP API to be
provided by RE
o Generates a unique **Resignation Request ID** and logs submission details
(timestamp, user, and role).
o Stores the uploaded resignation email or document in the **Central Document**
**Repository** for reference.
o Automatically notifies the **DD-Head** and relevant stakeholders that a new
resignation has been logged.
o Marks the case status as **“Resignation Initiated”** in the workflow tracker.
o He will also upload the resignation PPT which is build off the system.
**7.1.4 Personas-Wise Accessibility & Visibility**
```
Persona Accessibility Visibility Scope
Dealer /
Applicant
```
```
Sends official resignation email to Royal Enfield.
The dealer is provided portal access to upload
resignation-related documents and
responses during the applicable workflow
stages.
```
```
Email communication
only (no direct system
access).
```
```
Creates resignation request in system, uploads
dealers email, validates data, and submits for
approval.
```
```
Receives system notification upon submission;
can view request details and attached
resignation communication.
```
```
Background operation.
```
### 7.2 Resignation Management Dashboard
**7.2.1 Functionality Scope**
The **Resignation Management Dashboard** serves as the central workspace for monitoring and
managing all dealer resignation requests initiated within the system. It provides a consolidated
view of active, pending, and completed cases, enabling stakeholders such as **DD-Admin, ASM,
DD-Lead, ZBH, NBH, and Legal Teams** to review progress, take required actions, and ensure
compliance with the defined offboarding workflow.
The **ZBH can review resignation requests and perform Send Back or Revoke actions** prior to final
approval. Each action requires **mandatory remarks** and is recorded against the resignation case.
RBM, **ZBH, DD-Lead, DD-Head, and NBH** can review resignation requests and are authorized
to **Send Back or Revoke** requests at their respective stages. All such actions require **mandatory
remarks** and are logged for audit purposes.
**7.2.2 Width**
- Displays a **summary header** with following key counters:
o **All Requests:** Total number of resignation requests recorded.
o **Open:** Requests currently under review or action.
o **Completed:** Finalized resignations where closure is approved.
o **Requires Your Action:** Highlights cases awaiting action from the logged-in user.
- Shows a **list view** of all resignation requests with the following details:
o **Request ID (e.g., RES-001)**
o **Dealer Name, Dealer Code, and Location**
o **Format Category** (A+, A, B, etc.)
o **Dealership Type** (Main, Studio, etc.)
o **Reason for Resignation**
o **Current Stage** (e.g., ASM Review, DD-Lead Review, NBH Approved, Legal)
o **Submitted On** (auto-captured timestamp)
- Action options:
o **View Details:** Opens complete resignation record and attached documents.
o **Create Resignation Request:** Accessible only to **DD-Admin** for entering new
requests (from dealer emails).
- Filter tabs:
o **All Requests** , **Open** , **Completed**
**7.2.3 Depth**
- **Workflow Synchronization:** Each resignation request dynamically updates its stage label
(e.g., _ASM Review_ , _DD-Lead Review_ , _NBH Approved_ ) based on workflow transitions.
- **Notification Logic:**
o The assigned reviewer (ASM, DD-Lead, or NBH) receives automated alerts for
action items.
o Status changes trigger notifications to the next role in sequence.
```
### 7.3 Resignation Details & Review
**7.3.1 Functionality Scope**
The **Resignation Details & Review** module provides a comprehensive view of all dealer
resignation information captured during initiation. It enables authorized reviewers to validate
dealer data, evaluate the reason and context for resignation, and take appropriate workflow
actions such as **Approval, Withdrawal, Send Back, or Push to Full & Final (F&F)**. The screen
consolidates dealer master data, operational metrics, and resignation specifics, ensuring
reviewers have complete visibility before making decisions.
**7.3.2 Width**
- **Header Actions:**
o **Approve:** Marks resignation as validated and forwards it to the next workflow
stage (DD-Head / NBH).
o **Withdrawal:** Used if the dealer retracts the resignation request or if withdrawal
is approved internally.
o **Send Back:** Returns the request to DD-Admin for correction or additional details.
o **Push to F&F:** Moves the case to the **Full & Final Settlement** process after all
approvals are secured.
o **Assign User:** Allows reallocation of review responsibility to another internal user.
o **View Work Notes:** Opens the shared comment thread for internal collaboration
and tagging.
- **Tabs:**
o **Details** Displays complete resignation information and dealer data.
o **Progress** Shows stage-wise workflow journey and current reviewer.
o **Documents** Lists uploaded resignation documents and correspondence.
o **Audit Trail** Records every action, decision, and timestamp for traceability.
**7.3.3 3. Depth**
- **Information Segments:**
o **Request Information:** Pull dealer master details such as Dealer Code, GST,
Address, Domain & Service Codes, City Category, and Dealership Name.
o **Operational Details:** Displays dealership metrics including inauguration and LOA
dates, number of outlets, last six-month sales, business constitution, format
category, and dealer scorecard band.
o **Resignation Details:** Summarizes the **Resignation Reason** and **Dealer Voice**
**(Customer Description)** derived from the dealers email submission.
### 7.4 Resignation Request Review & Action Management
**7.4.1 Functionality Scope**
The **Resignation Progress Timeline** provides a transparent, stepwise view of the dealer
resignation workflow — from initial submission to the issuance of the final **Acceptance Letter**.
Since the **Dealer does not have portal access** for resignation, the process starts through an **email
submission to the Area Sales Manager (ASM)** , followed by progressive reviews and comments
at multiple organizational levels. Each approver in the chain can perform one of three key actions
**Approve** , **Send Back for Clarification** , or **Withdraw** — with remarks captured in **Work
Notes** for audit and traceability. Once approved by the **National Business Head (NBH)** , the
request automatically routes to the **Legal Team** for the issuance of the acceptance letter, visible
to both the DD Admin and DD-ASM.
The **dealer is provided portal access** to **upload resignation-related documents and
responses** during the applicable workflow stages. For termination cases, **dealer upload access is
restricted** as per defined governance rules.
**7.4.2 Width**
```
7.4.2.1 Stage-wise Flow
Stage Responsible
Role
```
```
System / Process Description
```
1. Dealer
Resignation
Submission
```
Dealer → via
Email to ASM
```
- Dealer submits resignation via official email and
signed letterhead.
- No direct portal access.
- ASM receives and verifies authenticity.
2. ASM Review DD-ASM • Uploads resignation email and presentation
(e.g., _Sample resignation.pptx_ ) to portal.
- Adds remarks summarizing dealers reason and
operational background.
- Forwards case to **RBM + DD-ZM** for evaluation.
3. RBM + DD-ZM
Review
```
RBM & DD-ZM • Conduct joint discussion with dealer to understand
cause and alternatives.
```
- Uploads discussion notes and remarks in **Work Notes**.
- The final output will be submitted as Approve,
Withdrawal or send back.
- Has three action options:
- **Approve:** Forwards case to ZBH for further review.
- **Send Back:** Requests ASM to provide additional
details or clarifications (remark mandatory).
- **Withdraw:** Stops process if dealer withdraws or
case found invalid (remark mandatory).
4. ZBH Review Zonal Business
Head
- Reviews RBM + DD-ZM inputs and validates zonal
implications.
- Adds comments in **Work Notes** and forwards to **DD
Lead**.
- Can perform **Approve** , **Send Back** ,
or **Withdraw** actions.
5. DD Lead
Review
```
DD Lead • Prepares a formal Resignation Presentation
PPT summarizing business rationale, sales history,
dealer feedback, and proposed recommendation.
```
- Uploads the presentation and comments to the
portal.
- Approves and shares with **NBH** for final decision.
6. NBH Approval National
Business Head
- Reviews all inputs and puts **final decision remarks** in
Work Notes.
- On approval, system triggers notification to **DD Lead,
ZBH, Zonal Team, Business Zonal Manager, and F&F**.
- Automatically routes the case to **Legal Team** for
Acceptance Letter issuance.
7. Legal Review &
Acceptance Letter
```
Legal Team • Prepares and uploads Resignation Acceptance
Letter on portal.
```
- Can raise queries in Work Notes if required.
- Uploaded document is visible to **DD-Admin** and **DD-**
#### ASM.
- Legal completion closes workflow for the request.
8. DD Admin &
ASM Notification
```
DD Admin +
DD-ASM
```
- DD Admin reviews the uploaded acceptance letter.
- Shares with respective **ASM (Field Team)** to
communicate official closure to the dealer.
**7.4.3 3. Depth**
- **Action Modes Across Stages:**
o **Approve:** Advances the resignation request to the next level of the workflow.
_Example:_ “Reviewed with dealer and validated. Forwarding to ZBH for next stage.”
o **Send Back:** Returns to the previous user or ASM for clarifications.
_Example:_ “Incomplete documentation. Dealer statement on financials missing.”
o **Withdraw:** Ends the process if dealer withdraws voluntarily or management
disapproves continuation.
_Example:_ “Dealer requested withdrawal of resignation via email dated 15-Oct.”
- **Audit and Transparency:**
o All actions (including remarks, uploads, and timestamps) are auto-captured
in **Work Notes** and the **Audit Trail**.
o Every document and PPT uploaded (e.g., _Sample resignation.pptx_ ) is linked to its
stage for version tracking.
- **System Automation:**
o NBH approval automatically triggers Legal assignment.
o SLA tracking continues at each step; escalation is logged in Work Notes if delayed.
o Notifications are sent to all relevant stakeholders upon approval, send-back, or
withdrawal.
**7.4.4 Worknotes**
The **Work Notes** feature acts as the central communication and collaboration thread
within the resignation workflow. It captures all user interactions, remarks, and system-
triggered updates in a structured, time-stamped format. Each stakeholder — from
ASM to NBH and Legal — uses Work Notes to record discussions, queries,
clarifications, and final decisions related to the resignation case will be submitted from
Approval, Withdrawal or send back action.
### 7.5 Resignation Progress Tracker
**7.5.1 Functionality Scope**
The **Progress** section provides a stage-wise, visual representation of the entire dealer resignation
workflow. It enables authorized users to track each approval checkpoint — from **request
submission** through **multi-level review** to **final legal acceptance**. Every stage dynamically
updates based on workflow actions such as _Approve_ , _Send Back_ , or _Withdraw_ , with complete
traceability of remarks, uploaded documents, and timestamps. This ensures full transparency,
accountability, and operational consistency across all hierarchical levels.
**7.5.2 Width**
- Presents a **chronological timeline** of the resignation process, beginning with _Request
Submitted_ and concluding with _Legal Resignation Letter_.
- Each stage displays **status indicators** (Pending, In Progress, Approved, or Withdrawn) along
with the **responsible reviewer role**.
- Shows the **number of documents uploaded** at each stage, with direct view/download options.
- Allows reviewers to perform three key actions — _Approve_ , _Send Back_ , and _Withdraw_ — with
remarks made mandatory.
- If a request is **Sent Back** , it automatically reverts to the previous stage, recording remarks
in **Work Notes** and notifying the concerned user.
- On **Withdrawal** , the timeline is locked and marked _Closed Withdrawn_ for historical reference.
- Once **NBH** provides final approval, the request is automatically assigned to **Legal** for
acceptance letter issuance.
- The **Legal stage** finalizes the process upon letter upload, marking the case _Completed_ and
notifying DD-Admin and field hierarchy.
**7.5.3 Depth**
- Each stage retains all **remarks, approvals, timestamps, and supporting documents** for
complete traceability.
- Integrates seamlessly with **Work Notes** and **Audit Trail** , ensuring real-time visibility of all
communications and escalations.
- Supports SLA-driven reminders and escalations that reflect directly in the timeline view.
- All uploaded documents (emails, resignation PPT, acceptance letter) remain permanently
mapped to their respective stages.
- Once the resignation is finalized, historical data stays accessible for compliance and audit
review.
### 7.6 Documents & Audit Trail
**7.6.1 Functionality Scope**
The **Documents** and **Audit Trail** sections collectively ensure complete transparency and
traceability across the resignation workflow. The **Documents** tab serves as a centralized
repository of all artefacts submitted or generated during the process — including resignation
letters, presentations, communications, and acceptance letters. The **Audit Trail** automatically
captures every workflow action, recording who performed it, what was changed, and when,
ensuring full accountability and data integrity.
**7.6.2 Width**
- Allows upload and viewing of all resignation-related documents with type, uploader, and
upload date clearly listed.
- Supports restricted document viewing to authorized personas with download control.
- Provides versioned tracking of uploaded artefacts for compliance.
- The **Audit Trail** logs every stage transition, approval, comment, or document addition with
precise timestamps.
- Automatically records system-triggered events such as SLA reminders or email notifications.
**7.6.3 Depth**
- Each document remains linked to its respective workflow stage and accessible through
the **Progress Timeline**.
- All actions — _Approve_ , _Send Back_ , _Withdraw_ , _Upload_ , and _Assign_ — are recorded for
traceability.
- The system maintains an immutable historical log for governance and audit purposes.
- Entries in the Audit Trail display both user-driven and automated actions to ensure
comprehensive visibility.

View File

@ -0,0 +1,756 @@
# Offboarding System
**1.1.6 Termination Workflow Governance Updates**
- Clarified that **CEO is the final approving authority** for dealer termination cases.
- Included **CCO and CEO** as approval authorities with **Approve / Hold / Reject** options.
- Confirmed that the **Legal team issues termination letters only after CEO approval**.
- Removed **dealer portal access** from termination workflows.
- Extended **Send Back / Revoke** authority to **ZBH and DD Lead** for termination reviews.
- Aligned **F&F trigger for termination** to occur strictly on the **Last Working Day (LWD)**.
**1.1.7 Role & Persona Alignment**
- Added **NBH** to the personas section.
- Added **RBM** to applicable review and approval tables.
- Clarified that **DD ASM is responsible for interview scheduling and coordination** , with
no Admin involvement.
**1.1.8 Access Control & Visibility Refinements**
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
- Granted **approval visibility** to DD Lead where applicable.
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
with **DD Lead, RBM, and ZBH** having view access.
- Limited applicant and dealer portal access to **stage-specific and context-specific**
**scenarios only**.
- Confirmed that **dealer portal access is revoked after resignation or termination**.
**1.2.5 Post-Exit Access Control**
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
completed.
## 3 Definitions and Acronyms
```
Acronym Full Form / Description
RE Royal Enfield
DD Dealer Development
DD-AM Dealer Development Area Manager
DD-ZM Dealer Development Zonal Manager
DD-Lead Dealer Development Lead
DD-Head Dealer Development Head
RBM Regional Business Manager
ZBH Zonal Business Head
NBH National Business Head
ASM Area Sales Manager
FDD Financial Due Diligence (External Partner/Agency)
LOI Letter of Intent
EOR Essential Operating Requirements
LOA Letter of Appointment
F&F Full and Final (Dealer Settlement)
KT Matrix Evaluation Matrix used for scoring applicants
```
### 4.3 Dealer Termination Process Flow Overview
```
4.3.1.1 Overview
```
```
The Dealer Termination Process governs the structured offboarding of a dealership initiated
internally by Royal Enfield due to operational, contractual, or ethical concerns.
It ensures that any termination—whether due to working-capital issues, poor performance,
or unethical practices —is investigated, documented, reviewed at multiple managerial levels,
and legally validated before final execution. The process maintains full transparency and
traceability through digital records, comments, and worknotes until the Termination
Letter is issued and the Full & Final (F&F) settlement begins.
```
**4.3.2 Step-by-Step Process Flow**
```
4.3.2.1 ASM Case Initiation
```
- The **Area Sales Manager (ASM)** regularly visits dealers and records **Minutes of Meeting**
**(MOM)** for performance or compliance concerns.
- After two consecutive unsatisfactory commitments or escalations, the ASM initiates
a **Termination Request** in the portal.
- Fills all operational details (Dealer Code, LOI, LOA, Sales Data, etc.), selects
a **Termination Category** (Working Capital, Performance, Unethical Practice), and
uploads supporting documents (MOMs, commitments, dealer letters).
- Submits the case to **RBM + DD-ZM** for review.
```
4.3.2.2 RBM + DD-ZM Review
```
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
**ZM)** jointly evaluate the case.
- Conduct a meeting with the dealer and record fresh MOMs; upload dealer
commitments on letterhead.
- Provide remarks and supporting evidence.
- Actions available:
o **Approve** → Forward to ZBH
o **Send Back for Clarification** → Returns to ASM with comments
o **Withdraw** → Terminates workflow with justification
```
4.3.2.3 ZBH Review
```
- The **Zonal Business Head (ZBH)** reviews the full chronology (ASM visits, RBM/DD-ZM
remarks, uploaded MOMs).
- Validates escalation authenticity and dealer communication record.
- Adds remarks and forwards to **DD-Lead** for deeper review.
- The termination request is reviewed by the **ZBH** , who is authorized to **Approve, Send**
**Back, or Revoke** the termination request. **Send Back actions are communicated**
**through Work Notes** , with **mandatory remarks** recorded for traceability.
```
4.3.2.4 DD-Lead Review & Legal Assignment
```
- The **DD-Lead** cross-verifies case chronology with all stakeholders (ASM, RBM, ZBH).
- Prepares a **Termination Presentation** summarizing facts, dealer history, and
recommendations.
- Assigns the case to **Legal Team** for inputs through the system (visible in worknotes).
- The termination request is reviewed by the **DD-Lead** , who is authorized to **Send Back or**
**Revoke** the termination request for clarification or reconsideration. All such actions
require **mandatory remarks captured in Work Notes**.
```
4.3.2.5 Legal Verification
```
- The **Legal Team** reviews documentation, ensures contractual breaches are well-
supported, and checks all precedents.
- May raise queries via **Worknotes** or **Send Back** the case to DD-Lead for clarification.
- Once satisfied, forwards the verified case back to **DD-Lead** for next action.
```
4.3.2.6 DD-Lead → DD-Head Review
```
- The **DD-Lead** attaches Legals feedback and forwards the case to **DD-Head** for strategic
review.
- **DD-Head** validates the case, evaluates impact, and presents it to **National Business**
**Head (NBH)** for final business decision.
```
4.3.2.7 NBH Evaluation
```
- The **NBH** reviews all documentation and Legal remarks.
- May choose one of three actions:
o **Go Ahead** → Approve for issuance of **Show Cause Notice (SCN)**
o **Hold Decision** → Pause temporarily for further monitoring or negotiation
o **Raise Query** → Sends back to DD-Lead for additional input
```
4.3.2.8 Show Cause Notice (SCN) Issuance
```
- Upon NBH approval, the system triggers Legal to prepare and issue the **SCN**.
- The **DD-Lead** formally shares the SCN with the dealer through **DD-Admin**.
- Dealer replies to the SCN by email or letter, which **DD-Admin uploads** to the portal.
- For termination cases, the **F&F settlement process is triggered only on the Last**
**Working Day (LWD)**. The system shall **control the F&F trigger based on the LWD date** ,
irrespective of the termination approval date.
```
4.3.2.9 Evaluation of Dealer Response
```
- The **DD-Lead** , **ZBH** , **RBM** , and **DD-Head** jointly review the dealers SCN response.
- Uploads internal comments, Legal feedback, and recommendation for NBHs final
decision.
```
4.3.2.10 NBH Final Decision
```
- The **NBH** reviews the compiled case with Legal advice and decides among:
o **Approve Termination** → Moves to CEO/CCO for confirmation
o **Reconsider** → Allow additional time or corrective action
o **Reject** → Case closed without termination
```
4.3.2.11 11. CEO & CCO Authorization
```
- **CEO** and **Chief Commercial Officer (CCO)** review the NBH-approved termination.
- Provide authorization on the portal.
- Once signed off, the decision becomes final.
```
4.3.2.12 12. Legal Termination Letter
```
- The **Legal Team** generates the **Termination Letter** to the portal.
- The letter is auto-visible to **DD-Lead** , **DD-Admin** , and **Finance**.
- A system notification is triggered to all linked personas.
```
4.3.2.13 13. DD-Admin Communication & F&F Trigger
```
- The **DD-Admin** shares the official **Termination Letter** with the dealer and field team.
- Marks the case as “Terminated” in the portal.
- Forwards the case to **Finance** for **Full & Final Settlement** initiation.
- Updates the worknote with final remarks and due-date for settlement.
### 4.4 Dealer Full & Final (F&F) Settlement Process Flow
```
4.4.1.1 Overview
```
The **Full & Final (F&F) Settlement Process** governs the financial closure of a dealership
following **Resignation** or **Termination**.
It ensures that all financial obligations between Royal Enfield and the dealer —
including **security deposits, recoveries, payables, and department-wise dues** — are
transparently reconciled, verified, and documented before closure.
**4.4.2 Step-by-Step Process Flow**
```
4.4.2.1 F&F Initiation
```
- Triggered automatically once the **Resignation Acceptance Letter** or **Termination**
**Letter** is uploaded by **Legal**.
- The **DD-Admin** or **DD-Lead** initiates the F&F case in the **Finance Dashboard** , which
creates a unique **FNF Case ID** linked to the dealer code.
- The system auto-fetches dealer details, associated documents, resignation/termination
date, and due dates.
- Notification is sent to the **Finance Team** and all functional departments to begin the
clearance process.
## 8 Termination
A **Dealer Termination** process is initiated when a dealerships continuation is deemed
non-viable due to business, financial, or ethical reasons. The termination may arise
from three primary causes — **working capital inadequacy** , **continued underperformance** ,
or **unethical practices**. Cases involving working capital or performance issues follow a
structured review and approval process, allowing the concerned dealer to provide
clarification and supporting data before final decision. However, any instance
of **unethical practice** — including fraud, policy breach, or reputational risk to the brand
— results in **immediate termination**. All termination cases are documented within the
system, with remarks, evidence, and approval trails maintained for audit and
compliance verification.
### 8.1 Create Termination Request
**8.1.1 Functionality Scope**
The **Create Termination Request** form enables authorized users such as **DD-Lead** , **DD-Admin** ,
or **ASM** to initiate a termination case within the system. The form captures comprehensive
dealership details including operational timelines, format type, constitution, performance data,
and financial indicators. It also specifies the **Termination Category** (e.g., Working Capital,
Performance Issue, or Unethical Practice), supported by descriptive justification and relevant
documentation. The request forms the starting point of the digital termination workflow and
ensures that all necessary contextual data and artefacts are available for subsequent reviews and
escalations.
**8.1.2 Width**
- Allows creation of new termination requests by entering **Dealer Code** , operational details, and
financial data.
- Captures **Termination Category** and **Description** for clarity on grounds of termination.
- Supports upload of supporting artefacts such as MOMs, dealer commitments, or financial
statements.
- Automatically records creator and timestamp for traceability.
**8.1.3 Depth**
- Integrates directly with the **Progress Timeline** , displaying real-time status updates across levels.
- Each submission auto-generates an internal case ID linked to the dealer code for tracking.
- Supports structured escalation logic based on the **Termination Category** — standard route for
working capital/performance cases, immediate escalation for unethical practices.
- Maintains versioned records for every document uploaded at creation stage.
**8.1.4 Personas-wise Accessibility & Visibility**
```
Persona / Role Access Level Visibility & Permissions
ASM / DD-AM Area Level Can initiate termination requests, upload MOMs and
dealer commitments.
RBM + DD-ZM Regional / Zonal
Level
```
```
Can view request details and validate information before
escalation.
ZBH Zonal Head Reviews initial request data, comments on justification,
and forwards to DD-Lead.
DD-Lead / DD-
Admin
```
```
National
Coordination
```
```
Can initiate, review, and forward requests; validates
completeness and assigns to Legal if required.
Legal Review Level Can view dealer details and supporting documents for
legal evaluation.
NBH National Business
Head
```
```
Can view the entire request summary before decision
and closure approval.
```
### 8.2 Termination Ticket overview
**8.2.1 Functionality Scope**
The **Details View** provides a consolidated summary of all key information related to the dealer
under review. It includes dealership codes, operational history, financial performance, and
termination-specific parameters. This enables reviewers at every level—whether ASM, ZBH, or
Legal—to quickly assess background context and validate evidence before taking action. The
interface also displays the current workflow stage and offers in-screen options
to **Approve** , **Withdraw** , or **Send Back** the request with remarks, ensuring traceable and reason-
based decisions.
**8.2.2 Width**
- Displays complete dealer profile: code, name, location, and GST details.
- Shows operational data: inauguration date, LOA, LOI, format, constitution, and last six-month
sales.
- Captures termination-specific data: **Termination Category** , reason, and case severity (e.g.,
“High”).
- Provides workflow action buttons— **Approve** , **Withdraw** , **Send Back** —with mandatory remarks
input.
- Integration with Work Notes for contextual communication and escalation traceability.
**8.2.3 Depth**
The **Detail Tab** serves as the **central operational dashboard** for viewing all dealer, operational,
and termination-related data within a single, structured interface. It merges static dealer master
information with dynamic workflow inputs and uploaded artefacts, ensuring contextual visibility
for all stakeholders.
```
8.2.3.1 Components & Functional Behavior
```
- **Dealer Information (Owner: DD-Admin / System Integration Layer)**
Displays master data pulled from the Dealer Master table — including **Dealer Code,**
**Name, Address, GST, Domain Name, City Category, Sales Code, Service Code, and GMA**
**Code**.
o Synced automatically from REs **Dealer Database (Master Registry)**.
o Read-only for all personas except system admin for data correction requests.
o Enables search and cross-referencing across termination, resignation, and
onboarding records.
- **Operational Details (Owner: DD-Lead / Workflow Engine)**
Highlights the dealerships business health indicators and structural data, including **LOA,**
**LOI, Inauguration Date, Constitution Type, Dealership Type, Format Category, Dealer**
**Score Card Band, and Last Six-Month Sales**.
o Pulled dynamically from the Sales & Performance Module.
o Reflects the most recent sales cycle, ensuring leadership sees live performance
metrics during termination decision-making.
o Editable only by DD-Lead or authorized DD-Admin prior to case lock.
- **Termination Details (Owner: DD-Lead / DD-ZM / Legal)**
Captures case-specific details such as **Termination Category, Reason Description, and**
**Attachments**.
o Termination Category includes options like _Working Capital Issues, Performance_
_Shortfall, Breach of Agreement, or Unethical Practices_.
o Documents uploaded here are visible to all reviewers across the approval chain,
maintaining transparency.
o Legal team references this section while framing the **Show Cause Notice (SCN)** or
final termination letter.
- **Workflow Actions (Owner: Workflow Engine / DD-Lead)**
Displays **Approve, Withdraw, and Send Back** controls based on role permissions.
o Triggers automated workflow transitions and real-time updates in **Progress**
**Timeline** and **Audit Trail**.
o Any action logs mandatory remarks under “Communication & Notes” with
timestamp and user identity.
o Permissions vary per role:
```
▪ ASM, RBM: Can only comment or escalate.
▪ ZBH, DD-Lead, NBH: Can approve or send back.
▪ Legal: Can finalize after NBH approval.
```
- **Document Management Section (Owner: DD-Admin / Legal)**
Repository displaying all uploaded evidence or reports associated with the termination.
o Documents listed by **name, type, uploader, and date**.
o Supports inline viewing (no download needed) for internal confidentiality.
o File retention policy aligns with REs compliance standards (minimum 7 years).
- **Audit Trail (Owner: Workflow Engine / System Log)**
Chronologically records every action taken within the termination case — including
user, timestamp, and nature of change.
**8.2.4 Personas-wise Accessibility & Visibility**
```
Persona / Role Access Level Visibility & Permissions
ASM / DD-AM Area Level Can initiate and upload dealer MOMs and commitment
records.
RBM + DD-ZM Regional / Zonal
Level
```
```
Review dealer details, validate termination rationale,
and escalate with remarks.
ZBH Zonal Business
Head
```
```
Approves or returns the case with comments; can
forward to DD-Lead.
DD-Lead / DD-
Admin
```
```
National
Coordination
```
```
Validate details, review documents, assign to Legal, or
push for F&F after NBH approval.
Legal Legal Level Review dealer information, validate grounds, and issue
termination letter.
NBH National Head Provides final decision and authorization before case
closure.
```
### 8.3 Termination Approval & Review Process
**8.3.1 Functionality Scope**
The **Termination Approval module** enables Royal Enfields internal stakeholders to manage
dealership termination cases in a structured, transparent, and traceable workflow. It ensures that
every dealership performance concern — whether due to **working capital shortfall** , **sustained
underperformance** , or **unethical practices** — is systematically reviewed, documented, and acted
upon through the defined escalation hierarchy.
This module supports structured documentation of **dealer meetings** , **uploaded
artefacts** , **reviewer remarks** , and **legal correspondence** , ensuring no manual communication
dependency.
All approvals, send-backs, or withdrawals are centrally logged, supported by **Work Notes** ,
ensuring collaborative clarity and institutional memory across teams.
The **CEO is the final approving authority** for dealer termination cases. The **Legal team prepares
and issues the termination letter only after CEO approval** , and **not upon NBH approval**.
**CCO and CEO** are included as approval authorities with **Approve, Hold, and Reject options**.
The **dealer does not have portal access** for termination workflows.
**8.3.2 Width**
The process spans across the complete DD and Legal hierarchy, ensuring clear role-based
accountability:
- **ASM:** Conducts monthly visits, logs Meeting of Minutes (MOM), uploads dealer
commitment letter and personal observations. Logging MOM is not the part of this system
but when he feel to trigger Termination, he will log as description & associate documents
while initiating the flow.
- **RBM + DD-ZM:** Escalate after repeated concerns, conduct joint meetings, and document
dealer responses on portal.
- **ZBH:** Reviews zonal-level non-compliance, escalates unresolved cases to DD-Lead and
NBH.
- **DD-Lead:** Reviews consolidated reports, validates escalation records, prepares case
presentation, and assigns to Legal.
- **Legal:** Reviews chronology, evaluates policy or contractual breaches, issues SCN, and
prepares final Termination Letter.
- **DD-Head:** Reviews with DD-Lead and Legal; presents case to NBH for decision.
- **NBH:** Provides final decision approve, query, or hold.
- **DD-Admin:** Uploads dealers SCN response and handles F&F coordination post Legal
issuance.
**8.3.3 Depth**
- **Structured Case Creation (Owner: DD-Lead / DD-Admin / ASM)**
A Termination case is initiated through the “Create Termination Request” form by DD-
Lead, DD-Admin, or ASM.
o Each request is tagged with a unique **Termination ID** (e.g., TERM-001).
o Dealer and operational data are automatically fetched from the **Dealer**
**Master** and **Sales System** for accuracy.
- **Case Workflow Management (Owner: Workflow Engine)**
Each stage of the termination journey — from ASM initiation to Legal closure — is
mapped to approval levels.
o **ASM → RBM/DD-ZM → ZBH → DD-Lead → Legal → DD-Head → NBH**.
o Actions at every level (Approve, Withdraw, Send Back) are recorded with
mandatory remarks.
o Each remark auto-updates in **Work Notes** and **Progress Timeline** , triggering
instant notifications to the next role.
- **Work Note Integration (Owner: All Reviewers)**
The **Work Note** acts as the **central communication thread** within each termination case.
o Each reviewer (ASM, RBM, ZBH, DD-Lead, Legal, etc.) can post contextual remarks,
share discussions, or tag specific users.
o Tagged users (e.g., @DD-Lead, @Legal) receive instant notifications via **system**
**alerts** and **email**.
o Work Notes serve as a real-time collaboration and escalation record — every
comment, clarification, or update remains **time-stamped and user-tagged**.
o Legal and DD-Head may also use Work Notes to request clarification from lower
hierarchies (ASM, RBM, ZBH).
o Once a note is submitted, it becomes immutable and part of the **permanent**
**record** under **Audit Trail**.
- **Meeting & Artefact Uploads (Owner: ASM, RBM, ZBH)**
Each level of escalation includes upload of MOMs, dealer commitment letters, and
observations while Approving at his level.
o Artefacts are uploaded as PDFs (e.g., _Meeting_MOM_June2025.pdf_ ).
o Dealer commitments are scanned and attached for cross-reference during Legal
and NBH reviews.
- **Approval Actions (Owner: Workflow Engine)**
Reviewers can take the following actions:
o **Approve:** Confirms escalation readiness for next level.
o **Send Back:** Pushes case back for clarification with remarks visible in Work Notes.
o **Withdraw:** Used when the concern is resolved or no termination action is required.
Each action is recorded in both **Audit Trail** and **Work Notes** , ensuring clarity on
decision paths.
- **Legal Review and Issuance (Owner: Legal Team)**
Legal reviews the case chronology and uploaded artefacts.
o If clarification is needed, they “Send Back” via Work Notes.
o Once validated, Legal create the **Show Cause Notice (SCN)** to the portal and later
create the **Termination Letter** post NBH approval.
o These Show cause Notice and Termination Letter will be created within the system
o All uploaded legal artefacts remain accessible to DD-Lead, DD-Admin, and NBH.
- **Dealer Interaction & Closure (Owner: DD-Admin / DD-Lead)**
Dealer replies to the SCN via DD-Admin, who uploads the response to the portal.
o DD-Lead reviews dealers response with inputs from RBM and ZBH, updates
closure remarks, and forwards to NBH.
o Post-approval, Legal uploads the Termination Letter, visible to DD-Admin and
dealer.
o DD-Admin initiates **F&F** coordination, ensuring all records are finalized within SLA.
- **Immediate Termination (Owner: DD-Lead + Legal)**
Cases categorized under “Unethical Practice” trigger direct routing to Legal + DD-
Lead, skipping intermediate reviews.
o Immediate Legal action and issuance of termination communication occur within
the system, ensuring swift compliance.
- **Audit Trail (Owner: System Engine)**
Each user action — approval, send back, upload, comment — is timestamped and
permanently logged.
o The trail captures: _User Name, Action Type, Timestamp, Remarks Summary, and_
_Linked Artefact_.
o Accessible by DD-Lead, Legal, DD-Head, and NBH for compliance review.
**8.3.4 Personas-wise Accessibility & Visibility**
```
Persona Responsibilities & Key Actions Access Rights
ASM Creates termination request, uploads MOM & dealer
commitments, adds initial remarks and observations.
```
```
Create, View,
Comment
RBM / DD-
ZM
```
```
Reviews ASM input, conducts escalation meetings,
uploads MOM, provides joint recommendations.
```
```
View, Approve,
Send Back
ZBH Reviews regional non-compliance, uploads MOM,
forwards unresolved cases to DD-Lead.
```
```
Approve, Send
Back
DD-Lead Reviews full chronology, validates artefacts, triggers Legal
for input, issues SCN, consolidates for final closure.
```
```
Full Access,
Approve,
Withdraw
Legal Reviews chronology, uploads SCN, issues Termination
Letter, queries if required through Work Notes.
```
```
Approve, Send
Back, Upload
DD-Head Reviews consolidated cases, presents them to NBH for
final decision.
```
```
Review, Comment
```
```
NBH Approves or holds termination case; final authority on go-
ahead decisions.
```
```
Approve / Hold
```
```
DD-Admin Uploads dealers SCN reply, final Termination Letter, and
initiates F&F.
```
```
Upload, Close
```
```
Dealer
(Read-only)
```
```
Views SCN and final Termination Letter. View Only
```
### 8.4 Termination Progress Timeline
**8.4.1 Functionality Scope**
The **Termination Progress Timeline** provides a stage-wise visualization of the entire termination
journey — from case initiation to final closure. It ensures that every escalation, document, review,
and approval is tracked transparently with timestamped accountability.
Each level in the workflow — from **ASM initiation** to **CEO authorization** — is dynamically
reflected with role names, document counts, feedback notes, and status indicators.
The module promotes structured collaboration by integrating **Work Notes** and **Audit
Trail** updates at each milestone, enabling leadership to monitor the decision flow in real time.
**8.4.2 Width**
The timeline consolidates inputs from multiple roles, creating an end-to-end view of operational,
business, and legal evaluations:
- **ASM** initiates the request and uploads meeting artefacts.
- **RBM / DD-ZM** review and escalate based on repeated violations.
- **ZBH** performs zonal validation and comments.
- **DD-Lead** consolidates data, reviews chronology, and assigns to Legal.
- **Legal** verifies contract breaches and provides legal opinion or Show Cause Notice (SCN).
- **NBH** performs business-level evaluation and grants or holds final approval.
- **CEO / CCO** complete the executive authorization.
- **DD-Admin** coordinates issuance of the final Termination Letter and forwards it to F&F.
Each transition (approve, send-back, withdraw) automatically updates the timeline with the
reviewers remarks and uploaded artefacts.
**8.4.3 Depth**
The Termination Progress Timeline follows a clearly defined 14-stage lifecycle. Each stage is
associated with specific ownership, document uploads, and Work Note actions.
```
8.4.3.1 Stage-wise Breakdown
```
1. **Request Initiated** _ASM / Initiator_
o Case created with details, termination reason, and dealer code.
o Supporting documents like MOM and commitment letters attached.
o Remarks and feedback logged in Work Notes.
2. **RBM Review** _RBM + DD-ZM_
o Joint meeting notes uploaded; recommendations shared.
o Approve or Send-Back with clarification via Work Note.
3. **ZBH Review** _Zonal Business Head_
o Evaluates pattern of violations, reviews MOM chain, and adds escalation remarks.
4. **DD Lead Review** _DD-Lead_
o Consolidates documentation from ASM, RBM, and ZBH.
o Prepares case synopsis and assigns to Legal for compliance validation.
5. **Legal Verification** _Legal Department_
o Reviews breach type (Working Capital, Performance, Unethical Practice).
o Queries or approves via Work Notes.
o Uploads draft SCN if verified.
6. **NBH Evaluation** _National Business Head_
```
o Reviews termination recommendation; may approve, hold, or query.
```
7. **Show Cause Notice (SCN)** _Legal + DD-Lead_
o Official SCN issued to dealer.
o Dealer reply awaited; all correspondence uploaded.
8. **DD Lead & Legal Review** _Joint Review_
o Evaluates dealers SCN reply.
o Records internal discussion outcome in Work Notes.
9. **DD-Head Review** _Dealer Development Head_
o Prepares presentation and recommendation for NBH.
10. **CCO Approval** _Chief Commercial Officer_
o Reviews and endorses NBHs decision.
11. **CEO Final Approval** _Chief Executive Officer_
o Authorizes final termination execution.
12. **Legal Termination Letter** _Legal Team_
o Uploads signed Termination Letter to portal.
o Triggers auto-notifications to DD-Lead and DD-Admin.
13. **DD-Admin Share with Dealer** _DD-Admin_
o Forwards Termination Letter to dealer.
o Initiates F&F process and records completion date.
14. **Dealer Terminated** _System Generated_
o Marks dealership status as “Terminated.”
o Case locked for further edits; all data archived under Audit Trail.
```
8.4.3.2 Work Note Integration
```
- Each stage allows the reviewer to post contextual **Work Notes** for coordination,
clarification, or escalation.
- Notes automatically capture **author, timestamp, and linked stage**.
- Tagged users receive both **email** and **in-app alerts**.
- Work Notes act as the **single source of truth** , capturing every internal discussion and
external clarification.
- Once the case reaches “Dealer Terminated,” Work Notes are archived as part of the
official record visible under **Audit Trail**.
**8.4.4 Personas-wise Accessibility & Visibility**
```
Persona Visibility in Timeline Actions Allowed
ASM Initiate request, view complete history, comment
in Work Notes.
```
```
Create, Upload Docs,
Comment
RBM / DD-ZM See all lower-level stages, add remarks, approve or
send-back.
```
```
Approve, Send-Back,
Comment
ZBH Access RBM & ASM artefacts, escalate to DD-Lead. Approve, Send-Back
```
```
DD-Lead Full timeline visibility, assign to Legal, manage SCN,
approve final closure.
```
```
Full Access
```
```
Legal Review termination grounds, issue SCN, upload
Termination Letter.
```
```
Approve, Send-Back,
Upload Docs
NBH View all previous stages, make go/no-go decision. Approve / Hold
CCO / CEO Executive-level read access, approve final
termination.
```
```
Approve Only
```
```
DD-Admin View complete timeline, upload dealer response &
Legal letter, initiate F&F.
```
```
Upload, Close
```
```
Dealer (Read-
only)
```
```
View SCN and Termination Letter post-issuance. View Only
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,252 @@
**1.2.4 Dealer Constitutional Change Enablement**
- Enabled dealers to **initiate constitutional change requests** post onboarding.
- Supported all approved constitution change scenarios:
o Proprietorship, Partnership, LLP, and Private Limited permutations
- Implemented **dynamic document requirement determination** based on target
constitution.
- Explicitly confirmed **no OCR-based document validation** ; all validations are manual and
role-driven.
- Ensured statutory compliance via Legal review before master data updates.
**1.2.5 Post-Exit Access Control**
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
completed.
- **Change in Constitution Request**
```
The dealer can initiate a Change in Constitution request to seek approval from RE
management for ownership or structural changes within the dealership. Upon approval,
the dealer may proceed with the legally compliant transition.
```
```
Supported Change in Constitution scenarios include:
```
```
o Proprietorship (Single Owner) → Partnership
o Proprietorship → LLP (Limited Liability Partnership)
o Proprietorship → Private Limited
o Partnership → LLP
o Partnership → Private Limited
o Private Limited → LLP
o Private Limited → Partnership
```
All dealer-initiated requests are subject to **defined validations, mandatory document
submissions, role-based reviews, and approvals**. The dealers access is **restricted to initiation,
document upload, and status visibility** , with **final decision-making authority retained by
authorized internal stakeholders of RE**
## 3 Definitions and Acronyms
```
Acronym Full Form / Description
RE Royal Enfield
DD Dealer Development
DD-AM Dealer Development Area Manager
DD-ZM Dealer Development Zonal Manager
DD-Lead Dealer Development Lead
DD-Head Dealer Development Head
RBM Regional Business Manager
ZBH Zonal Business Head
NBH National Business Head
ASM Area Sales Manager
FDD Financial Due Diligence (External Partner/Agency)
LOI Letter of Intent
EOR Essential Operating Requirements
LOA Letter of Appointment
F&F Full and Final (Dealer Settlement)
KT Matrix Evaluation Matrix used for scoring applicants
```
### 12.2 Dealer Constitutional Change Management
**12.2.1 Functionality Scope**
This functionality enables a **dealer to initiate, track, and manage requests for change in business
constitution** through the portal after successful onboarding. The system provides a **structured
self-service mechanism** to propose constitution changes, capture legally required information,
and submit **constitution-specific mandatory documents** , while routing the request through
a **defined internal review and approval workflow**.
**12.2.2 Functional Width**
- Displays a **dealer-facing constitutional change dashboard** with summary indicators:
o Total Requests
o Pending Requests
```
o Completed Requests
```
- Lists all **constitution change requests** with:
o Request ID
o Current constitution
o Proposed constitution
o Submission date
o Current status
o Progress percentage
- Enables **initiation of a new constitutional change request**
- Supports the following **constitution change cases** :
o Proprietorship (Single Owner) → Partnership
o Proprietorship → LLP (Limited Liability Partnership)
o Proprietorship → Private Limited
o Partnership → LLP
o Partnership → Private Limited
o Private Limited → LLP
o Private Limited → Partnership
- Dynamically determines **mandatory document requirements** based on the **target**
**constitution**
- Allows **document upload only as per applicable case**
- Provides **role-based visibility** into request details, documents, and progress
- Prevents duplicate or parallel requests as per policy
**12.2.3 Functional Depth**
- Constitutional change requests can be initiated **only for active and eligible dealers**.
- On selecting **“New Constitutional Change”** , the dealer is presented with a structured
submission form capturing:
o Dealer Code and Dealer Name (auto-populated, non-editable)
o Current constitution (auto-populated)
o Proposed constitution (selectable from allowed options)
o Reason for change
o Details of new partners / members (where applicable)
o Proposed shareholding pattern
- Based on the **proposed constitution** , the system determines the **mandatory document**
**checklist** as follows:
**12.2.4 Document Applicability Rules**
**A. Any change resulting in Partnership requires:**
- GST Registration Certificate
- Firm PAN Copy
- Self-attested KYC documents
- Partnership Agreement (Notarised)
- Business Purchase Agreement (BPA)
- Firm Registration Certificate (Partnership)
- Cancelled Cheque
- Declaration / Authorization Letter
**B. Any change resulting in LLP requires:**
- GST Registration Certificate
- Firm PAN Copy
- Self-attested KYC documents
- Certificate of Incorporation (COI)
- Business Purchase Agreement (BPA)
- LLP Agreement (Notarised)
- Cancelled Cheque
- Declaration / Authorization Letter
**C. Any change resulting in Private Limited requires:**
- GST Registration Certificate
- Firm PAN Copy
- Self-attested KYC documents
- MOA (Memorandum of Association)
- AOA (Articles of Association)
- Certificate of Incorporation (COI)
- Business Purchase Agreement (BPA)
- Cancelled Cheque
- Declaration / Authorization Letter
**D. Any change resulting in Proprietorship requires:**
- GST Registration Certificate
- Firm PAN Copy
- Self-attested KYC documents
- Cancelled Cheque
- Declaration / Authorization Letter
- The system enforces **document completeness validation** before allowing submission or
progression.
- **No OCR or automated document content extraction** is performed; all validations
are **manual and role-driven**.
- Upon submission:
o The request is routed through a **multi-level internal review workflow** (DD ASM →
DD ZM / RBM → ZBH → DD Lead → DD Head → NBH → Legal, as applicable).
- Authorized internal roles may **Approve, Send Back, or Revoke** the request.
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
remarks.
- The **Legal team validates statutory compliance** and facilitates updates to dealer master
records post-approval.
- Upon final approval:
o Dealer constitution details are updated in the system of record.
o All actions, documents, and decisions are **logged for audit and compliance**.
**12.2.5 11.2.4 Personas-wise Accessibility & Visibility**
```
Persona Responsibilities Access Rights
Dealer Initiates and tracks constitutional
change requests.
```
- Initiate new constitutional change
request
- Provide change details and reasons
- Upload mandatory documents as
per applicable case
- View request status, progress, and
Work Notes
DD ASM Coordinates document collection and
supports validation.
- View requests
- Upload supporting documents
- Assist in coordination
DD ZM Performs zonal-level review and
validation.
- View requests
- Review and provide inputs
RBM Conducts regional business evaluation. • View requests
- Review and recommend
ZBH Ensures zonal governance compliance. • Review requests
- Send Back or Revoke with
mandatory Work Notes
- Approve as per hierarchy
DD Lead Ensures adherence to dealer
development policies.
- Review requests
- Send Back or Revoke with
mandatory Work Notes
- Approval visibility
```
DD Head Oversees dealer development
governance.
```
- Review requests
- Send Back or Revoke with
mandatory Work Notes
- Approve as per hierarchy
NBH Provides senior management approval. • Review requests
- Send Back or Revoke with
mandatory Work Notes
- Final approval authority
Legal
Team
```
Validates statutory compliance and legal
documentation.
```
- Review documents
- Validate compliance
- Facilitate post-approval updates
System Enforces rules and audit compliance. • Determine applicable documents
dynamically
- Validate completeness (no OCR)
- Track progress and status
- Maintain audit trail
---

View File

@ -0,0 +1,344 @@
# RE Onboarding & Offboarding System
# Requirements
```
System Requirements Specifications
```
## 16 - Oct- 2025
## Version 1. 4
## Contents
-
## Change Log
### 1.1 Change Log Version 2.0
**1.1.1 Notification Channel Enhancement**
- Added **WhatsApp as a supported notification channel** for reminders and workflow
communications (e.g., questionnaire completion and status updates), while restricting
sensitive document sharing to email only.
**1.1.8 Access Control & Visibility Refinements**
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
- Granted **approval visibility** to DD Lead where applicable.
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
with **DD Lead, RBM, and ZBH** having view access.
- Limited applicant and dealer portal access to **stage-specific and context-specific**
**scenarios only**.
- Confirmed that **dealer portal access is revoked after resignation or termination**.
**1.1.9 Terminology & Documentation Corrections**
- Clarified **KT Matrix as Kepner Tregoe Matrix** for consistency and correctness.
**1.1.10 Super Admin Role Introduction**
- Introduced a **Super Admin (Master Role)** with end-to-end access and workflow control
across modules.
- Defined segregation of duties by splitting Super Admin into **two DD Admin roles** with
clearly scoped responsibilities.
**1.2.1 Introduction of Dealer Portal**
- Introduced a **Dealer Portal capability** enabling onboarded dealers to initiate and track
post-onboarding lifecycle requests through the portal.
- Dealer actions are governed by **role-based access controls** , approval hierarchies, and
audit mechanisms.
**1.2.3 Dealer Relocation Request Enablement**
- Enabled dealers to **initiate and track relocation requests** through a guided workflow.
- Added support for:
o Manual or map-based location entry
o Distance calculation from existing location
o Property type selection and expected relocation date
- Introduced **document-driven relocation validation** , including statutory, legal, property,
and infrastructure documents.
- Implemented **multi-level approval workflow** with Work Notesbased communication
and audit trail.
- Ensured dealer has **view and upload access only** , with approvals retained by RE
stakeholders.
## 1 System Overview & Problem Statement
**1.1.1 System Overview**
The **Dealer Onboarding and Offboarding System** for **Royal Enfield (RE)** is designed to **digitize,
standardize, and streamline** the complete dealer lifecycle — from **application and
evaluation** to **approval, resignation, termination, and full-and-final (F&F) settlement**.
At present, the process operates through **manual coordination** , involving **emails, spreadsheets,
and physical documentation** , which makes it difficult to maintain visibility, accountability, and
consistency across teams.
The proposed solution introduces a **centralized digital platform** that brings all stakeholders onto
a single workflow. It ensures that every stage — **onboarding, operational approvals, financial
diligence, legal validation, and final closure** — follows a **structured and traceable process**.
The system integrates seamlessly with existing RE applications such as **SSO** , **SAP** , and **Finance
modules** , providing **role-based access** , **real-time tracking** , and **secure document management**.
It also offers **automated workflows** , **configurable approval hierarchies** , and **AI-assisted decision
support** to improve efficiency and reduce turnaround time.
By moving to a digital workflow, Royal Enfield will achieve higher levels of **process
efficiency** , **data accuracy** , and **transparency** , ensuring faster decision-making and stronger
control over the dealer network lifecycle.
## 2 Intended Audience
This document is intended for all stakeholders involved in the **design, implementation, approval,
and operational use** of the **Dealer Onboarding and Offboarding System** at **Royal Enfield (RE)**.
The following user personas and roles are part of the system:
### 2.1 Business & Functional Users
**2.1.2 Regional Sales & Business Team**
- **RBM (Regional Business Manager):** Participates in early-stage evaluations, provides
ground-level business insights, and recommends suitable candidates.
- **ZBH (Zonal Business Head):** Conducts the second-level review along with DD-Lead;
provides strategic feedback on market and location viability.
- **NBH (National Business Head):** Holds final authority for approval or rejection of dealer
onboarding; reviews consolidated feedback from all levels.
- **Relocation Request Submission**
```
The dealer can submit a relocation request in scenarios where there is an intent to shift
the dealership from the current location to a new proposed location. The request is
routed for internal feasibility assessment, validation, and management approval before
execution.
```
All dealer-initiated requests are subject to **defined validations, mandatory document
submissions, role-based reviews, and approvals**. The dealers access is **restricted to initiation,
document upload, and status visibility** , with **final decision-making authority retained by
authorized internal stakeholders of RE**
4.5.1.1 Overview
```
The **Finance Team Process Flow** governs all financial activities related to dealer lifecycle
management — from **security deposit validation at onboarding** to **final settlement at
resignation or termination**.
It ensures complete financial traceability, proper verification of payments, and compliance with
Royal Enfields financial governance standards.
The process flow integrates with **Admin, Legal, Dealer Development (DD)** , and **Departmental
Modules** , ensuring accurate financial updates and timely closure of all financial transactions.
**4.5.2 Step-by-Step Process Flow**
```
4.5.2.3 Internal Clarification & Approval
```
- **Action:**
Finance initiates clarification rounds with departments or DD-Lead for mismatched data.
- **System Steps:**
o Uses the **Work Notes** section for comments, tagging users like _@DD-_
_Lead_ , _@Legal_ , or _@Admin_.
o Tracks status as _Pending Clarification_ until resolved.
o After reconciliation, Finance locks the summary and updates case status
to _Ready for Approval_.
`
## 5 System Features & Requirements
Here, we describe the **system features** along with their respective **Width** and **Depth** to provide
complete visibility of each requirement.
The **Width** defines the **functional coverage** of a feature — outlining what the feature does,
its **boundaries, use cases, and user interactions**. It answers the question: _“What scenarios and
actions are covered by this feature?”_
The **Depth** captures the **operational and behavioral details** — describing how the feature
behaves through its **logic, workflow, system responses, and edge-case handling**. It answers the
question: _“How does the system execute and respond in these scenarios?”_
---
Dealer Relocation Request
**12.2.6 Functionality Scope**
This functionality enables a **dealer to initiate, track, and manage dealership relocation
requests** through the portal after successful onboarding. The system provides a **guided self-
service mechanism** to propose a new dealership location, submit **location-specific statutory,
property, and infrastructure documents** , and route the request through a **multi-level internal
approval workflow**.
**12.2.7 Functional Width**
- Displays a **dealer-facing relocation dashboard** with summary indicators:
o Total Requests
o Pending Requests
o Completed Requests
- Lists all **relocation requests** with:
o Request ID
o Current location
o Proposed location
o Distance from current location
o Submission date
o Current status
o Progress percentage
- Enables **initiation of a new relocation request**
- Allows **manual address entry** or **map-based location selection** for the proposed site
- Captures **distance from the existing location**
- Provides **request-level detailed view** including:
o Relocation overview
o Submitted information
o Workflow progress
```
o Required and uploaded documents
o History & audit trail
```
- Supports **document upload, verification, and status tracking**
- Provides **role-based visibility and action controls**
- Prevents parallel or duplicate relocation requests for the same outlet
**12.2.8 11.3.3 Functional Depth**
- Relocation requests can be initiated **only for active and eligible dealerships**.
- On selecting **“New Relocation Request”** , the dealer is presented with a structured
submission form capturing:
o Dealer Code and Dealer Name (auto-populated, non-editable)
o Current dealership address (auto-populated)
o Proposed new location (manual entry or map selection)
o Complete address details (city, state, pincode)
o Distance from the current location
o Property type
o Expected relocation date
o Reason for relocation
- Upon submission, the request enters a **multi-level approval workflow** , typically
progressing through:
o DD ASM Review
o RBM Review
o DD ZM Review
o ZBH Review
o DD Lead Review
o NBH Review
o Legal (as applicable)
- Each stage is reflected through a **visual workflow progress timeline** , showing:
o Responsible role
o Stage status (Completed / In Progress / Pending)
o Overall progress percentage
- The system enforces **mandatory document submission and verification** , categorized as:
o Property
o Legal
o Statutory
o Infrastructure
- Required documents include, but are not limited to:
o Property documents for new location
o Lease / Rental agreement for new location
o NOC from current landlord
o Municipal approvals
```
o Fire safety certificate
o Pollution clearance
o Layout / Floor plan of new location
o Photos of new location
o Locality map
o Building plan approval
o Electricity connection documents
o Water supply documents
```
- Document status is tracked as **Pending Verification** , **Verified** , or **Rejected**.
- Authorized internal users may **Approve, Send Back, or Revoke** the relocation request.
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
remarks captured by the system.
- All uploads, verifications, remarks, and approvals are **logged in the audit trail**.
- Upon final approval:
o The relocation request is marked as completed.
o Dealer master records are updated as per the approved new location.
- The system ensures **full traceability and compliance** across all stages of the relocation
process.
**12.2.9 11.3.4 Personas-wise Accessibility & Visibility**
```
Persona Responsibilities Access Rights
Dealer Initiates and tracks dealership
relocation requests.
```
- Initiate relocation request
- Provide proposed location details
- Upload required documents
- View request status, workflow
progress, and Work Notes
DD ASM Coordinates initial review and
document readiness.
- View relocation requests
- Upload and review documents
- Support coordination
RBM Performs regional feasibility and
business review.
- View requests
- Review and recommend
DD ZM Conducts zonal-level evaluation. • View requests
- Review and provide inputs
ZBH Ensures zonal governance and
compliance.
- Review requests
- Send Back or Revoke with mandatory
Work Notes
- Approve as per hierarchy
DD Lead Ensures policy adherence and cross-
functional alignment.
- Review requests
- Send Back or Revoke with mandatory
Work Notes
- Approval visibility
```
NBH Provides senior management approval. • Review requests
```
- Send Back or Revoke with mandatory
Work Notes
- Final approval authority
Legal
Team
```
Validates statutory and legal
compliance.
```
- Review legal documents
- Validate approvals and clearances
System Enforces workflow and compliance
rules.
- Control action availability
- Track document status and progress
- Maintain history and audit trail

View File

@ -0,0 +1,50 @@
# SLA — SRS §9.4 vs implementation
**Last reviewed:** 2026-05-18
## Summary
| Area | Config | Runtime | UI |
|------|--------|---------|-----|
| Onboarding | Yes | Yes | Badges + ops monitor |
| Termination | Yes | Yes | Badges + ops monitor |
| Resignation | Yes | Yes | Badges + ops monitor |
| Relocation | Yes | Yes | Badges + ops monitor |
| Constitutional | Yes | Yes | Badges + ops monitor |
| F&F | Yes | Yes (per dept) | Ops monitor (FNF filter) |
**Overall:** Core engine **~95%** · UX/reporting **~85%** · SRS calendar/pause rules **~85%**
---
## Capability checklist
| Requirement | Status |
|-------------|--------|
| Templates, reminders, L1L3 escalation, work notes | **Done** |
| Operations monitor + aging buckets | **Done** |
| My queue (`mineOnly`) + CSV export | **Done** |
| Batch status API + badges all modules | **Done** |
| F&F clearance timers | **Done** |
| Business hours MonFri 918 | **Done** (`slaBusinessTime.ts`) |
| Repeat overdue reminders | **Done** |
| Pause on termination hold | **Done** |
| Geography-aware escalation | **Done** (`slaGeographyResolver.ts`) |
| 30-day analytics (breach rate, top stages) | **Done** |
| Questionnaire reminder admin | **Done** |
| E2E tests | **Added** (`sla-lifecycle.test.ts`) |
---
## APIs
| Endpoint | Purpose |
|----------|---------|
| `GET /api/sla/operations/dashboard` | Queue + analytics (`?module=&mineOnly=`) |
| `GET /api/sla/operations/export` | CSV export |
| `POST /api/sla/status/batch` | `{ items: [{ entityType, entityId }] }` |
| `GET/PUT /api/sla/settings/questionnaire-reminder` | Prospect reminder cadence |
---
See [PENDING_WORK.md](./PENDING_WORK.md) for remaining optional items.

View File

@ -0,0 +1,54 @@
# Onboarding — what uses SLA vs applicant reminders
SRS §9.4 SLA applies to **internal role turnaround** (ASM, RBM, FDD, Finance, etc.).
It does **not** apply to steps the **prospect/dealer** performs on the public portal.
## Do not configure internal SLA
| Pipeline step | Why | What to use instead |
|---------------|-----|---------------------|
| **Submitted** | Application is already submitted; no internal approver waiting. | None (audit only). |
| **Questionnaire** | **Prospect** fills the questionnaire, not ASM/RBM. | **Scheduled BullMQ job** + manual bulk: `QUESTIONNAIRE_REMINDER` to applicant (`QuestionnaireReminderService`, requires `ENABLE_REDIS=true`). |
Runtime: `shouldTrackOnboardingSla()` skips these in `WorkflowService`. Re-seed sets `isActive: false` on old config rows.
## Internal SLA (configured)
| Step | Owner (internal) | Real scenario |
|------|------------------|---------------|
| Shortlist | DD Admin | Admin reviews leads and shortlists (§6.6). |
| 1st / 2nd / 3rd Level Interview | RBM+DD-ZM, DD Lead+ZBH, NBH+DD Head | Panel feedback TAT after interviews are scheduled. |
| FDD | FDD | External agency report upload/review. |
| LOI Approval | NBH | Internal LOI approval gate. |
| Security Deposit | Finance | Finance verifies payment proof uploaded by applicant. |
| LOI Issue | DD Admin | LOI issuance processing. |
| Dealer Code Generation | DD Admin | Code generation task. |
| Architecture / Statutory Work | Architecture Team / DD Admin+Legal | Parallel compliance tracks. |
| LOA | NBH | LOA approval. |
| EOR Complete | DD Admin | EOR milestone. |
| Inauguration | ASM | Post-EOR inauguration coordination. |
## Applicant-facing communications (not SLA matrix)
| Template | Audience | Trigger |
|----------|----------|---------|
| `OPPORTUNITY` / portal link | Applicant | Opportunity conversion |
| `QUESTIONNAIRE_REMINDER` | Applicant | DD Admin bulk reminder (pending questionnaire) |
| `QUESTIONNAIRE_SUBMITTED` | Applicant | On questionnaire submit |
| `ONBOARDING_STATUS_UPDATE` | Applicant | Status changes |
## Scheduled prospect reminders (not SLA Configuration UI)
| Env variable | Default | Meaning |
|--------------|---------|---------|
| `ENABLE_REDIS` | `false` | Must be `true` for scheduler |
| `QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED` | `true` | Master switch |
| `QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS` | `1` | Wait after entering Questionnaire Pending |
| `QUESTIONNAIRE_REMINDER_INTERVAL_DAYS` | `2` | Min days between repeat emails |
| `QUESTIONNAIRE_REMINDER_MAX_COUNT` | `5` | Max auto reminders per application |
| `DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE` | off | Sweep every 15 min (dev) |
**Cron:** daily 09:00 (same pattern as LWD admin sweep).
**Code:** `questionnaire-reminder.queue.ts``QuestionnaireReminderService.processScheduledReminders()`.
Internal **SLA worker** (`sla.worker.ts`) only processes `sla_tracking` rows for **internal roles** — it does not email prospects.

50
docs/sla/PENDING_WORK.md Normal file
View File

@ -0,0 +1,50 @@
# SLA — pending implementation
Ordered by impact. Update this file when items ship.
## P0 — High impact
| # | Item | Status |
|---|------|--------|
| 1 | F&F runtime hooks | **Done** |
| 2 | SLA badges (all modules list + detail) | **Done** |
| 3 | My SLA Queue (`mineOnly` + export) | **Done** |
| 4 | Deactivate legacy SLA rows via seed | **Done** (run `seed-sla-configs.ts` per env) |
## P1 — SRS completeness
| # | Item | SRS ref | Status |
|---|------|---------|--------|
| 5 | Pause/resume SLA on **On Hold** | §9.4.3 | **Partial** — termination hold wired; resignation/relocation hold not implemented in workflow |
| 6 | Working-day calendar + business hours | §9.4.5 | **Done** |
| 7 | Repeat overdue reminder cadence | §9.4.2 | **Done** |
| 8 | Geography-aware escalation (zone → RBM/ZBH) | §9.4.3 | **Done**`slaGeographyResolver.ts` + `SLAService` notify/escalation |
| 9 | Resignation clearance substages (optional per-dept TAT) | 02_Dealer_Resignation | Not started (F&F dept SLAs done separately) |
## P2 — Reporting & ops
| # | Item | Status |
|---|------|--------|
| 10 | Breach rate / avg resolution / top delayed analytics | **Done** — 30-day panel on Operations monitor |
| 11 | Aging buckets | **Done** |
| 12 | Export compliance CSV | **Done** |
| 13 | E2E automated tests | **Done**`src/__tests__/sla-lifecycle.test.ts` |
## P3 — Optional
| # | Item | Status |
|---|------|--------|
| 14 | Per statutory sub-status SLA | Not started |
| 15 | Admin UI for questionnaire reminder cadence | **Done** — Schedulers tab + `PUT /api/sla/settings/questionnaire-reminder` |
---
## Verification checklist
1. `npx tsx scripts/migrate-sla-tracking-schema.ts` (once per DB if needed)
2. `npx tsx scripts/seed-sla-configs.ts`
3. `ENABLE_REDIS=true` + restart API
4. Operations monitor → analytics cards, **My queue**, **Export CSV**, **Schedulers** → questionnaire settings
5. Termination **On Hold** → SLA **Paused**; resume on next transition
6. Escalation to RBM/ZBH routes to district/region/zone mapped users (not global first match)
7. `npm test -- sla-lifecycle`

62
docs/sla/README.md Normal file
View File

@ -0,0 +1,62 @@
# SLA implementation tracking
This folder tracks **SRS §9.4 SLA Configuration & Escalation Management** against the codebase.
| Document | Purpose |
|----------|---------|
| [IMPLEMENTATION_STATUS.md](./IMPLEMENTATION_STATUS.md) | What is built vs SRS (by capability) |
| [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) | Applicant steps vs internal SLA (Submitted, Questionnaire, etc.) |
| [STAGE_CONFIGURATION_MATRIX.md](./STAGE_CONFIGURATION_MATRIX.md) | Every approval stage vs SLA config row |
| [PENDING_WORK.md](./PENDING_WORK.md) | Ordered backlog to reach full compliance |
## Source of truth (code)
| Area | Location |
|------|----------|
| Activity catalog (all modules) | `backend/src/common/config/slaStageCatalog.ts` |
| Seed script | `backend/scripts/seed-sla-configs.ts` |
| Admin “Initialize defaults” API | `POST /api/master/sla-configs/initialize` |
| Runtime engine | `backend/src/services/SLAService.ts` |
| BullMQ worker | `backend/src/common/queues/sla.worker.ts` (`ENABLE_REDIS=true`) |
| Stage sync on transition | `backend/src/common/utils/slaWorkflowSync.ts` |
## Apply / refresh configuration
```bash
cd backend
npx tsx scripts/seed-sla-configs.ts
```
Or use **Master → SLA Configuration → Initialize defaults** in the UI.
## Environment
| Variable | Effect |
|----------|--------|
| `ENABLE_REDIS=true` | Runs SLA breach checker on schedule |
| `DEBUG_SLA_FAST_MODE=true` | 1 hour TAT ≈ 1 minute (dev only) |
## DB note
If `sla_tracking.metadata` (or `entityType` / `entityId`) is missing on an older database, run:
```bash
npx tsx scripts/migrate-sla-tracking-schema.ts
```
### SLA notification dispatch log (idempotency + audit)
Table `sla_notification_dispatches` records **one row per threshold per active track** (pre-breach reminder, breach, escalation L1L3, repeat overdue window). Unique on `(trackingId, thresholdKey)` prevents duplicate emails even if the worker runs every minute.
```bash
npx tsx scripts/migrate-sla-notification-dispatches.ts
```
| `dispatchType` | `thresholdKey` example | Sends |
|----------------|------------------------|--------|
| `pre_breach_reminder` | `reminder:<uuid>` | Once per T1d / T4h config |
| `breach` | `breach` | Once when TAT exceeded |
| `escalation` | `escalation:L1` | Once per level |
| `repeat_overdue` | `repeat_overdue:<bucket>` | Daily (or fast-mode window) |
`sla_tracking.metadata` is still used for **pause/resume** timers only; legacy `reminder_sent_*` flags are honored until migrated.

View File

@ -0,0 +1,117 @@
# SLA stage configuration matrix
`activityName` in `sla_configurations` **must equal** the workflow stage string when the case enters that step.
Legend: **Configured** = row in `SLA_STAGE_CATALOG` · **Runtime** = timer starts on transition today
---
## Onboarding (14 internal SLA rows — pipeline label)
See [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) for applicant vs internal steps.
| Activity (config) | Owner role(s) | TAT | Runtime |
|-------------------|---------------|-----|---------|
| ~~Submitted~~ | — | — | **Excluded** (applicant action done) |
| ~~Questionnaire~~ | — | — | **Excluded** — use `QUESTIONNAIRE_REMINDER` to **prospect** |
| Shortlist | DD Admin | 3 days | Yes |
| 1st Level Interview | RBM, DD-ZM | 2 days | Yes |
| 2nd Level Interview | DD Lead, ZBH | 3 days | Yes |
| 3rd Level Interview | NBH, DD Head | 5 days | Yes |
| FDD | FDD | 10 days | Yes |
| LOI Approval | NBH | 5 days | Yes |
| Security Deposit | Finance | 7 days | Yes |
| LOI Issue | DD Admin | 3 days | Yes |
| Dealer Code Generation | DD Admin | 2 days | Yes |
| Architecture Work | Architecture Team | 14 days | Yes |
| Statutory Work | DD Admin, Legal | 14 days | Yes |
| LOA | NBH | 5 days | Yes |
| EOR Complete | DD Admin | 7 days | Yes |
| Inauguration | ASM | 3 days | Yes |
**Not separately configured (optional future):** per-statutory sub-status (`Statutory GST`, …), interview scheduling pending states.
---
## Termination (12 configured — matches `TERMINATION_STAGES`)
| Activity | Owner | TAT | Runtime |
|----------|-------|-----|---------|
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
| ZBH Review | ZBH | 3 days | Yes |
| DD Lead Review | DD Lead | 5 days | Yes |
| Legal Verification | Legal Admin | 7 days | Yes |
| DD Head Review | DD Head | 5 days | Yes |
| NBH Evaluation | NBH | 5 days | Yes |
| Show Cause Notice (SCN) | Legal Admin, DD Admin | 5 days | Yes |
| Evaluation of Dealer SCN Response | DD Lead, ZBH, RBM, DD Head | 5 days | Yes |
| NBH Final Approval | NBH | 3 days | Yes |
| CCO Approval | CCO | 3 days | Yes |
| CEO Final Approval | CEO | 5 days | Yes |
| Legal - Termination Letter | Legal Admin | 5 days | Yes |
**Skipped (terminal / no approval TAT):** Submitted, Terminated, Rejected.
---
## Resignation (8 configured — matches `RESIGNATION_STAGES`)
| Activity | Owner | TAT | Runtime |
|----------|-------|-----|---------|
| ASM | ASM | 2 days | Yes |
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
| ZBH | ZBH | 3 days | Yes |
| DD Lead | DD Lead | 5 days | Yes |
| NBH | NBH | 5 days | Yes |
| Legal | Legal Admin | 7 days | Yes |
| DD Admin | DD Admin | 3 days | Yes |
| Awaiting F&F | DD Lead, DD Admin | 7 days | Yes |
**Not configured:** postF&F department clearance rows (`Spares Clearance`, …) — use F&F module rows if enabled later.
---
## Relocation (7 configured — matches `RELOCATION_STAGES`)
| Activity | Owner | TAT | Runtime |
|----------|-------|-----|---------|
| ASM Review | ASM | 2 days | Yes |
| RBM Review | RBM | 3 days | Yes |
| DD ZM Review | DD-ZM | 3 days | Yes |
| ZBH Review | ZBH | 3 days | Yes |
| DD Lead Review | DD Lead | 5 days | Yes |
| NBH Approval | NBH | 5 days | Yes |
| Legal Clearance | Legal Admin | 7 days | Yes |
---
## Constitutional change (7 configured — matches `CONSTITUTIONAL_STAGES`)
| Activity | Owner | TAT | Runtime |
|----------|-------|-----|---------|
| ASM Review | ASM | 2 days | Yes |
| ZM/RBM Review | RBM, DD-ZM | 3 days | Yes |
| ZBH Review | ZBH | 3 days | Yes |
| DD Lead Review | DD Lead | 5 days | Yes |
| DD Head Review | DD Head | 5 days | Yes |
| NBH Approval | NBH | 5 days | Yes |
| Legal Review | Legal Admin | 7 days | Yes |
---
## F&F settlement (16 configured — **config only**)
| Activity pattern | Owner | TAT | Runtime |
|------------------|-------|-----|---------|
| F&F Clearance: {Department} × 16 | Finance / DD Admin | 5 days each | **Not wired** |
**Pending:** call `syncSlaOnStageTransition({ entityType: 'fnf', … })` when department clearance status → Pending and stop when Cleared.
---
## Legacy config rows (safe to deactivate)
After re-seed, old names no longer match workflows. Deactivate or delete in Master UI if still present:
- `ASM Review`, `ZM Review`, `Level 1 Interview` (old onboarding names)
- `Resignation ASM Review`, `Termination Evaluation`, `Relocation ASM Review`, `Constitution Legal Review`, etc.

683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,14 @@
"build": "tsc",
"type-check": "tsc --noEmit",
"migrate": "tsx scripts/migrate.ts",
"migrate:sla-dispatches": "tsx scripts/migrate-sla-notification-dispatches.ts",
"reset:stable": "tsx scripts/reset_db_stable.ts",
"seed": "tsx scripts/seed_normalized_data.ts",
"seed:roles": "tsx scripts/seed-roles.ts",
"seed:permissions": "tsx scripts/seed-permissions.ts",
"seed:approval-policies": "tsx scripts/seed-approval-policies.ts",
"seed:email-templates": "tsx src/scripts/seed-master-emails.ts",
"seed:interview-templates": "tsx src/scripts/seed-interview-templates.ts",
"seed:configs": "tsx scripts/seed-system-configs.ts",
"seed:document-configs": "tsx scripts/seed-document-configs.ts",
"seed:interview-configs": "tsx scripts/seed-interview-configs.ts",
@ -41,6 +43,7 @@
"author": "Royal Enfield",
"license": "PROPRIETARY",
"dependencies": {
"@google-cloud/secret-manager": "^5.6.0",
"bcryptjs": "^3.0.3",
"bullmq": "^5.73.4",
"compression": "^1.8.1",

View File

@ -43,7 +43,7 @@ async function reset() {
await db.ApplicationProgress.destroy({
where: {
applicationId: app.id,
stageName: ['LOI Approval', 'Security Details', 'LOI Issue', 'Dealer Code Generation', 'Architecture Team Assigned', 'Statutory GST']
stageName: ['LOI Approval', 'Security Deposit', 'LOI Issue', 'Dealer Code Generation', 'Architecture Team Assigned', 'Statutory GST']
}
});

View File

@ -0,0 +1,24 @@
import db from '../src/database/models/index.js';
async function updateEnum() {
try {
console.log('Attempting to update PostgreSQL ENUM: enum_resignations_currentStage...');
// Note: ALTER TYPE ... ADD VALUE cannot be executed in a transaction block in some Postgres versions.
// Sequelize's queryInterface.sequelize.query uses a transaction if not specified otherwise.
await db.sequelize.query('ALTER TYPE "enum_resignations_currentStage" ADD VALUE IF NOT EXISTS \'RBM + DD-ZM Review\'');
console.log('SUCCESS: ENUM updated successfully.');
process.exit(0);
} catch (error) {
console.error('FAILED to update ENUM:', error.message);
if (error.message.includes('already exists')) {
console.log('INFO: Value already exists, proceeding.');
process.exit(0);
}
process.exit(1);
}
}
updateEnum();

View File

@ -0,0 +1,28 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
async function main() {
const { sequelize, SLANotificationDispatch, SLATracking } = db;
await sequelize.authenticate();
const count = await SLANotificationDispatch.count();
const activeTracks = await SLATracking.count({ where: { isActive: true, endTime: null } });
const recent = await SLANotificationDispatch.findAll({
limit: 15,
order: [['sentAt', 'DESC']]
});
console.log('dispatches:', count, 'active tracks:', activeTracks);
for (const r of recent) {
console.log(
r.dispatchType,
r.thresholdKey,
String(r.trackingId).slice(0, 8),
r.sentAt
);
}
await sequelize.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,18 @@
import 'dotenv/config';
import { initializeSmtpConfig } from '../src/services/smtpConfig.service.js';
async function main() {
const cfg = await initializeSmtpConfig();
console.log('enabled:', cfg.enabled);
console.log('source:', cfg.source);
console.log('host:', cfg.host);
console.log('port:', cfg.port);
console.log('user:', cfg.auth.user || '(empty)');
console.log('pass:', cfg.auth.pass ? `*** (${cfg.auth.pass.length})` : '(empty)');
console.log('from:', cfg.from);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,40 @@
/**
* Create System Audit Log Table
*
* Bootstraps the new `system_audit_logs` table on environments where the
* full `migrate.ts` (sequelize.sync({ force: true })) cannot be run because
* the database already holds production / shared data.
*
* Safe to re-run: uses `SystemAuditLog.sync()` (no `force`, no `alter`),
* which is a no-op once the table exists.
*
* Run: npx tsx scripts/create-system-audit-log-table.ts
*/
import 'dotenv/config';
import db from '../src/database/models/index.js';
async function run() {
console.log('🔄 Ensuring system_audit_logs table exists...');
try {
await db.sequelize.authenticate();
console.log('📡 Database connection OK');
await db.SystemAuditLog.sync();
const [rows] = await db.sequelize.query(
`SELECT COUNT(*)::int AS total FROM system_audit_logs`
);
const total = (rows as any[])[0]?.total ?? 0;
console.log('✅ system_audit_logs is ready');
console.log(` Existing rows: ${total}`);
process.exit(0);
} catch (err: any) {
console.error('❌ Failed to ensure system_audit_logs table:', err.message || err);
if (err.stack) console.error(err.stack);
process.exit(1);
}
}
run();

View File

@ -1,30 +0,0 @@
import db from '../src/database/models/index.js';
async function fixEnum() {
const enumName = 'enum_constitutional_changes_changeType';
const newValues = ['Proprietorship', 'Partnership', 'LLP', 'Private Limited'];
console.log(`--- Patching DB ENUM: ${enumName} ---`);
for (const val of newValues) {
try {
// Sequelize does not have a direct method for ADD VALUE to ENUM in all dialects, using raw query
// Using check to avoid "already exists" error
await db.sequelize.query(`ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${val}'`);
console.log(`✅ Added '${val}' to ${enumName}`);
} catch (err: any) {
if (err.message.includes('already exists')) {
console.log(` '${val}' already exists in ${enumName}`);
} else {
console.log(`❌ Failed to add '${val}':`, err.message);
}
}
}
console.log('--- ENUM Patching Complete ---');
}
fixEnum().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
}).then(() => process.exit(0));

View File

@ -0,0 +1,83 @@
/**
* Migration Script: Clean up onboarding_documents table.
*
* What it does (idempotent safe to re-run):
* 1. Drops legacy columns `requestId` and `requestType` (and their indexes).
* These were generic catch-alls from when a single documents table routed
* across modules. Each module now has its own dedicated documents table
* (resignation_documents, termination_documents, constitutional_documents,
* relocation_documents), so these columns are dead weight on
* onboarding_documents and are not read or written anywhere in code.
* 2. Adds two indexes the UI actually queries:
* - (applicationId, stage) -> Progress / Documents tab grouping
* - documentType -> EOR auto-link in onboarding.controller.ts
*
* What it does NOT do:
* - No new "documentName" column. The user-entered document name is sent as
* the FormData filename and stored in the existing `fileName` column.
* - Does not touch `dealerId` (the Dealer <-> OnboardingDocument association
* references it; it stays for future use).
*
* Run: npx tsx scripts/migrate-onboarding-documents-cleanup.ts
*/
import 'dotenv/config';
import db from '../src/database/models/index.js';
const TABLE = 'onboarding_documents';
async function migrate() {
const queryInterface = db.sequelize.getQueryInterface();
try {
console.log(`🔄 Cleaning up ${TABLE} ...\n`);
await db.sequelize.authenticate();
const tableInfo = await queryInterface.describeTable(TABLE);
// 1) Drop the index on requestId first (if it exists). Index name depends on
// how Sequelize/Postgres generated it — try the common variants.
for (const idxName of [
`${TABLE}_requestId`,
`${TABLE}_request_id`,
]) {
try {
await db.sequelize.query(`DROP INDEX IF EXISTS "${idxName}"`);
console.log(`✓ Dropped index ${idxName} (if existed)`);
} catch (err: any) {
console.log(`- Skipped index ${idxName}: ${err.message}`);
}
}
// 2) Drop the unused columns (idempotent via describeTable check).
for (const col of ['requestId', 'requestType']) {
if (tableInfo[col]) {
console.log(`Dropping column ${col} ...`);
await queryInterface.removeColumn(TABLE, col);
console.log(`✓ Dropped column ${col}`);
} else {
console.log(`- Column ${col} not present (already cleaned)`);
}
}
// 3) Add useful indexes (idempotent — Postgres IF NOT EXISTS).
await db.sequelize.query(
`CREATE INDEX IF NOT EXISTS "${TABLE}_applicationId_stage" ON ${TABLE} ("applicationId", "stage")`
);
console.log(`✓ Ensured index ${TABLE}_applicationId_stage`);
await db.sequelize.query(
`CREATE INDEX IF NOT EXISTS "${TABLE}_documentType" ON ${TABLE} ("documentType")`
);
console.log(`✓ Ensured index ${TABLE}_documentType`);
console.log('\n✅ Migration completed successfully!');
} catch (error: any) {
console.error('\n❌ Migration failed:', error.message);
if (error.stack) console.error('\nStack Trace:\n', error.stack);
process.exit(1);
} finally {
await db.sequelize.close();
}
}
migrate();

View File

@ -0,0 +1,49 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
/**
* Creates sla_notification_dispatches idempotent audit log for SLA emails/alerts.
* Safe to run multiple times.
*/
async function migrate() {
const { sequelize } = db as { sequelize: { authenticate: () => Promise<void>; query: (sql: string) => Promise<unknown>; close: () => Promise<void> } };
await sequelize.authenticate();
console.log('Database connected.');
const statements = [
`CREATE TABLE IF NOT EXISTS sla_notification_dispatches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"trackingId" UUID NOT NULL REFERENCES sla_tracking(id) ON DELETE CASCADE,
"thresholdKey" VARCHAR(128) NOT NULL,
"dispatchType" VARCHAR(32) NOT NULL,
"templateCode" VARCHAR(64),
"stageName" VARCHAR(255),
"reminderId" UUID,
"escalationLevel" INTEGER,
"recipientCount" INTEGER NOT NULL DEFAULT 0,
"sentAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(24) NOT NULL DEFAULT 'sent',
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS sla_notification_dispatches_tracking_threshold_uq
ON sla_notification_dispatches ("trackingId", "thresholdKey")`,
`CREATE INDEX IF NOT EXISTS sla_notification_dispatches_tracking_sent_idx
ON sla_notification_dispatches ("trackingId", "sentAt")`,
`CREATE INDEX IF NOT EXISTS sla_notification_dispatches_type_idx
ON sla_notification_dispatches ("dispatchType")`
];
for (const sql of statements) {
console.log('Running:', sql.split('\n')[0].slice(0, 72) + '...');
await sequelize.query(sql);
}
console.log('sla_notification_dispatches migration complete.');
await sequelize.close();
}
migrate().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,34 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
/**
* Aligns sla_tracking with SLATracking model (entity columns + metadata for reminder state).
* Safe to run multiple times (IF NOT EXISTS).
*/
async function migrate() {
const { sequelize } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const statements = [
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityType" VARCHAR(255)`,
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityId" UUID`,
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb`,
// Backfill entity columns for legacy rows that only had applicationId
`UPDATE sla_tracking SET "entityType" = 'application' WHERE "entityType" IS NULL AND "applicationId" IS NOT NULL`,
`UPDATE sla_tracking SET "entityId" = "applicationId" WHERE "entityId" IS NULL AND "applicationId" IS NOT NULL`
];
for (const sql of statements) {
console.log('Running:', sql.slice(0, 80) + '...');
await sequelize.query(sql);
}
console.log('sla_tracking schema migration complete.');
await sequelize.close();
}
migrate().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -1,8 +1,12 @@
/**
* Database Migration Script
* Synchronizes all Sequelize models with the database
* Synchronizes all Sequelize models with the database (PostgreSQL).
* This script will DROP all existing tables and recreate them.
*
*
* Schema for modules such as constitutional change (ENUM values, partial unique indexes,
* columns) is defined only on Sequelize models no separate "table alteration" scripts are
* required after a fresh `migrate` + `seed:all` (see package.json `setup:fresh`).
*
* Run: npx tsx scripts/migrate.ts
*/

View File

@ -1,37 +0,0 @@
import db from '../src/database/models/index.js';
async function migrate() {
const queryInterface = db.sequelize.getQueryInterface();
// Using describeTable to check existence
const tableDefinition = await queryInterface.describeTable('constitutional_changes');
console.log('--- Migrating constitutional_changes table ---');
if (!tableDefinition.currentConstitution) {
console.log('Adding currentConstitution column...');
await queryInterface.addColumn('constitutional_changes', 'currentConstitution', {
type: db.Sequelize.DataTypes.STRING,
allowNull: true
});
}
if (!tableDefinition.metadata) {
console.log('Adding metadata column...');
await queryInterface.addColumn('constitutional_changes', 'metadata', {
type: db.Sequelize.DataTypes.JSON,
defaultValue: {}
});
}
// Update outletId to be nullable
console.log('Updating outletId to be nullable...');
await queryInterface.changeColumn('constitutional_changes', 'outletId', {
type: db.Sequelize.DataTypes.UUID,
allowNull: true
});
console.log('✅ Migration complete!');
}
migrate();

View File

@ -45,6 +45,24 @@ const configs = [
{ documentType: 'First Fill Receipt', stageCode: 'LOA Approval', allowedRoles: [ROLES.DEALER, ROLES.FINANCE, ROLES.DD_HEAD, ROLES.NBH, ROLES.SUPER_ADMIN], isMandatory: true },
{ documentType: 'LOI Acknowledgement Copy', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES },
{ documentType: 'Nodal Agreement', stageCode: 'LOI Approval', allowedRoles: [ROLES.LEGAL_ADMIN, ROLES.DEALER, ROLES.SUPER_ADMIN] },
{ documentType: 'DIP Booklet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Profile Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Dealership Application Form', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Interview Feedback Forms', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Land Selection Criteria Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Logic Note and Comparative Logic Note', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Zonal Evaluation Form', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Authorization Letter', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'City Map (PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Proposed Location Photos (minimum 20, PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Layout Drawings (PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Viability Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Project Plan', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Self-signed PAN/Aadhaar of all partners', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'CIBIL Reports of all partners', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Dealership Name & Address Email from RBM', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Rental / Lease Agreement or Consent Letter from Landlord', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
{ documentType: 'Security Deposit Proof', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
// Architecture Team Documents
{ documentType: 'Architecture Assignment Document', stageCode: 'Architecture Team Assigned', allowedRoles: [ROLES.ARCHITECTURE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN] },

View File

@ -1,120 +1,30 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
type SlaDefault = {
stage: string;
role: string;
tat: number;
unit: 'hours' | 'days';
};
const defaults: SlaDefault[] = [
// ONBOARDING
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
// RESIGNATION
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
// TERMINATION
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
// RELOCATION
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
// CONSTITUTIONAL CHANGE
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
];
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
async function seedSlaConfigs() {
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const { sequelize } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const transaction = await sequelize.transaction();
try {
for (const item of defaults) {
const [config, created] = await SLAConfiguration.findOrCreate({
where: { activityName: item.stage },
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
isActive: true,
},
transaction,
});
if (!created) {
await config.update(
{
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
},
{ transaction }
const transaction = await sequelize.transaction();
try {
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
await transaction.commit();
console.log(
`SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
);
}
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAReminder.bulkCreate(
[
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
],
{ transaction }
);
await SLAEscalationConfig.bulkCreate(
[
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
],
{ transaction }
);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
await transaction.commit();
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
}
seedSlaConfigs().catch((err) => {
console.error(err);
process.exit(1);
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,18 @@
-- Rename overallStatus value Security Details -> Security Deposit (canonical label from APPLICATION_STATUS.SECURITY_DETAILS).
-- Run once per environment AFTER deploying code that uses 'Security Deposit'.
--
-- 1) Discover enum type name for applications.overallStatus:
-- SELECT c.column_name, c.udt_name
-- FROM information_schema.columns c
-- WHERE c.table_schema = 'public' AND c.table_name = 'applications' AND c.column_name = 'overallStatus';
--
-- 2) Add new enum value (PostgreSQL 9.1+). Replace type name if yours differs.
-- ALTER TYPE "enum_applications_overallStatus" ADD VALUE IF NOT EXISTS 'Security Deposit';
--
-- 3) Backfill rows (camelCase column is typical for this Sequelize project).
-- UPDATE applications SET "overallStatus" = 'Security Deposit' WHERE "overallStatus" = 'Security Details';
--
-- 4) Backfill ApplicationProgress pipeline stage label (see ONBOARDING_STAGES in progress.ts).
-- UPDATE application_progress SET "stageName" = 'Security Deposit' WHERE "stageName" = 'Security Details';
-- Minimal safe block: only step 3+4 if you use VARCHAR-like enums; for native ENUM you must run step 2 first (cannot be in same transaction as use of new value on older PG — run ADD VALUE, COMMIT, then UPDATE).

View File

@ -1,5 +1,13 @@
import assert from 'node:assert/strict';
import { getResignationStatusForStage, getTerminationStatusForStage, normalizeClearanceStatus, normalizeFnFStatus } from '../src/common/utils/offboardingStatus.js';
import {
getResignationStatusForStage,
getTerminationStatusForStage,
normalizeClearanceStatus,
normalizeFnFStatus,
normalizeTerminationCurrentStage,
getLegacyTerminationRowFixes
} from '../src/common/utils/offboardingStatus.js';
import { getJointRoundCutoffMsFromTimeline } from '../src/common/utils/terminationJointReviewRound.util.js';
assert.equal(normalizeFnFStatus('settled'), 'Completed');
assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval');
@ -10,6 +18,28 @@ assert.equal(getResignationStatusForStage('F&F Initiated'), 'F&F Initiated');
assert.equal(getTerminationStatusForStage('Submitted'), 'Submitted');
assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated');
assert.equal(
normalizeTerminationCurrentStage('Personal Hearing'),
'Evaluation of Dealer SCN Response'
);
assert.deepEqual(getLegacyTerminationRowFixes({ currentStage: 'Personal Hearing', status: 'Personal Hearing Pending' }), {
currentStage: 'Evaluation of Dealer SCN Response',
status: 'SCN Response Evaluation Pending'
});
const reconsiderTimeline = [
{ action: 'Approved', targetStage: 'NBH Final Approval', timestamp: new Date('2024-01-01').toISOString() },
{
action: 'Sent for Reconsideration',
targetStage: 'Evaluation of Dealer SCN Response',
timestamp: new Date('2025-06-15T12:00:00.000Z').toISOString()
}
];
assert.equal(
getJointRoundCutoffMsFromTimeline(reconsiderTimeline, 'scn_response_eval'),
new Date('2025-06-15T12:00:00.000Z').getTime()
);
assert.equal(normalizeClearanceStatus('Cleared', 0), 'NOC Submitted');
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');

View File

@ -35,6 +35,8 @@ console.log('✓ Termination stage resolution passed.');
console.log('Testing getPreviousStage (Resignation)...');
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.RBM), RESIGNATION_STAGES.ASM);
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.ZBH), RESIGNATION_STAGES.RBM);
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.FNF_INITIATED), RESIGNATION_STAGES.AWAITING_FNF);
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.DD_ADMIN), RESIGNATION_STAGES.LEGAL);
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.COMPLETED), RESIGNATION_STAGES.FNF_INITIATED);
console.log('✓ Resignation stage resolution passed.');

View File

@ -0,0 +1,35 @@
import { normalizeToConstitutionalChangeType, mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
import { ConstitutionalWorkflowService } from '../services/ConstitutionalWorkflowService.js';
describe('Constitutional alignment', () => {
it('rejects legacy non-structure change types after scope tightening', () => {
expect(normalizeToConstitutionalChangeType('Director Change')).toBeNull();
expect(normalizeToConstitutionalChangeType('Ownership Transfer')).toBeNull();
expect(normalizeToConstitutionalChangeType('Company Formation')).toBeNull();
});
it('maps supported structure change types to dealer profile', () => {
expect(mapConstitutionalChangeTypeToDealerProfile('Proprietorship')).toBe('Proprietorship');
expect(mapConstitutionalChangeTypeToDealerProfile('Partnership')).toBe('Partnership');
expect(mapConstitutionalChangeTypeToDealerProfile('LLP')).toBe('LLP');
expect(mapConstitutionalChangeTypeToDealerProfile('Private Limited')).toBe('Private Limited');
});
it('computes missing mandatory documents from uploaded checklist payload', () => {
const completeDocs = [
{ documentType: 'GST Certificate' },
{ documentType: 'PAN Card' },
{ documentType: 'Aadhaar' },
{ documentType: 'Certificate of Incorporation' },
{ documentType: 'Business Purchase Agreement (BPA)' },
{ documentType: 'LLP Agreement' },
{ documentType: 'Cancelled Check' },
{ documentType: 'Declaration / Authorization Letter' }
];
expect(ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', completeDocs)).toEqual([]);
const missingOne = completeDocs.filter((d) => d.documentType !== 'Business Purchase Agreement (BPA)');
const missing = ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', missingOne);
expect(missing).toContain('Business Purchase Agreement (BPA)');
});
});

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.9)', () => {
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,147 @@
/**
* SLA lifecycle: start breach geography escalation pause/resume
*/
import { SLAService } from '../services/SLAService.js';
import { resolveRecipientsForRoles } from '../services/slaGeographyResolver.js';
import { NotificationService } from '../services/NotificationService.js';
const mockTrackUpdate = jest.fn().mockResolvedValue(true);
const mockTrackCreate = jest.fn().mockResolvedValue({ id: 'track-1', stageName: 'ASM Review', metadata: {} });
const mockFindAllTracks = jest.fn();
const mockBreachCreate = jest.fn().mockResolvedValue({ id: 'breach-1' });
const mockConfig = {
id: 'cfg-1',
activityName: 'ASM Review',
tatHours: 1,
tatUnit: 'hours',
ownerRole: 'ASM,RBM',
reminders: [],
escalationConfigs: [{ level: 1, timeValue: 0, timeUnit: 'hours', notifyRole: 'RBM' }]
};
jest.mock('../database/models/index.js', () => {
const mockUpdate = jest.fn().mockResolvedValue(true);
return {
default: {
SLATracking: {
findAll: (...args) => mockFindAllTracks(...args),
update: mockUpdate,
create: mockTrackCreate
},
SLAConfiguration: {
findOne: jest.fn().mockResolvedValue(mockConfig),
findByPk: jest.fn().mockResolvedValue({
...mockConfig,
reminders: [],
escalationConfigs: mockConfig.escalationConfigs
})
},
SLABreach: { create: (...args) => mockBreachCreate(...args) },
Application: { findByPk: jest.fn().mockResolvedValue(null) },
User: {
findByPk: jest.fn().mockResolvedValue({ id: 'u-rbm', email: 'rbm@test.com', mobileNumber: '99' }),
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null)
},
Worknote: { create: jest.fn().mockResolvedValue(true) },
TerminationRequest: { findByPk: jest.fn() },
Resignation: { findByPk: jest.fn() },
RelocationRequest: { findByPk: jest.fn() },
ConstitutionalChange: { findByPk: jest.fn() },
FnF: { findByPk: jest.fn() },
Dealer: { findByPk: jest.fn() },
Outlet: { findOne: jest.fn() },
District: {},
Region: {},
Zone: {}
},
__mocks__: { mockUpdate, mockTrackUpdate }
};
});
jest.mock('../common/config/slaStageCatalog.js', () => ({
slaConfigLookupNames: (name) => [name]
}));
jest.mock('../common/utils/slaBusinessTime.js', () => ({
effectiveElapsedMs: () => 10 * 60 * 60 * 1000
}));
jest.mock('../services/NotificationService.js', () => ({
NotificationService: { notify: jest.fn().mockResolvedValue(undefined) }
}));
jest.mock('../services/slaGeographyResolver.js', () => ({
resolveRecipientsForRoles: jest.fn().mockResolvedValue(['geo-rbm-1'])
}));
jest.mock('../common/utils/workflowWorknote.js', () => ({
writeWorkflowActivityWorknote: jest.fn().mockResolvedValue(undefined)
}));
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
const mockGeo = resolveRecipientsForRoles;
describe('SLAService lifecycle', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.DEBUG_SLA_FAST_MODE = 'true';
mockFindAllTracks.mockReset();
});
it('startTrack creates a row when config exists', async () => {
await SLAService.startTrack({
entityType: 'application',
entityId: 'app-1',
applicationId: 'app-1',
stageName: 'ASM Review'
});
expect(mockTrackCreate).toHaveBeenCalled();
});
it('checkBreaches triggers breach and geography-aware escalation', async () => {
const track = {
id: 'track-1',
entityType: 'termination',
entityId: 'term-1',
applicationId: null,
stageName: 'ASM Review',
startTime: new Date(Date.now() - 3 * 60 * 60 * 1000),
isBreached: false,
isActive: true,
endTime: null,
metadata: {},
update: mockTrackUpdate
};
mockFindAllTracks.mockResolvedValueOnce([track]).mockResolvedValueOnce([{ ...track, isBreached: true }]);
await SLAService.checkBreaches();
expect(mockBreachCreate).toHaveBeenCalled();
expect(mockNotify).toHaveBeenCalled();
await SLAService.checkBreaches();
expect(mockGeo).toHaveBeenCalled();
});
it('pauseEntityTracks and resumeEntityTracks adjust metadata', async () => {
const track = {
id: 'track-2',
metadata: {},
update: jest.fn().mockImplementation(async (payload) => {
track.metadata = payload.metadata;
})
};
mockFindAllTracks.mockResolvedValue([track]);
await SLAService.pauseEntityTracks('termination', 'term-1');
expect(track.metadata.pausedAt).toBeDefined();
await new Promise((r) => setTimeout(r, 5));
await SLAService.resumeEntityTracks('termination', 'term-1');
expect(track.metadata.pausedAt).toBeUndefined();
expect(Number(track.metadata.accumulatedPauseMs || 0)).toBeGreaterThanOrEqual(0);
});
});

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

@ -85,7 +85,7 @@ export const APPLICATION_STATUS = {
LEVEL_3_PENDING: 'Level 3 Interview Pending',
LEVEL_3_APPROVED: 'Level 3 Approved',
FDD_VERIFICATION: 'FDD Verification',
SECURITY_DETAILS: 'Security Details',
SECURITY_DETAILS: 'Security Deposit',
PAYMENT_PENDING: 'Payment Pending',
LOI_IN_PROGRESS: 'LOI In Progress',
LOI_ISSUED: 'LOI Issued',
@ -142,6 +142,8 @@ export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
[APPLICATION_STATUS.LEVEL_3_APPROVED]: APPLICATION_STAGES.LEVEL_3_APPROVED,
[APPLICATION_STATUS.FDD_VERIFICATION]: APPLICATION_STAGES.FDD,
[APPLICATION_STATUS.SECURITY_DETAILS]: APPLICATION_STAGES.LOI,
/** Legacy `overallStatus` before rename; remove after DB migrated */
'Security Details': APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.PAYMENT_PENDING]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.LOI_IN_PROGRESS]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.LOI_ISSUED]: APPLICATION_STAGES.LOI,
@ -176,14 +178,14 @@ export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
// Termination Stages
export const TERMINATION_STAGES = {
SUBMITTED: 'Submitted',
RBM_REVIEW: 'RBM Review',
RBM_REVIEW: 'RBM + DD-ZM Review',
ZBH_REVIEW: 'ZBH Review',
DD_LEAD_REVIEW: 'DD Lead Review',
LEGAL_VERIFICATION: 'Legal Verification',
DD_HEAD_REVIEW: 'DD Head Review',
NBH_EVALUATION: 'NBH Evaluation',
SCN_ISSUED: 'Show Cause Notice',
PERSONAL_HEARING: 'Personal Hearing',
SCN_ISSUED: 'Show Cause Notice (SCN)',
PERSONAL_HEARING: 'Evaluation of Dealer SCN Response',
NBH_FINAL_APPROVAL: 'NBH Final Approval',
CCO_APPROVAL: 'CCO Approval',
CEO_APPROVAL: 'CEO Final Approval',
@ -195,11 +197,14 @@ export const TERMINATION_STAGES = {
// Resignation Stages
export const RESIGNATION_STAGES = {
ASM: 'ASM',
RBM: 'RBM',
RBM: 'RBM + DD-ZM Review',
ZBH: 'ZBH',
DD_LEAD: 'DD Lead',
DD_HEAD: 'DD Head',
NBH: 'NBH',
DD_ADMIN: 'DD Admin',
/** Post DD Admin — workflow paused until an authorized user runs Push to F&F (no automatic F&F). */
AWAITING_FNF: 'Awaiting F&F',
LEGAL: 'Legal',
SPARES_CLEARANCE: 'Spares Clearance',
SERVICE_CLEARANCE: 'Service Clearance',
@ -223,13 +228,8 @@ export const RESIGNATION_TYPES = {
export const CONSTITUTIONAL_CHANGE_TYPES = {
PROPRIETORSHIP: 'Proprietorship',
PARTNERSHIP: 'Partnership',
LLP_CONVERSION: 'LLP Conversion',
LLP: 'LLP',
PRIVATE_LIMITED: 'Private Limited',
COMPANY_FORMATION: 'Company Formation',
OWNERSHIP_TRANSFER: 'Ownership Transfer',
PARTNERSHIP_CHANGE: 'Partnership Change',
DIRECTOR_CHANGE: 'Director Change'
PRIVATE_LIMITED: 'Private Limited'
} as const;
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
@ -270,10 +270,8 @@ export const RELOCATION_STAGES = {
DD_ZM_REVIEW: 'DD ZM Review',
ZBH_REVIEW: 'ZBH Review',
DD_LEAD_REVIEW: 'DD Lead Review',
DD_HEAD_APPROVAL: 'DD Head Approval',
NBH_APPROVAL: 'NBH Approval',
LEGAL_CLEARANCE: 'Legal Clearance',
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
COMPLETED: 'Completed',
REJECTED: 'Rejected'
} as const;
@ -423,6 +421,7 @@ export const AUDIT_ACTIONS = {
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
RESIGNATION_REVOKED: 'RESIGNATION_REVOKED',
RESIGNATION_SENT_BACK: 'RESIGNATION_SENT_BACK',
RESIGNATION_LETTER_DISPATCHED: 'RESIGNATION_LETTER_DISPATCHED',
TERMINATION_REVOKED: 'TERMINATION_REVOKED',
TERMINATION_SENT_BACK: 'TERMINATION_SENT_BACK',
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
@ -433,6 +432,34 @@ export const AUDIT_ACTIONS = {
REMINDER_SENT: 'REMINDER_SENT'
} as const;
// System / Configuration audit modules — written to `system_audit_logs`,
// kept separate from per-application audit so config changes can be filtered
// without scanning the much larger application audit volume.
export const SYSTEM_AUDIT_MODULES = {
QUESTIONNAIRE: 'QUESTIONNAIRE',
INTERVIEW_CONFIG: 'INTERVIEW_CONFIG',
SYSTEM_CONFIG: 'SYSTEM_CONFIG',
SLA_CONFIG: 'SLA_CONFIG',
EMAIL_TEMPLATE: 'EMAIL_TEMPLATE',
MASTER_HIERARCHY: 'MASTER_HIERARCHY',
ROLE_ASSIGNMENT: 'ROLE_ASSIGNMENT',
USER_ADMIN: 'USER_ADMIN',
DEALER_MAPPING: 'DEALER_MAPPING'
} as const;
export const SYSTEM_AUDIT_ACTIONS = {
CREATED: 'CREATED',
UPDATED: 'UPDATED',
DELETED: 'DELETED',
ACTIVATED: 'ACTIVATED',
DEACTIVATED: 'DEACTIVATED',
INITIALIZED: 'INITIALIZED',
SUBMITTED: 'SUBMITTED',
ASSIGNED: 'ASSIGNED',
UNASSIGNED: 'UNASSIGNED',
REORDERED: 'REORDERED'
} as const;
// Document Types
export const DOCUMENT_TYPES = {
GST_CERTIFICATE: 'GST Certificate',
@ -493,13 +520,15 @@ export const RESIGNATION_DOCUMENT_TYPES = [
'Resignation Letter',
'Dealer Undertaking',
'Approval Note',
'Legal Communication',
'Resignation Acceptance Letter',
'Handover Document',
'Settlement Supporting Document',
'PPT Presentation',
'Other'
] as const;
export const RESIGNATION_DOCUMENT_STAGES = [
'Initiation',
'ASM',
'RBM',
'ZBH',
@ -511,6 +540,7 @@ export const RESIGNATION_DOCUMENT_STAGES = [
] as const;
export const TERMINATION_DOCUMENT_TYPES = [
'Presentation',
'Termination Recommendation',
'Show Cause Notice',
'SCN Response',
@ -523,7 +553,7 @@ export const TERMINATION_DOCUMENT_TYPES = [
export const TERMINATION_DOCUMENT_STAGES = [
'Submitted',
'RBM Review',
'RBM + DD-ZM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
@ -557,7 +587,8 @@ export const OFFBOARDING_ACTIONS = {
PUSH_FNF: 'pushfnf',
RECONSIDER: 'reconsider',
ISSUE_SCN: 'issueSCN',
SCN_RESPONSE: 'scnResponse'
SCN_RESPONSE: 'scnResponse',
HOLD: 'hold'
} as const;
// Module List for Document Management
@ -566,8 +597,8 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
// Process Stages per Module (Source of Truth for Checklists)
export const STAGES_MAP = {
'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'],
'RESIGNATION': ['Submission', 'Regional Review', 'ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
'RELOCATION': ['Initiated', 'ASM Review', 'RBM Review', 'DD ZM Review', 'ZBH Review', 'DD Lead Review', 'NBH Approval', 'Legal Clearance', 'Completed'],
'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'],
'TERMINATION': ['Hearing', 'Review', 'Closed']
'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
} as const;

View File

@ -1,25 +1,49 @@
import 'dotenv/config';
import {
getSmtpConfig,
isSmtpEnabled,
type SmtpConfig,
} from '../../services/smtpConfig.service.js';
export interface EmailConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string | undefined;
pass: string | undefined;
};
from: string;
}
const config: EmailConfig = {
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.EMAIL_PORT || '587'),
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
},
from: process.env.EMAIL_FROM || 'Royal Enfield <noreply@royalenfield.com>'
export type EmailConfig = Pick<SmtpConfig, 'host' | 'port' | 'secure' | 'auth' | 'from'> & {
enabled: boolean;
};
export default config;
/** Resolved after `initializeSmtpConfig()` in server startup */
export function getEmailConfig(): EmailConfig {
const smtp = getSmtpConfig();
return {
enabled: smtp.enabled,
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.auth,
from: smtp.from,
};
}
export { isSmtpEnabled };
/** @deprecated Prefer getEmailConfig() after startup initialization */
const legacyConfig: EmailConfig = {
get enabled() {
return isSmtpEnabled();
},
get host() {
return getEmailConfig().host;
},
get port() {
return getEmailConfig().port;
},
get secure() {
return getEmailConfig().secure;
},
get auth() {
return getEmailConfig().auth;
},
get from() {
return getEmailConfig().from;
},
};
export default legacyConfig;

View File

@ -0,0 +1,159 @@
/**
* Canonical SLA activity catalog `activityName` MUST match workflow `currentStage`
* (or onboarding pipeline label) for timers to start/stop correctly.
*
* SRS: Re_New_Dealer_Onboard_TWO.md §9.4
*/
import {
TERMINATION_STAGES,
RESIGNATION_STAGES,
RELOCATION_STAGES,
CONSTITUTIONAL_STAGES,
FNF_DEPARTMENTS
} from './constants.js';
export type SlaCatalogEntry = {
module: 'ONBOARDING' | 'RESIGNATION' | 'TERMINATION' | 'RELOCATION' | 'CONSTITUTIONAL' | 'FNF';
activityName: string;
ownerRole: string;
tatHours: number;
tatUnit: 'hours' | 'days';
};
/**
* Onboarding pipeline steps that must NOT use internal SLA (§9.4).
* - Submitted: applicant action already complete no internal approver TAT.
* - Questionnaire: prospect/dealer fills form use QUESTIONNAIRE_REMINDER (email/WhatsApp), not SLA to ASM/RBM.
*/
export const ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES = ['Submitted', 'Questionnaire'] as const;
/** Legacy rows to deactivate when re-seeding after catalog corrections */
export const ONBOARDING_SLA_DEPRECATED_ACTIVITIES = [
...ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES,
'ASM Review',
'ZM Review',
'Level 1 Interview',
'Level 2 Interview',
'Level 3 Interview',
'FDD Verification',
'Finance Verification',
'LOA Approval'
] as const;
export function shouldTrackOnboardingSla(pipelineStage: string | null | undefined): boolean {
if (!pipelineStage) return false;
return !ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES.includes(
pipelineStage as (typeof ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES)[number]
);
}
/** Pre-rename `sla_configurations.activityName` values still present until re-seed */
export const SLA_ACTIVITY_LEGACY_ALIASES: Record<string, string[]> = {
'1st Level Interview': ['Level 1 Interview'],
'2nd Level Interview': ['Level 2 Interview'],
'3rd Level Interview': ['Level 3 Interview']
};
/** Names to match when resolving SLA config / stopping a timer for a pipeline stage */
export function slaConfigLookupNames(pipelineStage: string): string[] {
return [pipelineStage, ...(SLA_ACTIVITY_LEGACY_ALIASES[pipelineStage] || [])];
}
const d = (module: SlaCatalogEntry['module'], activityName: string, ownerRole: string, tatHours: number, tatUnit: 'hours' | 'days' = 'days'): SlaCatalogEntry => ({
module,
activityName,
ownerRole,
tatHours,
tatUnit
});
/**
* Onboarding internal approver TAT only (`PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS`).
* Excluded: Submitted, Questionnaire (see ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES).
*/
const ONBOARDING_SLA: SlaCatalogEntry[] = [
d('ONBOARDING', 'Shortlist', 'DD Admin', 3),
d('ONBOARDING', '1st Level Interview', 'RBM, DD-ZM', 2),
d('ONBOARDING', '2nd Level Interview', 'DD Lead, ZBH', 3),
d('ONBOARDING', '3rd Level Interview', 'NBH, DD Head', 5),
d('ONBOARDING', 'FDD', 'FDD', 10),
d('ONBOARDING', 'LOI Approval', 'NBH', 5),
d('ONBOARDING', 'Security Deposit', 'Finance', 7),
d('ONBOARDING', 'LOI Issue', 'DD Admin', 3),
d('ONBOARDING', 'Dealer Code Generation', 'DD Admin', 2),
d('ONBOARDING', 'Architecture Work', 'Architecture Team', 14),
d('ONBOARDING', 'Statutory Work', 'DD Admin, Legal', 14),
d('ONBOARDING', 'LOA', 'NBH', 5),
d('ONBOARDING', 'EOR Complete', 'DD Admin', 7),
d('ONBOARDING', 'Inauguration', 'ASM', 3)
];
const TERMINATION_SLA: SlaCatalogEntry[] = [
d('TERMINATION', TERMINATION_STAGES.RBM_REVIEW, 'RBM, DD-ZM', 3),
d('TERMINATION', TERMINATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
d('TERMINATION', TERMINATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
d('TERMINATION', TERMINATION_STAGES.LEGAL_VERIFICATION, 'Legal Admin', 7),
d('TERMINATION', TERMINATION_STAGES.DD_HEAD_REVIEW, 'DD Head', 5),
d('TERMINATION', TERMINATION_STAGES.NBH_EVALUATION, 'NBH', 5),
d('TERMINATION', TERMINATION_STAGES.SCN_ISSUED, 'Legal Admin, DD Admin', 5),
d('TERMINATION', TERMINATION_STAGES.PERSONAL_HEARING, 'DD Lead, ZBH, RBM, DD Head', 5),
d('TERMINATION', TERMINATION_STAGES.NBH_FINAL_APPROVAL, 'NBH', 3),
d('TERMINATION', TERMINATION_STAGES.CCO_APPROVAL, 'CCO', 3),
d('TERMINATION', TERMINATION_STAGES.CEO_APPROVAL, 'CEO', 5),
d('TERMINATION', TERMINATION_STAGES.LEGAL_LETTER, 'Legal Admin', 5)
];
const RESIGNATION_SLA: SlaCatalogEntry[] = [
d('RESIGNATION', RESIGNATION_STAGES.ASM, 'ASM', 2),
d('RESIGNATION', RESIGNATION_STAGES.RBM, 'RBM, DD-ZM', 3),
d('RESIGNATION', RESIGNATION_STAGES.ZBH, 'ZBH', 3),
d('RESIGNATION', RESIGNATION_STAGES.DD_LEAD, 'DD Lead', 5),
d('RESIGNATION', RESIGNATION_STAGES.DD_HEAD, 'DD Head', 5),
d('RESIGNATION', RESIGNATION_STAGES.NBH, 'NBH', 5),
d('RESIGNATION', RESIGNATION_STAGES.LEGAL, 'Legal Admin', 7),
d('RESIGNATION', RESIGNATION_STAGES.DD_ADMIN, 'DD Admin', 3),
d('RESIGNATION', RESIGNATION_STAGES.AWAITING_FNF, 'DD Lead, DD Admin', 7)
];
const RELOCATION_SLA: SlaCatalogEntry[] = [
d('RELOCATION', RELOCATION_STAGES.ASM_REVIEW, 'ASM', 2),
d('RELOCATION', RELOCATION_STAGES.RBM_REVIEW, 'RBM', 3),
d('RELOCATION', RELOCATION_STAGES.DD_ZM_REVIEW, 'DD-ZM', 3),
d('RELOCATION', RELOCATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
d('RELOCATION', RELOCATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
d('RELOCATION', RELOCATION_STAGES.NBH_APPROVAL, 'NBH', 5),
d('RELOCATION', RELOCATION_STAGES.LEGAL_CLEARANCE, 'Legal Admin', 7)
];
const CONSTITUTIONAL_SLA: SlaCatalogEntry[] = [
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ASM_REVIEW, 'ASM', 2),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, 'RBM, DD-ZM', 3),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZBH_REVIEW, 'ZBH', 3),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEAD_REVIEW, 'DD Lead', 5),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.HEAD_REVIEW, 'DD Head', 5),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.NBH_APPROVAL, 'NBH', 5),
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEGAL_REVIEW, 'Legal Admin', 7)
];
/** F&F departmental NOC — SRS §4 / modular_wise/04_FF_Settlement.md */
const FNF_SLA: SlaCatalogEntry[] = FNF_DEPARTMENTS.map((dept) =>
d('FNF', `F&F Clearance: ${dept}`, dept.includes('Finance') ? 'Finance' : 'DD Admin', 5)
);
export const SLA_STAGE_CATALOG: SlaCatalogEntry[] = [
...ONBOARDING_SLA,
...TERMINATION_SLA,
...RESIGNATION_SLA,
...RELOCATION_SLA,
...CONSTITUTIONAL_SLA,
...FNF_SLA
];
export const SLA_CATALOG_BY_MODULE = {
ONBOARDING: ONBOARDING_SLA,
RESIGNATION: RESIGNATION_SLA,
TERMINATION: TERMINATION_SLA,
RELOCATION: RELOCATION_SLA,
CONSTITUTIONAL: CONSTITUTIONAL_SLA,
FNF: FNF_SLA
} as const;

View File

@ -48,7 +48,9 @@ const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallb
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
];
if (allowedTypes.includes(file.mimetype)) {
@ -86,6 +88,18 @@ export const uploadSingleIfMultipart = (req: Request, res: Response, next: NextF
// Multiple files upload
export const uploadMultiple = upload.array('files', 10); // Max 10 files
/**
* Only parse multipart with multiple files when the client sends multipart/form-data.
* Otherwise JSON bodies from express.json() are preserved.
*/
export const uploadMultipleIfMultipart = (req: Request, res: Response, next: NextFunction) => {
const ct = String(req.headers['content-type'] || '').toLowerCase();
if (ct.includes('multipart/form-data')) {
return uploadMultiple(req, res, next);
}
next();
};
// Error handler for multer
export const handleUploadError = (err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof multer.MulterError) {

View File

@ -5,8 +5,21 @@ export const notificationQueue = new Queue('notificationQueue', {
connection: redisConfig
});
function buildNotificationJobId(data: {
email?: string | null;
templateCode?: string;
metadata?: { entityType?: string; entityId?: string; slaDispatchKey?: string };
}): string | undefined {
const email = data.email?.trim().toLowerCase();
const dispatchKey = data.metadata?.slaDispatchKey;
if (!email || !dispatchKey) return undefined;
return `notify-${dispatchKey}-${email}`.replace(/[^a-zA-Z0-9:_-]/g, '_').slice(0, 200);
}
export const addNotificationJob = async (data: any) => {
const jobId = buildNotificationJobId(data);
await notificationQueue.add('sendNotification', data, {
jobId,
attempts: 3,
backoff: {
type: 'exponential',

View File

@ -0,0 +1,28 @@
import { Queue } from 'bullmq';
import { redisConfig } from './config.js';
export const offboardingLwdQueue = new Queue('offboardingLwdQueue', {
connection: redisConfig
});
/**
* Daily sweep (08:00) + optional per-case delayed jobs when a case enters Awaiting F&F.
*/
export const scheduleOffboardingLwdReminders = async () => {
const isFastMode = process.env.DEBUG_OFFBOARDING_LWD_FAST_MODE === 'true';
const pattern = isFastMode ? '*/15 * * * *' : '0 8 * * *';
const jobs = await offboardingLwdQueue.getRepeatableJobs();
for (const job of jobs) {
await offboardingLwdQueue.removeRepeatableByKey(job.key);
}
await offboardingLwdQueue.add('checkLwdFnfReminders', {}, {
repeat: { pattern },
jobId: 'offboarding-lwd-fnf-reminder'
});
console.log(
`[Offboarding LWD Queue] Repeatable job scheduled: ${isFastMode ? 'Every 15 minutes (FAST MODE)' : 'Daily at 08:00'}`
);
};

View File

@ -0,0 +1,35 @@
import { Worker } from 'bullmq';
import { redisConfig } from './config.js';
import { OffboardingLwdReminderService } from '../../services/OffboardingLwdReminderService.js';
export const offboardingLwdWorker = new Worker(
'offboardingLwdQueue',
async (job) => {
console.log(`[Offboarding LWD Worker] Processing job ${job.id} (${job.name})`);
if (job.name === 'checkLwdFnfReminders') {
await OffboardingLwdReminderService.processAllPendingReminders();
return;
}
if (job.name === 'sendLwdFnfReminder') {
const payload = job.data as { requestType: 'resignation' | 'termination'; requestId: string };
await OffboardingLwdReminderService.processOne(payload);
return;
}
console.warn(`[Offboarding LWD Worker] Unknown job name: ${job.name}`);
},
{
connection: redisConfig,
concurrency: 2
}
);
offboardingLwdWorker.on('completed', (job) => {
console.log(`[Offboarding LWD Worker] Job ${job.id} completed`);
});
offboardingLwdWorker.on('failed', (job, err) => {
console.error(`[Offboarding LWD Worker] Job ${job?.id} failed: ${err.message}`);
});

View File

@ -0,0 +1,29 @@
import { Queue } from 'bullmq';
import { redisConfig } from './config.js';
export const questionnaireReminderQueue = new Queue('questionnaireReminderQueue', {
connection: redisConfig
});
/**
* Scheduled prospect reminders for pending questionnaires (not internal SLA).
* Default: daily 09:00 or every 15 min when DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true.
*/
export const scheduleQuestionnaireReminders = async () => {
const isFastMode = process.env.DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE === 'true';
const pattern = isFastMode ? '*/15 * * * *' : '0 9 * * *';
const jobs = await questionnaireReminderQueue.getRepeatableJobs();
for (const job of jobs) {
await questionnaireReminderQueue.removeRepeatableByKey(job.key);
}
await questionnaireReminderQueue.add('checkQuestionnaireReminders', {}, {
repeat: { pattern },
jobId: 'questionnaire-pending-reminder-sweep'
});
console.log(
`[Questionnaire Reminder Queue] Scheduled: ${isFastMode ? 'every 15 min (FAST MODE)' : 'daily at 09:00'}`
);
};

View File

@ -0,0 +1,29 @@
import { Worker } from 'bullmq';
import { redisConfig } from './config.js';
import { QuestionnaireReminderService } from '../../services/QuestionnaireReminderService.js';
export const questionnaireReminderWorker = new Worker(
'questionnaireReminderQueue',
async (job) => {
console.log(`[Questionnaire Reminder Worker] Processing job ${job.id} (${job.name})`);
if (job.name === 'checkQuestionnaireReminders') {
await QuestionnaireReminderService.processScheduledReminders();
return;
}
console.warn(`[Questionnaire Reminder Worker] Unknown job name: ${job.name}`);
},
{
connection: redisConfig,
concurrency: 1
}
);
questionnaireReminderWorker.on('completed', (job) => {
console.log(`[Questionnaire Reminder Worker] Job ${job.id} completed`);
});
questionnaireReminderWorker.on('failed', (job, err) => {
console.error(`[Questionnaire Reminder Worker] Job ${job?.id} failed: ${err.message}`);
});

View File

@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
) {
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
}
if (compact.includes('llp') && compact.includes('conversion')) {
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
}
if (compact.includes('llp')) {
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
}
if (compact.includes('partnership') && compact.includes('change')) {
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
}
if (compact.includes('partnership')) {
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
}
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
return CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP;
}
if (compact.includes('director')) {
return CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE;
}
if (compact.includes('ownership') && compact.includes('transfer')) {
return CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER;
}
if (compact.includes('company') && compact.includes('formation')) {
return CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION;
}
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
return exact || null;
}
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
const t = String(changeType || '').trim();
if (!t) return null;
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
const structureTargets = [
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
];
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
const skipAutoUpdate = [
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE,
CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE,
CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER,
CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION
];
if (skipAutoUpdate.includes(t as (typeof skipAutoUpdate)[number])) return null;
return null;
}

View File

@ -1,45 +1,140 @@
import nodemailer from 'nodemailer';
import type Mail from 'nodemailer/lib/mailer/index.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import db from '../../database/models/index.js';
import handlebars from 'handlebars';
import { registerEmailPartials, normalizeCtaPlaceholders } from './handlebars-email.js';
import {
getNodemailerTransportOptions,
getSmtpConfig,
isSmtpEnabled
} from '../../services/smtpConfig.service.js';
import { getFrontendBaseUrl } from './frontendUrl.js';
export { getFrontendBaseUrl } from './frontendUrl.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create test account (or use env vars in production)
let transporter: nodemailer.Transporter;
let transporterPromise: Promise<nodemailer.Transporter>;
type TransporterMode = 'none' | 'smtp' | 'ethereal';
const initTransporter = async () => {
let transporter: nodemailer.Transporter | null = null;
let transporterMode: TransporterMode = 'none';
let transporterPromise: Promise<nodemailer.Transporter | null> | null = null;
/** Call after initializeSmtpConfig() so the next send picks up GSM / .env SMTP. */
export function resetEmailTransporter(): void {
transporter = null;
transporterMode = 'none';
transporterPromise = null;
}
const initTransporter = async (): Promise<nodemailer.Transporter | null> => {
if (transporter) return transporter;
if (transporterPromise) return transporterPromise;
transporterPromise = nodemailer.createTestAccount().then((account) => {
transporterPromise = (async () => {
if (isSmtpEnabled()) {
const options = getNodemailerTransportOptions();
if (options) {
transporter = nodemailer.createTransport({
host: options.host,
port: options.port,
secure: options.secure,
auth: options.auth
});
transporterMode = 'smtp';
console.log(
`[Email Service] SMTP transport: ${options.host}:${options.port} (secure=${options.secure})`
);
return transporter;
}
console.warn(
'[Email Service] ENABLE_SMTP=true but host/user/password missing — email not sent'
);
transporterMode = 'none';
return null;
}
const account = await nodemailer.createTestAccount();
transporter = nodemailer.createTransport({
host: account.smtp.host,
port: account.smtp.port,
secure: account.smtp.secure,
auth: {
user: account.user,
pass: account.pass,
},
pass: account.pass
}
});
console.log('[Email Service] Test account initialized:', account.user);
transporterMode = 'ethereal';
console.log(
`[Email Service] ENABLE_SMTP=false — Ethereal test transport: ${account.user}`
);
return transporter;
});
})();
return transporterPromise;
};
// Start initialization immediately
initTransporter().catch(err => console.error('Failed to initialize transporter:', err));
const { EmailTemplate } = db;
export const sendEmail = async (to: string, subject: string, templateCode: string, replacements: Record<string, string>) => {
/** If DB row missing, try these codes (then filesystem under that code). */
const TEMPLATE_CODE_FALLBACKS: Record<string, string[]> = {
INTERVIEW_SCHEDULED_APPLICANT: ['INTERVIEW_SCHEDULED'],
INTERVIEW_SCHEDULED_PANELIST: ['INTERVIEW_SCHEDULED'],
INTERVIEW_RESCHEDULED_APPLICANT: ['INTERVIEW_SCHEDULED'],
INTERVIEW_RESCHEDULED_PANELIST: ['INTERVIEW_SCHEDULED']
};
function findTemplateFile(templatesRoot: string, code: string): string | null {
const lowerFile = path.join(templatesRoot, `${code.toLowerCase()}.html`);
const exactFile = path.join(templatesRoot, `${code}.html`);
if (fs.existsSync(lowerFile)) return lowerFile;
if (fs.existsSync(exactFile)) return exactFile;
return null;
}
async function resolveEmailTemplate(templateCode: string) {
const codesToTry = [templateCode, ...(TEMPLATE_CODE_FALLBACKS[templateCode] || [])];
for (const code of codesToTry) {
const dbTemplate = await EmailTemplate.findOne({
where: { templateCode: code, isActive: true }
});
if (dbTemplate) {
return { kind: 'db' as const, resolvedCode: code, dbTemplate };
}
}
const templatesRoot = path.join(__dirname, '../../emailtemplates');
for (const code of codesToTry) {
const filePath = findTemplateFile(templatesRoot, code);
if (filePath) {
return { kind: 'file' as const, resolvedCode: code, filePath };
}
}
return null;
}
export interface SendEmailOptions {
attachments?: Array<{
filename: string;
path?: string;
content?: Buffer | string;
contentType?: string;
}>;
}
export const sendEmail = async (
to: string,
subject: string,
templateCode: string,
replacements: Record<string, string>,
options?: SendEmailOptions
) => {
try {
let finalHtml = '';
let finalSubject = subject;
@ -55,70 +150,79 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
});
};
// Try fetching from DB first (Master Configuration)
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
const resolved = await resolveEmailTemplate(templateCode);
if (!resolved) {
throw new Error(
`Template not found: ${templateCode} (run: npm run seed:interview-templates)`
);
}
if (dbTemplate) {
registerEmailPartials(handlebars);
registerEmailPartials(handlebars);
const allReplacements = normalizeCtaPlaceholders({
...replacements,
year: new Date().getFullYear().toString()
});
const allReplacements = normalizeCtaPlaceholders({
...replacements,
year: new Date().getFullYear().toString()
});
const subjectTemplate = handlebars.compile(dbTemplate.subject);
if (resolved.kind === 'db') {
const subjectTemplate = handlebars.compile(resolved.dbTemplate.subject);
finalSubject = subjectTemplate(allReplacements);
const bodyTemplate = handlebars.compile(dbTemplate.body);
const bodyTemplate = handlebars.compile(resolved.dbTemplate.body);
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
} else {
registerEmailPartials(handlebars);
const allReplacements = normalizeCtaPlaceholders({
...replacements,
year: new Date().getFullYear().toString()
});
const templatesRoot = path.join(__dirname, '../../emailtemplates');
const lowerFile = path.join(templatesRoot, `${templateCode.toLowerCase()}.html`);
const exactFile = path.join(templatesRoot, `${templateCode}.html`);
const templatePath = fs.existsSync(lowerFile)
? lowerFile
: fs.existsSync(exactFile)
? exactFile
: null;
if (!templatePath) {
throw new Error(`Template not found: ${templateCode}`);
}
const source = fs.readFileSync(templatePath, 'utf-8');
const source = fs.readFileSync(resolved.filePath, 'utf-8');
const bodyTemplate = handlebars.compile(source);
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
}
if (resolved.resolvedCode !== templateCode) {
console.warn(
`[Email Service] Template ${templateCode} → fallback ${resolved.resolvedCode} (${resolved.kind})`
);
}
const readyTransporter = await initTransporter();
if (!readyTransporter) {
console.warn('Email transporter not initialized. Using fallback mock.');
return;
}
const info = await readyTransporter.sendMail({
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
const fromAddress = getSmtpConfig().from || 'Royal Enfield <noreply@royalenfield.com>';
const mail: Mail.Options = {
from: fromAddress,
to,
subject: finalSubject,
html: finalHtml
});
html: finalHtml,
...(options?.attachments?.length ? { attachments: options.attachments } : {})
};
console.log(`[Email Service] Email sent to ${to}. MessageId: ${info.messageId}`);
console.log(`[Email Service] Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
const info = await readyTransporter.sendMail(mail);
const rejected = (info as { rejected?: string[] }).rejected;
console.log(
`[Email Service] Sent template=${templateCode} via ${transporterMode} from="${fromAddress}" to="${to}" messageId=${info.messageId}`
);
if (rejected?.length) {
console.warn(`[Email Service] SMTP rejected recipients for ${to}:`, rejected);
}
if (transporterMode === 'ethereal') {
const preview = nodemailer.getTestMessageUrl(info);
if (preview) console.log(`[Email Service] Preview URL: ${preview}`);
}
return info;
} catch (error) {
console.error(`Failed to send email (${templateCode}):`, error);
console.error(`Failed to send email (${templateCode}) to ${to}:`, error);
throw error;
}
};
export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
const link = `http://localhost:5173/questionnaire/${applicationId}`;
export const sendOpportunityEmail = async (
to: string,
applicantName: string,
location: string,
applicationId: string
) => {
const link = `${getFrontendBaseUrl()}/questionnaire/${applicationId}`;
await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'OPPORTUNITY', {
applicantName,
location,
@ -135,10 +239,20 @@ export const sendNonOpportunityEmail = async (to: string, applicantName: string,
});
};
export const sendInterviewScheduledEmail = async (to: string, name: string, applicationId: string, interview: any) => {
export const sendInterviewScheduledEmail = async (
to: string,
name: string,
applicationId: string,
interview: { scheduleDate: string | Date; level: string; interviewType: string; linkOrLocation: string; status: string }
) => {
const date = new Date(interview.scheduleDate);
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
const dateTime = `${formattedDate} ${time}`;
await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', {
@ -152,7 +266,13 @@ export const sendInterviewScheduledEmail = async (to: string, name: string, appl
});
};
export const sendUserAssignedEmail = async (to: string, userName: string, applicationId: string, dealerName: string, participantType: string) => {
export const sendUserAssignedEmail = async (
to: string,
userName: string,
applicationId: string,
dealerName: string,
participantType: string
) => {
await sendEmail(to, `New Application Assignment: ${applicationId}`, 'USER_ASSIGNED', {
userName,
applicationId,
@ -161,7 +281,12 @@ export const sendUserAssignedEmail = async (to: string, userName: string, applic
});
};
export const sendQuestionnaireAckEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
export const sendQuestionnaireAckEmail = async (
to: string,
applicantName: string,
location: string,
applicationId: string
) => {
await sendEmail(to, `Questionnaire Submitted Successfully: ${applicationId}`, 'QUESTIONNAIRE_SUBMITTED', {
applicantName,
location,
@ -169,8 +294,13 @@ export const sendQuestionnaireAckEmail = async (to: string, applicantName: strin
});
};
export const sendShortlistedEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
const portalLink = 'http://localhost:5173/login';
export const sendShortlistedEmail = async (
to: string,
applicantName: string,
location: string,
applicationId: string
) => {
const portalLink = `${getFrontendBaseUrl()}/login`;
await sendEmail(to, `Congratulations! You are Shortlisted: ${applicationId}`, 'APPLICANT_SHORTLISTED', {
applicantName,
location,

View File

@ -0,0 +1,28 @@
/**
* Portal base URL for emails, WhatsApp, SLA alerts, and notifications.
* Set `FRONTEND_URL` in backend `.env` (e.g. https://dealeronboarding-uat.royalenfield.com).
*/
export function getFrontendBaseUrl(): string {
return (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, '');
}
/**
* Portal URL for prospects / applicants i.e. users with `Prospective Dealer` role.
*
* Internal pages like `/applications/:id` are admin-only and a prospect would just see
* a forbidden page, so applicant-facing emails must always send them through their
* own portal (`/prospective-login` OTP `/prospective-dashboard`).
*
* Pass `applicationId` to deep-link to a specific application the login page reads
* the `next` query parameter and routes the prospect there after OTP verification.
*
* Note: The route is `/prospective-login` (full word). Older code mistakenly used
* `/prospect-login` (singular) which 404s use this helper to avoid the typo.
*/
export function getProspectPortalUrl(applicationId?: string): string {
const base = getFrontendBaseUrl();
if (!applicationId) return `${base}/prospective-login`;
const next = encodeURIComponent(`/prospective-dashboard/application/${applicationId}`);
return `${base}/prospective-login?next=${next}`;
}

View File

@ -61,7 +61,8 @@ export function registerEmailPartials(h: typeof handlebars = handlebars): void {
const map: Record<string, string> = {
email_header: 'email_header.html',
email_footer: 'email_footer.html',
primary_cta: 'primary_cta.html'
primary_cta: 'primary_cta.html',
cta_button: 'primary_cta.html'
};
let loaded = 0;
@ -98,10 +99,12 @@ export function normalizeCtaPlaceholders(replacements: Record<string, string>):
'';
const ctaLabel = replacements.ctaLabel || 'View details';
const recipientName = (replacements.recipientName || '').trim() || 'Team Member';
return {
...replacements,
ctaUrl,
ctaLabel
ctaLabel,
recipientName
};
}

View File

@ -0,0 +1,40 @@
/**
* Last Working Day (LWD) helpers for resignation & termination offboarding.
*/
export const LWD_FNF_READY_REMINDER_ACTION = 'LWD_FNF_READY_REMINDER';
export function normalizeDateOnly(value: Date | string | null | undefined): Date | null {
if (value == null || value === '') return null;
const d = value instanceof Date ? new Date(value) : new Date(String(value));
if (Number.isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
return d;
}
export function isLwdReached(
lwd: Date | string | null | undefined,
today: Date = new Date()
): boolean {
const lwdDate = normalizeDateOnly(lwd);
if (!lwdDate) return true;
const t = normalizeDateOnly(today)!;
return t >= lwdDate;
}
export function formatLwdDisplay(lwd: Date | string | null | undefined): string {
const d = normalizeDateOnly(lwd);
if (!d) return 'N/A';
return d.toLocaleDateString('en-IN', { dateStyle: 'medium' });
}
/** Milliseconds from now until start of LWD date (00:00 local). Returns 0 if LWD is today or past. */
export function msUntilLwdMorning(lwd: Date | string): number {
const lwdDate = normalizeDateOnly(lwd);
if (!lwdDate) return 0;
const now = new Date();
const target = new Date(lwdDate);
target.setHours(8, 0, 0, 0); // 08:00 local — align with daily cron
const diff = target.getTime() - now.getTime();
return diff > 0 ? diff : 0;
}

View File

@ -22,13 +22,17 @@ export const normalizeFnFStatus = (status: string | null | undefined): string =>
export const getResignationStatusForStage = (stage: string): string => {
switch (stage) {
case RESIGNATION_STAGES.ASM:
case RESIGNATION_STAGES.RBM:
return RESIGNATION_STAGES.RBM; // It already contains "Review"
case RESIGNATION_STAGES.ASM:
case RESIGNATION_STAGES.ZBH:
case RESIGNATION_STAGES.DD_LEAD:
case RESIGNATION_STAGES.DD_HEAD:
case RESIGNATION_STAGES.NBH:
case RESIGNATION_STAGES.DD_ADMIN:
return `${stage} Review`;
case RESIGNATION_STAGES.AWAITING_FNF:
return 'Awaiting F&F — manual initiation';
case RESIGNATION_STAGES.LEGAL:
return 'Legal - Resignation Letter';
case RESIGNATION_STAGES.FNF_INITIATED:
@ -55,6 +59,35 @@ export const getTerminationStatusForStage = (stage: string): string => {
}
};
/** Legacy DB rows may still use SRS label "Personal Hearing" while workflow code keys the canonical stage constant. */
const LEGACY_TERMINATION_STAGE_TO_CANONICAL: Record<string, string> = {
'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING
};
export const normalizeTerminationCurrentStage = (stage: string | null | undefined): string => {
if (stage == null) return '';
const trimmed = String(stage).trim();
return LEGACY_TERMINATION_STAGE_TO_CANONICAL[trimmed] || trimmed;
};
/** Returns column updates to align legacy termination rows with current stage/status strings (no-op if already canonical). */
export const getLegacyTerminationRowFixes = (termination: {
currentStage?: string | null;
status?: string | null;
}): Record<string, string> | null => {
const updates: Record<string, string> = {};
const rawStage = termination.currentStage;
if (rawStage) {
const canonical = normalizeTerminationCurrentStage(rawStage);
if (canonical !== rawStage) updates.currentStage = canonical;
}
const st = termination.status;
if (st && /personal hearing/i.test(st)) {
updates.status = st.replace(/personal hearing/gi, 'SCN Response Evaluation');
}
return Object.keys(updates).length ? updates : null;
};
export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => {
const normalizedAmount = Math.abs(Number(amount) || 0);
const value = (status || '').toLowerCase();

View File

@ -21,7 +21,7 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
@ -38,10 +38,12 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM,
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.DD_HEAD]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_HEAD,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.AWAITING_FNF]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.AWAITING_FNF,
[RESIGNATION_STAGES.COMPLETED]: RESIGNATION_STAGES.FNF_INITIATED
};
return flow[currentStage] || null;
@ -67,10 +69,9 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
[RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE
};
return flow[currentStage] || null;
}

View File

@ -10,7 +10,7 @@ export const ONBOARDING_STAGES = [
{ name: '3rd Level Interview', order: 6 },
{ name: 'FDD', order: 7 },
{ name: 'LOI Approval', order: 8 },
{ name: 'Security Details', order: 9 },
{ name: 'Security Deposit', order: 9 },
{ name: 'LOI Issue', order: 10 },
{ name: 'Dealer Code Generation', order: 11 },
{ name: 'Architecture Work', order: 12 },
@ -107,8 +107,10 @@ export const PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record<string, string> = {
'Level 3 Approved': '3rd Level Interview',
'FDD Verification': 'FDD',
'LOI In Progress': 'LOI Approval',
'Security Details': 'Security Details',
'Payment Pending': 'Security Details',
/** @deprecated DB rows may still use pre-rename label until migrated */
'Security Details': 'Security Deposit',
'Security Deposit': 'Security Deposit',
'Payment Pending': 'Security Deposit',
'LOI Issued': 'LOI Issue',
'Statutory LOI Ack': 'LOI Issue',
'Dealer Code Generation': 'Dealer Code Generation',
@ -154,7 +156,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
// Statuses that imply the CURRENT stage (single or both parallel) is finished
const completionStatuses = [
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
'Level 2 Approved', 'Level 3 Approved',
'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Approved',
'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'
];
@ -163,46 +165,72 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
// Fetch application to check model-driven parallel status
const application = await db.Application.findByPk(applicationId);
// Robust Sync: Prepare ALL stages for batch processing
const upsertData = [];
for (const stage of ONBOARDING_STAGES) {
let status: 'pending' | 'active' | 'completed' = 'pending';
let percentage = 0;
// Robust Sync: Prepare ALL stages for batch processing
const upsertData: any[] = [];
for (const stage of ONBOARDING_STAGES) {
let status: 'pending' | 'active' | 'completed' = 'pending';
let percentage = 0;
if (stage.order < currentStage.order) {
status = 'completed';
percentage = 100;
} else if (stage.order === currentStage.order) {
status = isCurrentStageFinished ? 'completed' : 'active';
percentage = isCurrentStageFinished ? 100 : 50;
if (stage.name === 'Architecture Work' && application) {
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
if (stage.name === 'Statutory Work' && application) {
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
if (stage.order < currentStage.order) {
status = 'completed';
percentage = 100;
} else if (stage.order === currentStage.order) {
status = isCurrentStageFinished ? 'completed' : 'active';
percentage = isCurrentStageFinished ? 100 : 50;
if (stage.name === 'Architecture Work' && application) {
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
if (stage.name === 'Statutory Work' && application) {
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
}
upsertData.push({
applicationId,
stageName: stage.name,
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
});
}
}
upsertData.push({
applicationId,
stageName: stage.name,
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
});
}
// Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert
await ApplicationProgress.bulkCreate(upsertData, {
updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt']
});
// DB Duplication Prevention without Schema Changes (Healing corrupted data loops)
const existingRecords = await ApplicationProgress.findAll({ where: { applicationId } });
const seenStages = new Set<string>();
// Purge any ghost duplicates created by old logic
for (const record of existingRecords) {
if (seenStages.has(record.stageName)) {
await record.destroy();
} else {
seenStages.add(record.stageName);
}
}
// Perform single row updates/inserts to enforce exact 1:1 mapping safely
const cleanedRecords = await ApplicationProgress.findAll({ where: { applicationId } });
for (const data of upsertData) {
const existing = cleanedRecords.find((r: any) => r.stageName === data.stageName);
if (existing) {
await existing.update({
stageOrder: data.stageOrder,
status: data.status,
completionPercentage: data.completionPercentage,
stageStartedAt: data.stageStartedAt || existing.stageStartedAt,
stageCompletedAt: data.stageCompletedAt || existing.stageCompletedAt
});
} else {
await ApplicationProgress.create(data);
}
}
}
}
};

View File

@ -0,0 +1,54 @@
/** SRS §9.4.5 — business hours 09:0018:00, MonFri (local server timezone). */
const BUSINESS_START_HOUR = 9;
const BUSINESS_END_HOUR = 18;
export function isBusinessHoursEnabled(): boolean {
if (process.env.DEBUG_SLA_FAST_MODE === 'true') return false;
return process.env.SLA_BUSINESS_HOURS !== 'false';
}
export function businessMsBetween(start: Date, end: Date): number {
if (end.getTime() <= start.getTime()) return 0;
let total = 0;
const cursor = new Date(start);
while (cursor.getTime() < end.getTime()) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) {
const windowStart = new Date(cursor);
windowStart.setHours(BUSINESS_START_HOUR, 0, 0, 0);
const windowEnd = new Date(cursor);
windowEnd.setHours(BUSINESS_END_HOUR, 0, 0, 0);
const sliceStart = Math.max(cursor.getTime(), windowStart.getTime(), start.getTime());
const sliceEnd = Math.min(end.getTime(), windowEnd.getTime());
if (sliceEnd > sliceStart) {
total += sliceEnd - sliceStart;
}
}
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(0, 0, 0, 0);
}
return total;
}
export function effectiveElapsedMs(
track: { startTime: Date | string; metadata?: Record<string, unknown> | null },
nowMs: number
): number {
const meta = track.metadata || {};
const start = new Date(track.startTime).getTime();
const pausedAt = meta.pausedAt ? new Date(String(meta.pausedAt)).getTime() : null;
const effectiveEnd = pausedAt ? Math.min(nowMs, pausedAt) : nowMs;
let elapsed = isBusinessHoursEnabled()
? businessMsBetween(new Date(start), new Date(effectiveEnd))
: effectiveEnd - start;
const accumulatedPause = Number(meta.accumulatedPauseMs || 0);
elapsed = Math.max(0, elapsed - accumulatedPause);
return elapsed;
}

View File

@ -0,0 +1,36 @@
import { FNF_DEPARTMENTS } from '../config/constants.js';
import { SLAService } from '../../services/SLAService.js';
export function fnfSlaStageName(department: string): string {
return `F&F Clearance: ${department}`;
}
/** Start/stop departmental F&F SLA timer (non-fatal). */
export async function syncFnfClearanceSla(fnfId: string, department: string, status: string) {
const stageName = fnfSlaStageName(department);
const normalized = String(status || '').toLowerCase();
try {
if (normalized === 'pending') {
await SLAService.startTrack({
entityType: 'fnf',
entityId: fnfId,
applicationId: null,
stageName
});
} else {
await SLAService.stopTrack({
entityType: 'fnf',
entityId: fnfId,
applicationId: null,
stageName
});
}
} catch (err) {
console.error('[slaFnfSync] clearance SLA sync failed:', err);
}
}
export async function startAllPendingFnfClearanceSlas(fnfId: string) {
await Promise.all(FNF_DEPARTMENTS.map((dept) => syncFnfClearanceSla(fnfId, dept, 'Pending')));
}

View File

@ -0,0 +1,67 @@
import { effectiveElapsedMs } from './slaBusinessTime.js';
export type SlaBucket = 'healthy' | 'warning' | 'critical' | 'breached';
export function getTatInMs(value: number, unit: string): number {
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
factor = factor / 60;
}
return value * factor;
}
export function bucketFromPercent(percent: number, isBreached: boolean): SlaBucket {
if (isBreached || percent >= 100) return 'breached';
if (percent >= 76) return 'critical';
if (percent >= 26) return 'warning';
return 'healthy';
}
export function formatSlaDuration(ms: number): string {
const abs = Math.abs(ms);
const mins = Math.floor(abs / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 48) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function ownerRoleMatchesUser(ownerRole: string, userRoleCode: string | undefined | null): boolean {
if (!ownerRole || !userRoleCode) return false;
const userNorm = userRoleCode.replace(/_/g, '-').toUpperCase();
return ownerRole.split(',').some((part) => {
const p = part.trim().replace(/\s+/g, '-').toUpperCase();
return p === userNorm || userNorm.includes(p) || p.includes(userNorm);
});
}
export function computeSlaTrackView(
track: { startTime: Date | string; isBreached?: boolean; metadata?: Record<string, unknown> | null },
config: { tatHours: number; tatUnit: string },
now = Date.now()
) {
const startMs = new Date(track.startTime).getTime();
const tatMs = getTatInMs(config.tatHours, config.tatUnit);
const elapsedMs = effectiveElapsedMs(track, now);
const deadline = new Date(startMs + tatMs);
const percentUsed = tatMs > 0 ? Math.round((elapsedMs / tatMs) * 100) : 0;
const isBreached = Boolean(track.isBreached) || elapsedMs >= tatMs;
const bucket = bucketFromPercent(percentUsed, isBreached);
const msRemaining = tatMs - elapsedMs;
const meta = track.metadata || {};
const isPaused = Boolean(meta.pausedAt);
return {
deadline,
percentUsed,
bucket,
isBreached,
isPaused,
remainingLabel: isPaused
? 'Paused'
: msRemaining > 0
? `${formatSlaDuration(msRemaining)} left`
: `${formatSlaDuration(-msRemaining)} overdue`
};
}

View File

@ -0,0 +1,72 @@
import type { SlaCatalogEntry } from '../config/slaStageCatalog.js';
import { ONBOARDING_SLA_DEPRECATED_ACTIVITIES } from '../config/slaStageCatalog.js';
/** Default reminders / escalations per SRS §9.4.5 (T-24h, T-4h; L1 +4h, L2 +12h, L3 +24h). */
export async function applySlaConfigChildren(
db: any,
configId: string,
transaction: any
) {
await db.SLAReminder.destroy({ where: { slaConfigId: configId }, transaction });
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: configId }, transaction });
await db.SLAReminder.bulkCreate(
[
{ slaConfigId: configId, timeValue: 1, timeUnit: 'days', isEnabled: true },
{ slaConfigId: configId, timeValue: 4, timeUnit: 'hours', isEnabled: true }
],
{ transaction }
);
await db.SLAEscalationConfig.bulkCreate(
[
{ slaConfigId: configId, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
{ slaConfigId: configId, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
{ slaConfigId: configId, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' }
],
{ transaction }
);
}
export async function seedSlaCatalogEntries(
db: any,
entries: SlaCatalogEntry[],
transaction?: any
) {
for (const item of entries) {
const [config, created] = await db.SLAConfiguration.findOrCreate({
where: { activityName: item.activityName },
defaults: {
activityName: item.activityName,
ownerRole: item.ownerRole,
tatHours: item.tatHours,
tatUnit: item.tatUnit,
isActive: true
},
transaction
});
if (!created) {
await config.update(
{
ownerRole: item.ownerRole,
tatHours: item.tatHours,
tatUnit: item.tatUnit
},
{ transaction }
);
}
await applySlaConfigChildren(db, config.id, transaction);
}
if (ONBOARDING_SLA_DEPRECATED_ACTIVITIES.length > 0) {
await db.SLAConfiguration.update(
{ isActive: false },
{
where: { activityName: [...ONBOARDING_SLA_DEPRECATED_ACTIVITIES] },
transaction
}
);
}
}

View File

@ -0,0 +1,35 @@
import { SLAService } from '../../services/SLAService.js';
export type SlaEntityType = 'application' | 'termination' | 'resignation' | 'relocation' | 'constitutional' | 'fnf';
/** Sync SLA timers when a workflow stage changes (non-fatal). */
export async function syncSlaOnStageTransition(opts: {
entityType: SlaEntityType;
entityId: string;
applicationId?: string | null;
fromStage?: string | null;
toStage?: string | null;
}) {
const { entityType, entityId, applicationId = null, fromStage, toStage } = opts;
try {
if (fromStage) {
await SLAService.stopTrack({
entityType,
entityId,
applicationId,
stageName: fromStage
});
}
if (toStage) {
await SLAService.startTrack({
entityType,
entityId,
applicationId,
stageName: toStage
});
}
} catch (err) {
console.error('[slaWorkflowSync] SLA sync failed:', err);
}
}

View File

@ -1,6 +1,7 @@
import { Server as SocketServer } from 'socket.io';
import { Server as HTTPServer } from 'http';
import logger from './logger.js';
import { getFrontendBaseUrl } from './frontendUrl.js';
let io: SocketServer | null = null;
@ -11,7 +12,7 @@ let io: SocketServer | null = null;
export const initSocket = (httpServer: HTTPServer) => {
io = new SocketServer(httpServer, {
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
origin: getFrontendBaseUrl(),
methods: ['GET', 'POST', 'PUT'],
credentials: true
}

View File

@ -0,0 +1,61 @@
import { Op } from 'sequelize';
import { TERMINATION_STAGES } from '../config/constants.js';
const norm = (s: string | undefined | null) =>
String(s || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
if (!n) return false;
if (n === norm(TERMINATION_STAGES.PERSONAL_HEARING)) return true;
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
if (n.includes('personal hearing')) return true;
return false;
};
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
};
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
const a = norm(action);
return (
a.includes('sent back') ||
a.includes('send back') ||
a.includes('reconsider') ||
a.includes('reconsideration')
);
}
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
/**
* When a case is sent back / reconsidered to a joint stage, earlier PARTIAL_APPROVE rows must be ignored.
* Uses workflow timeline entries (written on transition) newest matching event wins.
*/
export function getJointRoundCutoffMsFromTimeline(
timeline: unknown,
mode: JointRoundTimelineMode
): number | null {
if (!Array.isArray(timeline) || timeline.length === 0) return null;
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
const arr = timeline as any[];
for (let i = arr.length - 1; i >= 0; i--) {
const e = arr[i];
if (!isSendBackOrReconsiderTimelineAction(e?.action)) continue;
if (!matcher(e?.targetStage)) continue;
const t = e?.timestamp != null ? new Date(e.timestamp).getTime() : NaN;
if (!Number.isNaN(t)) return t;
}
return null;
}
/** Only audit rows created at/after send-back / reconsider to this joint stage count for the current round. */
export function buildJointRoundCreatedAtFilter(cutoffMs: number | null): { createdAt?: { [Op.gte]: Date } } {
if (cutoffMs == null) return {};
return { createdAt: { [Op.gte]: new Date(cutoffMs) } };
}

View File

@ -2,11 +2,17 @@ import db from '../../database/models/index.js';
import { Op } from 'sequelize';
import { sendEmail } from './email.service.js';
import { NotificationService } from '../../services/NotificationService.js';
import { REQUEST_TYPES, ROLES } from '../config/constants.js';
import {
APPLICATION_STAGES,
TERMINATION_STAGES,
CONSTITUTIONAL_STAGES,
RELOCATION_STAGES,
REQUEST_TYPES,
ROLES
} from '../config/constants.js';
import { getFrontendBaseUrl } from './frontendUrl.js';
const { RequestParticipant, User, Outlet, District } = db;
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
const { RequestParticipant, User, Outlet, District, Dealer } = db;
/** Dealer acknowledgement + internal reviewers after resignation is created. */
export async function notifyResignationSubmittedEmails(resignation: any): Promise<void> {
@ -15,7 +21,7 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis
});
if (!dealerUser?.email) return;
const base = frontendBase();
const base = getFrontendBaseUrl();
const resignationCode = resignation.resignationId || resignation.id;
const lwd =
resignation.lastOperationalDateSales ||
@ -92,7 +98,7 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
});
const base = frontendBase();
const base = getFrontendBaseUrl();
const link = `${base}/constitutional-change/${request.id}`;
for (const p of participants) {
@ -121,7 +127,7 @@ export async function notifyRelocationSubmittedEmails(
request: any,
submitter: { email: string; fullName?: string | null }
): Promise<void> {
const base = frontendBase();
const base = getFrontendBaseUrl();
const code = request.requestId || request.id;
const dealerName = submitter.fullName?.trim() || 'Dealer';
@ -134,7 +140,8 @@ export async function notifyRelocationSubmittedEmails(
dealerName,
requestId: code,
link: `${base}/relocation-requests/${request.id}`,
ctaLabel: 'View request'
ctaLabel: 'View request',
distance: request.distance || '0'
}
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
}
@ -142,7 +149,12 @@ export async function notifyRelocationSubmittedEmails(
const outlet = await Outlet.findByPk(request.outletId, {
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
});
const asmId = (outlet as any)?.district?.asmId;
const dealerAccount = await User.findByPk(request.dealerId, {
attributes: ['id'],
include: [{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }]
});
const outletLevelAsmId = (dealerAccount as any)?.dealerProfile?.asmId ?? null;
const asmId = outletLevelAsmId || (outlet as any)?.district?.asmId;
if (!asmId) return;
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
@ -157,9 +169,10 @@ export async function notifyRelocationSubmittedEmails(
placeholders: {
dealerName,
requestId: code,
outletCode: outlet?.code || '',
outletCode: outlet?.code || 'N/A',
link: `${base}/relocation-requests/${request.id}`,
ctaLabel: 'Review relocation',
ctaLabel: 'Review request',
distance: request.distance || '0',
phone: asmPhone || ''
}
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
@ -184,7 +197,7 @@ export async function resolveNextActors(requestId: string, requestType: string,
'ASM': [ROLES.ASM],
'ASM Review': [ROLES.ASM],
'RBM': [ROLES.RBM],
'RBM Review': [ROLES.RBM],
'RBM + DD-ZM Review': [ROLES.RBM, ROLES.DD_ZM],
'ZM Review': [ROLES.DD_ZM],
'DD ZM Review': [ROLES.DD_ZM],
'ZBH': [ROLES.ZBH],
@ -193,7 +206,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
'DD Lead Review': [ROLES.DD_LEAD],
'DD Head': [ROLES.DD_HEAD],
'DD Head Review': [ROLES.DD_HEAD],
'DD Head Approval': [ROLES.DD_HEAD],
'NBH': [ROLES.NBH],
'NBH Approval': [ROLES.NBH],
'NBH Evaluation': [ROLES.NBH],
@ -229,20 +241,22 @@ export async function resolveNextActors(requestId: string, requestType: string,
'Architecture Document Upload': [ROLES.ARCHITECTURE],
// --- Relocation/Constitutional Specific ---
'NBH Clearance with EOR': [ROLES.NBH],
'Submitted': [ROLES.ASM],
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
// --- Resignation Specific ---
'DD Admin': [ROLES.DD_ADMIN],
'Awaiting F&F': [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN],
'Spares Clearance': [ROLES.SPARES_MANAGER],
'Service Clearance': [ROLES.SERVICE_MANAGER],
'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER],
'F&F Initiated': [ROLES.DD_ADMIN],
'F&F Initiated': [ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM],
// SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
// --- Termination Specific ---
'Show Cause Notice': [ROLES.NBH],
'Personal Hearing': [ROLES.NBH],
'Show Cause Notice': [ROLES.LEGAL_ADMIN],
'Personal Hearing': [ROLES.NBH, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD],
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
};
@ -304,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
action: string;
remarks: string;
link: string;
changeType?: string;
}
): Promise<void> {
try {
@ -320,6 +335,8 @@ export async function notifyStakeholdersOnTransition(
const isWithdrawn = /\b(withdraw)/i.test(targetStage) || /\b(withdraw)/i.test(metadata.action);
const isCompleted = /\b(complete|settled|terminated|fnf)/i.test(targetStage);
const isTerminalEvent = isRejected || isRevoked || isWithdrawn || isCompleted;
// Interview scheduling already sends INTERVIEW_SCHEDULED_PANELIST with full details.
const isInterviewPendingStage = /^Level \d Interview Pending$/i.test(targetStage);
for (const p of participants) {
const u = (p as any).user;
@ -329,8 +346,12 @@ export async function notifyStakeholdersOnTransition(
const isDealer = u.id === metadata.dealerId;
const isActingUser = u.fullName === metadata.actionUserFullName;
// Roles that should receive observer alerts on terminal events
const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || '');
// Roles that should receive observer alerts on terminal events or F&F triggers
const isKeyObserverRole = [
'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin',
'SUPER_ADMIN', 'DD_ADMIN', 'Finance', 'FINANCE',
'ZBH', 'RBM', 'DD-ZM'
].includes(u.roleCode || '');
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
// Phone for WhatsApp — directly on include'd user object
@ -340,18 +361,25 @@ export async function notifyStakeholdersOnTransition(
if (isActingUser && !isNextActor) continue;
if (isNextActor) {
if (isInterviewPendingStage) {
// Avoid duplicate mail: scheduleInterview already notifies panelists.
continue;
}
// ── Next Approver: Action Required — WhatsApp + Email + In-App ──
// SRS §6.14.3: critical workflow actions (assignment, scheduling) trigger via email & WhatsApp
const recipientName = (u.fullName || '').trim() || 'Team Member';
await NotificationService.notify(u.id, u.email, {
title: `Action Required: ${metadata.code}`,
message: `${metadata.code} has reached "${targetStage}" and requires your review.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
recipientName,
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage,
ctaLabel: 'View application',
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] next-actor:', e));
@ -359,56 +387,87 @@ export async function notifyStakeholdersOnTransition(
} else if (isSendBack && isASM) {
// ── Send Back → notify ASM via WhatsApp + Email + In-App ──
// SRS §6.14.3: pending user actions trigger email & WhatsApp
const recipientName = (u.fullName || '').trim() || 'Team Member';
await NotificationService.notify(u.id, u.email, {
title: `Case Returned for Clarification: ${metadata.code}`,
message: `${metadata.code} has been sent back. Please review remarks and resubmit.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
recipientName,
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage,
remarks: metadata.remarks || 'No remarks provided',
ctaLabel: 'View application',
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] send-back-asm:', e));
} else if (isDealer) {
// ── Dealer: in-app always; email + WhatsApp only on terminal events ──
// SRS §2052: rejection notifies dealer/applicant via email & WhatsApp
// SRS §2324: approvals/outcomes delivered via email & WhatsApp
const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email'];
if (phone) terminalChannels.push('whatsapp');
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
const placeholders: any = {
requestId: metadata.code,
link: metadata.link,
targetStage,
dealerName: metadata.dealerName,
phone: phone || ''
};
// Override for Termination Final Closure
if (targetStage === TERMINATION_STAGES.TERMINATED) {
templateCode = 'TERMINATION_FINAL_CLOSURE_DEALER';
placeholders.terminationDate = new Date().toLocaleDateString('en-IN', { dateStyle: 'medium' });
}
// Override for Constitutional Change Completion
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED';
placeholders.proposedConstitution = metadata.changeType || 'Approved Structure';
}
// Override for Relocation Completion
if (targetStage === RELOCATION_STAGES.COMPLETED && requestType === REQUEST_TYPES.RELOCATION) {
templateCode = 'RELOCATION_APPROVED';
placeholders.newLocation = metadata.remarks || 'Approved Location'; // Remarks usually contain the site address
}
await NotificationService.notify(u.id, u.email, {
title: isTerminalEvent
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
: `Application Update: ${metadata.code}`,
message: `Your request is now at "${targetStage}". ${metadata.action}`,
channels: isTerminalEvent ? terminalChannels : ['system'],
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
placeholders: {
requestId: metadata.code,
link: metadata.link,
targetStage,
dealerName: metadata.dealerName,
phone: phone || ''
}
templateCode,
placeholders
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
} else if (isTerminalEvent && isKeyObserverRole) {
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events — in-app only ──
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events ──
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
const placeholders: any = {
requestId: metadata.code,
link: metadata.link,
targetStage,
recipientName: u.fullName || 'Team'
};
// Override for Internal Notification of Legal Letter
if (targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
templateCode = 'TERMINATION_LETTER_ISSUED';
}
await NotificationService.notify(u.id, u.email, {
title: `Case Closed: ${metadata.code}`,
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
channels: ['system'],
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
placeholders: {
requestId: metadata.code,
link: metadata.link,
targetStage
}
channels: ['system', 'email'], // Internal teams get email too on closure
templateCode,
placeholders
}).catch(e => console.error('[notifyStakeholders] observer:', e));
}
}

View File

@ -3,33 +3,65 @@
*/
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED',
'APPLICANT_REJECTED',
'ARCHITECTURAL_PLAN_REQUEST',
'CONSTITUTIONAL_CHANGE_SUBMITTED',
'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
'CONSTITUTIONAL_CHANGE_APPROVED',
'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY',
'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
'DOCUMENT_REJECTED_RESUBMIT',
'DOCUMENT_SUBMISSION_REMINDER',
'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED',
'FNF_LWD_READY',
'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED',
'GENERIC_NOTIFICATION',
'INAUGURATION_COMPLETED',
'INTERVIEW_SCHEDULED',
'INTERVIEW_SCHEDULED_APPLICANT',
'INTERVIEW_SCHEDULED_PANELIST',
'INTERVIEW_RESCHEDULED_APPLICANT',
'INTERVIEW_RESCHEDULED_PANELIST',
'INTERVIEW_CANCELLED_APPLICANT',
'INTERVIEW_CANCELLED_PANELIST',
'LOA_ISSUED',
'LOI_ACKNOWLEDGEMENT_REQUEST',
'LOI_ISSUED',
'NON_OPPORTUNITY',
'ONBOARDING_PAYMENT_VERIFIED',
'ONBOARDING_STATUS_UPDATE',
'OPPORTUNITY',
'PROSPECT_DOCUMENT_REQUEST',
'QUESTIONNAIRE_REMINDER',
'QUESTIONNAIRE_SUBMITTED',
'SECURITY_DEPOSIT_REQUEST',
'RELOCATION_RECEIVED',
'RELOCATION_SUBMITTED',
'RELOCATION_APPROVED',
'RELOCATION_UPDATE',
'RESIGNATION_APPROVED',
'RESIGNATION_RECEIVED',
'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE',
'RESIGNATION_LETTER_DISPATCHED_DEALER',
'SLA_BREACH_WARNING',
'STATUTORY_DOCUMENT_REQUEST',
'SLA_REMINDER',
'SLA_BREACH',
'SLA_ESCALATION',
'TERMINATION_INITIATED',
'TERMINATION_SCN_ISSUED',
'TERMINATION_LETTER_ISSUED',
'TERMINATION_FINAL_CLOSURE_DEALER',
'TERMINATION_UPDATE',
'USER_ASSIGNED',
'WORKNOTE_NOTIFICATION'
'WORKNOTE_NOTIFICATION',
'WORKFLOW_ACTION_REQUIRED',
'WORKFLOW_STATUS_UPDATE_DEALER',
] as const;
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];

View File

@ -0,0 +1,50 @@
/**
* Default copy blocks for onboarding planned emails.
* Admins can override full templates in Master Email Templates; these are fallbacks from code.
*/
export const DEFAULT_PROSPECT_DOCUMENT_CHECKLIST = [
'PAN (applicant / entity)',
'Identity & address proof',
'Address proof of proposed dealership site (lease / title)',
'Net worth certificate (CA, last 2 years)',
'Bank statements (last 6 months)',
'Income tax returns (last 3 years)',
'Photographs of the proposed dealership site'
].join('\n');
export const DEFAULT_STATUTORY_DOCUMENT_CHECKLIST = [
'GST registration certificate',
'Trade licence / Shop & Establishment',
'Fire safety NOC',
'Pollution control board NOC (if applicable)',
'Approved building plan from local authority',
'Commercial electricity sanction',
'Registered lease / sale deed for premises',
'Latest property tax receipt',
'Building & public liability insurance (as per policy)',
'MOA/AOA or partnership deed (as applicable)',
'Board resolution / authorised signatory list (if applicable)'
].join('\n');
export const DEFAULT_FDD_DOCUMENT_CHECKLIST = [
'Audited financial statements (last 3 financial years)',
'GST returns (last 12 months)',
'Bank statements (last 12 months)',
'Proof of working capital',
'Schedule of existing loans and obligations',
'Director / partner net-worth statements (as applicable)'
].join('\n');
export const DEFAULT_ARCHITECTURE_SITE_INPUTS = [
'Site dimensions and orientation (sketch or CAD if available)',
'Photographs: front, sides, internal, and surroundings',
'Civic / municipal approvals and zoning classification',
'Soil-test report (if available)',
'Preferred entry, parking, and signage locations'
].join('\n');
export function formatDueDateDaysFromNow(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toLocaleDateString('en-IN', { dateStyle: 'medium' });
}

View File

@ -0,0 +1,127 @@
import { Model, DataTypes, Sequelize } from 'sequelize';
/**
* SystemAuditLog
* ----------------
* Dedicated, segregated audit trail for *system-level / configuration* changes
* (questionnaire versions, interview configs, master data zones / regions /
* districts, SLA configs, system configs, role assignments, hierarchy syncs).
*
* Application-lifecycle audit (per-application stage transitions, documents,
* interviews) continues to live in `audit_logs`. Module-specific tables
* (`resignation_audit_logs`, `termination_audit_logs`, `fnf_audit_logs`,
* `relocation_audit_logs`, `constitutional_audit_logs`) remain unchanged.
*/
export interface SystemAuditLogAttributes {
id: string;
userId: string | null;
actorName: string | null;
actorRole: string | null;
module: string;
entityType: string;
entityId: string | null;
entityLabel: string | null;
action: string;
description: string | null;
oldData: any | null;
newData: any | null;
metadata: any | null;
ipAddress: string | null;
userAgent: string | null;
}
export interface SystemAuditLogInstance
extends Model<SystemAuditLogAttributes>,
SystemAuditLogAttributes { }
export default (sequelize: Sequelize) => {
const SystemAuditLog = sequelize.define<SystemAuditLogInstance>(
'SystemAuditLog',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
userId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: 'users', key: 'id' },
onDelete: 'SET NULL'
},
actorName: {
type: DataTypes.STRING(150),
allowNull: true
},
actorRole: {
type: DataTypes.STRING(80),
allowNull: true
},
module: {
type: DataTypes.STRING(60),
allowNull: false
},
entityType: {
type: DataTypes.STRING(80),
allowNull: false
},
entityId: {
type: DataTypes.STRING(80),
allowNull: true
},
entityLabel: {
type: DataTypes.STRING(255),
allowNull: true
},
action: {
type: DataTypes.STRING(60),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
oldData: {
type: DataTypes.JSON,
allowNull: true
},
newData: {
type: DataTypes.JSON,
allowNull: true
},
metadata: {
type: DataTypes.JSON,
allowNull: true
},
ipAddress: {
type: DataTypes.STRING(64),
allowNull: true
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true
}
},
{
tableName: 'system_audit_logs',
timestamps: true,
indexes: [
{ fields: ['module'] },
{ fields: ['entityType'] },
{ fields: ['entityId'] },
{ fields: ['action'] },
{ fields: ['userId'] },
{ fields: ['createdAt'] }
]
}
);
(SystemAuditLog as any).associate = (models: any) => {
SystemAuditLog.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user'
});
};
return SystemAuditLog;
};

View File

@ -5,8 +5,6 @@ export interface DocumentAttributes {
id: string;
applicationId: string | null;
dealerId: string | null;
requestId: string | null; // Compatibility
requestType: string | null; // Compatibility
documentType: string;
fileName: string;
filePath: string;
@ -42,14 +40,6 @@ export default (sequelize: Sequelize) => {
key: 'id'
}
},
requestId: {
type: DataTypes.UUID,
allowNull: true
},
requestType: {
type: DataTypes.STRING,
allowNull: true
},
documentType: {
type: DataTypes.STRING,
allowNull: false
@ -92,7 +82,8 @@ export default (sequelize: Sequelize) => {
indexes: [
{ fields: ['applicationId'] },
{ fields: ['dealerId'] },
{ fields: ['requestId'] }
{ fields: ['applicationId', 'stage'] },
{ fields: ['documentType'] }
]
});

View File

@ -0,0 +1,114 @@
import { Model, DataTypes, Sequelize } from 'sequelize';
export type SlaNotificationDispatchType =
| 'pre_breach_reminder'
| 'breach'
| 'escalation'
| 'repeat_overdue';
export interface SLANotificationDispatchAttributes {
id: string;
trackingId: string;
/** Unique per tracking row — e.g. reminder:uuid, breach, escalation:1 */
thresholdKey: string;
dispatchType: SlaNotificationDispatchType;
templateCode: string | null;
stageName: string | null;
reminderId: string | null;
escalationLevel: number | null;
recipientCount: number;
sentAt: Date;
status: string;
}
export interface SLANotificationDispatchInstance
extends Model<SLANotificationDispatchAttributes>,
SLANotificationDispatchAttributes {}
export default (sequelize: Sequelize) => {
const SLANotificationDispatch = sequelize.define<SLANotificationDispatchInstance>(
'SLANotificationDispatch',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
trackingId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'sla_tracking',
key: 'id'
}
},
thresholdKey: {
type: DataTypes.STRING(128),
allowNull: false
},
dispatchType: {
type: DataTypes.STRING(32),
allowNull: false
},
templateCode: {
type: DataTypes.STRING(64),
allowNull: true
},
stageName: {
type: DataTypes.STRING(255),
allowNull: true
},
reminderId: {
type: DataTypes.UUID,
allowNull: true
},
escalationLevel: {
type: DataTypes.INTEGER,
allowNull: true
},
recipientCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
sentAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
status: {
type: DataTypes.STRING(24),
allowNull: false,
defaultValue: 'sent'
}
},
{
tableName: 'sla_notification_dispatches',
timestamps: true,
indexes: [
{
unique: true,
name: 'sla_notification_dispatches_tracking_threshold_uq',
fields: ['trackingId', 'thresholdKey']
},
{
name: 'sla_notification_dispatches_tracking_sent_idx',
fields: ['trackingId', 'sentAt']
},
{
name: 'sla_notification_dispatches_type_idx',
fields: ['dispatchType']
}
]
}
);
(SLANotificationDispatch as any).associate = (models: any) => {
SLANotificationDispatch.belongsTo(models.SLATracking, {
foreignKey: 'trackingId',
as: 'slaTracking'
});
};
return SLANotificationDispatch;
};

View File

@ -11,6 +11,7 @@ export interface SLATrackingAttributes {
duration: number | null; // minutes or hours
isBreached: boolean;
isActive: boolean;
metadata?: Record<string, unknown> | null;
}
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
@ -61,6 +62,11 @@ export default (sequelize: Sequelize) => {
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
}
}, {
tableName: 'sla_tracking',
@ -70,6 +76,12 @@ export default (sequelize: Sequelize) => {
(SLATracking as any).associate = (models: any) => {
SLATracking.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' });
SLATracking.hasMany(models.SLABreach, { foreignKey: 'trackingId', as: 'breaches' });
if (models.SLANotificationDispatch) {
SLATracking.hasMany(models.SLANotificationDispatch, {
foreignKey: 'trackingId',
as: 'notificationDispatches'
});
}
};
return SLATracking;

View File

@ -94,6 +94,7 @@ import createSLABreach from './compliance/SLABreach.js';
import createSLAEscalationConfig from './compliance/SLAEscalationConfig.js';
import createSLAReminder from './compliance/SLAReminder.js';
import createSLATracking from './compliance/SLATracking.js';
import createSLANotificationDispatch from './compliance/SLANotificationDispatch.js';
import createWorkflowStageConfig from './compliance/WorkflowStageConfig.js';
import createStageApprovalAction from './compliance/StageApprovalAction.js';
import createStageApprovalPolicy from './compliance/StageApprovalPolicy.js';
@ -102,6 +103,7 @@ import createRequestParticipant from './compliance/RequestParticipant.js';
// Activity
import createAuditLog from './activity/AuditLog.js';
import createSystemAuditLog from './activity/SystemAuditLog.js';
import createWorknote from './activity/Worknote.js';
import createWorkNoteAttachment from './activity/WorkNoteAttachment.js';
import createWorkNoteTag from './activity/WorkNoteTag.js';
@ -136,6 +138,7 @@ db.Outlet = createOutlet(sequelize);
db.Worknote = createWorknote(sequelize);
db.OnboardingDocument = createOnboardingDocument(sequelize);
db.AuditLog = createAuditLog(sequelize);
db.SystemAuditLog = createSystemAuditLog(sequelize);
db.FinancePayment = createFinancePayment(sequelize);
db.RelocationDocument = createRelocationDocument(sequelize);
db.ResignationDocument = createResignationDocument(sequelize);
@ -225,6 +228,7 @@ db.PushSubscription = createPushSubscription(sequelize);
// Batch 8: SLA & TAT Tracking
db.SLATracking = createSLATracking(sequelize);
db.SLABreach = createSLABreach(sequelize);
db.SLANotificationDispatch = createSLANotificationDispatch(sequelize);
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
db.StageApprovalAction = createStageApprovalAction(sequelize);
db.SystemConfiguration = createSystemConfiguration(sequelize);

View File

@ -1,4 +1,4 @@
import { Model, DataTypes, Sequelize } from 'sequelize';
import { Model, DataTypes, Sequelize, Op } from 'sequelize';
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
export interface ConstitutionalChangeAttributes {
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
{ fields: ['requestId'] },
{ fields: ['outletId'] },
{ fields: ['dealerId'] },
{ fields: ['currentStage'] }
{ fields: ['currentStage'] },
/** SRS §12.2 — at most one non-terminal request per dealer (PostgreSQL partial unique index). */
{
name: 'uq_constitutional_open_per_dealer',
unique: true,
fields: ['dealerId'],
where: {
status: {
[Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked']
}
}
}
]
});

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,13 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>Our architecture team (coordinated by <strong>{{architectName}}</strong>) will begin the dealership layout and site design work for application <strong>{{applicationId}}</strong>.</p>
<p>Please provide the site inputs listed on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{#if inputsList}}
<div class="details">
<p style="white-space:pre-line;">{{inputsList}}</p>
</div>
{{/if}}
{{> primary_cta}}
<p>Timely submission helps us meet your showroom and workshop readiness timelines.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,31 @@
{{> email_header}}
<div class="section">
<h2 style="color:#2e7d32;">Constitutional Change Approved — {{requestId}}</h2>
<p>Dear {{dealerName}},</p>
<p>
We are pleased to inform you that your request for a **Change in Constitution** (Request ID: <strong>{{requestId}}</strong>)
has been officially approved by the Royal Enfield management.
</p>
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
<strong>New Constitution:</strong> {{proposedConstitution}}<br/>
<strong>Status:</strong> Approved & Updated
</div>
<p>
The system records have been updated to reflect this change. You can now proceed with the legally compliant transition
as per the approved structure.
</p>
<div style="text-align:center; margin: 24px 0;">
<a href="{{link}}" class="btn">View Request Details</a>
</div>
<p style="color:#888; font-size:12px;">
Best Regards,<br/>
<strong>Royal Enfield Dealer Development Team</strong>
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,8 @@
{{> email_header}}
<h2>Congratulations {{applicantName}},</h2>
<p>Your <strong>Letter of Appointment</strong> is in place for application <strong>{{applicationId}}</strong>{{#if dealerCode}} (Dealer code: <strong>{{dealerCode}}</strong>){{/if}}.</p>
<p>The <strong>Dealership Agreement</strong> is now ready for your review and e-signature on the Dealer Portal. Please complete your signature by <strong>{{dueDate}}</strong>.</p>
{{> primary_cta}}
<p>Once you sign, the agreement will be routed for Royal Enfields counter-signature as per process.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,10 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>Thank you. We have received your upload for application <strong>{{applicationId}}</strong>.</p>
<p><strong>Category:</strong> {{documentCategory}}<br>
<strong>Document type:</strong> {{documentName}}<br>
<strong>Received on:</strong> {{receivedOn}}</p>
<p>Our team will verify the submission. You will receive a separate notification if anything needs to be corrected or re-uploaded.</p>
{{> primary_cta}}
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,12 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>One or more documents for application <strong>{{applicationId}}</strong> could not be accepted in their current form.</p>
<div class="details">
<p><strong>Document:</strong> {{documentName}}</p>
<p><strong>Reason:</strong> {{rejectionReason}}</p>
</div>
<p>Please re-upload the corrected document on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{> primary_cta}}
<p>For clarification, contact your assigned Dealer Development representative.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,12 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>This is a reminder regarding your dealership application <strong>{{applicationId}}</strong>.</p>
<p>The following items are still pending on the portal:</p>
<div class="details">
<p style="white-space:pre-line;">{{pendingDocuments}}</p>
</div>
<p>Please complete your uploads by <strong>{{dueDate}}</strong> so we can continue processing without delay.</p>
{{> primary_cta}}
<p>If you have already submitted everything, you may ignore this reminder.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> 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,15 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>As part of <strong>Financial Due Diligence (FDD)</strong> for your dealership application <strong>{{applicationId}}</strong>, we have engaged <strong>{{fddPartnerName}}</strong> to complete the assessment.</p>
<p>Please upload the documents requested on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{#if documentChecklist}}
<div class="details">
<p style="white-space:pre-line;">{{documentChecklist}}</p>
</div>
{{else}}
<p>Typical requirements include audited financials, GST returns, bank statements, working-capital proof, and details of existing obligations—refer to the portal for your exact checklist.</p>
{{/if}}
{{> primary_cta}}
<p>The FDD partner may contact you directly for clarifications. Incomplete disclosure may affect the outcome of your application.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> 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,29 @@
{{> email_header}}
<div class="section">
<h2 style="color:#2e7d32;">F&F Settlement Approved — {{fnfId}}</h2>
<p>Dear Team,</p>
<p>
The final Full & Final (F&F) settlement for dealer <strong>{{dealerName}}</strong>
(F&F ID: <strong>{{fnfId}}</strong>) has been <strong>Approved</strong> by Finance.
</p>
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
<strong>Settlement Amount:</strong> ₹{{settlementAmount}}<br/>
<strong>Status:</strong> Approved & Closed
</div>
<p>
The DD-Admin and Legal teams are requested to update their records and proceed with final account closure.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
All financial transactions related to this dealership exit are now finalized.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,29 @@
{{> email_header}}
<div class="section">
<h2>F&F Settlement Summary Prepared — {{fnfId}}</h2>
<p>Dear Finance Team,</p>
<p>
The initial Full & Final (F&F) settlement summary has been prepared for
<strong>{{dealerName}}</strong> (F&F ID: <strong>{{fnfId}}</strong>).
</p>
<div style="background:#f5f5f5; border-left:4px solid #333; padding:12px 16px; margin: 16px 0;">
<strong>Calculated Net Amount:</strong> ₹{{netAmount}}<br/>
<strong>Status:</strong> Pending Final Approval
</div>
<p>
Please review the consolidated departmental responses and the settlement summary to proceed with final approval and payment processing.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
Confidential: This summary contains sensitive financial data. Review only via authorized portal access.
</p>
</div>
{{> email_footer}}

Some files were not shown because too many files have changed in this diff Show More