new mail templates added for edge scenerios

This commit is contained in:
laxman h 2026-05-12 20:00:29 +05:30
parent 0ab90ee356
commit eeae163782
21 changed files with 2554 additions and 13 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,17 @@
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED',
'APPLICANT_REJECTED',
'ARCHITECTURAL_PLAN_REQUEST',
'CONSTITUTIONAL_CHANGE_SUBMITTED',
'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
'CONSTITUTIONAL_CHANGE_APPROVED',
'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY',
'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
'DOCUMENT_REJECTED_RESUBMIT',
'DOCUMENT_SUBMISSION_REMINDER',
'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED',
'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED',
@ -22,13 +28,16 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'INTERVIEW_CANCELLED_APPLICANT',
'INTERVIEW_CANCELLED_PANELIST',
'LOA_ISSUED',
'LOI_ACKNOWLEDGEMENT_REQUEST',
'LOI_ISSUED',
'NON_OPPORTUNITY',
'ONBOARDING_PAYMENT_VERIFIED',
'ONBOARDING_STATUS_UPDATE',
'OPPORTUNITY',
'PROSPECT_DOCUMENT_REQUEST',
'QUESTIONNAIRE_REMINDER',
'QUESTIONNAIRE_SUBMITTED',
'SECURITY_DEPOSIT_REQUEST',
'RELOCATION_RECEIVED',
'RELOCATION_SUBMITTED',
'RELOCATION_APPROVED',
@ -38,6 +47,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE',
'SLA_BREACH_WARNING',
'STATUTORY_DOCUMENT_REQUEST',
'SLA_REMINDER',
'SLA_BREACH',
'SLA_ESCALATION',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>This is a reminder regarding your dealership application <strong>{{applicationId}}</strong>.</p>
<p>The following items are still pending on the portal:</p>
<div class="details">
<p style="white-space:pre-line;">{{pendingDocuments}}</p>
</div>
<p>Please complete your uploads by <strong>{{dueDate}}</strong> so we can continue processing without delay.</p>
{{> primary_cta}}
<p>If you have already submitted everything, you may ignore this reminder.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,15 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>As part of <strong>Financial Due Diligence (FDD)</strong> for your dealership application <strong>{{applicationId}}</strong>, we have engaged <strong>{{fddPartnerName}}</strong> to complete the assessment.</p>
<p>Please upload the documents requested on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{#if documentChecklist}}
<div class="details">
<p style="white-space:pre-line;">{{documentChecklist}}</p>
</div>
{{else}}
<p>Typical requirements include audited financials, GST returns, bank statements, working-capital proof, and details of existing obligations—refer to the portal for your exact checklist.</p>
{{/if}}
{{> primary_cta}}
<p>The FDD partner may contact you directly for clarifications. Incomplete disclosure may affect the outcome of your application.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,7 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>Your <strong>Letter of Intent (LOI)</strong> for application <strong>{{applicationId}}</strong> is awaiting your acknowledgement on the Dealer Portal.</p>
<p>Please review the document and complete your acknowledgement by <strong>{{dueDate}}</strong>. We cannot move to the next onboarding steps until this is done.</p>
{{> primary_cta}}
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,15 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<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>
{{#if documentList}}
<div class="details">
<p><strong>Document checklist:</strong></p>
<p style="white-space:pre-line;">{{documentList}}</p>
</div>
{{/if}}
<p>All documents should be clear, in PDF or JPG format, and within the size limits shown on the portal.</p>
{{> primary_cta}}
<p>For queries, contact your Royal Enfield Dealer Development representative or write to <a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,14 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>Following acknowledgement of your Letter of Intent for application <strong>{{applicationId}}</strong>, the next step is to remit the <strong>security deposit of ₹{{amount}}</strong> before <strong>{{dueDate}}</strong>.</p>
<p>You may pay online through the portal or transfer funds as per the instructions shown after you log in.</p>
{{#if bankDetails}}
<div class="details">
<p><strong>Bank transfer details (if applicable):</strong></p>
<p style="white-space:pre-line;">{{bankDetails}}</p>
</div>
{{/if}}
{{> primary_cta}}
<p>After payment, please upload the payment confirmation (UTR / reference) on the portal. Finance will verify and you will receive a confirmation notification.</p>
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -0,0 +1,13 @@
{{> email_header}}
<h2>Hi {{applicantName}},</h2>
<p>Your <strong>Letter of Intent (LOI)</strong> has been issued for application <strong>{{applicationId}}</strong>. The next step is to submit statutory and compliance documents required for dealership authorisation.</p>
<p>Please upload the documents listed on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
{{#if statutoryList}}
<div class="details">
<p style="white-space:pre-line;">{{statutoryList}}</p>
</div>
{{/if}}
<p>Our DD-Admin and Legal teams will verify each submission. You will be notified if any document needs to be re-submitted.</p>
{{> primary_cta}}
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
{{> email_footer}}

View File

@ -7,6 +7,7 @@ import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '..
import { WorkflowService } from '../../services/WorkflowService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { NotificationService } from '../../services/NotificationService.js';
import { formatDueDateDaysFromNow, DEFAULT_FDD_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js';
export const getAssignment = async (req: Request, res: Response) => {
try {
@ -99,8 +100,11 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'USER_ASSIGNED',
placeholders: {
userName: fddUser.fullName || 'Team',
applicantName: application.applicantName || '',
applicationId: application.applicationId,
dealerName: application.applicantName || '',
participantType: 'FDD Partner',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
ctaLabel: 'View Assignment',
phone: phone || ''
@ -108,6 +112,30 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
}).catch((e: any) => console.error('[FDD] Agency notify failed:', e));
}
const portalBaseFdd = process.env.FRONTEND_URL || 'http://localhost:5173';
if (application.email) {
const applicantAcct = await db.User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber']
});
const fddPartnerName = fddUser ? (fddUser as any).fullName || 'Assigned FDD partner' : 'Royal Enfield FDD team';
await NotificationService.notify(applicantAcct?.id ?? null, application.email, {
title: `FDD documents required — ${application.applicationId}`,
message: 'Please upload the financial documents requested for due diligence.',
channels: ['email'],
templateCode: 'FDD_DOCUMENT_REQUEST',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
fddPartnerName,
dueDate: formatDueDateDaysFromNow(14),
documentChecklist: DEFAULT_FDD_DOCUMENT_CHECKLIST,
link: `${portalBaseFdd}/applications/${application.id}`,
ctaLabel: 'Upload FDD documents'
}
}).catch((e: any) => console.error('[FDD] Applicant document request email failed:', e));
}
res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment });
} catch (error) {
console.error('Assign FDD agency error:', error);

View File

@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User, DealerCode } = db;
import { formatDueDateDaysFromNow } from '../../constants/onboarding-email-defaults.js';
import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
@ -249,6 +250,47 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
}
}
const appFull = await db.Application.findByPk(request.applicationId, {
include: [{ model: DealerCode, as: 'dealerCode', required: false }]
});
if (appFull?.email) {
const portalBaseLoa = process.env.FRONTEND_URL || 'http://localhost:5173';
const dc = (appFull as any).dealerCode;
const dealerCodeStr = dc?.salesCode || dc?.serviceCode || 'Available on portal after SAP sync';
const applicantAcct = await User.findOne({
where: { email: appFull.email },
attributes: ['id', 'mobileNumber']
});
await NotificationService.notify(applicantAcct?.id ?? null, appFull.email, {
title: `LOA issued — ${appFull.applicationId}`,
message: 'Your Letter of Appointment is ready. View it on the Dealer Portal.',
channels: ['email'],
templateCode: 'LOA_ISSUED',
placeholders: {
applicantName: appFull.applicantName || '',
applicationId: appFull.applicationId,
dealerCode: dealerCodeStr,
link: `${portalBaseLoa}/applications/${appFull.id}`,
ctaLabel: 'View LOA'
}
}).catch((e: any) => console.error('[LOA] Applicant LOA email failed:', e));
await NotificationService.notify(applicantAcct?.id ?? null, appFull.email, {
title: `Sign dealership agreement — ${appFull.applicationId}`,
message: 'Please review and e-sign your dealership agreement on the portal.',
channels: ['email'],
templateCode: 'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
placeholders: {
applicantName: appFull.applicantName || '',
applicationId: appFull.applicationId,
dealerCode: dealerCodeStr,
dueDate: formatDueDateDaysFromNow(14),
link: `${portalBaseLoa}/applications/${appFull.id}`,
ctaLabel: 'Review & sign agreement'
}
}).catch((e: any) => console.error('[LOA] Agreement signature email failed:', e));
}
res.json({ success: true, message: 'LOA fully approved and issued' });
} else {
// SEQUENTIAL APPROVAL BRIDGE:

View File

@ -1,11 +1,12 @@
import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User, SecurityDeposit } = db;
import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { NotificationService } from '../../services/NotificationService.js';
import { sendEmail } from '../../common/utils/email.service.js';
import { formatDueDateDaysFromNow, DEFAULT_STATUTORY_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js';
const LOI_STAGE_CODE = 'LOI_APPROVAL';
@ -70,12 +71,22 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => {
const request = await LoiRequest.findByPk(requestId);
if (!request) return res.status(404).json({ success: false, message: 'LOI Request not found' });
const latestGen = await LoiDocumentGenerated.findOne({
where: { requestId: request.id },
order: [['generatedAt', 'DESC']]
});
const loiDocId = (documentId as string) || latestGen?.id;
if (!loiDocId || !req.user?.id) {
return res.status(400).json({
success: false,
message: 'LOI document reference and a signed-in applicant are required to acknowledge the LOI.'
});
}
await LoiAcknowledgement.create({
requestId,
applicationId: request.applicationId,
documentId,
acknowledgedAt: new Date(),
status: 'Acknowledged'
loiDocId,
applicantId: req.user.id,
remarks: null
});
const application = await db.Application.findByPk(request.applicationId);
@ -84,6 +95,37 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => {
reason: 'LOI Acknowledged by applicant',
progressPercentage: 90
});
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
const deposit =
(await SecurityDeposit.findOne({
where: { applicationId: application.id, depositType: 'SECURITY_DEPOSIT' }
})) || (await SecurityDeposit.findOne({ where: { applicationId: application.id } }));
const amountNum = deposit?.amount != null ? Number(deposit.amount) : 200000;
const amountStr = Number.isFinite(amountNum) ? amountNum.toLocaleString('en-IN') : '200,000';
const applicantUser = await User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber']
});
const phone = (applicantUser as any)?.mobileNumber || application.phone || '';
await NotificationService.notify(applicantUser?.id ?? null, application.email, {
title: `Security deposit — ${application.applicationId}`,
message: `Please remit the security deposit for ${application.applicationId} as per your LOI.`,
channels: phone ? ['email', 'whatsapp'] : ['email'],
templateCode: 'SECURITY_DEPOSIT_REQUEST',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
amount: amountStr,
dueDate: formatDueDateDaysFromNow(14),
bankDetails: 'Use the payment instructions shown on the Dealer Portal after you sign in.',
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'Pay or upload proof',
phone: String(phone || '')
}
}).catch((e: any) => console.error('[LOI] Security deposit email failed:', e));
}
res.json({ success: true, message: 'LOI Acknowledged by applicant' });
@ -405,11 +447,13 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
docId = docRecord.id;
}
const doc = docId ? await LoiDocumentGenerated.create({
requestId,
documentId: docId,
version: '1.0'
}) : null;
const doc = docId
? await LoiDocumentGenerated.create({
requestId: reqRecord ? reqRecord.id : requestId,
documentId: docId,
version: '1.0'
})
: null;
// Bridge: Transition from LOI Issued -> Dealer Code Generation
const request = await LoiRequest.findByPk(requestId);
@ -455,6 +499,24 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
}).catch((e: any) => console.error('[LOI] stakeholder notify failed:', e));
}
}
if (application.email) {
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
await NotificationService.notify(null, application.email, {
title: `Statutory documents — ${application.applicationId}`,
message: 'Please upload statutory and compliance documents on the Dealer Portal.',
channels: ['email'],
templateCode: 'STATUTORY_DOCUMENT_REQUEST',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
dueDate: formatDueDateDaysFromNow(21),
statutoryList: DEFAULT_STATUTORY_DOCUMENT_CHECKLIST,
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'Upload statutory documents'
}
}).catch((e: any) => console.error('[LOI] Statutory document email failed:', e));
}
}
}

