smtp implemented and sla tracker enhancement done

This commit is contained in:
Laxman 2026-05-20 20:25:06 +05:30
parent 9c6c585073
commit c9c03f8761
37 changed files with 3372 additions and 1010 deletions

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,3 +43,20 @@ If `sla_tracking.metadata` (or `entityType` / `entityId`) is missing on an older
```bash
npx tsx scripts/migrate-sla-tracking-schema.ts
```
### SLA notification dispatch log (idempotency + audit)
Table `sla_notification_dispatches` records **one row per threshold per active track** (pre-breach reminder, breach, escalation L1L3, repeat overdue window). Unique on `(trackingId, thresholdKey)` prevents duplicate emails even if the worker runs every minute.
```bash
npx tsx scripts/migrate-sla-notification-dispatches.ts
```
| `dispatchType` | `thresholdKey` example | Sends |
|----------------|------------------------|--------|
| `pre_breach_reminder` | `reminder:<uuid>` | Once per T1d / T4h config |
| `breach` | `breach` | Once when TAT exceeded |
| `escalation` | `escalation:L1` | Once per level |
| `repeat_overdue` | `repeat_overdue:<bucket>` | Daily (or fast-mode window) |
`sla_tracking.metadata` is still used for **pause/resume** timers only; legacy `reminder_sent_*` flags are honored until migrated.

683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -99,10 +99,12 @@ export function normalizeCtaPlaceholders(replacements: Record<string, string>):
'';
const ctaLabel = replacements.ctaLabel || 'View details';
const recipientName = (replacements.recipientName || '').trim() || 'Team Member';
return {
...replacements,
ctaUrl,
ctaLabel
ctaLabel,
recipientName
};
}

View File

@ -336,6 +336,8 @@ export async function notifyStakeholdersOnTransition(
const isWithdrawn = /\b(withdraw)/i.test(targetStage) || /\b(withdraw)/i.test(metadata.action);
const isCompleted = /\b(complete|settled|terminated|fnf)/i.test(targetStage);
const isTerminalEvent = isRejected || isRevoked || isWithdrawn || isCompleted;
// Interview scheduling already sends INTERVIEW_SCHEDULED_PANELIST with full details.
const isInterviewPendingStage = /^Level \d Interview Pending$/i.test(targetStage);
for (const p of participants) {
const u = (p as any).user;
@ -360,18 +362,25 @@ export async function notifyStakeholdersOnTransition(
if (isActingUser && !isNextActor) continue;
if (isNextActor) {
if (isInterviewPendingStage) {
// Avoid duplicate mail: scheduleInterview already notifies panelists.
continue;
}
// ── Next Approver: Action Required — WhatsApp + Email + In-App ──
// SRS §6.14.3: critical workflow actions (assignment, scheduling) trigger via email & WhatsApp
const recipientName = (u.fullName || '').trim() || 'Team Member';
await NotificationService.notify(u.id, u.email, {
title: `Action Required: ${metadata.code}`,
message: `${metadata.code} has reached "${targetStage}" and requires your review.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
recipientName,
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage,
ctaLabel: 'View application',
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] next-actor:', e));
@ -379,17 +388,20 @@ export async function notifyStakeholdersOnTransition(
} else if (isSendBack && isASM) {
// ── Send Back → notify ASM via WhatsApp + Email + In-App ──
// SRS §6.14.3: pending user actions trigger email & WhatsApp
const recipientName = (u.fullName || '').trim() || 'Team Member';
await NotificationService.notify(u.id, u.email, {
title: `Case Returned for Clarification: ${metadata.code}`,
message: `${metadata.code} has been sent back. Please review remarks and resubmit.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
recipientName,
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage,
remarks: metadata.remarks || 'No remarks provided',
ctaLabel: 'View application',
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] send-back-asm:', e));

View File

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

View File

@ -76,6 +76,12 @@ export default (sequelize: Sequelize) => {
(SLATracking as any).associate = (models: any) => {
SLATracking.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' });
SLATracking.hasMany(models.SLABreach, { foreignKey: 'trackingId', as: 'breaches' });
if (models.SLANotificationDispatch) {
SLATracking.hasMany(models.SLANotificationDispatch, {
foreignKey: 'trackingId',
as: 'notificationDispatches'
});
}
};
return SLATracking;

View File

@ -94,6 +94,7 @@ import createSLABreach from './compliance/SLABreach.js';
import createSLAEscalationConfig from './compliance/SLAEscalationConfig.js';
import createSLAReminder from './compliance/SLAReminder.js';
import createSLATracking from './compliance/SLATracking.js';
import createSLANotificationDispatch from './compliance/SLANotificationDispatch.js';
import createWorkflowStageConfig from './compliance/WorkflowStageConfig.js';
import createStageApprovalAction from './compliance/StageApprovalAction.js';
import createStageApprovalPolicy from './compliance/StageApprovalPolicy.js';
@ -227,6 +228,7 @@ db.PushSubscription = createPushSubscription(sequelize);
// Batch 8: SLA & TAT Tracking
db.SLATracking = createSLATracking(sequelize);
db.SLABreach = createSLABreach(sequelize);
db.SLANotificationDispatch = createSLANotificationDispatch(sequelize);
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
db.StageApprovalAction = createStageApprovalAction(sequelize);
db.SystemConfiguration = createSystemConfiguration(sequelize);

View File

@ -2,7 +2,7 @@
<div class="section">
<h2>Action Required: {{requestId}}</h2>
<p>Hi,</p>
<p>Hi {{recipientName}},</p>
<p>
The request <strong>{{requestId}}</strong>
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}

View File

@ -583,9 +583,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
type,
scheduledAt: formatIST(scheduledDateObj),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: applicantPhone,
ctaLabel: 'View Schedule'
ctaLabel: 'View application'
}
}).catch(err => console.error('Failed to notify applicant via WhatsApp/Email:', err))
);
@ -710,9 +710,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
type,
scheduledAt: formatIST(scheduledDateObj),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: pPhone || '',
ctaLabel: 'Open Assessment'
ctaLabel: 'View application'
}
});
}
@ -813,7 +813,7 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
type,
scheduledAt: formatIST(interview.scheduleDate),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: application.mobileNumber || '',
ctaLabel: 'View Schedule'
}
@ -840,7 +840,7 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
type,
scheduledAt: formatIST(interview.scheduleDate),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: panelist.mobileNumber || '',
ctaLabel: 'Open Assessment'
}

View File

