in-app notiofication enhanced an SLA feimplementation done partially

This commit is contained in:
laxman h 2026-04-20 19:54:30 +05:30
parent 5004508e91
commit 0d6821b7a9
18 changed files with 1037 additions and 169 deletions

190
docs/TEST_CASES.md Normal file
View File

@ -0,0 +1,190 @@
# Dealer Offboarding — Test Cases & QA Tracker
> **Location:** `/backend/docs/TEST_CASES.md`
> **Last Updated:** 2026-04-20
> **Modules Covered:** Termination, Resignation, F&F Settlement, Constitutional Change, Relocation
> **Status Legend:** ✅ Fixed | ❌ Open | 🔄 In Progress | ⚠️ Known Issue
---
## 1. Termination Module
### 1.1 Push to F&F Action
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| T-01 | Click "Push to F&F" when stage is `NBH Evaluation` | Backend returns **400**`Cannot trigger F&F from NBH Evaluation. Complete CEO and Legal approvals first.` | ✅ Fixed | Backend gate added in `termination.controller.ts` |
| T-02 | Click "Push to F&F" when stage is `Legal - Termination Letter` | F&F settlement created successfully; termination status → `F&F Initiated`; stage → `Terminated` | ✅ Fixed | Stage lock + correct status applied |
| T-03 | Click "Push to F&F" multiple times on the same record | Only one F&F record created (idempotency check via `db.FnF.findOne`) | ✅ Fixed | `TerminationWorkflowService.initiateFnF` resolves existing record |
| T-04 | After "Push to F&F", verify "Approve" button visibility | Approve button **must be hidden** — workflow locked | ✅ Fixed | `isSettlementPhase` flag disables all approval actions |
| T-05 | "Push to F&F" button visible to ASM role | Button **must not be visible** to ASM | ✅ Fixed | Role restricted to `DD Lead, DD Head, NBH, DD Admin, Super Admin` |
| T-06 | "Push to F&F" button visible at early stages (e.g. ZBH Review) | Button **must be hidden** until stage is `Legal - Termination Letter` or `Terminated` | ✅ Fixed | Frontend `canPushToFnF` condition updated |
| T-07 | After "Push to F&F", another approver tries to approve | Action rejected — buttons completely hidden | ✅ Fixed | `isSettlementPhase` + `isFinalState` guard |
---
### 1.2 Stage Approval Flow
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| T-08 | Sending `action: "pushfnf"` via API when ticket is mid-flow | Previously fell into generic "Approve" block and advanced stage incorrectly | ✅ Fixed | Explicit `pushfnf` handler added |
| T-09 | RBM approves at `RBM Review` | Stage advances to `ZBH Review` | ✅ | Standard flow |
| T-10 | NBH at `NBH Evaluation` clicks "Issue SCN" | Stage advances to `Show Cause Notice` | ✅ | `canIssueSCN` permission guarded |
| T-11 | ASM tries to Approve/Send Back | Buttons **must not be visible** — ASM is Create/View/Comment only | ✅ | SRS §8.3.4 enforced |
---
### 1.3 Progress Timeline / Status Display
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| T-12 | Termination reaches `Dealer Terminated` stage | All stages including "Dealer Terminated" show **green checkmark** | ✅ Fixed | `isSuccessFinal` flag added to `getProgressStatus()` |
| T-13 | Termination status is `F&F Initiated` | All completed stages show green; "Dealer Terminated" shows green | ✅ Fixed | `F&F Initiated`, `Settled` included in `isSuccessFinal` |
| T-14 | Termination is `Rejected` | Shows all stages up to rejection point; no misleading pending | ✅ | `isTerminal` logic handles rejected states |
---
## 2. Resignation Module
### 2.1 Push to F&F Action
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| R-01 | Click "Push to F&F" before Legal stage | Backend returns **400**`Cannot trigger F&F from [stage]. Move request to Legal stage first.` | ✅ | Gate in `resignation.controller.ts` |
| R-02 | Click "Push to F&F" at Legal stage without a Legal-stage document | Backend returns **400**`Legal-stage acceptance/communication document is required first.` | ✅ | Document gate enforced |
| R-03 | Click "Push to F&F" at Legal stage with document present | F&F initiated; stage → `F&F Initiated` | ✅ | |
| R-04 | Dealer role attempts to withdraw after reaching NBH stage | Withdraw button **must be hidden** | ✅ | `isPastNBH` guard in `canWithdraw` |
| R-05 | "Push to F&F" visible to ASM role | Button **must NOT be visible** | ✅ | Role whitelist enforced |
---
## 3. F&F Settlement Module
### 3.1 Document Management
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| F-01 | Upload document via F&F Details page | File stored, mapped in `clearanceDocuments`, appears in Submitted Documents table | ✅ Fixed | Multipart upload implemented in `settlement.controller.ts` |
| F-02 | Download uploaded F&F document | Forces direct file download (not new tab) | ✅ Fixed | Programmatic Blob fetch + hidden `<a>` tag |
| F-03 | Preview uploaded F&F document | ❌ Open | Pending implementation |
### 3.2 Status Display
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| F-04 | F&F Status badge text readability | White text on green/amber badges | ✅ Fixed | `text-white` applied in `FinanceFnFPage.tsx` |
---
## 4. Constitutional Change Module
### 4.1 Document Verification Permissions
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| C-01 | ASM sees "Verify" button on uploaded documents | Button **must NOT be visible** to ASM | ✅ Fixed | Role whitelist: `DD Lead, DD Head, NBH, Legal Admin, DD Admin, Super Admin` |
| C-02 | ASM sees "Reject" button on uploaded documents | Button **must NOT be visible** to ASM | ✅ Fixed | Same role whitelist |
| C-03 | DD Lead sees "Verify" button on uploaded documents | Button **must be visible** | ✅ | |
| C-04 | NBH sees "Verify" button | Button **must be visible** | ✅ | |
| C-05 | Dealer role sees "Verify" button | Button **must NOT be visible** | ✅ | |
---
## 5. Relocation Module
### 5.1 Document Verification Permissions
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| RL-01 | ASM sees "Verify" button on relocation documents | Button **must NOT be visible** | ✅ Fixed | Same role whitelist applied in `RelocationRequestDetails.tsx` |
| RL-02 | ASM sees "Reject" button on relocation documents | Button **must NOT be visible** | ✅ Fixed | |
| RL-03 | DD Admin sees "Verify" button | Button **must be visible** | ✅ | |
---
## 6. Role-Based Access Control (RBAC) Cross-Module
| # | Test Case | Expected Behaviour | Status | Notes |
|---|---|---|---|---|
| RBAC-01 | ASM document verify access — Constitutional Change | ❌ Hidden ✅ Fixed | See C-01 |
| RBAC-02 | ASM document verify access — Relocation | ❌ Hidden ✅ Fixed | See RL-01 |
| RBAC-03 | ASM workflow approve buttons — Termination | ASM sees no Approve/Send Back | ✅ | SRS §8.3.4 |
| RBAC-04 | Dealer sees full termination workflow details | Dealer is **read-only** | ✅ | |
| RBAC-05 | National roles access F&F data | ✅ Permitted | ✅ | Permission added |
---
## 7. Pending / Open Test Cases
> Items that still require QA testing or implementation.
| # | Test Case | Module | Priority | Notes |
|---|---|---|---|---|
| P-01 | F&F Document Preview modal | F&F | Medium | Upload/Download done; Preview pending |
| P-02 | SLA breach notification triggers | All modules | High | SLA configuration not yet built |
| P-03 | WhatsApp notification on SCN issued | Termination | High | Awaiting API dependency |
| P-04 | In-app notification on F&F initiation | F&F | Medium | |
| P-05 | Audit log context sweep across all modules | All modules | Medium | Ensure all actions are logged with correct entity context |
| P-06 | Admin SLA Matrix configuration | Admin Dashboard | High | Pending implementation |
---
## 8. Regression Checklist (Run after every release)
- [ ] Termination: "Push to F&F" rejected before Legal stage (T-01)
- [ ] Termination: Progress timeline shows all green on completion (T-12)
- [ ] Termination: "Push to F&F" button hidden at early stages (T-06)
- [ ] Resignation: Dealer withdrawal blocked after NBH (R-04)
- [ ] Constitutional: ASM cannot verify documents (C-01, C-02)
- [ ] Relocation: ASM cannot verify documents (RL-01, RL-02)
- [ ] F&F: Document download forces save to disk (F-02)
- [ ] F&F: Status badges show white text (F-04)
---
## 9. In-App & Email Notification Coverage
> All notifications flow through `notifyStakeholdersOnTransition()` in `workflow-email-notifications.ts`.
### 9.1 Root Cause (Fixed)
Old code only notified `nextActor` and `dealer`. ASM was silently skipped after the initial submission email.
**Fix applied:** `workflow-email-notifications.ts` now covers 4 notification paths:
1. **Next Actor**`Action Required` (system + email)
2. **ASM on Send Back**`Case Returned for Clarification` (system + email)
3. **Dealer** → status update (system always; email only on terminal events)
4. **Key Observers** (DD Lead, DD Head, NBH, DD Admin) → `Case Closed` (system only, on reject/revoke/complete)
### 9.2 Notification Test Cases
| # | Scenario | Who Should Be Notified | Channel | Status |
|---|---|---|---|---|
| N-01 | Constitutional change submitted | All participants incl. ASM | email + in-app | ✅ (submit hook) |
| N-02 | ASM approves → moves to ZM/RBM | ZM + RBM | email + in-app | ✅ Fixed |
| N-03 | ZBH approves → moves to DD Lead | DD Lead | email + in-app | ✅ Fixed |
| N-04 | Any approver sends back | ASM | email + in-app | ✅ Fixed |
| N-05 | Request rejected | Dealer (email + in-app), DD Lead/Head/NBH/DD Admin (in-app only) | mixed | ✅ Fixed |
| N-06 | Request revoked | Dealer (email + in-app), key observers (in-app only) | mixed | ✅ Fixed |
| N-07 | Request completed (legal approved) | Dealer (email + in-app), key observers (in-app only) | mixed | ✅ Fixed |
| N-08 | Relocation submitted | Dealer (email), ASM (email + in-app) | email + in-app | ✅ Already existed |
| N-09 | Resignation submitted | All participants incl. ASM | email + in-app | ✅ Already existed |
| N-10 | ZM/RBM joint: first approver approves | Other co-approver (ZM or RBM) | in-app | ✅ Verified |
| N-11 | Termination pushed to F&F | DD Admin, Finance | in-app | ✅ Verified |
| N-12 | ASM who submitted gets status update on any stage change | ASM | in-app | ✅ Verified |
### 9.3 Known Remaining Gaps (Open)
| ID | Scenario | Channels | Status |
|----|----------|----------|--------|
| N-G1 | ZM/RBM co-approver notification for partner approvals | System, Email, WhatsApp | **Verified (v2.1)** |
| N-G2 | F&F initiation notification to DD Admin/Finance | System, Email, WhatsApp | **Verified (v2.1)** |
| N-G3 | Dealer withdrawal from resignation notification to ASM | System, Email, WhatsApp | **Verified (v2.1)** |
| N-G4 | Interview Scheduling acknowledgment to Dealer | Email, WhatsApp | **Verified (v2.1)** |
| N-G5 | Interview Panelist assignment notification | Email, WhatsApp | **Verified (v2.1)** |
> [!NOTE]
> All WhatsApp notifications now dynamically resolve phone numbers from the participant's user record or the application record as per SRS §1.1.1.
---
*This file is maintained as a living document. Add new test cases as bugs are found or features are added.*

