typo chnge errors and Loi Document request mail templatd added

This commit is contained in:
Laxman 2026-05-26 12:41:14 +05:30
parent 29d67f6ca6
commit 80495a78a6
23 changed files with 1296 additions and 913 deletions

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

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. - **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 ### 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 ### 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. - **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

@ -101,6 +101,7 @@
| **B.8** | `SECURITY_DEPOSIT_REQUEST` | Onboarding | Applicant | email + whatsapp | **Live** | | **B.8** | `SECURITY_DEPOSIT_REQUEST` | Onboarding | Applicant | email + whatsapp | **Live** |
| **B.9** | `DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST` | Onboarding | Applicant | email | **Live** | | **B.9** | `DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST` | Onboarding | Applicant | email | **Live** |
| **B.10** | `ARCHITECTURAL_PLAN_REQUEST` | Onboarding | Applicant | email | **Live** | | **B.10** | `ARCHITECTURAL_PLAN_REQUEST` | Onboarding | Applicant | email | **Live** |
| **B.11** | *Orchestrator (no new template)* — Admin "Request Documents" action | Onboarding | Applicant | email + whatsapp (per bucket) | **Live** |
--- ---
@ -1403,39 +1404,51 @@ Templates are in `backend/src/emailtemplates/`, codes in `allowed-email-template
--- ---
## B.1 `PROSPECT_DOCUMENT_REQUEST`Initial prospect document collection *(implemented)* ## B.1 `PROSPECT_DOCUMENT_REQUEST`Prospect document collection *(implemented)*
| Field | Value | | Field | Value |
|---|---| |---|---|
| **File** | `backend/src/emailtemplates/prospect_document_request.html` | | **File** | `backend/src/emailtemplates/prospect_document_request.html` |
| **Subject** | `Documents Required — Royal Enfield Dealership Application {{applicationId}}` | | **Subject** | `Documents Required — Royal Enfield Dealership Application {{applicationId}}` |
| **Trigger** | Automatically after **shortlist** (same flow as shortlisted congratulations mail). | | **Triggers** | (a) **Automatic** — fires once at shortlist time (`onboarding.controller.ts → bulkShortlist`). (b) **Manual** — DD-Admin / Super Admin / DD Lead / DD Head presses **Request Documents** in the Application sidebar post-LOI-approval (see **B.11**). |
| **Recipient** | Applicant; WhatsApp if mobile on file. | | **Recipient** | Applicant; WhatsApp if mobile on file. |
| **Placeholders** | `applicantName`, `location`, `applicationId`, `dueDate`, `documentList`, `link`, `ctaLabel` | | **Placeholders** | `applicantName`, `location`, `applicationId`, `dueDate`, `documentList`, `link`, `ctaLabel`, `isShortlistContext` |
| **Context flag** | `isShortlistContext` (boolean). Set to `true` **only** by the shortlist trigger so the celebratory opening renders. The admin "Request Documents" action does *not* set this flag, so subsequent reminders read as neutral follow-ups instead of re-congratulating the prospect. |
**At shortlist (`isShortlistContext = true`):**
> Hi {{applicantName}}, > Hi {{applicantName}},
> >
> Congratulations on being shortlisted for the Royal Enfield Dealership opportunity at **{{location}}** (Application ID: **{{applicationId}}**). > Congratulations on being shortlisted for the Royal Enfield Dealership opportunity at **{{location}}** (Application ID: **{{applicationId}}**).
> >
> To proceed with your evaluation we need the following documents to be uploaded on the Dealer Portal by **{{dueDate}}**: > Please upload the documents listed below on the Dealer Portal by **{{dueDate}}**:
> >
> 1. PAN Card (Proprietor / Partners / Directors) > {{documentList}}
> 2. Aadhaar Card or Passport
> 3. Address Proof of the proposed dealership site (Lease deed / Title deed)
> 4. Net Worth Certificate from a Chartered Accountant (last 2 years)
> 5. Bank Statement (last 6 months)
> 6. Income Tax Returns (last 3 years)
> 7. Photograph(s) of the proposed dealership site
> >
> *(Final document list is determined by your application type — please refer to the portal for the authoritative checklist.)* > *(Final document list is determined by your application type — please refer to the portal for the authoritative checklist.)*
> >
> [ **Upload Documents** ] → opens `{{uploadLink}}` > [ **Upload Documents** ] → opens `{{link}}`
> >
> All documents must be clear, in PDF / JPG format, and under 10 MB per file. For any clarifications, contact your local RE representative or write to dealer-support@royalenfield.com. > All documents must be clear, in PDF / JPG format, and under 10 MB per file. For any clarifications, contact your local RE representative or write to dealer-support@royalenfield.com.
> >
> Regards, > Regards,
> Royal Enfield Dealer Development Team > Royal Enfield Dealer Development Team
**On admin follow-up (`isShortlistContext` omitted / falsy):**
> Hi {{applicantName}},
>
> We need a few documents to keep your Royal Enfield dealership application **{{applicationId}}** for **{{location}}** moving forward.
>
> Please upload the documents listed below on the Dealer Portal by **{{dueDate}}**:
>
> {{documentList}}
>
> [ **Upload Documents** ] → opens `{{link}}`
>
> Regards,
> Royal Enfield Dealer Development Team
--- ---
## B.2 `STATUTORY_DOCUMENT_REQUEST` — Statutory Documents Collection (Post LOI) ## B.2 `STATUTORY_DOCUMENT_REQUEST` — Statutory Documents Collection (Post LOI)
@ -1692,6 +1705,78 @@ Templates are in `backend/src/emailtemplates/`, codes in `allowed-email-template
--- ---
## B.11 On-Demand Document Request — Admin "Request Documents" action *(implemented)*
This is **not a new email template** — it is an **orchestration layer** wired into the application details sidebar that fans out to the existing applicant document templates (`B.1`, `B.2`, `B.6`, `B.10`) based on what the admin selects. It exists so a reviewer can re-poke the prospect for *exactly the documents that are still missing* at any point post-LOI without having to wait for a stage transition or send a generic broadcast.
### Where it lives
| Field | Value |
|---|---|
| **UI entry point** | Amber "Request Documents" button in the application sidebar action area. |
| **Backend endpoint** | `POST /onboarding/applications/:id/request-documents` (`onboarding.controller.ts → requestProspectDocuments`). |
| **Route registration** | `onboarding.routes.ts`. |
| **Audit action** | `DOCUMENT_REQUEST_SENT` — written to `AuditLog` with per-category email status, skipped uploads, due-days, and the requesting role. |
### Visibility gating
| Layer | Rule |
|---|---|
| **Role** | Visible only when current user is `DD Admin`, `Super Admin`, `DD Lead`, or `DD Head`. Server enforces the same allowlist on the endpoint. |
| **Stage** | Only when `application.overallStatus` is in the post-LOI corridor — i.e. `Security Deposit` / `Security Details` / `Payment Pending` / `LOI Issuance Pending` / `LOI Issued` / `Dealer Code Generation` / `Architecture Team Assigned` / `Architecture Document Upload` / `Architecture Team Completion` / `Statutory *` / `LOA Pending`. |
### Request body
```json
{
"documentTypes": ["Letter of Intent", "GST Certificate", "Bank Statement"],
"dueDays": 10,
"customMessage": "Please prioritise these — Finance review depends on them."
}
```
### Server-side orchestration
1. **Drop already-uploaded types.** The endpoint queries `OnboardingDocument` for the requested types and removes any that already exist. Their names are returned in `skippedAlreadyUploaded`.
2. **Categorise via `DocumentStageConfig.stageCode`** into one of: `LOI`, `Statutory`, `Architecture`, `FDD`, `Other`.
3. **Fan out — one email per non-empty bucket**, reusing the existing applicant templates:
| Bucket | Template Code | List Placeholder | Notes |
|---|---|---|---|
| LOI | `PROSPECT_DOCUMENT_REQUEST` | `documentList` | Neutral copy (no shortlist congrats) because the orchestrator does not pass `isShortlistContext`. |
| Statutory | `STATUTORY_DOCUMENT_REQUEST` | `statutoryList` | |
| Architecture | `ARCHITECTURAL_PLAN_REQUEST` | `inputsList` | Adds `architectName: 'Royal Enfield Architecture Team'` for the salutation line. |
| FDD | `FDD_DOCUMENT_REQUEST` | `documentChecklist` | Adds `fddPartnerName: 'Assigned FDD Partner'` for the body line. |
| Other | `PROSPECT_DOCUMENT_REQUEST` | `documentList` | Fallback for unclassified types; same neutral copy. |
4. **Channels per email**`email` always; `whatsapp` added when the applicant has a mobile number on file. The `customMessage` is dropped into each email's `message` body line, while the per-bucket `listKey` carries the numbered list of selected documents.
5. **Response payload** lets the UI render an accurate toast:
```json
{
"success": true,
"data": {
"requested": ["Letter of Intent", "Bank Statement"],
"skippedAlreadyUploaded": ["GST Certificate"],
"emailsSent": [
{ "category": "LOI", "status": "sent", "docCount": 1, "items": ["Letter of Intent"] },
{ "category": "FDD", "status": "sent", "docCount": 1, "items": ["Bank Statement"] }
]
}
}
```
### Recipient experience
The applicant receives **N emails**, where N is the number of non-empty buckets. Each email is self-contained — it only mentions the documents in that category and uses the wording of the corresponding existing template. There is no "we sent you 4 separate emails" preamble; this preserves backwards-compatible inbox behaviour with single-category sends from the stage-transition triggers.
### Why this matters for copy
- The `PROSPECT_DOCUMENT_REQUEST` template was previously hard-coded with the shortlist congrats line. Anyone reviewing the copy should know that this line is now **conditional on `isShortlistContext`** and will *not* appear when the admin re-requests documents later. See B.1 for both variants.
- The other three templates (`STATUTORY_*`, `ARCHITECTURAL_*`, `FDD_*`) were already neutral on tone, so no copy change was needed for the on-demand flow.
---
# Appendix — Common Header / Footer / CTA Partials # Appendix — Common Header / Footer / CTA Partials
### Header Partial — `partials/email_header.html` ### Header Partial — `partials/email_header.html`
@ -1759,3 +1844,5 @@ Renders a red action button when a CTA URL is supplied.
| Date | Author | Description | | Date | Author | Description |
|---|---|---| |---|---|---|
| 2026-05-11 | Engineering | Section B templates implemented in app; added `RE_Dealer_Email_Content_Client_Brief.md` for client copy prep. | | 2026-05-11 | Engineering | Section B templates implemented in app; added `RE_Dealer_Email_Content_Client_Brief.md` for client copy prep. |
| 2026-05-26 | Engineering | **B.1** `PROSPECT_DOCUMENT_REQUEST` — body made conditional on new `isShortlistContext` placeholder so follow-up re-requests do not repeat the shortlist congratulations. Added **B.11** documenting the admin **Request Documents** action (`POST /onboarding/applications/:id/request-documents`) which orchestrates per-category emails reusing B.1/B.2/B.6/B.10. |
| 2026-05-26 | Engineering | **All prospect / applicant templates**`link` placeholder now points to the prospect portal (`/prospective-login?next=/prospective-dashboard/application/:id`) instead of the internal admin screen `/applications/:id`, which the applicant cannot access. Added helper `getProspectPortalUrl()` (`backend/src/common/utils/frontendUrl.ts`) and updated `ProspectiveLoginPage` to honour the `next` query parameter for post-OTP deep-linking. Also fixed a long-standing typo where several emails linked to `/prospect-login` (singular) — the actual route is `/prospective-login`. Affects: B.1, B.2, B.3, B.4, B.5, B.6, B.9 acknowledgement chase, all milestone applicant emails via `WorkflowService` (B.5 LOA, dealer code, shortlist, rejection), interview applicant emails (B.7 / B.8), and bulk document-reminder send. Internal-user emails (workflow approvers, panelists, FDD partner) continue to use `/applications/:id`. |