View File

@ -14,6 +14,11 @@ import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
import { NotificationService } from '../../services/NotificationService.js';
import {
formatDueDateDaysFromNow,
DEFAULT_PROSPECT_DOCUMENT_CHECKLIST,
DEFAULT_ARCHITECTURE_SITE_INPUTS
} from '../../constants/onboarding-email-defaults.js';
const { DocumentStageConfig } = db;
@ -716,6 +721,25 @@ export const uploadDocuments = async (req: any, res: Response) => {
},
});
if (application.email) {
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
await NotificationService.notify(null, application.email, {
title: `Document received — ${application.applicationId}`,
message: `We received your upload: ${documentType}.`,
channels: ['email'],
templateCode: 'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
documentCategory: stage || 'Onboarding',
documentName: documentType,
receivedOn: new Date().toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }),
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'View uploads'
}
}).catch((mailErr: any) => console.error('[uploadDocuments] acknowledgement email:', mailErr));
}
res.status(201).json({
success: true,
message: 'Document uploaded successfully',
@ -822,6 +846,29 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
application.applicationId
).catch(err => console.error('Failed to send shortlist email:', err));
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
const applicantUser = await User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber']
});
const prospectPhone = (applicantUser as any)?.mobileNumber || application.phone || '';
await NotificationService.notify(applicantUser?.id ?? null, application.email, {
title: `Documents required — ${application.applicationId}`,
message: 'Please upload the requested documents to continue your dealership evaluation.',
channels: prospectPhone ? ['email', 'whatsapp'] : ['email'],
templateCode: 'PROSPECT_DOCUMENT_REQUEST',
placeholders: {
applicantName: application.applicantName || 'Applicant',
location: application.preferredLocation || application.city || 'your proposed location',
applicationId: application.applicationId,
dueDate: formatDueDateDaysFromNow(7),
documentList: DEFAULT_PROSPECT_DOCUMENT_CHECKLIST,
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'Upload documents',
phone: String(prospectPhone || '')
}
}).catch((err: any) => console.error('Failed to send prospect document request email:', err));
// Add manual assignees as participants if provided
if (assignedToArr.length > 0) {
for (const userId of assignedToArr) {
@ -1101,6 +1148,26 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) =>
defaults: { joinedMethod: 'auto' }
});
const architect = await User.findByPk(targetUserId, { attributes: ['fullName'] });
const portalBaseArch = process.env.FRONTEND_URL || 'http://localhost:5173';
if (application.email) {
await NotificationService.notify(null, application.email, {
title: `Architecture & site inputs — ${application.applicationId}`,
message: 'Please share site inputs for your dealership layout.',
channels: ['email'],
templateCode: 'ARCHITECTURAL_PLAN_REQUEST',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
architectName: architect?.fullName || 'Royal Enfield Architecture',
dueDate: formatDueDateDaysFromNow(10),
inputsList: DEFAULT_ARCHITECTURE_SITE_INPUTS,
link: `${portalBaseArch}/applications/${application.id}`,
ctaLabel: 'Provide site inputs'
}
}).catch((e: any) => console.error('[Architecture] Applicant email failed:', e));
}
res.json({ success: true, message: 'Architecture team assigned successfully' });
} catch (error) {
console.error('Assign architecture team error:', error);
@ -1682,3 +1749,154 @@ export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
res.status(500).json({ success: false, message: 'Error sending reminders' });
}
};
export const sendBulkDocumentReminders = async (req: AuthRequest, res: Response) => {
try {
const { applicationIds, pendingDocuments, dueDate } = req.body;
if (!applicationIds || !Array.isArray(applicationIds) || applicationIds.length === 0) {
return res.status(400).json({ success: false, message: 'applicationIds array is required' });
}
const applications = await Application.findAll({
where: { id: { [Op.in]: applicationIds } }
});
for (const app of applications) {
await NotificationService.sendDocumentSubmissionReminder(app.email, app.phone, app.applicantName, {
applicationId: app.applicationId,
pendingDocuments: pendingDocuments || undefined,
dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${app.id}`
});
await safeAuditLogCreate({
userId: req.user?.id || null,
action: 'REMINDER_SENT',
entityType: 'application',
entityId: app.id,
newData: {
template: 'DOCUMENT_SUBMISSION_REMINDER',
sentAt: new Date(),
context: pickApplicationAuditContext(app)
}
});
}
res.json({ success: true, message: `Document reminders sent to ${applications.length} applicant(s)` });
} catch (error) {
console.error('Send bulk document reminders error:', error);
res.status(500).json({ success: false, message: 'Error sending document reminders' });
}
};
export const sendBulkLoiAckReminders = async (req: AuthRequest, res: Response) => {
try {
const { applicationIds, dueDate } = req.body;
if (!applicationIds || !Array.isArray(applicationIds) || applicationIds.length === 0) {
return res.status(400).json({ success: false, message: 'applicationIds array is required' });
}
const applications = await Application.findAll({
where: { id: { [Op.in]: applicationIds } }
});
let sent = 0;
for (const app of applications) {
const lr = await db.LoiRequest.findOne({
where: { applicationId: app.id, status: 'Approved' }
});
if (!lr) continue;
const latestGen = await db.LoiDocumentGenerated.findOne({
where: { requestId: lr.id },
order: [['generatedAt', 'DESC']]
});
if (!latestGen) continue;
const ackCount = await db.LoiAcknowledgement.count({ where: { loiDocId: latestGen.id } });
if (ackCount > 0) continue;
await NotificationService.sendLoiAcknowledgementReminder(app.email, app.phone, app.applicantName, {
applicationId: app.applicationId,
dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/prospect-login`
});
sent++;
}
res.json({ success: true, message: `LOI acknowledgement reminders sent: ${sent} of ${applications.length} selected` });
} catch (error) {
console.error('Send bulk LOI ack reminders error:', error);
res.status(500).json({ success: false, message: 'Error sending LOI acknowledgement reminders' });
}
};
export const rejectOnboardingDocument = async (req: AuthRequest, res: Response) => {
try {
const allowed: string[] = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
if (!req.user?.roleCode || !allowed.includes(req.user.roleCode)) {
return res.status(403).json({ success: false, message: 'Forbidden: insufficient role to reject documents' });
}
const { id, documentId } = req.params;
const { rejectionReason, dueDate } = req.body as { rejectionReason?: string; dueDate?: string };
if (!rejectionReason || String(rejectionReason).trim().length === 0) {
return res.status(400).json({ success: false, message: 'rejectionReason 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' });
}
const doc = await OnboardingDocument.findOne({
where: { id: documentId, applicationId: application.id }
});
if (!doc) {
return res.status(404).json({ success: false, message: 'Document not found' });
}
await doc.update({ status: 'rejected' });
if (application.email) {
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
await NotificationService.notify(null, application.email, {
title: `Document re-upload required — ${application.applicationId}`,
message: `A document was not accepted: ${doc.documentType}.`,
channels: application.phone ? ['email', 'whatsapp'] : ['email'],
templateCode: 'DOCUMENT_REJECTED_RESUBMIT',
placeholders: {
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
documentName: doc.documentType,
rejectionReason: String(rejectionReason).trim(),
dueDate: dueDate || formatDueDateDaysFromNow(7),
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'Re-upload document',
phone: application.phone || ''
}
}).catch((e: any) => console.error('[rejectOnboardingDocument] email failed:', e));
}
await safeAuditLogCreate({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.DOCUMENT_REJECTED,
entityType: 'application',
entityId: application.id,
newData: {
documentId: doc.id,
documentRejected: true,
reason: rejectionReason,
context: pickApplicationAuditContext(application)
}
});
res.json({ success: true, message: 'Document marked as rejected and applicant notified' });
} catch (error) {
console.error('Reject onboarding document error:', error);
res.status(500).json({ success: false, message: 'Error rejecting document' });
}
};