View File

@ -2,7 +2,7 @@ import db from '../../database/models/index.js';
import { Op } from 'sequelize';
import { sendEmail } from './email.service.js';
import { NotificationService } from '../../services/NotificationService.js';
import { REQUEST_TYPES } from '../config/constants.js';
import { REQUEST_TYPES, ROLES } from '../config/constants.js';
const { RequestParticipant, User, Outlet, District } = db;
@ -11,7 +11,7 @@ const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
/** Dealer acknowledgement + internal reviewers after resignation is created. */
export async function notifyResignationSubmittedEmails(resignation: any): Promise<void> {
const dealerUser = await User.findByPk(resignation.dealerId, {
attributes: ['id', 'email', 'fullName']
attributes: ['id', 'email', 'fullName', 'mobileNumber']
});
if (!dealerUser?.email) return;
@ -22,7 +22,9 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis
resignation.lastOperationalDateServices ||
'As per application';
const dealerName = dealerUser.fullName || 'Dealer';
const dealerPhone = (dealerUser as any).mobileNumber || (dealerUser as any).phone || null;
// SRS §1.1.1 — submission acknowledgement via email + WhatsApp
await sendEmail(
dealerUser.email,
`We received your resignation request — ${resignationCode}`,
@ -36,36 +38,50 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis
}
).catch((err) => console.error('[notifyResignationSubmittedEmails] dealer ack:', err));
// WhatsApp acknowledgement to dealer
if (dealerPhone) {
await NotificationService.notify(dealerUser.id, dealerUser.email, {
title: `Resignation request received — ${resignationCode}`,
message: `Hi ${dealerName}, your resignation request has been received.`,
channels: ['whatsapp'],
templateCode: 'RESIGNATION_RECEIVED',
placeholders: { dealerName, resignationId: resignationCode, lwd: String(lwd), link: `${base}/dealer-resignation/${resignation.id}`, phone: dealerPhone }
}).catch((err) => console.error('[notifyResignationSubmittedEmails] dealer whatsapp:', err));
}
const participants = await RequestParticipant.findAll({
where: {
requestId: resignation.id,
requestType: REQUEST_TYPES.RESIGNATION,
userId: { [Op.ne]: resignation.dealerId }
},
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName'] }]
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
});
const internalLink = `${base}/resignation/${resignation.id}`;
for (const p of participants) {
const u = (p as any).user;
if (!u?.email) continue;
const uPhone = u.mobileNumber || u.phone || null;
await NotificationService.notify(u.id, u.email, {
title: `New resignation request: ${resignationCode}`,
message: `Submitted by ${dealerName}.`,
channels: ['email', 'system'],
channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'RESIGNATION_SUBMITTED',
placeholders: {
dealerName,
resignationId: resignationCode,
lwd: String(lwd),
link: internalLink,
ctaLabel: 'Review resignation'
ctaLabel: 'Review resignation',
phone: uPhone || ''
}
}).catch((err) => console.error('[notifyResignationSubmittedEmails] internal:', err));
}
}
/** Internal reviewers after constitutional request is created. */
/** Internal reviewers + dealer WhatsApp after constitutional request is created.
* SRS §1.1.1 WhatsApp is a supported submission notification channel. */
export async function notifyConstitutionalSubmittedEmails(request: any, dealerDisplayName: string): Promise<void> {
const participants = await RequestParticipant.findAll({
where: {
@ -73,7 +89,7 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi
requestType: REQUEST_TYPES.CONSTITUTIONAL,
userId: { [Op.ne]: request.dealerId }
},
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName'] }]
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
});
const base = frontendBase();
@ -82,17 +98,19 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi
for (const p of participants) {
const u = (p as any).user;
if (!u?.email) continue;
const uPhone = u.mobileNumber || u.phone || null;
await NotificationService.notify(u.id, u.email, {
title: `New constitutional change request: ${request.requestId}`,
message: `${dealerDisplayName} submitted a request.`,
channels: ['email', 'system'],
channels: uPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED',
placeholders: {
dealerName: dealerDisplayName,
changeType: request.changeType || '',
requestId: request.requestId,
link,
ctaLabel: 'Review request'
ctaLabel: 'Review request',
phone: uPhone || ''
}
}).catch((err) => console.error('[notifyConstitutionalSubmittedEmails]:', err));
}
@ -127,20 +145,22 @@ export async function notifyRelocationSubmittedEmails(
const asmId = (outlet as any)?.district?.asmId;
if (!asmId) return;
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName'] });
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
if (!asm?.email) return;
const asmPhone = (asm as any).mobileNumber || (asm as any).phone || null;
await NotificationService.notify(asm.id, asm.email, {
title: `New relocation request: ${code}`,
message: 'A dealer submitted an outlet relocation request.',
channels: ['email', 'system'],
channels: asmPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'RELOCATION_SUBMITTED',
placeholders: {
dealerName,
requestId: code,
outletCode: outlet?.code || '',
link: `${base}/relocation-requests/${request.id}`,
ctaLabel: 'Review relocation'
ctaLabel: 'Review relocation',
phone: asmPhone || ''
}
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
}
@ -157,51 +177,69 @@ export async function resolveNextActors(requestId: string, requestType: string,
const actorIds = new Set<string>();
// We try to match the new stage to specific user roles
const stageRoleMap: Record<string, string[]> = {
// Onboarding Specific
'Level 1 Interview': ['DD-ZM', 'RBM'],
'Level 1 Interview Pending': ['DD-ZM', 'RBM'],
'Interview Level 1': ['DD-ZM', 'RBM'],
'Level 2 Interview': ['ZBH', 'DD Lead'],
'Level 2 Interview Pending': ['ZBH', 'DD Lead'],
'Interview Level 2': ['ZBH', 'DD Lead'],
'Level 3 Interview': ['NBH', 'DD Head'],
'Level 3 Interview Pending': ['NBH', 'DD Head'],
'Interview Level 3': ['NBH', 'DD Head'],
'LOI Approval': ['NBH', 'DD Head'],
'LOI In Progress': ['NBH', 'DD Head'],
'LOA Approval': ['NBH', 'DD Head'],
'LOA Pending': ['NBH', 'DD Head'],
// --- Common/Shared Stages ---
'DD': [ROLES.ASM],
'ASM': [ROLES.ASM],
'ASM Review': [ROLES.ASM],
'RBM': [ROLES.RBM],
'RBM Review': [ROLES.RBM],
'ZM Review': [ROLES.DD_ZM],
'DD ZM Review': [ROLES.DD_ZM],
'ZBH': [ROLES.ZBH],
'ZBH Review': [ROLES.ZBH],
'DD Lead': [ROLES.DD_LEAD],
'DD Lead Review': [ROLES.DD_LEAD],
'DD Head': [ROLES.DD_HEAD],
'DD Head Review': [ROLES.DD_HEAD],
'DD Head Approval': [ROLES.DD_HEAD],
'NBH': [ROLES.NBH],
'NBH Approval': [ROLES.NBH],
'NBH Evaluation': [ROLES.NBH],
'NBH Final Approval': [ROLES.NBH],
'Legal': [ROLES.LEGAL_ADMIN],
'Legal Review': [ROLES.LEGAL_ADMIN],
'Legal Clearance': [ROLES.LEGAL_ADMIN],
'Legal Verification': [ROLES.LEGAL_ADMIN],
'Finance': [ROLES.FINANCE],
'CCO Approval': [ROLES.CCO],
'CEO Final Approval': [ROLES.CEO],
// Relocation / Resignation / termination common
'ASM': ['ASM'],
'ASM Review': ['ASM'],
'RBM': ['RBM'],
'RBM Review': ['RBM'],
'ZM Review': ['DD-ZM'],
'DD ZM Review': ['DD-ZM'], // Fixed role mapping for DD-ZM
'ZBH': ['ZBH'],
'ZBH Review': ['ZBH'],
'DD Lead': ['DD Lead'],
'DD Lead Review': ['DD Lead'],
'DD Head': ['DD Head'],
'DD Head Review': ['DD Head'],
'DD Head Approval': ['DD Head'],
'NBH': ['NBH'],
'NBH Approval': ['NBH'],
'NBH Evaluation': ['NBH'],
'NBH Final Approval': ['NBH'],
'Legal': ['Legal Admin'],
'Legal Clearance': ['Legal Admin'],
'Legal Review': ['Legal Admin'],
'Legal Verification': ['Legal Admin'],
'Finance': ['Finance'],
'Finance Review': ['Finance'],
'CCO': ['CCO'],
'CCO Approval': ['CCO'],
'CEO': ['CEO'],
'CEO Final Approval': ['CEO']
// --- Onboarding Specific ---
'Level 1 Interview': [ROLES.DD_ZM, ROLES.RBM],
'Level 1 Interview Pending': [ROLES.DD_ZM, ROLES.RBM],
'Interview Level 1': [ROLES.DD_ZM, ROLES.RBM],
'Level 2 Interview': [ROLES.ZBH, ROLES.DD_LEAD],
'Level 2 Interview Pending': [ROLES.ZBH, ROLES.DD_LEAD],
'Interview Level 2': [ROLES.ZBH, ROLES.DD_LEAD],
'Level 3 Interview': [ROLES.NBH, ROLES.DD_HEAD],
'Level 3 Interview Pending': [ROLES.NBH, ROLES.DD_HEAD],
'Interview Level 3': [ROLES.NBH, ROLES.DD_HEAD],
'LOI Approval': [ROLES.NBH, ROLES.DD_HEAD],
'LOI In Progress': [ROLES.NBH, ROLES.DD_HEAD],
'LOA Approval': [ROLES.NBH, ROLES.DD_HEAD],
'LOA Pending': [ROLES.NBH, ROLES.DD_HEAD],
'FDD Verification': [ROLES.FDD],
'FDD_VERIFICATION': [ROLES.FDD],
'Architecture Team Assigned': [ROLES.ARCHITECTURE],
'Architecture Document Upload': [ROLES.ARCHITECTURE],
// --- Relocation/Constitutional Specific ---
'NBH Clearance with EOR': [ROLES.NBH],
'Submitted': [ROLES.ASM],
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
// --- Resignation Specific ---
'DD Admin': [ROLES.DD_ADMIN],
'Spares Clearance': [ROLES.SPARES_MANAGER],
'Service Clearance': [ROLES.SERVICE_MANAGER],
'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER],
'F&F Initiated': [ROLES.DD_ADMIN],
// --- Termination Specific ---
'Show Cause Notice': [ROLES.NBH],
'Personal Hearing': [ROLES.NBH],
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
};
const expectedRoles = stageRoleMap[newStage] || [];
@ -222,6 +260,13 @@ export async function resolveNextActors(requestId: string, requestType: string,
/**
* Sends turn-based notifications to the Next Actor(s), the Dealer, and all Participants.
*
* Channels per scenario:
* - Next actor (action required) in-app + email + WhatsApp (SRS §6.14.3)
* - ASM on send-back in-app + email + WhatsApp (SRS §6.14.3 pending user actions)
* - Dealer on terminal event in-app + email + WhatsApp (SRS §2052 rejection/completion)
* - Dealer on interim stage in-app only
* - Key observers on terminal in-app only
*/
export async function notifyStakeholdersOnTransition(
requestId: string,
@ -240,42 +285,99 @@ export async function notifyStakeholdersOnTransition(
try {
const participants = await RequestParticipant.findAll({
where: { requestId, requestType },
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'roleCode'] }]
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'roleCode', 'mobileNumber'] }]
});
const nextActorIds = await resolveNextActors(requestId, requestType, targetStage);
const isSendBack = /\b(send|sent)\s*back\b/i.test(metadata.action);
const isRejected = /\b(reject)/i.test(targetStage) || /\b(reject)/i.test(metadata.action);
const isRevoked = /\b(revok)/i.test(targetStage) || /\b(revok)/i.test(metadata.action);
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;
for (const p of participants) {
const u = (p as any).user;
if (!u || !u.id) continue;
const isNextActor = nextActorIds.includes(u.id);
const isDealer = u.id === metadata.dealerId;
const isActingUser = u.fullName === metadata.actionUserFullName;
// Don't clutter the history of the person who just acted
if (u.fullName === metadata.actionUserFullName) {
continue;
}
// Roles that should receive observer alerts on terminal events
const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || '');
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
// Phone for WhatsApp — directly on include'd user object
const phone: string | null = (u as any).mobileNumber || null;
// Skip the person who just acted (they already know)
if (isActingUser && !isNextActor) continue;
if (isNextActor) {
// Next Approver Notification
// ── Next Approver: Action Required — WhatsApp + Email + In-App ──
// SRS §6.14.3: critical workflow actions (assignment, scheduling) trigger via email & WhatsApp
await NotificationService.notify(u.id, u.email, {
title: `Action Required: ${metadata.code}`,
message: `Application has reached ${targetStage} and requires your action.`,
channels: ['system', 'email'],
message: `${metadata.code} has reached "${targetStage}" and requires your review.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage
targetStage,
phone: phone || ''
}
}).catch(e => console.error(e));
} else if (isDealer) {
// Status Update for Dealer
}).catch(e => console.error('[notifyStakeholders] next-actor:', e));
} else if (isSendBack && isASM) {
// ── Send Back → notify ASM via WhatsApp + Email + In-App ──
// SRS §6.14.3: pending user actions trigger email & WhatsApp
await NotificationService.notify(u.id, u.email, {
title: `Application Update: ${metadata.code}`,
message: `Your application is now at ${targetStage}. ${metadata.action}`,
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: {
dealerName: metadata.dealerName,
requestId: metadata.code,
link: metadata.link,
targetStage,
remarks: metadata.remarks || 'No remarks provided',
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] send-back-asm:', e));
} else if (isDealer) {
// ── Dealer: in-app always; email + WhatsApp only on terminal events ──
// SRS §2052: rejection notifies dealer/applicant via email & WhatsApp
// SRS §2324: approvals/outcomes delivered via email & WhatsApp
const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email'];
if (phone) terminalChannels.push('whatsapp');
await NotificationService.notify(u.id, u.email, {
title: isTerminalEvent
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
: `Application Update: ${metadata.code}`,
message: `Your request is now at "${targetStage}". ${metadata.action}`,
channels: isTerminalEvent ? terminalChannels : ['system'],
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
placeholders: {
requestId: metadata.code,
link: metadata.link,
targetStage,
dealerName: metadata.dealerName,
phone: phone || ''
}
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
} else if (isTerminalEvent && isKeyObserverRole) {
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events — in-app only ──
await NotificationService.notify(u.id, u.email, {
title: `Case Closed: ${metadata.code}`,
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
channels: ['system'],
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
placeholders: {
@ -283,7 +385,7 @@ export async function notifyStakeholdersOnTransition(
link: metadata.link,
targetStage
}
}).catch(e => console.error(e));
}).catch(e => console.error('[notifyStakeholders] observer:', e));
}
}
} catch (error) {

View File

@ -6,7 +6,8 @@ export interface SLAEscalationConfigAttributes {
level: number;
timeValue: number;
timeUnit: 'hours' | 'days';
notifyEmail: string;
notifyEmail: string | null;
notifyRole: string | null;
}
export interface SLAEscalationConfigInstance extends Model<SLAEscalationConfigAttributes>, SLAEscalationConfigAttributes { }
@ -40,8 +41,12 @@ export default (sequelize: Sequelize) => {
},
notifyEmail: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true,
validate: { isEmail: true }
},
notifyRole: {
type: DataTypes.STRING,
allowNull: true
}
}, {
tableName: 'sla_config_escalations',

View File

@ -10,6 +10,7 @@ import * as EmailService from '../../common/utils/email.service.js';
import { APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { syncApplicationProgress } from '../../common/utils/progress.js';
import { NotificationService } from '../../services/NotificationService.js';
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
const district: any = await District.findByPk(locationId);
@ -434,6 +435,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
scheduledBy: req.user?.id
});
console.log('Interview created with ID:', interview.id);
const notificationPromises: Promise<any>[] = [];
// Note: WorkflowTransition relocated below participant insertion.
@ -446,10 +448,25 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
});
await interview.update({ linkOrLocation: meetLink });
// 2. WhatsApp Mock
await ExternalMocksService.mockSendWhatsApp(
application.phone,
`Dear ${application.applicantName}, your ${type} is scheduled at ${scheduledAt}. Join here: ${meetLink}`
// 2. Transmitted via Centralized Notification Service (SRS §6.14.3)
// Replacing direct mock call with production-ready service trigger
const applicantPhone = application.mobileNumber || application.phone || '';
notificationPromises.push(
NotificationService.notify(null, application.email, {
title: `Interview Scheduled: ${application.applicationId}`,
message: `Dear ${application.applicantName}, your ${type} is scheduled.`,
channels: applicantPhone ? ['email', 'whatsapp', 'system'] : ['email', 'system'],
templateCode: 'INTERVIEW_SCHEDULED_APPLICANT',
placeholders: {
applicantName: application.applicantName,
applicationId: application.applicationId,
type,
scheduledAt,
link: meetLink,
phone: applicantPhone,
ctaLabel: 'View Schedule'
}
}).catch(err => console.error('Failed to notify applicant via WhatsApp/Email:', err))
);
let participantIds: string[] = Array.isArray(participants) ? participants : [];
@ -506,10 +523,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
reason: `Interview Level ${levelNum} Scheduled`
});
// Fire and forget non-critical notifications to keep response fast, or use Promise.all
// For now, using Promise.all to ensure we catch errors but execute concurrently
const notificationPromises: Promise<any>[] = [];
// 3. User & Stakeholder Notifications (SRS §6.14.3)
if (application) {
notificationPromises.push(
EmailService.sendInterviewScheduledEmail(
@ -525,16 +539,27 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
for (const userId of participantIds) {
notificationPromises.push(
(async () => {
const panelist = await User.findByPk(userId);
const panelist = await User.findByPk(userId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
if (panelist) {
await EmailService.sendInterviewScheduledEmail(
panelist.email,
panelist.fullName,
application?.applicationId || application?.id || applicationId,
interview
);
const pPhone = panelist.mobileNumber || null;
await NotificationService.notify(panelist.id, panelist.email, {
title: `Interview Assignment: ${application?.applicationId || 'New Case'}`,
message: `You have been assigned as a panelist for a ${type} with ${application?.applicantName || 'Applicant'}.`,
channels: pPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'INTERVIEW_SCHEDULED_PANELIST',
placeholders: {
panelistName: panelist.fullName,
applicantName: application?.applicantName || 'Applicant',
applicationId: application?.applicationId || '',
type,
scheduledAt,
link: meetLink,
phone: pPhone || '',
ctaLabel: 'Open Assessment'
}
})().catch(err => console.error(`Failed to send panelist (${userId}) email:`, err))
});
}
})().catch(err => console.error(`Failed to notify panelist (${userId}):`, err))
);
}
}

View File

@ -1249,3 +1249,118 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
res.status(500).json({ success: false, message: 'Error saving system configuration' });
}
};
// --- SLA Configuration ---
export const getSlaConfigs = async (req: Request, res: Response) => {
try {
const configs = await db.SLAConfiguration.findAll({
include: [
{ model: db.SLAReminder, as: 'reminders' },
{ model: db.SLAEscalationConfig, as: 'escalationConfigs' }
],
order: [['activityName', 'ASC']]
});
res.json({ success: true, data: configs });
} catch (error) {
console.error('Get SLA configs error:', error);
res.status(500).json({ success: false, message: 'Error fetching SLA configurations' });
}
};
export const saveSlaConfig = async (req: Request, res: Response) => {
const transaction = await db.sequelize.transaction();
try {
const { id, activityName, ownerRole, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body;
let config;
if (id) {
config = await db.SLAConfiguration.findByPk(id, { transaction });
if (!config) throw new Error('SLA Configuration not found');
await config.update({ activityName, ownerRole, tatHours, tatUnit, isActive }, { transaction });
} else {
config = await db.SLAConfiguration.create({ activityName, ownerRole, tatHours, tatUnit, isActive: isActive !== undefined ? isActive : true }, { transaction });
}
// Handle Reminders
await db.SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
if (Array.isArray(reminders)) {
for (const r of reminders) {
await db.SLAReminder.create({
slaConfigId: config.id,
timeValue: r.timeValue,
timeUnit: r.timeUnit,
isEnabled: true
}, { transaction });
}
}
// Handle Escalations
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
if (Array.isArray(escalationConfigs)) {
for (const e of escalationConfigs) {
await db.SLAEscalationConfig.create({
slaConfigId: config.id,
level: e.level,
timeValue: e.timeValue,
timeUnit: e.timeUnit,
notifyEmail: e.notifyEmail,
notifyChannel: 'email'
}, { transaction });
}
}
await transaction.commit();
res.json({ success: true, data: config });
} catch (error) {
await transaction.rollback();
console.error('Save SLA config error:', error);
res.status(500).json({ success: false, message: 'Error saving SLA configuration' });
}
};
export const initializeDefaultSlas = async (req: Request, res: Response) => {
const transaction = await db.sequelize.transaction();
try {
const defaults = [
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM', tat: 7, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' }
];
for (const item of defaults) {
const [config] = await db.SLAConfiguration.findOrCreate({
where: { activityName: item.stage },
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit as any,
isActive: true
},
transaction
});
// Add a default reminder for each
await db.SLAReminder.findOrCreate({
where: { slaConfigId: config.id },
defaults: {
slaConfigId: config.id,
timeValue: 1,
timeUnit: 'days',
isEnabled: true
},
transaction
});
}
await transaction.commit();
res.json({ success: true, message: 'Default SLA configurations initialized' });
} catch (error) {
await transaction.rollback();
console.error('Init SLA error:', error);
res.status(500).json({ success: false, message: 'Error initializing SLAs' });
}
};

View File

@ -26,7 +26,10 @@ import {
getDDLeads,
saveDDLead,
getSystemConfigs,
saveSystemConfig
saveSystemConfig,
getSlaConfigs,
saveSlaConfig,
initializeDefaultSlas
} from './master.controller.js';
@ -69,4 +72,9 @@ router.post('/dd-leads', saveDDLead);
router.get('/system-configs', getSystemConfigs);
router.post('/system-configs', saveSystemConfig);
// --- SLA Configuration ---
router.get('/sla-configs', getSlaConfigs);
router.post('/sla-configs', saveSlaConfig);
router.post('/sla-configs/initialize', initializeDefaultSlas);
export default router;

View File

@ -652,6 +652,10 @@ const assignStageEvaluators = async (appIdOrId: string) => {
// --- INTERVIEWS ---
// --- GROUND STAKEHOLDERS (Area / District Level) ---
// SRS §9.3.4: ASM is responsible for ground opportunity identification and field validation.
if (district.asmId) evaluatorMappings['DD'] = [{ id: district.asmId, role: 'ASM' }];
// Level 1: DD-ZM (District manager) + RBM (Region manager)
if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' });
if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' });
@ -699,7 +703,7 @@ const assignStageEvaluators = async (appIdOrId: string) => {
// to prevent duplication in the participants list.
const userAssignments: Record<string, { stages: any[], roles: string[] }> = {};
const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL'];
const allStages = ['DD', 1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL'];
for (const stage of allStages) {
const assignments = evaluatorMappings[stage];
for (const assign of assignments) {

View File

@ -1,6 +1,7 @@
import { Response } from 'express';
import db from '../../database/models/index.js';
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy } = db;
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant } = db;
import { NotificationService } from '../../services/NotificationService.js';
import { Op } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js';
import {
@ -179,6 +180,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
outletId: resolvedOutletId,
dealerId: dealerUserId,
changeType: resolvedChangeType,
oldValue: resolvedCurrent,
newValue: resolvedChangeType,
description: remarksText,
currentConstitution: resolvedCurrent,
currentStage: CONSTITUTIONAL_STAGES.ASM_REVIEW,
@ -543,17 +546,45 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
});
try {
const jointLine = `[Joint Approval] ${req.user.fullName} approved at ZM/RBM stage. Waiting for ${waitingFor.join(', ')}.`;
const noteText = userComment ? `${jointLine} Remarks: ${userComment}` : jointLine;
await writeWorkflowActivityWorknote({
// Notify remaining approvers (co-approvers)
const remainingParticipants = await RequestParticipant.findAll({
where: {
requestId: request.id,
requestType: 'constitutional',
userId: req.user.id,
noteText,
noteType: 'internal'
requestType: REQUEST_TYPES.CONSTITUTIONAL
},
include: [{
model: User,
as: 'user',
attributes: ['id', 'email', 'fullName', 'roleCode', 'mobileNumber']
}]
});
} catch (wnErr) {
console.error('[constitutional] workflow worknote:', wnErr);
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
for (const p of remainingParticipants) {
const u = (p as any).user;
if (!u || u.id === req.user.id) continue;
const uRole = normalizeRoleKey(u.roleCode || '');
if (waitingFor.includes(uRole)) {
const phone = u.mobileNumber || u.phone || null;
await NotificationService.notify(u.id, u.email, {
title: `Action Required: Co-approval for ${request.requestId}`,
message: `${req.user.fullName} has approved ${request.requestId}. Your approval is still required to move to the next stage.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: request.dealerId, // We should ideally have dealer name, but request object has ID
requestId: request.requestId,
link: `${portalBase}/constitutional-change/${request.id}`,
targetStage: request.currentStage,
phone: phone || ''
}
});
}
}
} catch (notifyErr) {
console.error('[constitutional] co-approver notification failed:', notifyErr);
}
return res.json({

View File

@ -73,7 +73,7 @@ const ensureFinanceDraftsFromDepartmentClaims = async (fnfId: string, userId: st
}
};
export const getDepartments = async (req: Request, res: Response) => {
export const getDepartments = async (req: AuthRequest, res: Response) => {
try {
res.json({ success: true, departments: FNF_DEPARTMENTS });
} catch (error) {
@ -81,7 +81,42 @@ export const getDepartments = async (req: Request, res: Response) => {
}
};
export const getOnboardingPayments = async (req: Request, res: Response) => {
export const uploadFnFDocument = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const fileUrl = req.file ? `/uploads/documents/${req.file.filename}` : req.body.fileUrl;
if (!fileUrl) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
const fnf = await FnF.findByPk(id);
if (!fnf) {
return res.status(404).json({ success: false, message: 'F&F settlement not found' });
}
const updatedClearances = [
...(fnf.clearanceDocuments || []),
{
name: req.file?.originalname || 'Uploaded Document',
supportingDocument: fileUrl,
department: 'Finance', // Default to Finance for generic F&F docs
clearedAt: new Date().toISOString()
}
];
await fnf.update({
clearanceDocuments: updatedClearances
});
res.json({ success: true, url: fileUrl, name: req.file?.originalname || 'Document' });
} catch (error) {
console.error('Error uploading F&F document:', error);
res.status(500).json({ success: false, message: 'Error uploading F&F document' });
}
};
export const getOnboardingPayments = async (req: AuthRequest, res: Response) => {
try {
const payments = await FinancePayment.findAll({
include: [{
@ -285,6 +320,12 @@ export const getFnFById = async (req: Request, res: Response) => {
});
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
// Ensure participants exist (fixes legacy / missing participants silently)
if (!fnf.participants || (fnf as any).participants.length === 0) {
const { ParticipantService } = await import('../../services/ParticipantService.js');
await ParticipantService.assignFnFParticipants(resolvedId);
}
await ensureFinanceDraftsFromDepartmentClaims(resolvedId, null);
const fnfWithDrafts = await FnF.findByPk(resolvedId, {

View File

@ -14,15 +14,16 @@ router.get('/departments', settlementController.getDepartments);
// Finance user only routes
router.get('/onboarding', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getOnboardingPayments);
router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getFnFSettlements);
router.get('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD]) as any, settlementController.getFnFById);
router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.CCO, ROLES.CEO]) as any, settlementController.getFnFSettlements);
router.get('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN, ROLES.DD_HEAD, ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.CCO, ROLES.CEO]) as any, settlementController.getFnFById);
router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updatePayment);
router.put('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateFnF);
router.post('/fnf/:id/calculate', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.calculateFnF);
router.put('/fnf/:id/clearances/:clearanceId', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance);
router.put('/fnf/:id/clearances/:clearanceId', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, settlementController.updateClearance);
// Line item management
router.post('/fnf/:id/documents', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, settlementController.uploadFnFDocument);
router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem);
router.put('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateLineItem);
router.delete('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.deleteLineItem);

View File

@ -2,9 +2,55 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { SLAConfiguration, SLATracking, SLABreach } = db;
export const updateConfig = async (req: Request, res: Response) => {
const transaction = await db.sequelize.transaction();
try {
const { id } = req.params;
const { activityName, tatHours, tatUnit, isActive, reminders, escalationConfigs } = req.body;
const config = await SLAConfiguration.findByPk(id);
if (!config) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'SLA Configuration not found' });
}
await config.update({ activityName, tatHours, tatUnit, isActive }, { transaction });
// Update Reminders: Clear and recreate
await db.SLAReminder.destroy({ where: { slaConfigId: id }, transaction });
if (reminders && reminders.length > 0) {
await db.SLAReminder.bulkCreate(
reminders.map((r: any) => ({ ...r, slaConfigId: id })),
{ transaction }
);
}
// Update Escalations: Clear and recreate
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: id }, transaction });
if (escalationConfigs && escalationConfigs.length > 0) {
await db.SLAEscalationConfig.bulkCreate(
escalationConfigs.map((e: any) => ({ ...e, slaConfigId: id })),
{ transaction }
);
}
await transaction.commit();
res.json({ success: true, message: 'SLA Configuration updated successfully' });
} catch (error) {
await transaction.rollback();
console.error('Update SLA config error:', error);
res.status(500).json({ success: false, message: 'Error updating SLA config' });
}
};
export const getConfigs = async (req: Request, res: Response) => {
try {
const configs = await SLAConfiguration.findAll();
const configs = await SLAConfiguration.findAll({
include: [
{ model: db.SLAReminder, as: 'reminders' },
{ model: db.SLAEscalationConfig, as: 'escalationConfigs' }
]
});
res.json({ success: true, data: configs });
} catch (error) {
console.error('Get SLA configs error:', error);

View File

@ -6,6 +6,7 @@ import { authenticate } from '../../common/middleware/auth.js';
router.use(authenticate as any);
router.get('/configs', slaController.getConfigs);
router.put('/configs/:id', slaController.updateConfig);
router.get('/tracking/:applicationId', slaController.getTracking);
export default router;

View File

@ -309,6 +309,31 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
action: 'Sent Back',
remarks
});
} else if (action === 'pushfnf') {
if (termination.currentStage !== TERMINATION_STAGES.TERMINATED && termination.currentStage !== TERMINATION_STAGES.LEGAL_LETTER) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.`
});
}
logger.info(`[TerminationController] Forcibly initiating F&F (pushfnf) for Termination ${termination.requestId}`);
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
// Maintain timeline visibility
const timeline = [...(termination.timeline || []), {
stage: termination.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: 'Forced F&F Initiation',
remarks: remarks || 'F&F settlement initiated manually via Push to F&F'
}];
await termination.update({
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
status: 'F&F Initiated',
timeline
}, { transaction });
} else {
const stageFlow: Record<string, string> = {
[TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW,

View File

@ -151,7 +151,7 @@ export class ConstitutionalWorkflowService {
await dealer.update({ constitutionType: nextConstitution });
const auditRemarks = `Dealer master constitutionType updated: "${previous}" → "${nextConstitution}" (dealer profile id: ${dealer.id}). Change type on request: ${request.changeType}.`;
const auditRemarks = `Dealer master constitution updated from "${previous}" to "${nextConstitution}" via request ${request.requestId}.`;
const auditDetails = {
dealerProfileId: dealer.id,
dealerUserId: request.dealerId,
@ -181,7 +181,7 @@ export class ConstitutionalWorkflowService {
requestId: request.id,
requestType: 'constitutional',
userId: worknoteUserId,
noteText: `[Master data] Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`,
noteText: `Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`,
noteType: 'workflow'
});
} catch (wnErr) {

View File

@ -1,49 +1,127 @@
import db from '../database/models/index.js';
const { SLATracking, SLAConfiguration, SLABreach, Application, User } = db;
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
import { Op } from 'sequelize';
import { NotificationService } from './NotificationService.js';
export class SLAService {
/**
* Periodically check for SLA breaches across all active tracking records
* Periodically check for SLA breaches, reminders and escalations
*/
static async checkBreaches() {
console.log('[SLA Service] Starting breach check...');
// Find all active tracking records that are still 'In Progress' (not concluded)
const activeTracking = await SLATracking.findAll({
where: { isActive: true, isBreached: false, endTime: null },
include: [
{ model: Application, as: 'application' }
]
});
console.log('[SLA Service] Starting SLA status check...');
const now = new Date();
// 1. Handle Active Tracks (Reminders and Initial Breach)
const activeTracking = await SLATracking.findAll({
where: { isActive: true, endTime: null },
include: [{ model: Application, as: 'application' }]
});
for (const track of activeTracking) {
// Fetch SLA configuration for this specific stage and entity type
const config = await SLAConfiguration.findOne({
where: {
stageName: track.stageName,
entityType: track.entityType,
isActive: true
}
where: { activityName: track.stageName, isActive: true },
include: [
{ model: SLAReminder, as: 'reminders' },
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
]
});
if (!config) continue;
const startTime = new Date(track.startTime);
const slaHours = config.slaHours;
const deadline = new Date(startTime.getTime() + (slaHours * 60 * 60 * 1000));
const tatMs = this.getTatInMs(config.tatHours, config.tatUnit);
const deadline = new Date(startTime.getTime() + tatMs);
if (now > deadline) {
// BREACH DETECTED
console.log(`[SLA Service] Breach detected for ${track.entityType}: ${track.application?.applicationId || track.entityId}, Stage: ${track.stageName}`);
// CASE A: Not Breached Yet - Check Reminders
if (!track.isBreached && now < deadline) {
await this.processReminders(track, config, now, deadline);
}
// CASE B: Just Breached - Mark it and trigger Level 1 Escalation
else if (!track.isBreached && now >= deadline) {
await this.triggerBreach(track, now);
}
// CASE C: Already Breached - Check for Escalations
else if (track.isBreached) {
await this.processEscalations(track, config, now, deadline);
}
}
}
// Update tracking record
/**
* Start tracking SLA for a new stage
*/
static async startTrack(applicationId: string, stageName: string) {
console.log(`[SLA Service] Starting SLA track for App: ${applicationId}, Stage: ${stageName}`);
// Ensure NO other active tracks for this application exist
await SLATracking.update(
{ isActive: false, endTime: new Date() },
{ where: { applicationId, isActive: true, endTime: null } }
);
const config = await SLAConfiguration.findOne({
where: { activityName: stageName, isActive: true }
});
if (config) {
await SLATracking.create({
applicationId,
stageName,
startTime: new Date(),
isActive: true
});
}
}
/**
* Stop tracking SLA for a stage
*/
static async stopTrack(applicationId: string, stageName: string) {
console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`);
await SLATracking.update(
{ isActive: false, endTime: new Date() },
{ where: { applicationId, stageName, isActive: true } }
);
}
private static getTatInMs(value: number, unit: string): number {
const factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
return value * factor;
}
private static async processReminders(track: any, config: any, now: Date, deadline: Date) {
const msRemaining = deadline.getTime() - now.getTime();
for (const reminder of config.reminders || []) {
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
if (msRemaining <= reminderMs) {
const metadata = track.metadata || {};
const reminderKey = `reminder_sent_${reminder.id}`;
if (!metadata[reminderKey]) {
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Reminder: ${track.stageName}`,
message: `The application ${track.application?.applicationId} is approaching its SLA deadline for ${track.stageName}.`
});
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`);
metadata[reminderKey] = true;
await track.update({ metadata });
}
}
}
}
private static async triggerBreach(track: any, now: Date) {
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`);
await track.update({ isBreached: true });
// Create Breach Record
await SLABreach.create({
trackingId: track.id,
applicationId: track.applicationId,
@ -53,34 +131,187 @@ export class SLAService {
status: 'Open'
});
// Notify Stakeholders (Escalation)
await this.notifyEscalated(track);
}
}
console.log(`[SLA Service] Breach check completed at ${now.toISOString()}`);
}
private static async notifyEscalated(track: any) {
// Find the assigned user to notify them and potentially their manager
const application = await Application.findByPk(track.applicationId, {
include: [{ model: User, as: 'assignedToUser' }]
await this.notifyStakeholder(track, 'SLA_BREACH', {
title: `SLA BREACHED: ${track.stageName}`,
message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.`
});
if (application?.assignedToUser) {
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`);
}
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
const msSinceBreach = now.getTime() - deadline.getTime();
for (const esc of config.escalationConfigs || []) {
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]) {
console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`);
const { User, Application, District, Region, Zone } = db;
let targetEmail = esc.notifyEmail;
let recipientId = null;
// Runtime Resolution: Resolve role to a specific user/email if role is provided
if (esc.notifyRole) {
const app = await Application.findByPk(track.applicationId, {
include: [{
model: District,
as: 'district',
include: [
{ model: Region, as: 'region' },
{ model: Zone, as: 'zone' }
]
}]
});
if (app?.district) {
const d = app.district;
const r = d.region;
const z = d.zone;
// Map geography-bound roles
const roleMap: Record<string, string | null> = {
'ASM': d.asmId,
'DD-ZM': d.zmId,
'RBM': r?.rbmId || null,
'ZBH': z?.zbhId || null
};
if (roleMap[esc.notifyRole]) {
recipientId = roleMap[esc.notifyRole];
}
}
// Fallback/National roles: Resolve by roleCode singleton
if (!recipientId) {
const user = await User.findOne({
where: { roleCode: esc.notifyRole, status: 'active' },
order: [['createdAt', 'DESC']]
});
if (user) recipientId = user.id;
}
}
// Resolve final email and phone if we have a recipientId
let phone = null;
if (recipientId) {
const user = await User.findByPk(recipientId);
if (user) {
targetEmail = user.email;
phone = user.mobileNumber || user.phone || null;
}
}
if (targetEmail) {
await NotificationService.notify(recipientId, targetEmail, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
message: `The application ${track.application?.applicationId} remains incomplete after SLA breach for ${track.stageName}.`,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'SLA_ESCALATION',
placeholders: {
applicationId: track.application?.applicationId || '',
stageName: track.stageName,
level: esc.level,
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: phone || ''
}
});
}
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyEmail} for stage ${track.stageName}.`);
metadata[escKey] = true;
await track.update({ metadata });
}
}
}
}
private static async logWorkNote(applicationId: string, text: string) {
try {
const { Worknote, User } = db;
// Find a system user or admin to be the author
const admin = await User.findOne({ where: { role: 'Super Admin' } });
await Worknote.create({
applicationId,
userId: admin?.id || (await User.findOne())?.id,
noteText: text,
noteType: 'system',
status: 'active'
});
} catch (err) {
console.error('[SLA Service] Failed to log work note:', err);
}
}
private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) {
const { Application, User, SLAConfiguration } = db;
// 1. Get the configuration for this stage to find the Owner Role(s)
const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } });
if (!config || !config.ownerRole) return;
// 2. Resolve multiple roles (comma-separated)
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
const application = await Application.findByPk(track.applicationId, {
include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }]
});
if (!application) return;
const recipientIds = new Set<string>();
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
await NotificationService.notify(application.assignedTo, application.assignedToUser.email, {
title: `SLA BREACH: ${track.stageName}`,
message: `The application ${application.applicationId} has breached the SLA for ${track.stageName}. Current stage: ${application.currentStage}.`,
channels: ['email', 'system'],
templateCode: 'SLA_BREACH_WARNING',
for (const role of roles) {
let foundUserId = null;
// Resolve geography-bound roles
if (application.district) {
const d = application.district;
const roleMap: Record<string, string | null> = {
'ASM': d.asmId,
'DD-ZM': d.zmId,
'RBM': d.region?.rbmId || null,
'ZBH': d.zone?.zbhId || null
};
if (roleMap[role]) foundUserId = roleMap[role];
}
if (foundUserId) {
recipientIds.add(foundUserId);
} else {
// Fallback: Resolve all active users with this role
const users = await User.findAll({ where: { roleCode: role, status: 'active' } });
users.forEach((u: any) => recipientIds.add(u.id));
}
}
// 3. Send notifications to all resolved recipients
for (const userId of recipientIds) {
const user = await User.findByPk(userId);
if (!user) continue;
const phone = user.mobileNumber || user.phone || null;
await NotificationService.notify(userId, user.email, {
title: content.title,
message: content.message,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: template,
placeholders: {
applicationId: application.applicationId || String(application.id),
stageName: track.stageName,
currentStage: application.currentStage || '',
link: `${portalBase}/applications/${application.id}`,
ctaLabel: 'Open application'
phone: phone || ''
},
metadata: { type: 'error', applicationId: application.id }
metadata: { applicationId: application.id }
});
}
}

View File

@ -83,7 +83,8 @@ export class TerminationWorkflowService {
{ id: termination.dealerId },
{ dealerId: termination.dealerId }
]
}
},
attributes: ['id', 'email', 'fullName', 'mobileNumber']
});
if (user) {
@ -119,7 +120,8 @@ export class TerminationWorkflowService {
deadline: deadlineStr,
link: dealerPortalLink,
remarks: remarks || '',
ctaLabel: 'Submit response'
ctaLabel: 'Submit response',
phone: user?.mobileNumber || user?.phone || ''
}
});
}
@ -173,13 +175,13 @@ export class TerminationWorkflowService {
if (!dealerProfile) throw new Error('Dealer record not found for termination');
// 2. Resolve or Create FnF Settlement
let fnf = await db.FnF.findOne({ where: { terminationRequestId: terminationId } });
let fnf = await db.FnF.findOne({ where: { terminationRequestId: termination.id } });
let fnfId = fnf?.id;
if (!fnf) {
fnf = await db.FnF.create({
settlementId: NomenclatureService.generateFnFId(),
terminationRequestId: terminationId,
terminationRequestId: termination.id,
dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null,
status: 'Initiated',
@ -206,6 +208,33 @@ export class TerminationWorkflowService {
// 4. Assign Participants for F&F (Sub-application chat)
if (fnfId) {
await ParticipantService.assignFnFParticipants(fnfId);
// SRS §1.1.1: Notify DD Admin and Finance via Email & WhatsApp
try {
const adminUsers = await User.findAll({
where: { roleCode: [ROLES.DD_ADMIN, ROLES.FINANCE] },
attributes: ['id', 'email', 'fullName', 'mobileNumber']
});
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
for (const u of adminUsers) {
const phone = u.mobileNumber || null;
await NotificationService.notify(u.id, u.email, {
title: `F&F Settlement Initiated: ${termination.requestId}`,
message: `Full & Final Settlement has been initiated for ${dealerUser?.fullName || 'Dealer'}.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'FNF_INITIATED',
placeholders: {
dealerName: dealerUser?.fullName || 'Dealer',
requestId: termination.requestId,
link: `${portalBase}/fnf-settlements/${fnf.id}`,
phone: phone || ''
}
});
}
} catch (notifyErr) {
console.error('[TerminationWorkflowService] F&F initiation notification failed:', notifyErr);
}
}
return fnf;