View File

@ -538,7 +538,7 @@
## 1.9 LOA Issuance ## 1.9 LOA Issuance
### Stage: Letter of Authorization ### Stage: Letter of Agreement
**Approvers:** DD-Head → NBH **Approvers:** DD-Head → NBH
**Documents Involved:** LOA document, EOR Checklist, Site Inspection report **Documents Involved:** LOA document, EOR Checklist, Site Inspection report

View File

@ -518,9 +518,9 @@ application progresses — from initial registration to final inauguration and o
achieved. 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 **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 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 which have fulfilled all architectural, statutory, and financial prerequisites are authorized to
commence operations under Royal Enfields network. 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 compliance** , and the **dealership inauguration process** , providing complete visibility, audit
control, and cross-departmental coordination before official go-live. 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** 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 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** - Once statutory verification and site readiness are complete, **DD-Admin** initiates the **LOA**
**document preparation**. **document preparation**.
@ -3160,7 +3160,7 @@ o Statutory Documents: Eleven-step checklist for compliance uploads including:
▪ Domain ID ▪ Domain ID
▪ MSD Configuration ▪ MSD Configuration
▪ LOI Acknowledgement Copy ▪ 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 o EOR (Essential Operating Requirements): Verification of pre-opening operational
criteria. criteria.
o Inauguration: Final dealership launch milestone. o Inauguration: Final dealership launch milestone.

View File