View File

@ -6,7 +6,8 @@ import {
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders,
sendBulkDocumentReminders, sendBulkLoiAckReminders, rejectOnboardingDocument
} from './onboarding.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
@ -30,10 +31,13 @@ router.get('/document-configs/metadata', getDocumentConfigMetadata);
router.get('/document-configs', getDocumentConfigs);
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
router.post('/applications/reminders', sendBulkReminders);
router.post('/applications/document-reminders', sendBulkDocumentReminders);
router.post('/applications/loi-ack-reminders', sendBulkLoiAckReminders);
router.get('/applications/:id', checkRevocation as any, getApplicationById);
router.put('/applications/:id', checkRevocation as any, updateApplication);
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
router.post('/applications/:id/documents', uploadSingle, checkRevocation as any, uploadDocuments);
router.post('/applications/:id/documents/:documentId/reject', checkRevocation as any, rejectOnboardingDocument);
router.get('/applications/:id/documents', checkRevocation as any, getApplicationDocuments);
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
router.post('/applications/:id/convert-to-opportunity', checkRevocation as any, convertToOpportunity);

View File

@ -303,6 +303,76 @@ const seedTemplates = async () => {
subject: 'Update on Your Request — {{requestId}}',
fileName: 'workflow_status_update_dealer.html',
placeholders: ['dealerName', 'requestId', 'targetStage', 'remarks', 'link', 'ctaLabel']
},
{
templateCode: 'PROSPECT_DOCUMENT_REQUEST',
description: 'Post-shortlist: request KYC and business documents from the applicant',
subject: 'Documents Required — Royal Enfield Dealership Application {{applicationId}}',
fileName: 'prospect_document_request.html',
placeholders: ['applicantName', 'location', 'applicationId', 'dueDate', 'documentList', 'link', 'ctaLabel']
},
{
templateCode: 'STATUTORY_DOCUMENT_REQUEST',
description: 'After LOI: request statutory and compliance documents for authorisation',
subject: 'Statutory Documents Required — {{applicationId}}',
fileName: 'statutory_document_request.html',
placeholders: ['applicantName', 'applicationId', 'dueDate', 'statutoryList', 'link', 'ctaLabel']
},
{
templateCode: 'DOCUMENT_SUBMISSION_REMINDER',
description: 'Reminder for pending document uploads (manual / bulk send)',
subject: 'Reminder: Pending documents for {{applicationId}}',
fileName: 'document_submission_reminder.html',
placeholders: ['applicantName', 'applicationId', 'pendingDocuments', 'dueDate', 'link', 'ctaLabel']
},
{
templateCode: 'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
description: 'Acknowledgement when applicant uploads a document',
subject: 'Documents received — {{applicationId}}',
fileName: 'document_received_acknowledgement.html',
placeholders: ['applicantName', 'applicationId', 'documentCategory', 'documentName', 'receivedOn', 'link', 'ctaLabel']
},
{
templateCode: 'DOCUMENT_REJECTED_RESUBMIT',
description: 'When DD-Admin / Legal rejects an onboarding document',
subject: 'Action Required: Re-submit documents for {{applicationId}}',
fileName: 'document_rejected_resubmit.html',
placeholders: ['applicantName', 'applicationId', 'documentName', 'rejectionReason', 'dueDate', 'link', 'ctaLabel']
},
{
templateCode: 'FDD_DOCUMENT_REQUEST',
description: 'Applicant checklist when FDD agency is assigned',
subject: 'FDD Documents Required — {{applicationId}}',
fileName: 'fdd_document_request.html',
placeholders: ['applicantName', 'applicationId', 'fddPartnerName', 'dueDate', 'documentChecklist', 'link', 'ctaLabel']
},
{
templateCode: 'LOI_ACKNOWLEDGEMENT_REQUEST',
description: 'Chase applicant to acknowledge LOI on the portal',
subject: 'Action Required: Acknowledge your Letter of Intent — {{applicationId}}',
fileName: 'loi_acknowledgement_request.html',
placeholders: ['applicantName', 'applicationId', 'dueDate', 'link', 'ctaLabel']
},
{
templateCode: 'SECURITY_DEPOSIT_REQUEST',
description: 'After LOI acknowledgement: security deposit payment instructions',
subject: 'Security Deposit Payment Required — {{applicationId}}',
fileName: 'security_deposit_request.html',
placeholders: ['applicantName', 'applicationId', 'amount', 'dueDate', 'bankDetails', 'link', 'ctaLabel']
},
{
templateCode: 'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
description: 'After LOA: request e-signature on dealership agreement',
subject: 'Dealership Agreement ready for signature — {{applicationId}}',
fileName: 'dealership_agreement_signature_request.html',
placeholders: ['applicantName', 'applicationId', 'dealerCode', 'dueDate', 'link', 'ctaLabel']
},
{
templateCode: 'ARCHITECTURAL_PLAN_REQUEST',
description: 'When architecture lead is assigned: site inputs from applicant',
subject: 'Architectural Plan & Site Inputs Required — {{applicationId}}',
fileName: 'architectural_plan_request.html',
placeholders: ['applicantName', 'applicationId', 'architectName', 'dueDate', 'inputsList', 'link', 'ctaLabel']
}
];