View File

@ -7,6 +7,7 @@ import {
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
} from '../common/config/constants.js';
import { NotificationService } from './NotificationService.js';
import { SLAService } from './SLAService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
export class WorkflowService {
@ -100,7 +101,19 @@ export class WorkflowService {
},
});
// 4. Progress sync — non-fatal (DB state is already committed)
// 4. SLA Tracking — non-fatal
try {
if (previousStage) {
await SLAService.stopTrack(application.id, previousStage);
}
if (stageForDbColumn) {
await SLAService.startTrack(application.id, stageForDbColumn);
}
} catch (slaErr) {
console.error('[WorkflowService] SLA track transition failed (non-fatal):', slaErr);
}
// 5. Progress sync — non-fatal (DB state is already committed)
try {
await syncApplicationProgress(application.id, targetStatus);
} catch (syncErr) {
@ -112,7 +125,7 @@ export class WorkflowService {
try {
const user = await User.findOne({
where: { email: application.email },
attributes: ['id'],
attributes: ['id', 'mobileNumber'],
});
const targetUserId = user ? user.id : null;
@ -141,6 +154,7 @@ export class WorkflowService {
serviceCode: application.dealerCode?.serviceCode || 'N/A',
link: `${portalBase}/applications/${application.id}`,
ctaLabel,
phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || ''
},
});
} catch (notifyErr) {

View File

@ -123,7 +123,7 @@ async function prospectLogin(phone) {
async function mockUploadDocument(appId, token, docType) {
const formData = new FormData();
const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG');
const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-24 19-16-05.png');
const blob = new Blob([fileBuffer], { type: 'image/png' });
formData.append('file', blob, 'screenshot.png');
formData.append('documentType', docType);