@ -316,9 +316,9 @@ application progresses — from initial registration to final inauguration and o
achieved. 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 **generated and approved by NBH and DD-Head**. Upon successful LOA issuance, the
@ -2094,11 +2094,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 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 which have fulfilled all architectural, statutory, and financial prerequisites are authorized to
commence operations under Royal Enfields network. 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 compliance** , and the **dealership inauguration process** , providing complete visibility, audit
control, and cross-departmental coordination before official go-live. 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** infrastructure readiness** for issuance. LOA processing and issuance proceed **in parallel with**
@ -2116,7 +2116,7 @@ go-live.
**6.18.3 Depth** **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** - Once statutory verification and site readiness are complete, **DD-Admin** initiates the **LOA**
**document preparation**. **document preparation**.
@ -2431,7 +2431,7 @@ o Statutory Documents: Eleven-step checklist for compliance uploads including:
▪ Domain ID ▪ Domain ID
▪ MSD Configuration ▪ MSD Configuration
▪ LOI Acknowledgement Copy ▪ 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 o EOR (Essential Operating Requirements): Verification of pre-opening operational
criteria. criteria.
o Inauguration: Final dealership launch milestone. o Inauguration: Final dealership launch milestone.

View File

@ -5,3 +5,24 @@
export function getFrontendBaseUrl(): string { export function getFrontendBaseUrl(): string {
return (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, ''); 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

@ -1,7 +1,11 @@
{{> email_header}} {{> email_header}}
<h2>Hi {{applicantName}},</h2> <h2>Hi {{applicantName}},</h2>
{{#if isShortlistContext}}
<p>Congratulations on being shortlisted for the Royal Enfield dealership opportunity at <strong>{{location}}</strong> (Application ID: <strong>{{applicationId}}</strong>).</p> <p>Congratulations on being shortlisted for the Royal Enfield dealership opportunity at <strong>{{location}}</strong> (Application ID: <strong>{{applicationId}}</strong>).</p>
<p>To proceed with your evaluation, please upload the required documents on the Dealer Portal by <strong>{{dueDate}}</strong>.</p> {{else}}
<p>We need a few documents to keep your Royal Enfield dealership application <strong>{{applicationId}}</strong>{{#if location}} for <strong>{{location}}</strong>{{/if}} moving forward.</p>
{{/if}}
<p>Please upload the documents listed below on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{#if documentList}} {{#if documentList}}
<div class="details"> <div class="details">
<p><strong>Document checklist:</strong></p> <p><strong>Document checklist:</strong></p>

View File

@ -583,7 +583,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
type, type,
scheduledAt: formatIST(scheduledDateObj), scheduledAt: formatIST(scheduledDateObj),
meetLink, meetLink,
appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, appLink: getProspectPortalUrl(application.id),
phone: applicantPhone, phone: applicantPhone,
ctaLabel: 'View application' ctaLabel: 'View application'
} }
@ -813,7 +813,7 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
type, type,
scheduledAt: formatIST(interview.scheduleDate), scheduledAt: formatIST(interview.scheduleDate),
meetLink, meetLink,
appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, appLink: getProspectPortalUrl(application.id),
phone: application.mobileNumber || '', phone: application.mobileNumber || '',
ctaLabel: 'View Schedule' ctaLabel: 'View Schedule'
} }
@ -1043,7 +1043,7 @@ export const submitLevel2Feedback = async (req: AuthRequest, res: Response) => {
// --- AI Summary --- // --- AI Summary ---
import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; import { ExternalMocksService } from '../../common/utils/externalMocks.service.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../../common/utils/frontendUrl.js';
export const generateAiSummary = async (req: AuthRequest, res: Response) => { export const generateAiSummary = async (req: AuthRequest, res: Response) => {
try { try {

View File

@ -8,7 +8,7 @@ import { WorkflowService } from '../../services/WorkflowService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { NotificationService } from '../../services/NotificationService.js'; import { NotificationService } from '../../services/NotificationService.js';
import { formatDueDateDaysFromNow, DEFAULT_FDD_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js'; import { formatDueDateDaysFromNow, DEFAULT_FDD_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../../common/utils/frontendUrl.js';
export const getAssignment = async (req: Request, res: Response) => { export const getAssignment = async (req: Request, res: Response) => {
try { try {
@ -131,7 +131,7 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
fddPartnerName, fddPartnerName,
dueDate: formatDueDateDaysFromNow(14), dueDate: formatDueDateDaysFromNow(14),
documentChecklist: DEFAULT_FDD_DOCUMENT_CHECKLIST, documentChecklist: DEFAULT_FDD_DOCUMENT_CHECKLIST,
link: `${portalBaseFdd}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Upload FDD documents' ctaLabel: 'Upload FDD documents'
} }
}).catch((e: any) => console.error('[FDD] Applicant document request email failed:', e)); }).catch((e: any) => console.error('[FDD] Applicant document request email failed:', e));

View File

@ -6,7 +6,7 @@ import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowService } from '../../services/WorkflowService.js';
import { NotificationService } from '../../services/NotificationService.js'; import { NotificationService } from '../../services/NotificationService.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../../common/utils/frontendUrl.js';
const LOA_STAGE_CODE = 'LOA_APPROVAL'; const LOA_STAGE_CODE = 'LOA_APPROVAL';
@ -271,7 +271,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
applicantName: appFull.applicantName || '', applicantName: appFull.applicantName || '',
applicationId: appFull.applicationId, applicationId: appFull.applicationId,
dealerCode: dealerCodeStr, dealerCode: dealerCodeStr,
link: `${portalBaseLoa}/applications/${appFull.id}`, link: getProspectPortalUrl(appFull.id),
ctaLabel: 'View LOA' ctaLabel: 'View LOA'
} }
}).catch((e: any) => console.error('[LOA] Applicant LOA email failed:', e)); }).catch((e: any) => console.error('[LOA] Applicant LOA email failed:', e));
@ -286,7 +286,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
applicationId: appFull.applicationId, applicationId: appFull.applicationId,
dealerCode: dealerCodeStr, dealerCode: dealerCodeStr,
dueDate: formatDueDateDaysFromNow(14), dueDate: formatDueDateDaysFromNow(14),
link: `${portalBaseLoa}/applications/${appFull.id}`, link: getProspectPortalUrl(appFull.id),
ctaLabel: 'Review & sign agreement' ctaLabel: 'Review & sign agreement'
} }
}).catch((e: any) => console.error('[LOA] Agreement signature email failed:', e)); }).catch((e: any) => console.error('[LOA] Agreement signature email failed:', e));

View File

@ -7,7 +7,7 @@ import { WorkflowService } from '../../services/WorkflowService.js';
import { NotificationService } from '../../services/NotificationService.js'; import { NotificationService } from '../../services/NotificationService.js';
import { sendEmail } from '../../common/utils/email.service.js'; import { sendEmail } from '../../common/utils/email.service.js';
import { formatDueDateDaysFromNow, DEFAULT_STATUTORY_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js'; import { formatDueDateDaysFromNow, DEFAULT_STATUTORY_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../../common/utils/frontendUrl.js';
const LOI_STAGE_CODE = 'LOI_APPROVAL'; const LOI_STAGE_CODE = 'LOI_APPROVAL';
@ -122,7 +122,7 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => {
amount: amountStr, amount: amountStr,
dueDate: formatDueDateDaysFromNow(14), dueDate: formatDueDateDaysFromNow(14),
bankDetails: 'Use the payment instructions shown on the Dealer Portal after you sign in.', bankDetails: 'Use the payment instructions shown on the Dealer Portal after you sign in.',
link: `${portalBase}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Pay or upload proof', ctaLabel: 'Pay or upload proof',
phone: String(phone || '') phone: String(phone || '')
} }
@ -475,7 +475,7 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
{ {
applicantName: application.applicantName || 'Applicant', applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId, applicationId: application.applicationId,
link: `${getFrontendBaseUrl()}/prospect-login`, link: getProspectPortalUrl(application.id),
ctaLabel: 'View Your Application' ctaLabel: 'View Your Application'
} }
).catch((e: any) => console.error('[LOI] Applicant email failed:', e)); ).catch((e: any) => console.error('[LOI] Applicant email failed:', e));
@ -513,7 +513,7 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
applicationId: application.applicationId, applicationId: application.applicationId,
dueDate: formatDueDateDaysFromNow(21), dueDate: formatDueDateDaysFromNow(21),
statutoryList: DEFAULT_STATUTORY_DOCUMENT_CHECKLIST, statutoryList: DEFAULT_STATUTORY_DOCUMENT_CHECKLIST,
link: `${portalBase}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Upload statutory documents' ctaLabel: 'Upload statutory documents'
} }
}).catch((e: any) => console.error('[LOI] Statutory document email failed:', e)); }).catch((e: any) => console.error('[LOI] Statutory document email failed:', e));

View File

@ -738,7 +738,7 @@ export const uploadDocuments = async (req: any, res: Response) => {
documentCategory: stage || 'Onboarding', documentCategory: stage || 'Onboarding',
documentName: documentType, documentName: documentType,
receivedOn: new Date().toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }), receivedOn: new Date().toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }),
link: `${portalBase}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'View uploads' ctaLabel: 'View uploads'
} }
}).catch((mailErr: any) => console.error('[uploadDocuments] acknowledgement email:', mailErr)); }).catch((mailErr: any) => console.error('[uploadDocuments] acknowledgement email:', mailErr));
@ -867,9 +867,14 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
applicationId: application.applicationId, applicationId: application.applicationId,
dueDate: formatDueDateDaysFromNow(7), dueDate: formatDueDateDaysFromNow(7),
documentList: DEFAULT_PROSPECT_DOCUMENT_CHECKLIST, documentList: DEFAULT_PROSPECT_DOCUMENT_CHECKLIST,
link: `${portalBase}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Upload documents', ctaLabel: 'Upload documents',
phone: String(prospectPhone || '') phone: String(prospectPhone || ''),
// Show the "Congratulations on being shortlisted" line only at the
// genuine shortlist moment. Later document-request triggers (the
// admin's "Request Documents" action) omit this flag so the email
// reads as a neutral follow-up.
isShortlistContext: true
} }
}).catch((err: any) => console.error('Failed to send prospect document request email:', err)); }).catch((err: any) => console.error('Failed to send prospect document request email:', err));
@ -1166,7 +1171,7 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
architectName: architect?.fullName || 'Royal Enfield Architecture', architectName: architect?.fullName || 'Royal Enfield Architecture',
dueDate: formatDueDateDaysFromNow(10), dueDate: formatDueDateDaysFromNow(10),
inputsList: DEFAULT_ARCHITECTURE_SITE_INPUTS, inputsList: DEFAULT_ARCHITECTURE_SITE_INPUTS,
link: `${portalBaseArch}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Provide site inputs' ctaLabel: 'Provide site inputs'
} }
}).catch((e: any) => console.error('[Architecture] Applicant email failed:', e)); }).catch((e: any) => console.error('[Architecture] Applicant email failed:', e));
@ -1219,7 +1224,7 @@ export const updateArchitectureStatus = async (req: AuthRequest, res: Response)
}; };
import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; import { ExternalMocksService } from '../../common/utils/externalMocks.service.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../../common/utils/frontendUrl.js';
export const generateDealerCodes = async (req: AuthRequest, res: Response) => { export const generateDealerCodes = async (req: AuthRequest, res: Response) => {
try { try {
@ -1751,7 +1756,7 @@ export const sendBulkDocumentReminders = async (req: AuthRequest, res: Response)
applicationId: app.applicationId, applicationId: app.applicationId,
pendingDocuments: pendingDocuments || undefined, pendingDocuments: pendingDocuments || undefined,
dueDate: dueDate || formatDueDateDaysFromNow(7), dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${getFrontendBaseUrl()}/applications/${app.id}` link: getProspectPortalUrl(app.id)
}); });
await safeAuditLogCreate({ await safeAuditLogCreate({
userId: req.user?.id || null, userId: req.user?.id || null,
@ -1803,7 +1808,7 @@ export const sendBulkLoiAckReminders = async (req: AuthRequest, res: Response) =
await NotificationService.sendLoiAcknowledgementReminder(app.email, app.phone, app.applicantName, { await NotificationService.sendLoiAcknowledgementReminder(app.email, app.phone, app.applicantName, {
applicationId: app.applicationId, applicationId: app.applicationId,
dueDate: dueDate || formatDueDateDaysFromNow(7), dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${getFrontendBaseUrl()}/prospect-login` link: getProspectPortalUrl(application.id)
}); });
sent++; sent++;
} }
@ -1859,7 +1864,7 @@ export const rejectOnboardingDocument = async (req: AuthRequest, res: Response)
documentName: doc.documentType, documentName: doc.documentType,
rejectionReason: String(rejectionReason).trim(), rejectionReason: String(rejectionReason).trim(),
dueDate: dueDate || formatDueDateDaysFromNow(7), dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${portalBase}/applications/${application.id}`, link: getProspectPortalUrl(application.id),
ctaLabel: 'Re-upload document', ctaLabel: 'Re-upload document',
phone: application.phone || '' phone: application.phone || ''
} }
@ -1885,3 +1890,213 @@ export const rejectOnboardingDocument = async (req: AuthRequest, res: Response)
res.status(500).json({ success: false, message: 'Error rejecting document' }); res.status(500).json({ success: false, message: 'Error rejecting document' });
} }
}; };
/**
* Request documents from prospect gated to post-LOI-approval stages and admin/leadership roles.
*
* The admin picks one or more `documentTypes` (typically from the master DocumentStageConfig list).
* The endpoint:
* 1. Skips any types already uploaded for this application.
* 2. Groups the remaining types by category (LOI / Statutory / Architecture / FDD / Other)
* based on each DocumentStageConfig's `stageCode`.
* 3. Sends ONE email per non-empty category, reusing the existing applicant-facing templates
* (PROSPECT_DOCUMENT_REQUEST, STATUTORY_DOCUMENT_REQUEST, ARCHITECTURAL_PLAN_REQUEST,
* FDD_DOCUMENT_REQUEST). Each email carries only the items in that category.
* 4. Logs an audit entry summarising what was requested, skipped, and per-category email status.
*
* Body: { documentTypes: string[]; dueDays?: number; customMessage?: string }
*/
export const requestProspectDocuments = async (req: AuthRequest, res: Response) => {
try {
const allowed: string[] = [ROLES.DD_ADMIN, ROLES.SUPER_ADMIN, ROLES.DD_LEAD, ROLES.DD_HEAD];
if (!req.user?.roleCode || !allowed.includes(req.user.roleCode)) {
return res.status(403).json({
success: false,
message: 'Forbidden: only DD Admin, DD Lead, DD Head, or Super Admin can request documents'
});
}
const { id } = req.params;
const { documentTypes, dueDays, customMessage } = req.body as {
documentTypes?: string[];
dueDays?: number;
customMessage?: string;
};
if (!Array.isArray(documentTypes) || documentTypes.length === 0) {
return res.status(400).json({ success: false, message: 'documentTypes (non-empty array) is required' });
}
const targetId = id as string;
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(targetId);
const application = await Application.findOne({
where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId }
});
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
if (!application.email) {
return res.status(400).json({ success: false, message: 'Applicant email not on file; cannot send document request' });
}
// Stage gate: only after LOI Approval (Security Deposit corridor onward).
const postLoiStatuses = [
APPLICATION_STATUS.SECURITY_DETAILS, // 'Security Deposit' canonical
'Security Details', // legacy label still possible on older rows
APPLICATION_STATUS.PAYMENT_PENDING,
APPLICATION_STATUS.LOI_ISSUANCE_PENDING,
APPLICATION_STATUS.LOI_ISSUED,
APPLICATION_STATUS.DEALER_CODE_GENERATION,
APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED,
APPLICATION_STATUS.ARCHITECTURE_DOCUMENT_UPLOAD,
APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION,
APPLICATION_STATUS.STATUTORY_GST,
APPLICATION_STATUS.STATUTORY_PAN,
APPLICATION_STATUS.STATUTORY_NODAL,
APPLICATION_STATUS.STATUTORY_CHECK,
APPLICATION_STATUS.STATUTORY_PARTNERSHIP,
APPLICATION_STATUS.STATUTORY_FIRM_REG,
APPLICATION_STATUS.STATUTORY_VIRTUAL_CODE,
APPLICATION_STATUS.STATUTORY_DOMAIN,
APPLICATION_STATUS.STATUTORY_MSD,
APPLICATION_STATUS.STATUTORY_LOI_ACK,
APPLICATION_STATUS.LOA_PENDING,
];
if (!postLoiStatuses.includes(application.overallStatus as any)) {
return res.status(400).json({
success: false,
message: `Document requests are only available after LOI Approval. Current status: ${application.overallStatus}.`
});
}
// Drop types already uploaded — no point re-requesting them.
const uploadedDocs = await OnboardingDocument.findAll({
where: { applicationId: application.id, documentType: { [Op.in]: documentTypes } },
attributes: ['documentType']
});
const uploadedTypes = new Set<string>(uploadedDocs.map((d: any) => d.documentType));
const pendingTypes = documentTypes.filter(t => !uploadedTypes.has(t));
if (pendingTypes.length === 0) {
return res.status(400).json({
success: false,
message: 'All selected documents are already uploaded. Nothing to request.',
data: { skippedAlreadyUploaded: Array.from(uploadedTypes) }
});
}
// Categorize by DocumentStageConfig.stageCode so we can pick the right template per group.
const configs = await DocumentStageConfig.findAll({
where: { documentType: { [Op.in]: pendingTypes }, module: 'ONBOARDING' }
});
const configByType = new Map<string, any>();
for (const c of configs) configByType.set((c as any).documentType, c);
const categorize = (stageCode: string | null | undefined): 'LOI' | 'Statutory' | 'Architecture' | 'FDD' | 'Other' => {
const s = String(stageCode || '').toLowerCase();
if (s.startsWith('loi')) return 'LOI';
if (s.startsWith('statutory')) return 'Statutory';
if (s.startsWith('architecture')) return 'Architecture';
if (s.startsWith('fdd')) return 'FDD';
return 'Other';
};
const groups: Record<'LOI' | 'Statutory' | 'Architecture' | 'FDD' | 'Other', string[]> = {
LOI: [], Statutory: [], Architecture: [], FDD: [], Other: []
};
for (const docType of pendingTypes) {
const cfg = configByType.get(docType);
groups[categorize(cfg?.stageCode)].push(docType);
}
const portalBase = getFrontendBaseUrl();
const applicantAcct = await User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber']
});
const phone = (applicantAcct as any)?.mobileNumber || application.phone || '';
// Send prospect to their own portal — internal `/applications/:id` is admin-only.
const link = getProspectPortalUrl(application.id);
const dueDaysResolved = typeof dueDays === 'number' && dueDays > 0 ? dueDays : 14;
const formatList = (list: string[]) => list.map((d, i) => `${i + 1}. ${d}`).join('\n');
const sendBuckets: Array<{
category: 'LOI' | 'Statutory' | 'Architecture' | 'FDD' | 'Other';
template: string;
listKey: string;
extraPlaceholders?: Record<string, string>;
}> = [
{ category: 'LOI', template: 'PROSPECT_DOCUMENT_REQUEST', listKey: 'documentList' },
{ category: 'Statutory', template: 'STATUTORY_DOCUMENT_REQUEST', listKey: 'statutoryList' },
{ category: 'Architecture', template: 'ARCHITECTURAL_PLAN_REQUEST', listKey: 'inputsList',
extraPlaceholders: { architectName: 'Royal Enfield Architecture Team' } },
{ category: 'FDD', template: 'FDD_DOCUMENT_REQUEST', listKey: 'documentChecklist',
extraPlaceholders: { fddPartnerName: 'Assigned FDD Partner' } },
// Generic fallback for un-categorised types — reuses the same shortlist-era template
// since it already supports a `documentList` checklist field.
{ category: 'Other', template: 'PROSPECT_DOCUMENT_REQUEST', listKey: 'documentList' },
];
const emailResults: Array<{ category: string; status: 'sent' | 'failed'; docCount: number; items: string[]; error?: string }> = [];
for (const bucket of sendBuckets) {
const items = groups[bucket.category];
if (items.length === 0) continue;
try {
await NotificationService.notify(applicantAcct?.id ?? null, application.email, {
title: `Documents required — ${application.applicationId}`,
message: customMessage || `Please upload the requested ${bucket.category} documents on the Dealer Portal.`,
channels: phone ? ['email', 'whatsapp'] : ['email'],
templateCode: bucket.template,
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
location: application.preferredLocation || application.city || '',
dueDate: formatDueDateDaysFromNow(dueDaysResolved),
[bucket.listKey]: formatList(items),
link,
ctaLabel: 'Upload documents',
phone: phone || '',
...(bucket.extraPlaceholders || {})
}
});
emailResults.push({ category: bucket.category, status: 'sent', docCount: items.length, items });
} catch (e: any) {
console.error(`[requestProspectDocuments] ${bucket.category} email failed:`, e);
emailResults.push({
category: bucket.category,
status: 'failed',
docCount: items.length,
items,
error: e?.message
});
}
}
await safeAuditLogCreate({
userId: req.user?.id || null,
action: 'DOCUMENT_REQUEST_SENT',
entityType: 'application',
entityId: application.id,
newData: {
requestedDocumentTypes: pendingTypes,
skippedAlreadyUploaded: Array.from(uploadedTypes),
emailResults,
requestedByRole: req.user?.roleCode,
dueDays: dueDaysResolved,
customMessage: customMessage || null,
context: pickApplicationAuditContext(application)
}
});
return res.json({
success: true,
message: 'Document request emails dispatched',
data: {
requested: pendingTypes,
skippedAlreadyUploaded: Array.from(uploadedTypes),
emailsSent: emailResults
}
});
} catch (error) {
console.error('Request prospect documents error:', error);
return res.status(500).json({ success: false, message: 'Error requesting prospect documents' });
}
};

View File

@ -7,7 +7,8 @@ import {
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata, retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication, createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders, exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders,
sendBulkDocumentReminders, sendBulkLoiAckReminders, rejectOnboardingDocument sendBulkDocumentReminders, sendBulkLoiAckReminders, rejectOnboardingDocument,
requestProspectDocuments
} from './onboarding.controller.js'; } from './onboarding.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
import { checkRevocation } from '../../common/middleware/checkRevocation.js'; import { checkRevocation } from '../../common/middleware/checkRevocation.js';
@ -38,6 +39,7 @@ router.put('/applications/:id', checkRevocation as any, updateApplication);
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus); router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
router.post('/applications/:id/documents', uploadSingle, checkRevocation as any, uploadDocuments); router.post('/applications/:id/documents', uploadSingle, checkRevocation as any, uploadDocuments);
router.post('/applications/:id/documents/:documentId/reject', checkRevocation as any, rejectOnboardingDocument); router.post('/applications/:id/documents/:documentId/reject', checkRevocation as any, rejectOnboardingDocument);
router.post('/applications/:id/request-documents', checkRevocation as any, requestProspectDocuments);
router.get('/applications/:id/documents', checkRevocation as any, getApplicationDocuments); router.get('/applications/:id/documents', checkRevocation as any, getApplicationDocuments);
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity); router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
router.post('/applications/:id/convert-to-opportunity', checkRevocation as any, convertToOpportunity); router.post('/applications/:id/convert-to-opportunity', checkRevocation as any, convertToOpportunity);

View File

@ -320,10 +320,10 @@ const seedTemplates = async () => {
}, },
{ {
templateCode: 'PROSPECT_DOCUMENT_REQUEST', templateCode: 'PROSPECT_DOCUMENT_REQUEST',
description: 'Post-shortlist: request KYC and business documents from the applicant', description: 'Post-shortlist or follow-up document request to the applicant',
subject: 'Documents Required — Royal Enfield Dealership Application {{applicationId}}', subject: 'Documents Required — Royal Enfield Dealership Application {{applicationId}}',
fileName: 'prospect_document_request.html', fileName: 'prospect_document_request.html',
placeholders: ['applicantName', 'location', 'applicationId', 'dueDate', 'documentList', 'link', 'ctaLabel'] placeholders: ['applicantName', 'location', 'applicationId', 'dueDate', 'documentList', 'link', 'ctaLabel', 'isShortlistContext']
}, },
{ {
templateCode: 'STATUTORY_DOCUMENT_REQUEST', templateCode: 'STATUTORY_DOCUMENT_REQUEST',

View File

@ -1,5 +1,5 @@
import { sendEmail } from '../common/utils/email.service.js'; import { sendEmail } from '../common/utils/email.service.js';
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; import { getProspectPortalUrl } from '../common/utils/frontendUrl.js';
import db from '../database/models/index.js'; import db from '../database/models/index.js';
const { Notification, PushSubscription } = db; const { Notification, PushSubscription } = db;
@ -157,7 +157,7 @@ export class NotificationService {
applicantName, applicantName,
phone, phone,
location: options?.location ?? '', location: options?.location ?? '',
link: options?.link ?? `${getFrontendBaseUrl()}/prospect-login`, link: options?.link ?? getProspectPortalUrl(),
ctaLabel: 'Complete Questionnaire' ctaLabel: 'Complete Questionnaire'
} }
}); });
@ -186,7 +186,7 @@ export class NotificationService {
options.pendingDocuments || options.pendingDocuments ||
'Please sign in to the Dealer Portal to view your personalised document checklist.', 'Please sign in to the Dealer Portal to view your personalised document checklist.',
dueDate: options.dueDate || 'within seven calendar days', dueDate: options.dueDate || 'within seven calendar days',
link: options.link || `${getFrontendBaseUrl()}/applications`, link: options.link || getProspectPortalUrl(),
ctaLabel: 'Upload documents', ctaLabel: 'Upload documents',
phone: phone || '' phone: phone || ''
} }
@ -213,7 +213,7 @@ export class NotificationService {
applicantName, applicantName,
applicationId: options.applicationId, applicationId: options.applicationId,
dueDate: options.dueDate || 'within seven calendar days', dueDate: options.dueDate || 'within seven calendar days',
link: options.link || `${getFrontendBaseUrl()}/prospect-login`, link: options.link || getProspectPortalUrl(),
ctaLabel: 'Acknowledge LOI', ctaLabel: 'Acknowledge LOI',
phone: phone || '' phone: phone || ''
} }

View File

@ -6,6 +6,18 @@ import { computeSlaTrackView, ownerRoleMatchesUser } from '../common/utils/slaMe
export type SlaEntityRef = { entityType: string; entityId: string }; export type SlaEntityRef = { entityType: string; entityId: string };
/**
* `sla_tracking.entityId` is a UUID column in Postgres. Callers (e.g. the
* frontend, before a request payload has loaded) sometimes pass a human-
* readable request code such as `CC-2026-MAY-00002`, which makes Postgres
* throw `invalid input syntax for type uuid` and bubbles up as a 500.
*
* Cheaply filter those out before they hit the DB. We return `null` for
* non-UUID refs so the caller's response shape is preserved.
*/
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const isUuid = (value: string): boolean => UUID_RE.test(value);
export type SlaStatusSnapshot = { export type SlaStatusSnapshot = {
entityType: string; entityType: string;
entityId: string; entityId: string;
@ -28,14 +40,23 @@ export class SlaStatusService {
const result: Record<string, SlaStatusSnapshot | null> = {}; const result: Record<string, SlaStatusSnapshot | null> = {};
if (!refs.length) return result; if (!refs.length) return result;
// Seed every requested ref as null so the response shape stays stable
// even when we skip non-UUIDs (see UUID_RE above).
for (const ref of refs) {
result[this.refKey(ref.entityType, ref.entityId)] = null;
}
const uuidRefs = refs.filter((r) => isUuid(r.entityId));
if (!uuidRefs.length) return result;
const configs = await SLAConfiguration.findAll({ const configs = await SLAConfiguration.findAll({
where: { isActive: true }, where: { isActive: true },
attributes: ['activityName', 'ownerRole', 'tatHours', 'tatUnit'] attributes: ['activityName', 'ownerRole', 'tatHours', 'tatUnit']
}); });
const configByStage = new Map(configs.map((c: any) => [c.activityName, c])); const configByStage = new Map(configs.map((c: any) => [c.activityName, c]));
const entityIds = [...new Set(refs.map((r) => r.entityId))]; const entityIds = [...new Set(uuidRefs.map((r) => r.entityId))];
const entityTypes = [...new Set(refs.map((r) => r.entityType))]; const entityTypes = [...new Set(uuidRefs.map((r) => r.entityType))];
const tracks = await SLATracking.findAll({ const tracks = await SLATracking.findAll({
where: { where: {
@ -54,7 +75,7 @@ export class SlaStatusService {
} }
const now = Date.now(); const now = Date.now();
for (const ref of refs) { for (const ref of uuidRefs) {
const key = this.refKey(ref.entityType, ref.entityId); const key = this.refKey(ref.entityType, ref.entityId);
const track = trackByRef.get(key); const track = trackByRef.get(key);
if (!track) { if (!track) {

View File

@ -10,7 +10,7 @@ import {
} from '../common/config/constants.js'; } from '../common/config/constants.js';
import { NotificationService } from './NotificationService.js'; import { NotificationService } from './NotificationService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; import { getFrontendBaseUrl, getProspectPortalUrl } from '../common/utils/frontendUrl.js';
export class WorkflowService { export class WorkflowService {
/** /**
@ -137,8 +137,34 @@ export class WorkflowService {
// Progress Sync // Progress Sync
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e))); tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));
// Notifications // Applicant-facing email notifications
if (application.email && !metadata.skipNotification) { //
// Earlier this block fired `ONBOARDING_STATUS_UPDATE` for EVERY transition,
// which meant prospects got an email on internal events like "FDD Verification"
// when the FDD agency uploaded their report. That leaked internal review
// language to the applicant. We now allowlist only the milestones the
// applicant genuinely needs to hear about; everything else is silent for
// the prospect (internal teams still get their notifications via
// `notifyStakeholdersOnTransition` below).
//
// Side note: stages that have their own dedicated trigger elsewhere
// (interviews — INTERVIEW_SCHEDULED_APPLICANT in assessment.controller,
// FDD docs request — FDD_DOCUMENT_REQUEST in fdd.controller, questionnaire,
// etc.) are deliberately NOT in this allowlist to avoid duplicate emails.
const APPLICANT_MILESTONE_TEMPLATES: Record<string, { template: string; ctaLabel: string }> = {
'Shortlisted': { template: 'APPLICANT_SHORTLISTED', ctaLabel: 'View application' },
'LOI Issued': { template: 'LOI_ISSUED', ctaLabel: 'View LOI' },
'LOA Issued': { template: 'LOA_ISSUED', ctaLabel: 'View LOA' },
'Dealer Code Generated': { template: 'DEALER_CODE_READY', ctaLabel: 'View application' },
'Dealer Code Generation': { template: 'DEALER_CODE_READY', ctaLabel: 'View application' },
'Rejected': { template: 'APPLICANT_REJECTED', ctaLabel: 'View application' },
'LOI Rejected': { template: 'APPLICANT_REJECTED', ctaLabel: 'View application' },
'LOA Rejected': { template: 'APPLICANT_REJECTED', ctaLabel: 'View application' },
'Disqualified': { template: 'APPLICANT_REJECTED', ctaLabel: 'View application' },
};
const milestone = APPLICANT_MILESTONE_TEMPLATES[targetStatus];
if (milestone && application.email && !metadata.skipNotification) {
tasks.push((async () => { tasks.push((async () => {
const user = await User.findOne({ const user = await User.findOne({
where: { email: application.email }, where: { email: application.email },
@ -146,24 +172,11 @@ export class WorkflowService {
}); });
const targetUserId = user ? user.id : null; const targetUserId = user ? user.id : null;
let templateCode = 'ONBOARDING_STATUS_UPDATE';
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
let ctaLabel = 'View application';
if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI';
else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA';
if (['Rejected', 'LOI Rejected', 'LOA Rejected', 'Disqualified'].includes(targetStatus)) {
templateCode = 'APPLICANT_REJECTED';
ctaLabel = 'View application';
}
await NotificationService.notify(targetUserId, application.email, { await NotificationService.notify(targetUserId, application.email, {
title: `Onboarding Update: ${targetStatus}`, title: `Onboarding Update: ${targetStatus}`,
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
channels: ['email', 'whatsapp', 'system'], channels: ['email', 'whatsapp', 'system'],
templateCode: templateCode, templateCode: milestone.template,
placeholders: { placeholders: {
status: targetStatus, status: targetStatus,
applicantName: application.applicantName, applicantName: application.applicantName,
@ -173,12 +186,18 @@ export class WorkflowService {
reason: reason || 'N/A', reason: reason || 'N/A',
salesCode: application.dealerCode?.salesCode || 'N/A', salesCode: application.dealerCode?.salesCode || 'N/A',
serviceCode: application.dealerCode?.serviceCode || 'N/A', serviceCode: application.dealerCode?.serviceCode || 'N/A',
link: `${getFrontendBaseUrl()}/applications/${application.id}`, // Applicant has no access to the internal admin screen,
ctaLabel, // so route them through the prospect portal.
link: getProspectPortalUrl(application.id),
ctaLabel: milestone.ctaLabel,
phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || '' phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || ''
}, },
}); });
})().catch(e => console.error('[WorkflowService] notification failed:', e))); })().catch(e => console.error('[WorkflowService] notification failed:', e)));
} else if (!milestone) {
console.log(
`[WorkflowService] Skipping applicant email for non-milestone transition: ${targetStatus}`
);
} }
// Stakeholder Notifications // Stakeholder Notifications

View File

@ -381,78 +381,88 @@ async function triggerWorkflow() {
log(7, 'FDD Milestone Complete.'); log(7, 'FDD Milestone Complete.');
await delay(); await delay();
log(7.4, 'Uploading mandatory documents prior to LOI generation...'); // log(7.4, 'Uploading mandatory pre-LOI evidence documents (CIBIL / Site / Bank / GST / PAN)...');
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card']; // const preLoiDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
for (const doc of requiredDocs) { // for (const doc of preLoiDocs) {
await mockUploadDocument(applicationUUID, adminToken, doc); // await mockUploadDocument(applicationUUID, adminToken, doc);
} // }
await delay(1000); // await delay(1000);
// // 7.5 LOI APPROVAL // // 7.5 LOI APPROVAL (multi-approver: DD-Head + NBH)
// log(7.5, 'LOI Generation & Approval...'); // log(7.5, 'Requesting LOI and collecting required approvals...');
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken); // const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
// const loiRequestId = loiRes.data.id; // const loiRequestId = loiRes.data.id;
// log(7.5, `LOI Request created (ID: ${loiRequestId})`);
// // Head Approval // // DD-Head Approval
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved', // action: 'Approved',
// remarks: 'Head Authorization for LOI' // remarks: 'DD-Head authorization for LOI'
// }, headToken); // }, headToken);
// // NBH Approval // // NBH Approval — final approver flips overallStatus to "Security Details"
// // (LOI Approved → moves into the Security Deposit corridor before LOI Issue).
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved', // action: 'Approved',
// remarks: 'NBH Authorization for LOI' // remarks: 'NBH authorization for LOI'
// }, nbhToken); // }, nbhToken);
// log(7.5, 'LOI Milestone Complete.'); // log(7.5, 'LOI fully approved. Backend transitioned to Security Deposit corridor.');
// await delay(); // await delay();
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW) // // 8. SECURITY DEPOSIT — Finance verifies advance payment.
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...'); // // Backend keeps app at "Security Details" (no auto-jump to LOI Issued) — admin must
// // (a) upload LOI documents and (b) explicitly transition. See loa.controller.ts.
// log(8, 'Finance Verifying SECURITY_DEPOSIT (₹5L advance)...');
// const financeToken = await login(EMAILS.FINANCE); // const financeToken = await login(EMAILS.FINANCE);
// await apiRequest('/loa/security-deposit', 'POST', { // await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID, // applicationId: applicationUUID,
// amount: 500000, // amount: 500000,
// paymentReference: 'PAY-888999', // paymentReference: `PAY-SD-${Date.now()}`,
// depositType: 'SECURITY_DEPOSIT', // depositType: 'SECURITY_DEPOSIT',
// status: 'Verified' // status: 'Verified'
// }, financeToken); // }, financeToken);
// log(8, 'Security Deposit Verified.') // log(8, 'Security Deposit Verified.');
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required) // await delay();
// // 8.5 LOI DOCUMENTS COLLECTION
// // Per business rule (and the new UI "LOI Documents" stage), the dealer-side LOI artefacts
// // must be uploaded BEFORE the admin transitions the application to "LOI Issued".
// log(8.5, 'Uploading LOI Documents (Letter of Intent + Signed LOI) prior to LOI Issuance...');
// const loiDocs = ['Letter of Intent', 'Signed LOI'];
// for (const doc of loiDocs) {
// await mockUploadDocument(applicationUUID, adminToken, doc);
// }
// log(8.5, 'LOI Documents uploaded. Ready for LOI Issued transition.');
// await delay();
// // 9. LOI ISSUE — explicit admin transition + LOI document generation.
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); // let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`); // log(9, `Current status before LOI Issued transition: ${statusBeforeCodeGen}`);
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
// log(9, 'Ensuring mandatory PAN/GST/Bank fields are populated...');
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken); // await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
// await delay(300); // await delay(300);
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') { // if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
// log(9, 'Status is Security Deposit (or legacy Security Details); re-verifying Security Deposit to move to LOI Issued...'); // log(9, 'Applying admin transition: Security Deposit -> LOI Issued...');
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 500000,
// paymentReference: `PAY-RETRY-${Date.now()}`,
// depositType: 'SECURITY_DEPOSIT',
// status: 'Verified'
// }, financeToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
// }
// // Current backend flow keeps app at Security Deposit until explicit admin transition.
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
// log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', { // await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
// status: 'LOI Issued', // status: 'LOI Issued',
// stage: 'LOI', // stage: 'LOI',
// reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.' // reason: 'LOI documents collected and verified. Releasing LOI Issued milestone.'
// }, adminToken); // }, adminToken);
// await delay(); // await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); // statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`); // log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
// } // }
// // generateDocument bridges LOI Issued -> Dealer Code Generation (loi.controller.ts:459).
// log(9, 'Generating final LOI document (bridges to Dealer Code Generation)...');
// await apiRequest('/loi/generate-document', 'POST', { requestId: loiRequestId }, adminToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after LOI generateDocument: ${statusBeforeCodeGen}`);
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') { // if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`); // throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
// } // }
@ -467,11 +477,11 @@ async function triggerWorkflow() {
// await apiRequest('/loa/security-deposit', 'POST', { // await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID, // applicationId: applicationUUID,
// amount: 1500000, // amount: 1500000,
// paymentReference: 'PAY-FIN-999', // paymentReference: `PAY-FF-${Date.now()}`,
// depositType: 'FIRST_FILL', // depositType: 'FIRST_FILL',
// status: 'Verified' // status: 'Verified'
// }, financeToken); // }, financeToken);
// log(10, 'Final Security Deposit Verified.'); // log(10, 'First Fill Verified.');
// await delay(); // await delay();
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS // // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
@ -527,7 +537,7 @@ async function triggerWorkflow() {
// ]; // ];
// for (const item of eorItems) { // for (const item of eorItems) {
// process.stdout.write(`.`); // Visual progress // process.stdout.write(`.`);
// await apiRequest(`/eor/item/${checklistId}`, 'POST', { // await apiRequest(`/eor/item/${checklistId}`, 'POST', {
// ...item, // ...item,
// isCompliant: true, // isCompliant: true,
@ -542,7 +552,6 @@ async function triggerWorkflow() {
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.' // overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
// }, adminToken); // }, adminToken);
// // Status check
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken); // const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); // log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
// await delay(); // await delay();