View File

@ -162,4 +162,63 @@ export class NotificationService {
}
});
}
/**
* Reminder for pending onboarding document uploads (bulk or scheduled send).
*/
static async sendDocumentSubmissionReminder(
email: string,
phone: string | null | undefined,
applicantName: string,
options: { applicationId: string; pendingDocuments?: string; dueDate?: string; link?: string }
) {
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
const channels: ('email' | 'whatsapp')[] = ['email'];
if (phone) channels.push('whatsapp');
await this.notify(null, email, {
title: `Reminder: Pending documents — ${options.applicationId}`,
message: `Hi ${applicantName}, please upload pending documents for ${options.applicationId}.`,
channels,
templateCode: 'DOCUMENT_SUBMISSION_REMINDER',
placeholders: {
applicantName,
applicationId: options.applicationId,
pendingDocuments:
options.pendingDocuments ||
'Please sign in to the Dealer Portal to view your personalised document checklist.',
dueDate: options.dueDate || 'within seven calendar days',
link: options.link || `${base}/applications`,
ctaLabel: 'Upload documents',
phone: phone || ''
}
});
}
/**
* Chase applicant to acknowledge issued LOI (bulk send from DD-Admin).
*/
static async sendLoiAcknowledgementReminder(
email: string,
phone: string | null | undefined,
applicantName: string,
options: { applicationId: string; link?: string; dueDate?: string }
) {
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
const channels: ('email' | 'whatsapp')[] = ['email'];
if (phone) channels.push('whatsapp');
await this.notify(null, email, {
title: `Acknowledge your LOI — ${options.applicationId}`,
message: `Hi ${applicantName}, your Letter of Intent is awaiting acknowledgement on the portal.`,
channels,
templateCode: 'LOI_ACKNOWLEDGEMENT_REQUEST',
placeholders: {
applicantName,
applicationId: options.applicationId,
dueDate: options.dueDate || 'within seven calendar days',
link: options.link || `${base}/prospect-login`,
ctaLabel: 'Acknowledge LOI',
phone: phone || ''
}
});
}
}