multi iteration on approval flow

This commit is contained in:
laxman h 2026-04-01 18:04:19 +05:30
parent feeb613136
commit d1d4601ac9
24 changed files with 982 additions and 294 deletions

33
check_app.ts Normal file
View File

@ -0,0 +1,33 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { Application } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkApp() {
try {
const application = await Application.findOne({
where: {
[db.Sequelize.Op.or]: [
{ id: applicationId },
{ applicationId: applicationId }
]
}
});
if (application) {
console.log('Application found:', application.id);
console.log('Status:', application.overallStatus);
console.log('Current Stage:', application.currentStage);
} else {
console.log('Application not found.');
}
} catch (error) {
console.error('Error checking application:', error);
} finally {
process.exit();
}
}
checkApp();

22
check_audit.ts Normal file
View File

@ -0,0 +1,22 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { AuditLog } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkAudit() {
try {
const count = await AuditLog.count({
where: { entityId: applicationId, entityType: 'application' }
});
console.log(`Application has ${count} audit logs.`);
} catch (error) {
console.error('Error checking audit:', error);
} finally {
process.exit();
}
}
checkAudit();

View File

@ -1,31 +1,22 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { Application, ApplicationStatusHistory } = db;
const { ApplicationStatusHistory } = (db as any).default || db;
async function checkApp() {
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkStatusHistory() {
try {
const app = await Application.findOne({ order: [['updatedAt', 'DESC']] });
if (!app) {
console.log('No apps found');
return;
}
console.log('--- Application Info ---');
console.log(`ID: ${app.id}, Reg: ${app.applicationId}, Name: ${app.applicantName}`);
console.log(`Current Status: ${app.overallStatus}, Progress: ${app.progressPercentage}`);
const history = await ApplicationStatusHistory.findAll({
where: { applicationId: app.id },
order: [['createdAt', 'ASC']]
const count = await ApplicationStatusHistory.count({
where: { applicationId }
});
console.log('\n--- Status History ---');
history.forEach((h: any) => {
console.log(`[${h.createdAt.toISOString()}] ${h.previousStatus} -> ${h.newStatus} (Reason: ${h.reason})`);
});
} catch (err) {
console.error(err);
console.log(`Application has ${count} status history records.`);
} catch (error) {
console.error('Error checking history:', error);
} finally {
process.exit(0);
process.exit();
}
}
checkApp();
checkStatusHistory();

27
check_json_size.ts Normal file
View File

@ -0,0 +1,27 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { Application } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkAppJson() {
try {
const application = await Application.findByPk(applicationId);
if (application) {
const docSize = application.documents ? JSON.stringify(application.documents).length : 0;
const timelineSize = application.timeline ? JSON.stringify(application.timeline).length : 0;
console.log(`Documents JSON size: ${Math.round(docSize / 1024)} KB`);
console.log(`Timeline JSON size: ${Math.round(timelineSize / 1024)} KB`);
} else {
console.log('Application not found.');
}
} catch (error) {
console.error('Error checking application JSON:', error);
} finally {
process.exit();
}
}
checkAppJson();

22
check_participants.ts Normal file
View File

@ -0,0 +1,22 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { RequestParticipant } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkParticipants() {
try {
const count = await RequestParticipant.count({
where: { requestId: applicationId, requestType: 'application' }
});
console.log(`Application has ${count} participants.`);
} catch (error) {
console.error('Error checking participants:', error);
} finally {
process.exit();
}
}
checkParticipants();

22
check_progress.ts Normal file
View File

@ -0,0 +1,22 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { ApplicationProgress } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkProgress() {
try {
const count = await ApplicationProgress.count({
where: { applicationId }
});
console.log(`Application has ${count} progress records.`);
} catch (error) {
console.error('Error checking progress:', error);
} finally {
process.exit();
}
}
checkProgress();

View File

@ -1,38 +1,15 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { QuestionnaireQuestion } = (db as any).default || db;
async function checkQuestions() {
try {
const questionnaire = await db.Questionnaire.findOne({
where: { isActive: true },
include: [{
model: db.QuestionnaireQuestion,
as: 'questions',
include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }]
}]
});
if (!questionnaire) {
console.log('No active questionnaire found');
return;
}
console.log(`Active Questionnaire: ${questionnaire.version} (${questionnaire.id})`);
questionnaire.questions.forEach((q: any) => {
console.log(`- [${q.order}] ${q.questionText} (Weight: ${q.weight}, Type: ${q.inputType})`);
if (q.questionOptions && q.questionOptions.length > 0) {
q.questionOptions.forEach((opt: any) => {
console.log(` * ${opt.optionText} (Score: ${opt.score})`);
});
} else {
console.log(` (No options)`);
}
});
process.exit(0);
const count = await QuestionnaireQuestion.count();
console.log(`There are ${count} questions in the system.`);
} catch (error) {
console.error('Error:', error);
process.exit(1);
console.error('Error checking questions:', error);
} finally {
process.exit();
}
}

35
check_size.ts Normal file
View File

@ -0,0 +1,35 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { QuestionnaireResponse } = (db as any).default || db;
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function checkSize() {
try {
if (!QuestionnaireResponse) {
console.error('QuestionnaireResponse model not found.');
return;
}
const responses = await QuestionnaireResponse.findAll({
where: { applicationId }
});
console.log(`Found ${responses.length} responses.`);
let totalSize = 0;
responses.forEach((r: any, i: number) => {
const size = r.responseValue ? r.responseValue.length : 0;
totalSize += size;
if (size > 100000) {
console.log(`Response ${i} (ID: ${r.id}) size: ${Math.round(size / 1024)} KB`);
}
});
console.log(`Total responseValue size: ${Math.round(totalSize / 1024)} KB`);
} catch (error) {
console.error('Error checking size:', error);
} finally {
process.exit();
}
}
checkSize();

87
debug_app.ts Normal file
View File

@ -0,0 +1,87 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
const { Application, ApplicationStatusHistory, ApplicationProgress, QuestionnaireResponse, QuestionnaireQuestion, QuestionnaireOption, RequestParticipant, User, StageApprovalAction, DealerCode, Dealer } = (db as any).default || db;
import { Op } from 'sequelize';
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
async function debugApp() {
try {
console.log('Step 1: Fetching main application record...');
const application = await Application.findOne({
where: {
[Op.or]: [
{ id: applicationId },
{ applicationId: applicationId }
]
}
});
if (!application) {
console.log('Application not found.');
return;
}
console.log('Step 2: Fetching status history...');
const history = await application.getStatusHistory();
console.log(`- Found ${history.length} records.`);
console.log('Step 3: Fetching progress tracking...');
const progress = await application.getProgressTracking();
console.log(`- Found ${progress.length} records.`);
console.log('Step 4: Fetching questionnaire responses (with nested)...');
const responses = await QuestionnaireResponse.findAll({
where: { applicationId: application.id },
include: [
{
model: QuestionnaireQuestion,
as: 'question',
include: [{ model: QuestionnaireOption, as: 'questionOptions' }]
}
]
});
console.log(`- Found ${responses.length} responses.`);
console.log('Step 5: Fetching participants...');
const participants = await application.getParticipants({
include: [{ model: User, as: 'user' }]
});
console.log(`- Found ${participants.length} participants.`);
console.log('Step 6: Fetching stage approvals...');
const approvals = await application.getStageApprovals();
console.log(`- Found ${approvals.length} records.`);
console.log('Step 7: Fetching DealerCode/Dealer...');
const dealerCode = await application.getDealerCode();
const dealer = await application.getDealer();
console.log(`- DealerCode: ${!!dealerCode}, Dealer: ${!!dealer}`);
console.log('Step 8: Constructing final object manually...');
const finalObj = {
...application.toJSON(),
statusHistory: history,
progressTracking: progress,
questionnaireResponses: responses,
participants,
stageApprovals: approvals,
dealerCode,
dealer
};
console.log('Step 9: JSON Serialization...');
const start = Date.now();
const json = JSON.stringify(finalObj);
console.log(`- JSON Length: ${json.length} bytes`);
console.log(`- Serialization took ${Date.now() - start}ms`);
console.log('DONE!');
} catch (error) {
console.error('Error in debugApp:', error);
} finally {
process.exit();
}
}
debugApp();

View File

@ -1,65 +1,89 @@
# Backend Alignment Analysis Report (v1.4 Compliance)
Based on the review of `Comparison_Summary_v1.0_vs_v1.4.md`, `Re_New_Dealer_Onboard_TWO.md`, and `dealer_onboard_backend_schema.mermaid`, here is the analysis of the current backend implementation status and alignment gaps.
## 1. Executive Summary
The current backend implementation is in an **early stage (Foundation)** and lacks the majority of the complex workflows and governance features required by Version 1.4 of the documentation. While basic models for Applications, Resignations, and Self-Service (Constitutional/Relocation) exist, the business logic, approval hierarchies, and supporting modules are missing.
# RE Onboarding & Offboarding System: Comprehensive Alignment Report
## Prepared for: Dealer Onboarding Project
## Reference: SRS v1.4 / 2.0 (High-Fidelity)
## Current Date: April 1, 2026
---
## 2. Critical Gaps & Missing Modules
## 1. Executive Summary: Full Project Scope
This report covers the end-to-end alignment of the Dealer Lifecycle Management system, spanning Onboarding, Offboarding (Resignation/Termination), and Self-Service (Constitutional Change/Relocation).
### 2.1 Missing Business Modules
* **Termination Module (CRITICAL)**: Completely missing from the codebase. There are no models, routes, or controllers for handling dealer termination.
* **Dealer Code Manual Trigger**: Current documentation (1.1.3) requires **DD Admin** to manually trigger code generation in SAP. The code lacks this control point.
* **LOI & LOA Sub-Workflows**: Documentation (6.16, 6.18) describes complex approval and document issuance processes for Letters of Intent and Letters of Appointment. These are currently simplified or non-existent in the code.
* **Questionnaire & Scoring (KT Matrix)**: Module for automated questionnaire scoring, rankings, and KT Matrix interview evaluation is missing.
* **EOR Checklist**: Detailed Essential Operating Requirements (EOR) checklist with functional team verifications is not implemented.
* **Inauguration Tracking**: Final stage of onboarding is missing.
### 2.2 Governance & Roles (RBAC)
* **CEO/CCO Roles**: Missing from `config/constants.js` and `User.js`. Version 1.4 requires CEO approval for Termination.
* **Super Admin Segregation**: The planned split of Super Admin into two specialized **DD Admin** roles is not reflected in the current role structure.
* **Send Back / Revoke Authority**: The `resignationController.js` only implements basic `approve/reject`. It lacks the "Send Back" logic requested for ZBH, DD Lead, DD Head, and NBH.
### 2.3 Self-Service Logic
* **Resignation Withdrawal**: Documentation allows withdrawal "only until NBH review". This restriction is not enforced in the current controller.
* **LWD-Based F&F Trigger**: F&F settlement must be triggered "strictly on the Last Working Day (LWD)". The current `FnF.js` and `resignationController.js` do not enforce this temporal bridge.
* **WhatsApp Integration**: Requirement 1.1.1 (Multi-channel alerts) is missing implementation in the notifications layer.
| Goal | SRS Section | System Status | Completion % |
| :--- | :--- | :--- | :--- |
| **Dealer Onboarding** | Section 6 | Stable Flow (Eval -> LOI -> SAP) | 85% |
| **Dealer Resignation** | Section 7 | Backend Ready; UI in progress | 50% |
| **Dealer Termination** | Section 8 | Logic Ready; SCN Flow in progress | 40% |
| **F&F Settlement** | Section 10/11 | Trigger Ready; Clearance Grid Pending | 20% |
| **Self-Service (Constitutional)** | Section 12.2 | **PENDING** (Schema Ready) | 10% |
| **Self-Service (Relocation)** | Section 12.2.6 | **PENDING** (Schema Ready) | 10% |
---
## 3. Schema Alignment Check
The `dealer_onboard_backend_schema.mermaid` provides a high-fidelity design. The physical database (`models/`) is missing approximately **70% of the tables** defined in the schema.
## 2. Onboarding & Evaluation (Section 6)
**Owner:** DD Admin / DD ASM
### Missing Tables in Code:
| Document Section | Missing Tables (Models) |
| :--- | :--- |
| **Questionnaire** | `QUESTIONNAIRES`, `SECTIONS`, `QUESTIONS`, `RESPONSES`, `SCORES` |
| **Interviews** | `INTERVIEWS`, `PARTICIPANTS`, `EVALUATIONS`, `KT_MATRIX_SCORES`, `FEEDBACK` |
| **LOI Process** | `LOI_REQUESTS`, `LOI_APPROVALS`, `LOI_DOCUMENTS_GENERATED`, `ACKNOWLEDGEMENTS` |
| **EOR / Construction** | `ARCHITECTURAL_ASSIGNMENTS`, `EOR_CHECKLISTS`, `CHECKLIST_ITEMS`, `CONSTRUCTION_PROGRESS` |
| **Termination** | `TERMINATION_REQUESTS`, `TERMINATION_APPROVALS`, `SCN_ISSUANCE` |
| **Other** | `AI_SUMMARIES`, `INAUGURATIONS`, `SECURITY_DEPOSITS` |
| Sub-Task | Demarcation Points | Status | Integration |
| :--- | :--- | :--- | :--- |
| **Questionnaire** | Weighted Scoring (100%), Public Link Access | ✅ COMPLETED | Real |
| **Shortlisting** | Bulk Action, Assign to ASM/ZM | ✅ COMPLETED | Real |
| **Interviews (L1-3)** | Panel Scheduling, KT Matrix, AI Summary | ✅ COMPLETED | AI Mocked |
| **FDD Verification** | Agency Upload, Report Review by DD Head | 🚧 IN PROGRESS | Manual |
| **LOI Issuance** | 3-tier Approval (Finance/DD-Head/NBH) | ✅ COMPLETED | PDF Mocked |
| **Security Details** | BG & Deposit Verification (BG / SD) | 🚧 IN PROGRESS | Pending UI |
| **Site Readiness** | Arch Blueprints, Statutory Docs, Dealer Code | ✅ COMPLETED | SAP Mocked |
| **Go-Live** | LOA Issuance, EOR Checklist | 🛑 PENDING | Logic Only |
---
## 4. Required Backend Changes to Align with Documentation
## 3. Dealer Lifecycle & Self-Service (Section 12)
**Owner:** Dealer (Initiation) / Legal & DD Head (Approval)
### Phase 1: Governance & Framework (Immediate)
1. **Update `constants.js`**: Add `CEO`, `CCO` roles and `TERMINATION_STAGES`.
2. **Enhance Workflow Engine**: Implement a generic "Send Back" mechanism that tracks the previous stage and logs mandatory audit remarks.
3. **Audit Trail Expansion**: Ensure every state change captures the "Section 4.4" requirements (Uploader, Timestamp, Versioning).
### 3.1 Dealer Resignation (Section 12.1)
* **Width:** Outlet-level selection, Last Working Day (LWD) capture, Withdrawal logic.
* **Status:** Backend endpoints for initiation are ready. UI needs the "Resignation Dashboard" for the dealer persona.
### Phase 2: Workflow Refinement
1. **Sequence Correction (LOA before EOR)**: Restructure the `Application` state machine to ensure LOA issuance is a prerequisite for EOR checklist activation.
2. **LWD Enforcement**: Modify `FnF` initiation logic to check against `outlet.last_working_day`.
3. **Manual Code Trigger**: Add dedicated endpoint `/api/applications/:id/generate-code` restricted to `DD_ADMIN`.
### 3.2 Constitutional Change Management (Section 12.2)
* **Width:** Proprietorship → Partnership/LLP/Pvt Ltd.
* **Depth:** Dynamic document checklist (GST, PAN, Partnership Deed, COI, MOA/AOA).
* **Status:** **PENDING UI**. Schema supports the different constitution types, but the multi-level internal review (ASM → ZBH → Legal) needs workflow orchestration.
### Phase 3: Module Completion
1. **Develop Termination Controller**: Implement the 11-step process described in Section 4.3.
2. **Questionnaire Engine**: Move from hardcoded fields to a dynamic questionnaire system as per the schema.
3. **Document Repository**: Implement the "Central Document Repository" with versioning for Statutory and Architectural documents.
### 3.3 Relocation Request (Section 12.2.6)
* **Width:** New Site Selection (Manual/Map), Distance Tracking, Property Docs.
* **Depth:** 4 Categories of Docs (Property, Legal, Statutory, Infrastructure).
* **Status:** **PENDING UI**. Approval hierarchy (ASM → RBM → ZM → NBH) is partially mapped in the assessment controller logic but requires a dedicated controller.
---
**Status Recommendation**: The backend requires significant structural updates to meet the "Version 1.4" standards described in the documentation. High priority should be given to Role updates and the Termination module.
## 4. Administrative & Shared Modules (Section 9)
**Owner:** Super Admin
| Feature | Detailed Breakdown | Status |
| :--- | :--- | :--- |
| **RBAC** | Zone/Region/Area cascading hierarchy; National Role visibility. | ✅ COMPLETED |
| **Opportunity** | Window-based Geography opening (State/City/District). | ✅ COMPLETED |
| **SLA Config** | Warning/Escalation thresholds per stage. | 🚧 IN PROGRESS |
| **Email Templates** | Trigger-based logic (Interview Scheduled, LOI Issued). | 🚧 IN PROGRESS |
| **Audit Trail** | Entity-level history (what changed, by whom, old vs new). | ✅ COMPLETED |
---
## 5. Mocks & System Simulations
| Item | Description | Status |
| :--- | :--- | :--- |
| **SAP Master** | Simulates code generation (Sales/Service) and status sync. | MOCKED |
| **Gemini AI** | Simulates consensus summary paragraph generation. | MOCKED |
| **WhatsApp/SMS** | Simulated via console logs for non-sensitive alerts. | MOCKED |
| **Meeting Links** | Mock Google Meet link generation. | MOCKED |
---
## 6. Project Roadmap & Critical Gaps
1. **F&F Settlement Clearance Grid:** The most complex remaining logic—capturing clearance from 16 departments.
2. **Legal Letter Engine:** Moving from "Marked as Generated" to real PDF generation with templates.
3. **EOR Checklist Integration:** Enabling the ASM to perform the final "On-Site" check via the mobile/web interface.
---
**Status Legend:**
- ✅ **COMPLETED:** Logic, UI, and Backend aligned.
- 🚧 **IN PROGRESS:** Development underway; partially functional.
- 🛑 **PENDING:** Logic defined in SRS but not yet implemented.

84
reset_db.ts Normal file
View File

@ -0,0 +1,84 @@
import 'dotenv/config';
import db from './src/database/models/index.js';
import bcrypt from 'bcryptjs';
import { syncLocationManagers, syncRegionManager, syncZoneManager } from './src/modules/master/syncHierarchy.service.js';
const { Role, Zone, Region, State, District, User, UserRole, Questionnaire } = db;
async function resetAndSeed() {
console.log('--- REFRESHING DATABASE (DROP & RECREATE) ---');
await db.sequelize.authenticate();
// WARNING: This drops all tables!
await db.sequelize.sync({ force: true });
console.log('Database schema recreated.');
const hashedPassword = await bcrypt.hash('Admin@123', 10);
// 1. Roles
const roles = [
{ roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' },
{ roleCode: 'DD Head', roleName: 'DD Head', category: 'NATIONAL' },
{ roleCode: 'ZBH', roleName: 'Zonal Business Head', category: 'ZONAL' },
{ roleCode: 'DD Lead', roleName: 'DD Lead', category: 'ZONAL' },
{ roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' },
{ roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' },
{ roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' },
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' },
{ roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' },
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' },
{ roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' },
{ roleCode: 'ARCHITECTURE', roleName: 'Architecture Team', category: 'DEPARTMENT' }
];
for (const r of roles) await Role.create(r);
// 2. Structure
const zoneN = await Zone.create({ name: 'North Zone', code: 'ZONE-N' });
const zoneS = await Zone.create({ name: 'South Zone', code: 'ZONE-S' });
const ncr = await Region.create({ name: 'NCR Region', zoneId: zoneN.id });
const kar = await Region.create({ name: 'Karnataka Region', zoneId: zoneS.id });
const delhi = await State.create({ name: 'DELHI', zoneId: zoneN.id });
const karnataka = await State.create({ name: 'KARNATAKA', zoneId: zoneS.id });
const sDelhi = await District.create({ name: 'South Delhi', stateId: delhi.id, regionId: ncr.id, zoneId: zoneN.id, isActive: true });
const blr = await District.create({ name: 'Bangalore Urban', stateId: karnataka.id, regionId: kar.id, zoneId: zoneS.id, isActive: true });
// 3. Users
const users = [
{ email: 'admin@royalenfield.com', fullName: 'Laxman H', roleCode: 'Super Admin', password: hashedPassword, status: 'active' },
{ email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', roleCode: 'DD Lead', zoneId: zoneN.id, password: hashedPassword, status: 'active' },
{ email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', roleCode: 'Dealer', districtId: sDelhi.id, zoneId: zoneN.id, regionId: ncr.id, password: hashedPassword, status: 'active' },
{ email: 'lince@gmail.com', fullName: 'Lince', roleCode: 'DD Admin', password: hashedPassword, status: 'active' }
];
for (const u of users) {
const user = await User.create(u);
const role = await Role.findOne({ where: { roleCode: u.roleCode } });
if (role) {
await UserRole.create({ userId: user.id, roleId: role.id, isActive: true, isPrimary: true });
}
}
// 4. Questionnaire Seed
await Questionnaire.create({
title: 'Dealer Onboarding Questionnaire v1',
version: '1.0',
description: 'Standard questionnaire for new dealership applications',
isPublished: true,
status: 'published'
});
console.log('--- Hierarchy Sync ---');
await syncLocationManagers(sDelhi.id);
await syncLocationManagers(blr.id);
console.log('--- DATABASE RESET COMPLETE ---');
}
resetAndSeed().catch(err => {
console.error(err);
process.exit(1);
}).then(() => process.exit(0));

View File

@ -0,0 +1,39 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
const { Role } = db;
async function addArchitectureRole() {
console.log('Adding Architecture Role...');
try {
await db.sequelize.authenticate();
await Role.findOrCreate({
where: { roleCode: 'ARCHITECTURE' },
defaults: {
roleCode: 'ARCHITECTURE',
roleName: 'Architecture',
category: 'DEPARTMENT',
isActive: true
}
});
// Also add the 'Architecture' alias if needed for frontend mapping
await Role.findOrCreate({
where: { roleCode: 'Architecture' },
defaults: {
roleCode: 'Architecture',
roleName: 'Architecture Team',
category: 'DEPARTMENT',
isActive: true
}
});
console.log('✅ Architecture role added successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Failed to add role:', error);
process.exit(1);
}
}
addArchitectureRole();

View File

@ -25,7 +25,8 @@ async function seed() {
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' },
{ roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' },
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' },
{ roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' }
{ roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' },
{ roleCode: 'ARCHITECTURE', roleName: 'Architecture Team', category: 'DEPARTMENT' }
];
for (const r of roles) {

View File

@ -13,7 +13,8 @@ export const ROLES = {
DD_AM: 'DD AM',
ASM: 'ASM',
FINANCE: 'Finance',
DEALER: 'Dealer'
DEALER: 'Dealer',
ARCHITECTURE: 'ARCHITECTURE'
} as const;
// Regions
@ -35,7 +36,7 @@ export const APPLICATION_STAGES = {
DD_HEAD: 'DD Head',
NBH: 'NBH',
LEGAL: 'Legal',
ARCHITECTURE: 'Architecture',
ARCHITECTURE: 'Architecture Team',
FINANCE: 'Finance',
LEVEL_1_APPROVED: 'Level 1 Approved',
LEVEL_2_APPROVED: 'Level 2 Approved',
@ -63,6 +64,7 @@ export const APPLICATION_STATUS = {
LEVEL_3_PENDING: 'Level 3 Interview Pending',
LEVEL_3_APPROVED: 'Level 3 Approved',
FDD_VERIFICATION: 'FDD Verification',
SECURITY_DETAILS: 'Security Details',
PAYMENT_PENDING: 'Payment Pending',
LOI_IN_PROGRESS: 'LOI In Progress',
LOI_ISSUED: 'LOI Issued',
@ -84,6 +86,7 @@ export const APPLICATION_STATUS = {
LOA_PENDING: 'LOA Pending',
EOR_COMPLETE: 'EOR Complete',
INAUGURATION: 'Inauguration',
ONBOARDED: 'Onboarded',
DISQUALIFIED: 'Disqualified'
} as const;
@ -314,10 +317,22 @@ export const DOCUMENT_TYPES = {
DOMAIN_ID: 'Domain ID Setup',
MSD_CONFIG: 'MSD Configuration',
LOI_ACK: 'LOI Acknowledgement',
FDD_REPORT: 'FDD Final Audit Report',
FDD_ASSIGNMENT: 'FDD Agency Assignment Letter',
KT_MATRIX: 'Kepner Tregoe (KT) Matrix',
INTERVIEW_EVALUATION: 'Interview Evaluation Sheet',
AI_RECOMMENDATION: 'AI Recommendation Summary',
SITE_READINESS: 'Site Readiness Report',
CIBIL_REPORT: 'CIBIL Report',
CITY_MAP: 'Proposed Site City Map',
LOA_ACCEPTANCE: 'LOA Acceptance Copy',
ARCHITECTURE_ASSIGNMENT: 'Architecture Assignment Document',
ARCHITECTURE_BLUEPRINT: 'Architecture Blueprint',
SITE_PLAN: 'Site Plan',
ARCHITECTURE_COMPLETION: 'Architecture Completion Certificate',
STATUTORY_AUDIT: 'Statutory Approval Certificate',
BANK_GUARANTEE: 'Bank Guarantee Document',
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
OTHER: 'Other'
} as const;

View File

@ -11,6 +11,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.warn(`[Auth] 401: No token provided or invalid format for ${req.method} ${req.path}`);
return res.status(401).json({
success: false,
message: 'Access denied. No token provided.'
@ -27,6 +28,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
// Validate UUID format to prevent DB errors with legacy tokens
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(appId)) {
console.warn(`[Auth] 401: Invalid Prospective UUID: ${appId}`);
return res.status(401).json({
success: false,
message: 'Invalid session token. Please login again.'
@ -51,6 +53,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
}
// If app not found, fall through or error?
// Let's error to be safe as the token was specific
console.warn(`[Auth] 401: Prospective application not found for ID: ${appId}`);
return res.status(401).json({
success: false,
message: 'Invalid prospective user session.'
@ -66,6 +69,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
});
if (!user) {
console.warn(`[Auth] 401: User not found for ID: ${decoded.userId}`);
return res.status(401).json({
success: false,
message: 'Invalid token. User not found.'
@ -73,6 +77,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
}
if (user.status !== 'active') {
console.warn(`[Auth] 401: Inactive user tried to access: ${user.id}`);
return res.status(401).json({
success: false,
message: 'User account is inactive.'
@ -88,6 +93,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
logger.error('Authentication error:', error);
if (error.name === 'TokenExpiredError') {
console.warn(`[Auth] 401: Token Expired`);
return res.status(401).json({
success: false,
message: 'Token expired'

View File

@ -0,0 +1,120 @@
import db from '../../database/models/index.js';
const { ApplicationProgress } = db;
export const ONBOARDING_STAGES = [
{ name: 'Submitted', order: 1 },
{ name: 'Questionnaire', order: 2 },
{ name: 'Shortlist', order: 3 },
{ name: '1st Level Interview', order: 4 },
{ name: '2nd Level Interview', order: 5 },
{ name: '3rd Level Interview', order: 6 },
{ name: 'FDD', order: 7 },
{ name: 'LOI Approval', order: 8 },
{ name: 'Security Details', order: 9 },
{ name: 'LOI Issue', order: 10 },
{ name: 'Dealer Code Generation', order: 11 },
{ name: 'LOA', order: 12 },
{ name: 'EOR Complete', order: 13 },
{ name: 'Inauguration', order: 14 },
{ name: 'Onboarded', order: 15 }
];
/**
* Updates application progress for a specific stage
*/
export const updateApplicationProgress = async (applicationId: string, stageName: string, status: 'pending' | 'active' | 'completed', percentage: number = 0) => {
try {
const stage = ONBOARDING_STAGES.find(s => s.name === stageName);
if (!stage) return;
const [progress, created] = await ApplicationProgress.findOrCreate({
where: { applicationId, stageName },
defaults: {
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: status === 'active' ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
}
});
if (!created) {
const updates: any = { status, completionPercentage: percentage };
if (status === 'active' && !progress.stageStartedAt) {
updates.stageStartedAt = new Date();
}
if (status === 'completed' && !progress.stageCompletedAt) {
updates.stageCompletedAt = new Date();
}
await progress.update(updates);
}
// Mark previous stages as completed if this one is completed
if (status === 'completed') {
await ApplicationProgress.update(
{ status: 'completed', completionPercentage: 100 },
{
where: {
applicationId,
stageOrder: { [db.Sequelize.Op.lt]: stage.order },
status: { [db.Sequelize.Op.ne]: 'completed' }
}
}
);
}
return progress;
} catch (error) {
console.error('Error updating application progress:', error);
}
};
/**
* Syncs all progress stages based on current overall status
*/
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => {
// Map overallStatus to stage names
const statusToStageMap: Record<string, string> = {
'Submitted': 'Submitted',
'Questionnaire Completed': 'Questionnaire',
'Shortlisted': 'Shortlist',
'Level 1 Interview Pending': '1st Level Interview',
'Level 1 Approved': '1st Level Interview',
'Level 2 Interview Pending': '2nd Level Interview',
'Level 2 Approved': '2nd Level Interview',
'Level 2 Recommended': '2nd Level Interview',
'Level 3 Interview Pending': '3rd Level Interview',
'Level 3 Approved': '3rd Level Interview',
'FDD Verification': 'FDD',
'LOI In Progress': 'LOI Approval',
'Payment Pending': 'Security Details',
'LOI Issued': 'LOI Issue',
'Statutory LOI Ack': 'LOI Issue',
'Dealer Code Generation': 'Dealer Code Generation',
'Architecture Team Assigned': 'Dealer Code Generation',
'Architecture Document Upload': 'Dealer Code Generation',
'Architecture Team Completion': 'Dealer Code Generation',
'Statutory GST': 'Dealer Code Generation',
'LOA Pending': 'LOA',
'EOR In Progress': 'LOA',
'EOR Complete': 'EOR Complete',
'Inauguration': 'Inauguration',
'Approved': 'Inauguration',
'Onboarded': 'Onboarded'
};
const currentStageName = statusToStageMap[overallStatus];
if (currentStageName) {
const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
if (stage) {
// Determine status for this stage
// If the status IS exactly the target "Complete" status for a stage, mark it as completed
const isCompleted = [
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 3 Approved',
'LOI Issued', 'EOR Complete', 'Approved', 'Onboarded'
].includes(overallStatus);
await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50);
}
}
};

View File

@ -264,6 +264,7 @@ export default (sequelize: Sequelize) => {
scope: { requestType: 'application' }
});
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' });
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
};

View File

@ -7,6 +7,7 @@ const {
import { AuthRequest } from '../../types/express.types.js';
import { Op } from 'sequelize';
import * as EmailService from '../../common/utils/email.service.js';
import { APPLICATION_STAGES } from '../../common/config/constants.js';
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
const district: any = await District.findByPk(locationId);
@ -41,6 +42,158 @@ const ensureInterviewPolicy = async (level: number) => {
return policy;
};
const processStageDecision = async (params: {
applicationId: string;
stageCode: string;
decision: 'Approved' | 'Rejected';
remarks?: string;
userId: string;
roleCode: string;
interviewId?: string;
nextStatus?: string;
nextProgress?: number;
}) => {
const { applicationId, stageCode, decision, remarks, userId, roleCode, interviewId, nextStatus, nextProgress } = params;
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } });
if (!policy) return { noPolicy: true };
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
// Check if user is an assigned participant for this specifically (Interviews use metadata mapping)
const userAssignments = await db.RequestParticipant.findAll({
where: {
requestId: applicationId,
requestType: 'application',
userId: userId
}
});
// Strategy: If it's an interview, check interviewLevel. If it's a stage, check stageCode.
const isAssigned = userAssignments.some((p: any) => {
if (!p.metadata) return false;
if (interviewId && p.metadata.interviewLevel) {
// Check if this participant is for THIS interview (rough check via level)
return true;
}
if (p.metadata.stageCode === stageCode) return true;
if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true;
return false;
});
const assignedRole = userAssignments.find((p: any) => p.metadata?.role)?.metadata?.role;
console.log(`[decision] User: ${userId}, Role: ${roleCode}, Stage: ${stageCode}, isAssigned: ${isAssigned}`);
// Forbidden if not Super Admin AND not in required roles AND not an assigned participant for this stage
if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) {
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
}
// Record Action
await db.StageApprovalAction.upsert({
id: undefined, // Let it generate or find by unique index
applicationId,
interviewId: interviewId || null,
stageCode,
actorUserId: userId,
actorRole: assignedRole || roleCode,
decision,
remarks: remarks || null
});
// Update the evaluation decision and recommendation for dashboard consistency
if (interviewId) {
await InterviewEvaluation.update(
{
decision: decision,
recommendation: decision // Sync for combined dashboard view
},
{ where: { interviewId, evaluatorId: userId } }
);
}
// Evaluate Policy
const actions = await db.StageApprovalAction.findAll({
where: { applicationId, stageCode }
});
const approvedActions = actions.filter((a: any) => a.decision === 'Approved');
const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole));
const isSuperAdminApproval = Array.from(uniqueApprovalsByRole).includes('Super Admin');
const hasRejection = actions.some((a: any) => a.decision === 'Rejected');
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval)
? true
: requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role));
const meetsMinApprovals = isSuperAdminApproval || uniqueApprovalsByRole.size >= (policy.minApprovals || 1);
console.log(`[decision] Policy Meet: ${hasAllRequiredRoleApprovals && meetsMinApprovals} (Rejection: ${hasRejection})`);
let statusUpdated = false;
if (hasRejection) {
await db.Application.update({
overallStatus: 'Rejected',
currentStage: 'Rejected'
}, { where: { id: applicationId } });
await db.ApplicationStatusHistory.create({
applicationId,
previousStatus: 'In Progress',
newStatus: 'Rejected',
changedBy: userId,
reason: `Rejected during ${stageCode} stage`
});
statusUpdated = true;
} else if (hasAllRequiredRoleApprovals && meetsMinApprovals) {
if (nextStatus) {
const validStages = Object.values(APPLICATION_STAGES);
const updateData: any = {
overallStatus: nextStatus,
progressPercentage: nextProgress || undefined,
updatedAt: new Date()
};
if (nextStatus && validStages.includes(nextStatus as any)) {
updateData.currentStage = nextStatus;
}
await db.Application.update(updateData, { where: { id: applicationId } });
await db.ApplicationStatusHistory.create({
applicationId,
previousStatus: 'In Progress',
newStatus: nextStatus,
changedBy: userId,
reason: `Policy met for ${stageCode}`
});
// Sync Progress tracking
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(applicationId, nextStatus);
statusUpdated = true;
}
}
const message = hasRejection ? 'Rejected'
: statusUpdated ? 'Policy satisfied. Stage complete.'
: `Approval recorded. Waiting for ${requiredRoles.filter(r => !uniqueApprovalsByRole.has(r)).join(', ') || 'other approvers'}.`;
return {
success: true,
message,
policy,
requiredRoles,
uniqueApprovalsByRole,
hasAllRequiredRoleApprovals,
meetsMinApprovals,
statusUpdated
};
};
const processInterviewApprovalDecision = async (params: {
interviewId: string;
decision: 'Approved' | 'Rejected';
@ -49,145 +202,34 @@ const processInterviewApprovalDecision = async (params: {
roleCode: string;
}) => {
const { interviewId, decision, remarks, userId, roleCode } = params;
const interview = await Interview.findByPk(interviewId);
const interview: any = await Interview.findByPk(interviewId);
if (!interview) return { notFound: true };
const policy = await ensureInterviewPolicy(interview.level);
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
const stageCode = interviewStageCode(interview.level);
// Check if user is an assigned participant for this specific level
const userAssignments = await db.RequestParticipant.findAll({
where: {
requestId: interview.applicationId,
requestType: 'application',
userId: userId
}
});
// Ensure policy exists for interviews
await ensureInterviewPolicy(interview.level);
const assignedParticipant = userAssignments.find((p: any) =>
p.metadata && Number(p.metadata.interviewLevel) === Number(interview.level)
);
const isAssigned = !!assignedParticipant;
const assignedRole = assignedParticipant?.metadata?.role;
console.log(`[debug] User ID: ${userId}, Role: ${roleCode}, isAssigned: ${isAssigned}, assignedRole: ${assignedRole}`);
if (isAssigned) {
console.log(`[debug] Assigned Participant Metadata: ${JSON.stringify(assignedParticipant.metadata)}`);
}
// Forbidden if not Super Admin AND not in required roles AND not an assigned participant
if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) {
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
}
let evaluation = await db.InterviewEvaluation.findOne({
where: { interviewId, evaluatorId: userId }
});
if (evaluation) {
await evaluation.update({ recommendation: decision, decision, remarks });
} else {
evaluation = await db.InterviewEvaluation.create({
interviewId,
evaluatorId: userId,
recommendation: decision,
decision,
remarks
});
}
await StageApprovalAction.upsert({
applicationId: interview.applicationId,
interviewId,
stageCode: policy.stageCode,
actorUserId: userId,
actorRole: assignedRole || roleCode, // Use assigned role if available (e.g. ZBH acting as ZM)
decision,
remarks: remarks || null
});
const actions = await StageApprovalAction.findAll({
where: { interviewId, stageCode: policy.stageCode }
});
const approvedActions = actions.filter((a: any) => a.decision === 'Approved');
const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole));
console.log(`[debug] Interview Level: ${interview.level}, Stage: ${policy.stageCode}`);
console.log(`[debug] Required Roles: ${JSON.stringify(requiredRoles)}`);
console.log(`[debug] Approved Roles: ${JSON.stringify(Array.from(uniqueApprovalsByRole))}`);
console.log(`[debug] Approved Actions Count: ${approvedActions.length}`);
console.log(`[debug] Min Approvals Required: ${policy.minApprovals}`);
const hasRejection = actions.some((a: any) => a.decision === 'Rejected');
const hasAllRequiredRoleApprovals = requiredRoles.length === 0
? true
: requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role));
const meetsMinApprovals = uniqueApprovalsByRole.size >= (policy.minApprovals || 1);
console.log(`[debug] hasAllRequiredRoleApprovals: ${hasAllRequiredRoleApprovals}`);
console.log(`[debug] meetsMinApprovals: ${meetsMinApprovals}`);
console.log(`[debug] hasRejection: ${hasRejection}`);
if (hasRejection) {
await interview.update({ status: 'Completed' });
const application: any = await db.Application.findByPk(interview.applicationId);
let rejectionProgress = application?.progressPercentage || 0;
// Marker progress values to show which stage was last reached
if (policy.stageCode.includes('LEVEL1')) rejectionProgress = Math.max(rejectionProgress, 35);
if (policy.stageCode.includes('LEVEL2')) rejectionProgress = Math.max(rejectionProgress, 50);
if (policy.stageCode.includes('LEVEL3')) rejectionProgress = Math.max(rejectionProgress, 65);
await db.Application.update({
overallStatus: 'Rejected',
currentStage: 'Rejected',
progressPercentage: rejectionProgress
}, { where: { id: interview.applicationId } });
await db.ApplicationStatusHistory.create({
applicationId: interview.applicationId,
previousStatus: 'Interview Pending',
newStatus: 'Rejected',
changedBy: userId,
reason: 'Rejected in interview approval workflow'
});
} else if (hasAllRequiredRoleApprovals && meetsMinApprovals) {
await interview.update({ status: 'Completed', outcome: 'Selected' });
const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'Level 3 Approved' };
const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'FDD Verification' };
const progressMap: any = { 1: 40, 2: 55, 3: 70 };
const newStatus = nextStatusMap[interview.level] || 'Approved';
const application = await db.Application.findByPk(interview.applicationId);
const newProgress = progressMap[interview.level] || (application?.progressPercentage || 0);
await db.Application.update({
overallStatus: newStatus,
currentStage: newStatus,
progressPercentage: newProgress
}, { where: { id: interview.applicationId } });
await db.ApplicationStatusHistory.create({
const result = await processStageDecision({
applicationId: interview.applicationId,
previousStatus: 'Interview Pending',
newStatus,
changedBy: userId,
reason: `Approved via ${policy.stageCode} policy`
stageCode,
decision,
remarks,
userId,
roleCode,
interviewId,
nextStatus: nextStatusMap[interview.level] || 'Approved',
nextProgress: progressMap[interview.level]
});
if (result.statusUpdated) {
await interview.update({ status: 'Completed', outcome: decision === 'Approved' ? 'Selected' : 'Rejected' });
}
return {
success: true,
interview,
policy,
requiredRoles,
uniqueApprovalsByRole,
hasAllRequiredRoleApprovals,
meetsMinApprovals,
evaluation
};
return result;
};
// --- Questionnaires ---
@ -278,6 +320,10 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
reason: 'Questionnaire submitted by applicant'
});
// Sync Progress tracking
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(application.id, 'Questionnaire Completed');
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
} catch (error) {
console.error('Submit response error:', error);
@ -297,6 +343,22 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
const levelNum = typeof level === 'string' ? parseInt(level.replace(/\D/g, ''), 10) : level;
console.log(`Parsed Level: ${level} -> ${levelNum}`);
// Prevent duplicate interviews for the same level
const existingInterview = await Interview.findOne({
where: {
applicationId,
level: levelNum || 1,
status: { [Op.ne]: 'Cancelled' }
}
});
if (existingInterview) {
return res.status(400).json({
success: false,
message: `An interview for Level ${levelNum || 1} is already ${existingInterview.status.toLowerCase()}.`
});
}
console.log('Creating Interview record...');
const interview = await Interview.create({
applicationId,
@ -357,7 +419,8 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
if (participantIds.length > 0) {
console.log(`Processing ${participantIds.length} participants...`);
for (const userId of participantIds) {
// Processing participants concurrently
await Promise.all(participantIds.map(async (userId) => {
// 1. Add to Panel
await InterviewParticipant.create({
interviewId: interview.id,
@ -374,25 +437,32 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
userId
},
defaults: {
participantType: 'contributor', // 'interviewer' is not a valid enum value
participantType: 'contributor',
joinedMethod: 'interview'
}
});
}
}));
}
// 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>[] = [];
if (application) {
await EmailService.sendInterviewScheduledEmail(
notificationPromises.push(
EmailService.sendInterviewScheduledEmail(
application.email,
application.applicantName,
application.applicationId || application.id,
interview
).catch(err => console.error('Failed to send applicant email:', err))
);
}
// Notify panelists if needed
if (participantIds.length > 0) {
for (const userId of participantIds) {
notificationPromises.push(
(async () => {
const panelist = await User.findByPk(userId);
if (panelist) {
await EmailService.sendInterviewScheduledEmail(
@ -402,9 +472,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
interview
);
}
})().catch(err => console.error(`Failed to send panelist (${userId}) email:`, err))
);
}
}
// We don't necessarily need to wait for all emails to finish before returning success to user
// But for consistency and ensuring they are triggered, we'll wait with a timeout or just proceed
// Let's use Promise.all but keep it out of the main critical path if we want to be ultra fast.
// However, Promise.all already makes it much faster than sequential.
await Promise.all(notificationPromises);
console.log('Interview scheduling completed successfully.');
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
} catch (error) {
@ -866,3 +944,55 @@ export const getInterviewApprovalStatus = async (req: AuthRequest, res: Response
res.status(500).json({ success: false, message: 'Error fetching interview approval status' });
}
};
export const submitStageDecision = async (req: AuthRequest, res: Response) => {
try {
if (!req.user?.id || !req.user?.roleCode) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { applicationId, stageCode, decision, remarks, nextStatus, nextProgress } = req.body;
const result: any = await processStageDecision({
applicationId,
stageCode,
decision,
remarks,
userId: req.user.id,
roleCode: req.user.roleCode,
nextStatus,
nextProgress
});
if (result.noPolicy) {
// Fallback: If no policy, just update application status directly (legacy behavior)
if (nextStatus) {
await db.Application.update({
overallStatus: nextStatus,
currentStage: nextStatus,
progressPercentage: nextProgress || undefined
}, { where: { id: applicationId } });
}
return res.json({ success: true, message: 'Status updated (No policy found)' });
}
if (result.forbidden) {
return res.status(403).json({
success: false,
message: `Role ${result.currentRole} is not allowed to approve ${result.policy.stageCode}`
});
}
res.json({
success: true,
message: result.statusUpdated ? `Stage ${stageCode} completed and status moved to ${nextStatus}` : `Decision recorded for ${stageCode}. Waiting for other approvers.`,
data: {
statusUpdated: result.statusUpdated,
requiredRoles: result.requiredRoles,
approvedRoles: Array.from(result.uniqueApprovalsByRole),
meetsMinApprovals: result.meetsMinApprovals,
hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals
}
});
} catch (error) {
console.error('Submit stage decision error:', error);
res.status(500).json({ success: false, message: 'Error processing stage decision' });
}
};

View File

@ -20,6 +20,7 @@ router.post('/kt-matrix', assessmentController.submitKTMatrix);
router.post('/level2-feedback', assessmentController.submitLevel2Feedback);
router.post('/recommendation', assessmentController.updateRecommendation);
router.post('/decision', assessmentController.updateInterviewDecision);
router.post('/stage-decision', assessmentController.submitStageDecision);
router.get('/interviews/:interviewId/approval-status', assessmentController.getInterviewApprovalStatus);
router.get('/approval-policies', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.getStageApprovalPolicies);
router.put('/approval-policies/:stageCode', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.upsertStageApprovalPolicy);

View File

@ -164,9 +164,29 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
});
}
// Final Step: Update Application Status to Onboarded
await application.update({
overallStatus: 'Onboarded',
progressPercentage: 100,
updatedAt: new Date()
});
// Update progress tracking
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
await updateApplicationProgress(application.id, 'Onboarded', 'completed', 100);
// Add history entry
await db.ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus: application.overallStatus,
newStatus: 'Onboarded',
changedBy: req.user?.id,
reason: 'Dealer Onboarding Finalized'
});
res.status(201).json({
success: true,
message: 'Dealer profile, user account, and primary outlet created successfully',
message: 'Dealer profile, user account, and primary outlet created successfully. Application status is now ONBOARDED.',
data: {
dealer,
user: { email: user.email, role: user.roleCode },

View File

@ -117,6 +117,11 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
overallStatus: 'Approved',
progressPercentage: 100
}, { where: { id: checklist.applicationId } });
// Update Progress Tracking
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50);
}
}

View File

@ -172,9 +172,11 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole)
);
const hasRejection = stageActions.some((a: any) => a.decision === 'Rejected');
const hasAllRequiredRoleApprovals = requiredRoles.length === 0
? true
: requiredRoles.every((role: string) => approvedRoles.has(role));
const meetsMinApprovals = approvedRoles.size >= (policy.minApprovals || 1);
// 2. Handle Logic based on Action
@ -202,8 +204,8 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
});
await db.Application.update({
overallStatus: 'LOI Issued',
progressPercentage: 85
overallStatus: 'Security Details',
progressPercentage: 80
}, { where: { id: request.applicationId } });
res.json({ success: true, message: 'LOI Request fully approved and document generated' });

View File

@ -177,6 +177,7 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => {
]
},
{ model: db.RequestParticipant, as: 'participants', include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] },
{ model: db.StageApprovalAction, as: 'stageApprovals' },
{ model: db.DealerCode, as: 'dealerCode' },
{ model: db.Dealer, as: 'dealer' }
]
@ -231,6 +232,14 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) =
newData: { status, stage }
});
// Sync Progress tracking based on new status
try {
const { syncApplicationProgress } = await import('../../common/utils/progress.js');
await syncApplicationProgress(application.id, status);
} catch (progErr) {
console.error('Progress sync error:', progErr);
}
res.json({ success: true, message: 'Application status updated successfully' });
} catch (error) {
console.error('Update application status error:', error);
@ -551,39 +560,61 @@ const assignStageEvaluators = async (applicationId: string) => {
if (nationalUsers['NBH']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' });
// Persistence logic: Store in RequestParticipant with metadata
// Persistence logic: Consolidate all stage/interview assignments into the same user record
// to prevent duplication in the participants list.
const userAssignments: Record<string, { stages: any[], roles: string[] }> = {};
const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL'];
for (const stage of allStages) {
const assignments = evaluatorMappings[stage];
for (const assign of assignments) {
const { id: userId, role } = assign;
const whereClause: any = {
requestId: applicationId,
requestType: 'application',
userId,
participantType: 'contributor'
};
const existing = await db.RequestParticipant.findOne({ where: whereClause });
// If interview level, check metadata match. If stageCode, check metadata match.
const isInterview = typeof stage === 'number';
if (existing) {
const match = isInterview
? (existing.metadata?.interviewLevel === stage)
: (existing.metadata?.stageCode === stage);
if (match) continue;
if (!userAssignments[userId]) {
userAssignments[userId] = { stages: [], roles: [] };
}
userAssignments[userId].stages.push(stage);
userAssignments[userId].roles.push(role);
}
}
await db.RequestParticipant.create({
for (const [userId, assignment] of Object.entries(userAssignments)) {
const isInterview = assignment.stages.some(s => typeof s === 'number');
const primaryStage = assignment.stages[0];
const primaryRole = assignment.roles[0];
const [participant, created] = await db.RequestParticipant.findOrCreate({
where: {
requestId: applicationId,
requestType: 'application',
userId,
userId: userId
},
defaults: {
participantType: 'contributor',
joinedMethod: 'auto',
metadata: isInterview
? { interviewLevel: stage, role, autoMapped: true }
: { stageCode: stage, role, autoMapped: true }
metadata: {
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
role: primaryRole,
allAssignments: assignment.stages, // Store all assignments
autoMapped: true
}
}
});
if (!created) {
// Update metadata if it exists to include the new assignments
const meta = participant.metadata || {};
const currentAssignments = meta.allAssignments || [];
const mergedAssignments = [...new Set([...currentAssignments, ...assignment.stages])];
await participant.update({
metadata: {
...meta,
allAssignments: mergedAssignments,
// Maintain legacy fields for compatibility if they don't exist
interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null),
stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null)
}
});
}
}
@ -592,6 +623,7 @@ const assignStageEvaluators = async (applicationId: string) => {
}
};
export const retriggerEvaluators = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;

View File

@ -130,26 +130,18 @@ app.use('/api/termination', terminationRoutes);
// Backward Compatibility & Frontend Mapping Aliases
app.use('/api/applications', onboardingRoutes);
app.use('/api/resignation', resignationRoutes);
app.use('/api/resignations', resignationRoutes);
app.use('/api/resignation', resignationRoutes); // Singular alias
app.use('/api/constitutional', (req: Request, res: Response, next: NextFunction) => {
// Map /api/constitutional to /api/self-service/constitutional
req.url = '/constitutional' + req.url;
next();
}, selfServiceRoutes);
app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFunction) => {
// Alias for constitutional-change
req.url = '/constitutional' + req.url;
next();
}, selfServiceRoutes);
app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => {
// Map /api/relocations to /api/self-service/relocation
req.url = '/relocation' + req.url;
req.url = '/constitutional' + (req.url === '/' ? '' : req.url);
next();
}, selfServiceRoutes);
app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => {
// Alias for relocation
req.url = '/relocation' + req.url;
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
next();
}, selfServiceRoutes);
app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => {
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
next();
}, selfServiceRoutes);
app.use('/api/outlets', outletRoutes);