@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import { Response } from 'express';
import db from '../../database/models/index.js';
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant, DealerCode } = db;
@ -28,6 +29,29 @@ const STRUCTURE_TARGET_VALUES = new Set<string>(
);
const CLOSED_CONSTITUTIONAL_STATUSES = ['Completed', 'Closed', 'Rejected', 'Revoked'];
const CONSTITUTIONAL_DOC_REVIEW_ROLES = new Set<string>([
ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH,
ROLES.LEGAL_ADMIN,
ROLES.DD_ADMIN,
ROLES.SUPER_ADMIN
]);
const ensureDocumentIds = (documents: any[]): any[] =>
(Array.isArray(documents) ? documents : []).map((doc) =>
doc?.id ? doc : { ...doc, id: randomUUID() }
);
const findConstitutionalDocumentIndex = (documents: any[], documentId: string): number => {
if (!Array.isArray(documents)) return -1;
let idx = documents.findIndex((d) => d?.id === documentId);
if (idx >= 0) return idx;
const asNum = Number(documentId);
if (!Number.isNaN(asNum) && asNum >= 0 && asNum < documents.length) return asNum;
return documents.findIndex((d) => String(d?.docNumber) === documentId);
};
const resolveConstitutionalUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional');
return resolvedId;
@ -752,12 +776,14 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ success: false, message: 'Request not found' });
}
const normalizedDocs = ensureDocumentIds(documents);
await request.update({
documents: documents,
documents: normalizedDocs,
updatedAt: new Date()
});
const docCount = Array.isArray(documents) ? documents.length : 0;
const docCount = normalizedDocs.length;
await ConstitutionalAudit.create({
userId: req.user.id,
constitutionalChangeId: request.id,
@ -766,9 +792,116 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
details: { documentCount: docCount, stage: request.currentStage }
});
res.json({ success: true, message: 'Documents uploaded successfully' });
res.json({ success: true, message: 'Documents uploaded successfully', documents: normalizedDocs });
} catch (error) {
console.error('Upload documents error:', error);
res.status(500).json({ success: false, message: 'Error uploading documents' });
}
};
const applyConstitutionalDocumentDecision = async (
req: AuthRequest,
res: Response,
targetStatus: 'Verified' | 'Rejected'
) => {
try {
const { id, documentId } = req.params;
const { remarks } = req.body || {};
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const roleCode = req.user.roleCode || req.user.role || '';
if (!CONSTITUTIONAL_DOC_REVIEW_ROLES.has(roleCode)) {
return res.status(403).json({ success: false, message: 'Forbidden: insufficient role to review documents' });
}
if (targetStatus === 'Rejected' && !String(remarks || '').trim()) {
return res.status(400).json({ success: false, message: 'Remarks are required when rejecting a document' });
}
const resolvedId = await resolveConstitutionalUuid(String(id));
const request = await ConstitutionalChange.findOne({ where: { id: resolvedId } });
if (!request) {
return res.status(404).json({ success: false, message: 'Request not found' });
}
let currentDocuments = ensureDocumentIds(request.documents || []);
const documentIndex = findConstitutionalDocumentIndex(currentDocuments, String(documentId));
if (documentIndex === -1) {
return res.status(404).json({ success: false, message: 'Document not found' });
}
const docName =
currentDocuments[documentIndex].name ||
currentDocuments[documentIndex].fileName ||
`Document ${currentDocuments[documentIndex].docNumber ?? documentIndex}`;
currentDocuments[documentIndex] = {
...currentDocuments[documentIndex],
status: targetStatus,
verifiedOn: new Date().toISOString(),
verifiedBy: req.user.fullName || req.user.name || 'System',
...(targetStatus === 'Rejected'
? {
rejectedOn: new Date().toISOString(),
rejectedBy: req.user.fullName || req.user.name || 'System',
rejectionReason: String(remarks).trim(),
rejectionRemarks: String(remarks).trim()
}
: {})
};
const actionText = targetStatus === 'Verified' ? 'Document Verified' : 'Document Rejected';
const updatedTimeline = [
...(request.timeline || []),
{
stage: request.currentStage,
timestamp: new Date(),
user: req.user.fullName || req.user.name || 'System',
action: actionText,
remarks:
targetStatus === 'Verified'
? `Verified document: ${docName}`
: `Rejected document: ${docName}. ${String(remarks).trim()}`
}
];
await request.update({
documents: currentDocuments,
timeline: updatedTimeline,
updatedAt: new Date()
});
await ConstitutionalAudit.create({
userId: req.user.id,
constitutionalChangeId: request.id,
action:
targetStatus === 'Verified' ? AUDIT_ACTIONS.DOCUMENT_VERIFIED : AUDIT_ACTIONS.DOCUMENT_REJECTED,
remarks:
targetStatus === 'Verified'
? `Verified: ${docName}`
: `Rejected: ${docName}. ${String(remarks).trim()}`,
details: {
stage: request.currentStage,
documentId: currentDocuments[documentIndex].id,
documentName: docName,
status: targetStatus
}
});
return res.json({
success: true,
message:
targetStatus === 'Verified' ? 'Document verified successfully' : 'Document rejected successfully',
document: currentDocuments[documentIndex]
});
} catch (error) {
console.error('Constitutional document decision error:', error);
return res.status(500).json({ success: false, message: 'Error processing document decision' });
}
};
export const verifyDocument = async (req: AuthRequest, res: Response) =>
applyConstitutionalDocumentDecision(req, res, 'Verified');
export const rejectDocument = async (req: AuthRequest, res: Response) =>
applyConstitutionalDocumentDecision(req, res, 'Rejected');

View File

@ -11,5 +11,7 @@ router.get('/', authenticate as any, constitutionalController.getRequests);
router.get('/:id', authenticate as any, constitutionalController.getRequestById);
router.post('/:id/action', authenticate as any, constitutionalController.takeAction);
router.post('/:id/documents', authenticate as any, constitutionalController.uploadDocuments);
router.post('/:id/documents/:documentId/verify', authenticate as any, constitutionalController.verifyDocument);
router.post('/:id/documents/:documentId/reject', authenticate as any, constitutionalController.rejectDocument);
export default router;

View File

