typo chnge errors and Loi Document request mail templatd added
This commit is contained in:
parent
29d67f6ca6
commit
80495a78a6
File diff suppressed because one or more lines are too long
6
build/assets/index-DqVo88us.css
Normal file
6
build/assets/index-DqVo88us.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
793
build/assets/index-XdyJ-8da.js
Normal file
793
build/assets/index-XdyJ-8da.js
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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`. |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 Enfield’s network.
|
commence operations under Royal Enfield’s 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.
|
||||||
|
|||||||
@ -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 Enfield’s network.
|
commence operations under Royal Enfield’s 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.
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user