@ -38,7 +38,7 @@ const seedInterviewTemplates = async () => {
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">Open Assessment Dashboard</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">View Application</a>
</div>
<p>Please review the applicant's profile before the session.</p>
<p>Regards,<br>System Administrator</p>
@ -78,7 +78,7 @@ const seedInterviewTemplates = async () => {
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">Open Assessment Dashboard</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">View Application</a>
</div>
<p>Please update your calendar accordingly.</p>
<p>Regards,<br>System Administrator</p>
@ -120,13 +120,15 @@ const seedInterviewTemplates = async () => {
subject: 'Action Required: {{requestId}} — {{targetStage}}',
body: `
<html><body>
<h2>Dear Stakeholder,</h2>
<p>Application <strong>{{requestId}}</strong> (Dealer: {{dealerName}}) has reached the <strong>{{targetStage}}</strong> stage and requires your action/review.</p>
<p><a href="{{link}}">Review and Take Action</a></p>
<h2>Hi {{recipientName}},</h2>
<p>Application <strong>{{requestId}}</strong> (Applicant: {{dealerName}}) has reached the <strong>{{targetStage}}</strong> stage and requires your action/review.</p>
<div style="margin: 20px 0;">
<a href="{{link}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Application</a>
</div>
<p>Regards,<br>Royal Enfield Workflow System</p>
</body></html>
`,
placeholders: ['requestId', 'dealerName', 'targetStage', 'link']
placeholders: ['recipientName', 'requestId', 'dealerName', 'targetStage', 'link', 'ctaLabel']
},
{
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',

View File

@ -28,7 +28,7 @@ const seedMissingTemplates = async () => {
<h2 style="color:#fff;margin:0;font-size:20px;">Action Required</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Hi,</p>
<p>Hi {{recipientName}},</p>
<p>
The request <strong>{{requestId}}</strong>
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}
@ -48,7 +48,7 @@ const seedMissingTemplates = async () => {
<p style="color:#888;font-size:12px;">This is an automated notification. Do not reply to this email.</p>
</div>
</div>`,
placeholders: ['requestId', 'dealerName', 'targetStage', 'link', 'remarks', 'ctaLabel']
placeholders: ['recipientName', 'requestId', 'dealerName', 'targetStage', 'link', 'remarks', 'ctaLabel']
},
// ── 2. Workflow Status Update — Dealer ─────────────────────────────────

View File

@ -196,6 +196,14 @@ const startServer = async () => {
await db.sequelize.authenticate();
logger.info('Database connection established successfully');
const { initializeSmtpConfig, isSmtpEnabled } = await import('./services/smtpConfig.service.js');
const smtpConfig = await initializeSmtpConfig();
const { resetEmailTransporter } = await import('./common/utils/email.service.js');
resetEmailTransporter();
logger.info(
`SMTP: ${isSmtpEnabled() ? 'enabled' : 'disabled'} (source: ${smtpConfig.source}, host: ${smtpConfig.host})`
);
// Sync database (in development only)
if (process.env.NODE_ENV === 'development') {
// Temporarily disabling sync to resolve constraint errors during E2E testing

View File

@ -30,12 +30,8 @@ export class ConstitutionalWorkflowService {
.map((v: string) => this.normalizeDocLabel(v));
}
static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] {
const checklist = this.getDocumentChecklist(targetConstitution);
const uploaded = this.extractUploadedDocumentLabels(documents);
const hasToken = (aliases: string[]) => aliases.some((a) => uploaded.some((u) => u.includes(a)));
const aliasesByRequirement: Record<string, string[]> = {
private static aliasesByRequirement(): Record<string, string[]> {
return {
[DOCUMENT_TYPES.GST_CERTIFICATE]: ['gst'],
[DOCUMENT_TYPES.PAN_CARD]: ['pan', 'firm pan'],
[DOCUMENT_TYPES.AADHAAR]: ['aadhaar', 'kyc'],
@ -49,11 +45,53 @@ export class ConstitutionalWorkflowService {
[DOCUMENT_TYPES.AOA]: ['aoa'],
'Business Purchase Agreement (BPA)': ['business purchase agreement', 'bpa']
};
}
return checklist.filter((required) => {
const tokens = aliasesByRequirement[required] || [this.normalizeDocLabel(required)];
return !hasToken(tokens.map((t) => this.normalizeDocLabel(t)));
});
private static docMatchesRequirement(doc: any, required: string): boolean {
const aliases = this.aliasesByRequirement();
const tokens = aliases[required] || [this.normalizeDocLabel(required)];
const labels = [
doc?.documentType,
doc?.type,
doc?.name,
doc?.title,
doc?.label,
doc?.fileName
]
.filter((v: any) => typeof v === 'string' && v.trim().length > 0)
.map((v: string) => this.normalizeDocLabel(v));
return tokens.some((token) => labels.some((label) => label.includes(token) || token.includes(label)));
}
/** Readiness aligned with relocation: uploaded vs verified vs rejected. */
static getDocumentReadiness(targetConstitution: string, documents: any[]) {
const checklist = this.getDocumentChecklist(targetConstitution);
const docs = Array.isArray(documents) ? documents : [];
const missingUploads: string[] = [];
const pendingVerification: string[] = [];
for (const required of checklist) {
const matched = docs.filter((d) => this.docMatchesRequirement(d, required));
if (!matched.length) {
missingUploads.push(required);
continue;
}
const hasVerified = matched.some((d) => String(d.status || '').toLowerCase() === 'verified');
if (!hasVerified) {
pendingVerification.push(required);
}
}
return {
totalRequired: checklist.length,
missingUploads,
pendingVerification
};
}
static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] {
const readiness = this.getDocumentReadiness(targetConstitution, documents);
return [...readiness.missingUploads, ...readiness.pendingVerification];
}
/**

View File

@ -7,6 +7,12 @@ import { NotificationService } from './NotificationService.js';
import { resolveRecipientsForRoles } from './slaGeographyResolver.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import type { WorkflowActivityRequestType } from '../common/utils/workflowWorknote.js';
import {
claimSlaNotificationDispatch,
SlaDispatchKeys,
updateSlaDispatchRecipientCount,
wasSlaNotificationDispatched
} from './slaNotificationDispatch.service.js';
export type SlaTrackRef = {
entityType: string;
@ -16,6 +22,31 @@ export type SlaTrackRef = {
};
export class SLAService {
/** Stable idempotency key per reminder threshold (once per track). */
private static reminderSentKey(reminder: { id?: string; timeValue: number; timeUnit: string }): string {
if (reminder.id) return `reminder_sent_${reminder.id}`;
return `reminder_sent_${reminder.timeValue}_${reminder.timeUnit}`;
}
private static escalationSentKey(level: number): string {
return `esc_sent_L${level}`;
}
/** Sequelize JSONB must get a new object reference or updates may not persist. */
private static async persistTrackMetadata(
track: { metadata?: Record<string, unknown> | null; update: (payload: object) => Promise<unknown> },
patch: Record<string, unknown>
): Promise<Record<string, unknown>> {
const current =
track.metadata && typeof track.metadata === 'object' && !Array.isArray(track.metadata)
? { ...track.metadata }
: {};
const next = { ...current, ...patch };
await track.update({ metadata: next });
track.metadata = next;
return next;
}
private static async findConfigForStage(stageName: string) {
const names = slaConfigLookupNames(stageName);
return SLAConfiguration.findOne({
@ -192,22 +223,37 @@ export class SLAService {
}
private static async processRepeatOverdueReminder(track: any, now: Date) {
const meta = { ...(track.metadata || {}) };
// Fast mode compresses TAT for breach/escalation testing — do not spam repeat emails every minute
if (
process.env.DEBUG_SLA_FAST_MODE === 'true' &&
process.env.DEBUG_SLA_REPEAT_IN_FAST_MODE !== 'true'
) {
return;
}
let repeatMs = 24 * 60 * 60 * 1000;
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
repeatMs = 60 * 1000;
}
const last = meta.lastOverdueReminderAt ? new Date(String(meta.lastOverdueReminderAt)).getTime() : 0;
if (now.getTime() - last < repeatMs) return;
const thresholdKey = SlaDispatchKeys.repeatOverdueBucket(now.getTime(), repeatMs);
const claimed = await claimSlaNotificationDispatch({
trackingId: track.id,
thresholdKey,
dispatchType: 'repeat_overdue',
templateCode: 'SLA_REMINDER',
stageName: track.stageName
});
if (!claimed) return;
const caseLabel = await this.getCaseLabel(track);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
const recipientCount = await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Still Overdue: ${track.stageName}`,
message: `Case ${caseLabel} remains overdue for ${track.stageName}. Please close or escalate.`
message: `Case ${caseLabel} remains overdue for ${track.stageName}. Please close or escalate.`,
slaDispatchKey: `${track.id}:${thresholdKey}`
});
await updateSlaDispatchRecipientCount(track.id, thresholdKey, recipientCount);
await this.logSlaActivity(track, `[SLA] Repeat overdue reminder: ${track.stageName} still open.`);
meta.lastOverdueReminderAt = now.toISOString();
await track.update({ metadata: meta });
}
private static async processReminders(
@ -222,36 +268,66 @@ export class SLAService {
const caseLabel = await this.getCaseLabel(track);
for (const reminder of config.reminders || []) {
if (reminder.isEnabled === false) continue;
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
if (msRemaining <= reminderMs) {
const metadata = track.metadata || {};
const reminderKey = `reminder_sent_${reminder.id}`;
if (metadata[reminderKey]) continue;
const thresholdKey = SlaDispatchKeys.preBreachReminder(reminder);
const legacyKey = this.reminderSentKey(reminder);
if (await wasSlaNotificationDispatched(track.id, thresholdKey, track.metadata, legacyKey)) {
continue;
}
const claimed = await claimSlaNotificationDispatch({
trackingId: track.id,
thresholdKey,
dispatchType: 'pre_breach_reminder',
templateCode: 'SLA_REMINDER',
stageName: track.stageName,
reminderId: reminder.id ?? null
});
if (!claimed) continue;
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
console.log(
`[SLA Service] PRE_BREACH reminder (once): ${track.stageName}${timeStr} remaining [${thresholdKey}]`
);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
const recipientCount = await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Reminder: ${track.stageName}`,
message: `Case ${caseLabel} is approaching its SLA deadline for ${track.stageName}.`
message: `Case ${caseLabel} is approaching its SLA deadline for ${track.stageName}.`,
slaDispatchKey: `${track.id}:${thresholdKey}`
});
await updateSlaDispatchRecipientCount(track.id, thresholdKey, recipientCount);
await this.logSlaActivity(
track,
`[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`
);
metadata[reminderKey] = true;
await track.update({ metadata });
}
}
}
private static async triggerBreach(track: any, now: Date) {
const thresholdKey = SlaDispatchKeys.breach();
if (await wasSlaNotificationDispatched(track.id, thresholdKey, track.metadata, 'breach_notified')) {
return;
}
const claimed = await claimSlaNotificationDispatch({
trackingId: track.id,
thresholdKey,
dispatchType: 'breach',
templateCode: 'SLA_BREACH',
stageName: track.stageName
});
if (!claimed) return;
const caseLabel = await this.getCaseLabel(track);
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${caseLabel}`);
console.log(`[SLA Service] BREACHED (once): ${track.stageName} ${caseLabel}`);
await track.update({ isBreached: true });
track.isBreached = true;
await SLABreach.create({
trackingId: track.id,
@ -259,10 +335,12 @@ export class SLAService {
status: 'Open'
});
await this.notifyStakeholder(track, 'SLA_BREACH', {
const recipientCount = await this.notifyStakeholder(track, 'SLA_BREACH', {
title: `SLA BREACHED: ${track.stageName}`,
message: `Case ${caseLabel} has breached its SLA for ${track.stageName}.`
message: `Case ${caseLabel} has breached its SLA for ${track.stageName}.`,
slaDispatchKey: `${track.id}:${thresholdKey}`
});
await updateSlaDispatchRecipientCount(track.id, thresholdKey, recipientCount);
await this.logSlaActivity(
track,
@ -278,14 +356,27 @@ export class SLAService {
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
if (msSinceBreach >= escMs) {
const metadata = track.metadata || {};
const escKey = `esc_sent_L${esc.level}`;
if (metadata[escKey]) continue;
const thresholdKey = SlaDispatchKeys.escalation(esc.level);
const legacyKey = this.escalationSentKey(esc.level);
if (await wasSlaNotificationDispatched(track.id, thresholdKey, track.metadata, legacyKey)) {
continue;
}
const claimed = await claimSlaNotificationDispatch({
trackingId: track.id,
thresholdKey,
dispatchType: 'escalation',
templateCode: 'SLA_ESCALATION',
stageName: track.stageName,
escalationLevel: esc.level
});
if (!claimed) continue;
console.log(
`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'})`
`[SLA Service] ESCALATION (once) L${esc.level}: ${track.stageName} (Role: ${esc.notifyRole || 'N/A'}) [${thresholdKey}]`
);
let recipientCount = 0;
const recipientIds: string[] = [];
if (esc.notifyRole) {
recipientIds.push(
@ -301,6 +392,7 @@ export class SLAService {
}
if (recipientIds.length === 0 && esc.notifyEmail) {
recipientCount += 1;
await NotificationService.notify(null, esc.notifyEmail, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
@ -313,6 +405,11 @@ export class SLAService {
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: ''
},
metadata: {
entityType: track.entityType,
entityId: track.entityId,
slaDispatchKey: `${track.id}:${thresholdKey}`
}
});
}
@ -320,6 +417,7 @@ export class SLAService {
for (const recipientId of recipientIds) {
const user = await User.findByPk(recipientId);
if (!user?.email) continue;
recipientCount += 1;
const phone = user.mobileNumber || user.phone;
await NotificationService.notify(recipientId, user.email, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
@ -333,17 +431,21 @@ export class SLAService {
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: phone || ''
},
metadata: {
entityType: track.entityType,
entityId: track.entityId,
slaDispatchKey: `${track.id}:${thresholdKey}`
}
});
}
await updateSlaDispatchRecipientCount(track.id, thresholdKey, recipientCount);
await this.logSlaActivity(
track,
`[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyRole || esc.notifyEmail || 'supervisor'} for stage ${track.stageName}.`
);
metadata[escKey] = true;
await track.update({ metadata });
}
}
}
@ -416,10 +518,10 @@ export class SLAService {
private static async notifyStakeholder(
track: any,
template: string,
content: { title: string; message: string }
) {
content: { title: string; message: string; slaDispatchKey?: string }
): Promise<number> {
const config = await this.findConfigForStage(track.stageName);
if (!config?.ownerRole) return;
if (!config?.ownerRole) return 0;
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
const recipientIds = await resolveRecipientsForRoles(
@ -430,14 +532,16 @@ export class SLAService {
},
roles
);
if (recipientIds.length === 0) return;
if (recipientIds.length === 0) return 0;
const caseLabel = await this.getCaseLabel(track);
const link = this.linkForEntity(track.entityType, track.entityId);
let sent = 0;
for (const userId of recipientIds) {
const user = await User.findByPk(userId);
if (!user) continue;
if (!user?.email) continue;
sent += 1;
const phone = user.mobileNumber || user.phone || null;
await NotificationService.notify(userId, user.email, {
title: content.title,
@ -453,9 +557,11 @@ export class SLAService {
metadata: {
entityType: track.entityType,
entityId: track.entityId,
applicationId: track.applicationId
applicationId: track.applicationId,
slaDispatchKey: content.slaDispatchKey || `${track.id}:${template}`
}
});
}
return sent;
}
}

View File

@ -0,0 +1,426 @@
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import fs from 'fs';
import path from 'path';
import logger from '../common/utils/logger.js';
/** Never log raw passwords — show length only */
function maskSecretForLog(secretName: string, value: string | null): string {
if (value === null) return '(not found)';
if (!value) return '(empty)';
if (/PASSWORD|SECRET|TOKEN|PRIVATE_KEY/i.test(secretName)) {
return `*** (${value.length} characters)`;
}
return value;
}
type CredentialsMeta = {
projectId: string | null;
clientEmail: string | null;
keyPath: string;
};
/** SMTP-related secrets loaded from Google Secret Manager */
export const SMTP_SECRET_NAMES = [
'SMTP_HOST',
'SMTP_PORT',
'SMTP_USER',
'SMTP_PASSWORD',
'SMTP_SECURE',
'EMAIL_FROM',
] as const;
export type SmtpSecretName = (typeof SMTP_SECRET_NAMES)[number];
/**
* Default mapping: GSM secret name process.env key used by this app (.env.example uses EMAIL_*).
* Override via GCP_SECRET_MAP_FILE (JSON object).
*/
export const DEFAULT_SMTP_SECRET_ENV_MAP: Record<string, string> = {
SMTP_HOST: 'EMAIL_HOST',
SMTP_PORT: 'EMAIL_PORT',
SMTP_USER: 'EMAIL_USER',
SMTP_PASSWORD: 'EMAIL_PASSWORD',
SMTP_SECURE: 'EMAIL_SECURE',
EMAIL_FROM: 'EMAIL_FROM',
};
/**
* Google Secret Manager loads secrets into process.env (SMTP-focused helpers included).
*
* Env:
* - USE_GOOGLE_SECRET_MANAGER=true
* - GCP_KEY_FILE (service account JSON project_id is read from this file)
* - GCP_PROJECT_ID (optional override; otherwise uses project_id from credentials JSON)
* - GCP_SECRET_PREFIX (optional)
* - GCP_SECRET_MAP_FILE (optional JSON: secretName envVarName)
*/
class GoogleSecretManagerService {
private client: SecretManagerServiceClient | null = null;
private projectId = '';
private secretPrefix: string;
private secretMap: Record<string, string> = { ...DEFAULT_SMTP_SECRET_ENV_MAP };
private isInitialized = false;
private isLoaded = false;
constructor() {
this.secretPrefix = process.env.GCP_SECRET_PREFIX || '';
this.loadSecretMapFile();
}
private loadSecretMapFile(): void {
const mapFile = process.env.GCP_SECRET_MAP_FILE;
if (!mapFile) return;
try {
const mapFilePath = path.isAbsolute(mapFile) ? mapFile : path.resolve(process.cwd(), mapFile);
if (!fs.existsSync(mapFilePath)) {
logger.warn(`[Secret Manager] Secret mapping file not found: ${mapFilePath}`);
return;
}
const mapContent = fs.readFileSync(mapFilePath, 'utf8');
const customMap = JSON.parse(mapContent) as Record<string, string>;
this.secretMap = { ...this.secretMap, ...customMap };
logger.info(`[Secret Manager] Loaded secret mapping from ${mapFilePath}`);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`[Secret Manager] Failed to load secret mapping file: ${message}`);
}
}
isEnabled(): boolean {
return process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
}
private resolveCredentialsPath(envPath: string): string | null {
if (!envPath.trim()) return null;
const resolved = path.isAbsolute(envPath)
? envPath
: path.resolve(process.cwd(), envPath);
return fs.existsSync(resolved) ? resolved : null;
}
private resolveKeyFilePath(): string | null {
const keyFilePath = process.env.GCP_KEY_FILE || '';
if (!keyFilePath) return null;
const resolved = this.resolveCredentialsPath(keyFilePath);
if (resolved) return resolved;
logger.warn(
`[Secret Manager] Key file not found at: ${path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath)}`
);
return null;
}
private readCredentialsMeta(filePath: string): CredentialsMeta {
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(content) as { project_id?: string; client_email?: string };
return {
projectId: parsed.project_id?.trim() || null,
clientEmail: parsed.client_email?.trim() || null,
keyPath: filePath,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`[Secret Manager] Could not parse credentials file ${filePath}: ${message}`);
return { projectId: null, clientEmail: null, keyPath: filePath };
}
}
private readProjectIdFromCredentialsFile(filePath: string): string | null {
return this.readCredentialsMeta(filePath).projectId;
}
private logStartupContext(secretNames: readonly string[]): void {
const keyPath = this.resolveKeyFilePath();
const enabled = this.isEnabled();
logger.info('[Secret Manager] ─── Google Secret Manager ───');
logger.info(`[Secret Manager] Enabled: ${enabled}`);
logger.info(`[Secret Manager] USE_GOOGLE_SECRET_MANAGER=${process.env.USE_GOOGLE_SECRET_MANAGER ?? '(unset)'}`);
if (!enabled) {
logger.info('[Secret Manager] Skipping — set USE_GOOGLE_SECRET_MANAGER=true to load secrets');
return;
}
if (keyPath) {
const meta = this.readCredentialsMeta(keyPath);
logger.info(`[Secret Manager] Credentials file: ${keyPath}`);
if (meta.clientEmail) {
logger.info(`[Secret Manager] Service account: ${meta.clientEmail}`);
}
if (meta.projectId) {
logger.info(`[Secret Manager] project_id (from file): ${meta.projectId}`);
}
} else {
logger.warn(`[Secret Manager] GCP_KEY_FILE=${process.env.GCP_KEY_FILE ?? '(unset)'} — file not found`);
}
if (process.env.GCP_PROJECT_ID?.trim()) {
logger.info(`[Secret Manager] GCP_PROJECT_ID override: ${process.env.GCP_PROJECT_ID.trim()}`);
}
if (this.secretPrefix) {
logger.info(`[Secret Manager] Secret prefix: "${this.secretPrefix}-"`);
}
logger.info(`[Secret Manager] Secrets to load (${secretNames.length}):`);
for (const secretName of secretNames) {
const gsmName = this.secretPrefix ? `${this.secretPrefix}-${secretName}` : secretName;
const envVar = this.getEnvVarName(secretName);
logger.info(`[Secret Manager] • GSM "${gsmName}" → process.env.${envVar}`);
}
}
/**
* Resolve GCP project ID: env override project_id field in service account JSON.
*/
private resolveProjectId(): string {
const envOverride = process.env.GCP_PROJECT_ID?.trim();
if (envOverride) {
logger.debug(`[Secret Manager] Using GCP_PROJECT_ID from environment: ${envOverride}`);
return envOverride;
}
const keyPath =
this.resolveKeyFilePath() ||
this.resolveCredentialsPath(process.env.GOOGLE_APPLICATION_CREDENTIALS || '');
if (keyPath) {
const fromFile = this.readProjectIdFromCredentialsFile(keyPath);
if (fromFile) {
logger.info(`[Secret Manager] Project ID from credentials file: ${fromFile}`);
return fromFile;
}
logger.warn(`[Secret Manager] No project_id in credentials file: ${keyPath}`);
} else {
logger.warn('[Secret Manager] No credentials file found (set GCP_KEY_FILE)');
}
return '';
}
private ensureProjectId(): boolean {
if (!this.projectId) {
this.projectId = this.resolveProjectId();
}
return Boolean(this.projectId);
}
private async initializeClient(): Promise<void> {
if (this.client) return;
if (!this.ensureProjectId()) {
throw new Error(
'GCP project ID missing: set GCP_PROJECT_ID or use a credentials JSON with project_id'
);
}
const resolvedKeyPath = this.resolveKeyFilePath();
if (resolvedKeyPath) {
logger.info(`[Secret Manager] Authenticating with key file: ${resolvedKeyPath}`);
} else if (process.env.GCP_KEY_FILE) {
logger.warn('[Secret Manager] Key file missing — will try Application Default Credentials');
} else {
logger.warn('[Secret Manager] GCP_KEY_FILE not set — will try Application Default Credentials');
}
try {
this.client = new SecretManagerServiceClient({
projectId: this.projectId,
...(resolvedKeyPath ? { keyFilename: resolvedKeyPath } : {}),
});
logger.info(
`[Secret Manager] Client ready (project: ${this.projectId}, package: @google-cloud/secret-manager)`
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[Secret Manager] Failed to initialize client: ${message}`);
if (message.includes('Could not load the default credentials')) {
logger.error('[Secret Manager] Fix: set GCP_KEY_FILE to a valid service account JSON path');
}
throw error;
}
}
private async fetchSecretPayload(secretName: string): Promise<string | null> {
if (!this.client || !this.projectId) return null;
const fullSecretName = this.secretPrefix ? `${this.secretPrefix}-${secretName}` : secretName;
const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`;
const envVar = this.getEnvVarName(secretName);
logger.info(`[Secret Manager] Fetching "${fullSecretName}" (${name})`);
try {
const [version] = await this.client.accessSecretVersion({ name });
if (!version.payload?.data) {
logger.warn(`[Secret Manager] ✗ "${fullSecretName}" — empty payload`);
return null;
}
const raw = version.payload.data.toString().trim();
logger.info(
`[Secret Manager] ✓ "${fullSecretName}" → ${envVar} = ${maskSecretForLog(secretName, raw)}`
);
return raw;
} catch (error: unknown) {
const err = error as { code?: number | string; message?: string };
const isEmailSecret = /^(EMAIL_|SMTP_)/i.test(secretName);
if (err.code === 5 || err.code === 'NOT_FOUND' || err.message?.includes('not found')) {
const msg = `[Secret Manager] Secret not found: ${fullSecretName}`;
if (isEmailSecret) logger.info(msg);
else logger.debug(msg);
return null;
}
if (err.code === 7 || err.code === 'PERMISSION_DENIED' || err.message?.includes('Permission denied')) {
logger.warn(`[Secret Manager] Permission denied for secret: ${fullSecretName}`);
return null;
}
logger.warn(
`[Secret Manager] Failed to fetch '${fullSecretName}': ${err.message || 'unknown error'}`
);
return null;
}
}
private getEnvVarName(secretName: string): string {
return this.secretMap[secretName] || secretName.toUpperCase().replace(/-/g, '_');
}
/**
* Load secrets and merge into process.env (does not override existing non-empty env vars).
*/
async loadSecrets(secretNames: readonly string[]): Promise<Record<string, string>> {
const loaded: Record<string, string> = {};
const appliedToEnv: string[] = [];
const skippedLocal: string[] = [];
const notFound: string[] = [];
this.logStartupContext(secretNames);
if (!this.isEnabled()) {
return loaded;
}
if (!this.ensureProjectId()) {
logger.warn(
'[Secret Manager] Project ID not resolved — set GCP_KEY_FILE with project_id in JSON, or GCP_PROJECT_ID'
);
return loaded;
}
try {
await this.initializeClient();
if (!this.client) return loaded;
logger.info(`[Secret Manager] Starting fetch (project: ${this.projectId})...`);
for (const secretName of secretNames) {
const value = await this.fetchSecretPayload(secretName);
const envVar = this.getEnvVarName(secretName);
if (value === null) {
notFound.push(secretName);
continue;
}
loaded[envVar] = value;
const alreadySet = process.env[envVar] !== undefined && process.env[envVar] !== '';
if (alreadySet) {
skippedLocal.push(`${secretName}${envVar} (local .env wins)`);
logger.info(
`[Secret Manager] ⊘ Applied to memory only — ${envVar} already set in .env (${maskSecretForLog(secretName, process.env[envVar]!)})`
);
continue;
}
process.env[envVar] = value;
appliedToEnv.push(`${secretName}${envVar}`);
logger.info(`[Secret Manager] → Set process.env.${envVar}`);
}
this.isLoaded = true;
this.isInitialized = true;
logger.info('[Secret Manager] ─── Load summary ───');
logger.info(
`[Secret Manager] Fetched from GSM: ${Object.keys(loaded).length}/${secretNames.length}`
);
if (appliedToEnv.length > 0) {
logger.info(`[Secret Manager] Applied to process.env: ${appliedToEnv.join(', ')}`);
}
if (skippedLocal.length > 0) {
logger.info(`[Secret Manager] Skipped (local override): ${skippedLocal.join(', ')}`);
}
if (notFound.length > 0) {
logger.warn(`[Secret Manager] Not found in GSM: ${notFound.join(', ')}`);
logger.info(
`[Secret Manager] Create missing secrets, e.g.: gcloud secrets create SMTP_HOST --data-file=- --project=${this.projectId}`
);
}
logger.info('[Secret Manager] ─── Done ───');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
logger.error(`[Secret Manager] loadSecrets failed: ${message}`);
if (stack) logger.debug(stack);
logger.warn('[Secret Manager] Falling back to .env / existing environment variables');
}
return loaded;
}
/** Fetch only SMTP secrets from Secret Manager */
async loadSmtpSecrets(): Promise<Record<string, string>> {
return this.loadSecrets(SMTP_SECRET_NAMES);
}
async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> {
if (!this.isEnabled() || !this.ensureProjectId()) return null;
try {
if (!this.client) await this.initializeClient();
const value = await this.fetchSecretPayload(secretName);
if (value === null) return null;
const envVar = envVarName || this.getEnvVarName(secretName);
if (!process.env[envVar]) {
process.env[envVar] = value;
}
return value;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[Secret Manager] getSecretValue(${secretName}) failed: ${message}`);
return null;
}
}
isReady(): boolean {
return this.isInitialized;
}
hasLoaded(): boolean {
return this.isLoaded;
}
}
export const googleSecretManager = new GoogleSecretManagerService();
export async function initializeGoogleSecretManager(
secretNames?: readonly string[]
): Promise<void> {
await googleSecretManager.loadSecrets(secretNames ?? SMTP_SECRET_NAMES);
}
export async function initializeSmtpSecretsFromGoogle(): Promise<Record<string, string>> {
return googleSecretManager.loadSmtpSecrets();
}

View File

@ -0,0 +1,105 @@
import db from '../database/models/index.js';
import type { SlaNotificationDispatchType } from '../database/models/compliance/SLANotificationDispatch.js';
const { SLANotificationDispatch } = db;
export type ClaimSlaDispatchParams = {
trackingId: string;
thresholdKey: string;
dispatchType: SlaNotificationDispatchType;
templateCode?: string;
stageName?: string;
reminderId?: string | null;
escalationLevel?: number | null;
recipientCount?: number;
};
/** Build stable threshold keys used for idempotency */
export const SlaDispatchKeys = {
preBreachReminder(reminder: { id?: string; timeValue: number; timeUnit: string }) {
if (reminder.id) return `reminder:${reminder.id}`;
return `reminder:${reminder.timeValue}_${reminder.timeUnit}`;
},
breach: () => 'breach',
escalation(level: number) {
return `escalation:L${level}`;
},
repeatOverdueBucket(nowMs: number, repeatWindowMs: number) {
return `repeat_overdue:${Math.floor(nowMs / repeatWindowMs)}`;
}
};
/**
* Returns true if this threshold was already dispatched (DB or legacy metadata flag).
*/
export async function wasSlaNotificationDispatched(
trackingId: string,
thresholdKey: string,
legacyMetadata?: Record<string, unknown> | null,
legacyMetadataKey?: string
): Promise<boolean> {
const existing = await SLANotificationDispatch.findOne({
where: { trackingId, thresholdKey },
attributes: ['id']
});
if (existing) return true;
if (legacyMetadataKey && legacyMetadata?.[legacyMetadataKey]) {
return true;
}
return false;
}
/**
* Atomically claim a dispatch slot (unique per tracking + threshold).
* Returns true if this caller should send notifications; false if already sent.
*/
export async function claimSlaNotificationDispatch(
params: ClaimSlaDispatchParams
): Promise<boolean> {
try {
const [, created] = await SLANotificationDispatch.findOrCreate({
where: {
trackingId: params.trackingId,
thresholdKey: params.thresholdKey
},
defaults: {
trackingId: params.trackingId,
thresholdKey: params.thresholdKey,
dispatchType: params.dispatchType,
templateCode: params.templateCode ?? null,
stageName: params.stageName ?? null,
reminderId: params.reminderId ?? null,
escalationLevel: params.escalationLevel ?? null,
recipientCount: params.recipientCount ?? 0,
sentAt: new Date(),
status: 'sent'
}
});
return created;
} catch (error: unknown) {
const err = error as { name?: string; message?: string };
console.error(
`[SLA Dispatch] claim failed (${params.thresholdKey}): ${err.message || err.name}`
);
return false;
}
}
export async function updateSlaDispatchRecipientCount(
trackingId: string,
thresholdKey: string,
recipientCount: number
): Promise<void> {
await SLANotificationDispatch.update(
{ recipientCount },
{ where: { trackingId, thresholdKey } }
);
}
export async function listDispatchesForTracking(trackingId: string) {
return SLANotificationDispatch.findAll({
where: { trackingId },
order: [['sentAt', 'ASC']]
});
}

View File

@ -0,0 +1,204 @@
import logger from '../common/utils/logger.js';
import {
googleSecretManager,
initializeSmtpSecretsFromGoogle,
} from './googleSecretManager.service.js';
export interface SmtpConfig {
/** Whether outbound SMTP is allowed (ENABLE_SMTP=true) */
enabled: boolean;
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
from: string;
/** Where values were resolved from */
source: 'disabled' | 'env' | 'secret-manager' | 'mixed';
}
let cachedConfig: SmtpConfig | null = null;
let initialized = false;
function envFlagTrue(name: string, defaultWhenUnset = false): boolean {
const raw = process.env[name];
if (raw === undefined || raw === '') return defaultWhenUnset;
return raw === 'true' || raw === '1';
}
/** Toggle real SMTP usage (set false in local dev). */
export function isSmtpEnabled(): boolean {
return envFlagTrue('ENABLE_SMTP', false);
}
function readEnv(key: string): string | undefined {
const v = process.env[key];
return v !== undefined && v !== '' ? v : undefined;
}
/** Prefer EMAIL_* (.env.example), fall back to SMTP_* after GSM load */
function pickEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const v = readEnv(key);
if (v !== undefined) return v;
}
return undefined;
}
function parsePort(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) ? n : fallback;
}
function parseSecure(port: number, explicit?: string): boolean {
if (explicit !== undefined) {
return explicit === 'true' || explicit === '1';
}
return port === 465;
}
function buildConfigFromEnv(source: SmtpConfig['source']): SmtpConfig {
const enabled = isSmtpEnabled();
const host = pickEnv('EMAIL_HOST', 'SMTP_HOST') || 'smtp.gmail.com';
const port = parsePort(pickEnv('EMAIL_PORT', 'SMTP_PORT'), 587);
const secureRaw = pickEnv('EMAIL_SECURE', 'SMTP_SECURE');
const secure = parseSecure(port, secureRaw);
const user = pickEnv('EMAIL_USER', 'SMTP_USER') || '';
const pass = pickEnv('EMAIL_PASSWORD', 'SMTP_PASSWORD') || '';
const from =
pickEnv('EMAIL_FROM', 'SMTP_FROM') || 'Royal Enfield <noreply@royalenfield.com>';
return {
enabled,
host,
port,
secure,
auth: { user, pass },
from,
source,
};
}
function validateConfig(config: SmtpConfig): void {
if (!config.enabled) {
logger.info('[SMTP] Disabled via ENABLE_SMTP — email sending should use mocks or skip SMTP');
return;
}
const missing: string[] = [];
if (!config.host) missing.push('EMAIL_HOST / SMTP_HOST');
if (!config.auth.user) missing.push('EMAIL_USER / SMTP_USER');
if (!config.auth.pass) missing.push('EMAIL_PASSWORD / SMTP_PASSWORD');
if (missing.length > 0) {
logger.warn(`[SMTP] Enabled but missing: ${missing.join(', ')}`);
} else {
logger.info('[SMTP] ─── Resolved config ───');
logger.info(`[SMTP] source: ${config.source}`);
logger.info(`[SMTP] host: ${config.host}`);
logger.info(`[SMTP] port: ${config.port}`);
logger.info(`[SMTP] secure: ${config.secure}`);
logger.info(`[SMTP] user: ${config.auth.user || '(empty)'}`);
logger.info(
`[SMTP] password: ${config.auth.pass ? `*** (${config.auth.pass.length} chars)` : '(empty)'}`
);
logger.info(`[SMTP] from: ${config.from}`);
logger.info('[SMTP] ─── Ready ───');
}
}
/**
* Load SMTP settings: optional Google Secret Manager merge into env build config.
* Call once during server startup.
*/
export async function initializeSmtpConfig(): Promise<SmtpConfig> {
if (initialized && cachedConfig) {
return cachedConfig;
}
let source: SmtpConfig['source'] = 'env';
logger.info('[SMTP] ─── Initializing SMTP config ───');
logger.info(`[SMTP] ENABLE_SMTP=${process.env.ENABLE_SMTP ?? '(unset)'}${isSmtpEnabled() ? 'enabled' : 'disabled'}`);
logger.info(
`[SMTP] USE_GOOGLE_SECRET_MANAGER=${process.env.USE_GOOGLE_SECRET_MANAGER ?? '(unset)'}`
);
if (googleSecretManager.isEnabled()) {
logger.info('[SMTP] Loading SMTP secrets from Google Secret Manager...');
const loaded = await initializeSmtpSecretsFromGoogle();
source = Object.keys(loaded).length > 0 ? 'secret-manager' : 'env';
if (Object.keys(loaded).length > 0) {
logger.info(`[SMTP] GSM returned ${Object.keys(loaded).length} value(s):`);
for (const [envVar, value] of Object.entries(loaded)) {
const masked =
/PASSWORD|SECRET/i.test(envVar) ? `*** (${value.length} chars)` : value;
logger.info(`[SMTP] • ${envVar} = ${masked}`);
}
} else {
logger.warn('[SMTP] No secrets returned from GSM — will use .env if present');
}
const hasLocal =
Boolean(readEnv('EMAIL_HOST') || readEnv('SMTP_HOST')) &&
Boolean(readEnv('EMAIL_USER') || readEnv('SMTP_USER'));
if (Object.keys(loaded).length > 0 && hasLocal) {
source = 'mixed';
logger.info('[SMTP] Config source: mixed (GSM + local .env)');
}
} else {
logger.info('[SMTP] Google Secret Manager disabled — using .env only');
}
if (!isSmtpEnabled()) {
cachedConfig = {
...buildConfigFromEnv('disabled'),
enabled: false,
source: 'disabled',
};
initialized = true;
validateConfig(cachedConfig);
return cachedConfig;
}
cachedConfig = buildConfigFromEnv(source);
initialized = true;
validateConfig(cachedConfig);
return cachedConfig;
}
/** Resolved SMTP config (call after initializeSmtpConfig). */
export function getSmtpConfig(): SmtpConfig {
if (!cachedConfig) {
return buildConfigFromEnv('env');
}
return cachedConfig;
}
/** Nodemailer-compatible transport options */
export function getNodemailerTransportOptions(): {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
} | null {
const config = getSmtpConfig();
if (!config.enabled) return null;
if (!config.auth.user || !config.auth.pass) return null;
return {
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
};
}
export function resetSmtpConfigForTests(): void {
cachedConfig = null;
initialized = false;
}

View File

@ -23,7 +23,7 @@ const EMAILS = {
RBM_L1: 'manish@royalenfield.com',
ZM_L1: 'piyush@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
ZBH: 'manav@royalenfield.com',
ZBH: 'laxmanhalaki814@royalenfield.com',
NBH: 'yashwin@royalenfield.com',
DD_HEAD: 'ganesh@royalenfield.com',
FDD: 'fdd@royalenfield.com',
@ -252,99 +252,99 @@ async function triggerWorkflow() {
await delay();
// 5. LEVEL-2 INTERVIEW
log(5, 'Scheduling Level 2 Interview...');
// log(5, 'Scheduling Level 2 Interview...');
const intv2Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 2,
scheduledAt: new Date(Date.now() + 172800000).toISOString(),
type: 'Online',
location: 'Teams',
participants: [ddLead.id, zbhUser.id]
}, leadToken);
const interviewId2 = intv2Response.data.id;
// const intv2Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 2,
// scheduledAt: new Date(Date.now() + 172800000).toISOString(),
// type: 'Online',
// location: 'Teams',
// participants: [ddLead.id, zbhUser.id]
// }, leadToken);
// const interviewId2 = intv2Response.data.id;
log(5.1, 'DD-Lead Giving Feedback...');
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.5,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
{ type: 'Management Capabilities', comments: 'Strong team leadership.' },
{ type: 'Operational Understanding', comments: 'Knows the local market well.' }
],
recommendation: 'Selected'
}, leadToken);
// log(5.1, 'DD-Lead Giving Feedback...');
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
// { type: 'Management Capabilities', comments: 'Strong team leadership.' },
// { type: 'Operational Understanding', comments: 'Knows the local market well.' }
// ],
// recommendation: 'Selected'
// }, leadToken);
log(5.15, 'ZBH Giving Feedback...');
const zbhToken = await login(zbhUser.email);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.0,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Good alignment with brand.' },
{ type: 'Key Strengths', comments: 'Great location proposed.' },
{ type: 'Areas of Concern', comments: 'None at this time.' }
],
recommendation: 'Selected'
}, zbhToken);
// log(5.15, 'ZBH Giving Feedback...');
// const zbhToken = await login(zbhUser.email);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.0,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Good alignment with brand.' },
// { type: 'Key Strengths', comments: 'Great location proposed.' },
// { type: 'Areas of Concern', comments: 'None at this time.' }
// ],
// recommendation: 'Selected'
// }, zbhToken);
log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId2,
decision: 'Approved',
remarks: 'Cleared Level 2'
}, leadToken);
log(5, 'Level 2 Complete.');
await delay();
// log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId2,
// decision: 'Approved',
// remarks: 'Cleared Level 2'
// }, leadToken);
// log(5, 'Level 2 Complete.');
// await delay();
// 6. LEVEL-3 INTERVIEW
log(6, 'Scheduling Level 3 Interview...');
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// // 6. LEVEL-3 INTERVIEW
// log(6, 'Scheduling Level 3 Interview...');
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 3,
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
type: 'In-Person',
location: 'HO',
participants: [headUser.id, nbhUser.id]
}, leadToken);
const interviewId3 = intv3Response.data.id;
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 3,
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
// type: 'In-Person',
// location: 'HO',
// participants: [headUser.id, nbhUser.id]
// }, leadToken);
// const interviewId3 = intv3Response.data.id;
log(6.1, 'NBH Giving Feedback...');
const nbhToken = await login(EMAILS.NBH);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 10,
feedbackItems: [
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
],
recommendation: 'Selected'
}, nbhToken);
// log(6.1, 'NBH Giving Feedback...');
// const nbhToken = await login(EMAILS.NBH);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 10,
// feedbackItems: [
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
// ],
// recommendation: 'Selected'
// }, nbhToken);
log(6.15, 'DD-Head Giving Feedback...');
const headToken = await login(EMAILS.DD_HEAD);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 9.5,
feedbackItems: [
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
],
recommendation: 'Selected'
}, headToken);
// log(6.15, 'DD-Head Giving Feedback...');
// const headToken = await login(EMAILS.DD_HEAD);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
// ],
// recommendation: 'Selected'
// }, headToken);
log(6.2, 'Head Finalizing Level 3 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId3,
decision: 'Approved',
remarks: 'Cleared Level 3. Moving to FDD.'
}, headToken);
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
await delay();
// log(6.2, 'Head Finalizing Level 3 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId3,
// decision: 'Approved',
// remarks: 'Cleared Level 3. Moving to FDD.'
// }, headToken);
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
// await delay();
// // 6.3 FDD ASSIGNMENT
// log(6.3, 'Admin Assigning Application to FDD Agency...');