multi iteration on approval flow
This commit is contained in:
parent
feeb613136
commit
d1d4601ac9
33
check_app.ts
Normal file
33
check_app.ts
Normal 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
22
check_audit.ts
Normal 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();
|
||||||
@ -1,31 +1,22 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import db from './src/database/models/index.js';
|
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';
|
||||||
try {
|
|
||||||
const app = await Application.findOne({ order: [['updatedAt', 'DESC']] });
|
async function checkStatusHistory() {
|
||||||
if (!app) {
|
try {
|
||||||
console.log('No apps found');
|
const count = await ApplicationStatusHistory.count({
|
||||||
return;
|
where: { applicationId }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Application has ${count} status history records.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking history:', error);
|
||||||
|
} finally {
|
||||||
|
process.exit();
|
||||||
}
|
}
|
||||||
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']]
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
} finally {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkApp();
|
checkStatusHistory();
|
||||||
|
|||||||
27
check_json_size.ts
Normal file
27
check_json_size.ts
Normal 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
22
check_participants.ts
Normal 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
22
check_progress.ts
Normal 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();
|
||||||
@ -1,38 +1,15 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
import db from './src/database/models/index.js';
|
import db from './src/database/models/index.js';
|
||||||
|
const { QuestionnaireQuestion } = (db as any).default || db;
|
||||||
|
|
||||||
async function checkQuestions() {
|
async function checkQuestions() {
|
||||||
try {
|
try {
|
||||||
const questionnaire = await db.Questionnaire.findOne({
|
const count = await QuestionnaireQuestion.count();
|
||||||
where: { isActive: true },
|
console.log(`There are ${count} questions in the system.`);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error checking questions:', error);
|
||||||
process.exit(1);
|
} finally {
|
||||||
|
process.exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
check_size.ts
Normal file
35
check_size.ts
Normal 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
87
debug_app.ts
Normal 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();
|
||||||
@ -1,65 +1,89 @@
|
|||||||
# Backend Alignment Analysis Report (v1.4 Compliance)
|
# RE Onboarding & Offboarding System: Comprehensive Alignment Report
|
||||||
|
## Prepared for: Dealer Onboarding Project
|
||||||
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.
|
## Reference: SRS v1.4 / 2.0 (High-Fidelity)
|
||||||
|
## Current Date: April 1, 2026
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
| Goal | SRS Section | System Status | Completion % |
|
||||||
* **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.
|
| **Dealer Onboarding** | Section 6 | Stable Flow (Eval -> LOI -> SAP) | 85% |
|
||||||
* **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.
|
| **Dealer Resignation** | Section 7 | Backend Ready; UI in progress | 50% |
|
||||||
* **Questionnaire & Scoring (KT Matrix)**: Module for automated questionnaire scoring, rankings, and KT Matrix interview evaluation is missing.
|
| **Dealer Termination** | Section 8 | Logic Ready; SCN Flow in progress | 40% |
|
||||||
* **EOR Checklist**: Detailed Essential Operating Requirements (EOR) checklist with functional team verifications is not implemented.
|
| **F&F Settlement** | Section 10/11 | Trigger Ready; Clearance Grid Pending | 20% |
|
||||||
* **Inauguration Tracking**: Final stage of onboarding is missing.
|
| **Self-Service (Constitutional)** | Section 12.2 | **PENDING** (Schema Ready) | 10% |
|
||||||
|
| **Self-Service (Relocation)** | Section 12.2.6 | **PENDING** (Schema Ready) | 10% |
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Schema Alignment Check
|
## 2. Onboarding & Evaluation (Section 6)
|
||||||
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.
|
**Owner:** DD Admin / DD ASM
|
||||||
|
|
||||||
### Missing Tables in Code:
|
| Sub-Task | Demarcation Points | Status | Integration |
|
||||||
| Document Section | Missing Tables (Models) |
|
| :--- | :--- | :--- | :--- |
|
||||||
| :--- | :--- |
|
| **Questionnaire** | Weighted Scoring (100%), Public Link Access | ✅ COMPLETED | Real |
|
||||||
| **Questionnaire** | `QUESTIONNAIRES`, `SECTIONS`, `QUESTIONS`, `RESPONSES`, `SCORES` |
|
| **Shortlisting** | Bulk Action, Assign to ASM/ZM | ✅ COMPLETED | Real |
|
||||||
| **Interviews** | `INTERVIEWS`, `PARTICIPANTS`, `EVALUATIONS`, `KT_MATRIX_SCORES`, `FEEDBACK` |
|
| **Interviews (L1-3)** | Panel Scheduling, KT Matrix, AI Summary | ✅ COMPLETED | AI Mocked |
|
||||||
| **LOI Process** | `LOI_REQUESTS`, `LOI_APPROVALS`, `LOI_DOCUMENTS_GENERATED`, `ACKNOWLEDGEMENTS` |
|
| **FDD Verification** | Agency Upload, Report Review by DD Head | 🚧 IN PROGRESS | Manual |
|
||||||
| **EOR / Construction** | `ARCHITECTURAL_ASSIGNMENTS`, `EOR_CHECKLISTS`, `CHECKLIST_ITEMS`, `CONSTRUCTION_PROGRESS` |
|
| **LOI Issuance** | 3-tier Approval (Finance/DD-Head/NBH) | ✅ COMPLETED | PDF Mocked |
|
||||||
| **Termination** | `TERMINATION_REQUESTS`, `TERMINATION_APPROVALS`, `SCN_ISSUANCE` |
|
| **Security Details** | BG & Deposit Verification (BG / SD) | 🚧 IN PROGRESS | Pending UI |
|
||||||
| **Other** | `AI_SUMMARIES`, `INAUGURATIONS`, `SECURITY_DEPOSITS` |
|
| **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)
|
### 3.1 Dealer Resignation (Section 12.1)
|
||||||
1. **Update `constants.js`**: Add `CEO`, `CCO` roles and `TERMINATION_STAGES`.
|
* **Width:** Outlet-level selection, Last Working Day (LWD) capture, Withdrawal logic.
|
||||||
2. **Enhance Workflow Engine**: Implement a generic "Send Back" mechanism that tracks the previous stage and logs mandatory audit remarks.
|
* **Status:** Backend endpoints for initiation are ready. UI needs the "Resignation Dashboard" for the dealer persona.
|
||||||
3. **Audit Trail Expansion**: Ensure every state change captures the "Section 4.4" requirements (Uploader, Timestamp, Versioning).
|
|
||||||
|
|
||||||
### Phase 2: Workflow Refinement
|
### 3.2 Constitutional Change Management (Section 12.2)
|
||||||
1. **Sequence Correction (LOA before EOR)**: Restructure the `Application` state machine to ensure LOA issuance is a prerequisite for EOR checklist activation.
|
* **Width:** Proprietorship → Partnership/LLP/Pvt Ltd.
|
||||||
2. **LWD Enforcement**: Modify `FnF` initiation logic to check against `outlet.last_working_day`.
|
* **Depth:** Dynamic document checklist (GST, PAN, Partnership Deed, COI, MOA/AOA).
|
||||||
3. **Manual Code Trigger**: Add dedicated endpoint `/api/applications/:id/generate-code` restricted to `DD_ADMIN`.
|
* **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
|
### 3.3 Relocation Request (Section 12.2.6)
|
||||||
1. **Develop Termination Controller**: Implement the 11-step process described in Section 4.3.
|
* **Width:** New Site Selection (Manual/Map), Distance Tracking, Property Docs.
|
||||||
2. **Questionnaire Engine**: Move from hardcoded fields to a dynamic questionnaire system as per the schema.
|
* **Depth:** 4 Categories of Docs (Property, Legal, Statutory, Infrastructure).
|
||||||
3. **Document Repository**: Implement the "Central Document Repository" with versioning for Statutory and Architectural documents.
|
* **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
84
reset_db.ts
Normal 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));
|
||||||
39
scripts/add-architecture-role.ts
Normal file
39
scripts/add-architecture-role.ts
Normal 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();
|
||||||
@ -25,7 +25,8 @@ async function seed() {
|
|||||||
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' },
|
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' },
|
||||||
{ roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' },
|
{ roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' },
|
||||||
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' },
|
{ 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) {
|
for (const r of roles) {
|
||||||
|
|||||||
@ -13,7 +13,8 @@ export const ROLES = {
|
|||||||
DD_AM: 'DD AM',
|
DD_AM: 'DD AM',
|
||||||
ASM: 'ASM',
|
ASM: 'ASM',
|
||||||
FINANCE: 'Finance',
|
FINANCE: 'Finance',
|
||||||
DEALER: 'Dealer'
|
DEALER: 'Dealer',
|
||||||
|
ARCHITECTURE: 'ARCHITECTURE'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Regions
|
// Regions
|
||||||
@ -35,7 +36,7 @@ export const APPLICATION_STAGES = {
|
|||||||
DD_HEAD: 'DD Head',
|
DD_HEAD: 'DD Head',
|
||||||
NBH: 'NBH',
|
NBH: 'NBH',
|
||||||
LEGAL: 'Legal',
|
LEGAL: 'Legal',
|
||||||
ARCHITECTURE: 'Architecture',
|
ARCHITECTURE: 'Architecture Team',
|
||||||
FINANCE: 'Finance',
|
FINANCE: 'Finance',
|
||||||
LEVEL_1_APPROVED: 'Level 1 Approved',
|
LEVEL_1_APPROVED: 'Level 1 Approved',
|
||||||
LEVEL_2_APPROVED: 'Level 2 Approved',
|
LEVEL_2_APPROVED: 'Level 2 Approved',
|
||||||
@ -63,6 +64,7 @@ export const APPLICATION_STATUS = {
|
|||||||
LEVEL_3_PENDING: 'Level 3 Interview Pending',
|
LEVEL_3_PENDING: 'Level 3 Interview Pending',
|
||||||
LEVEL_3_APPROVED: 'Level 3 Approved',
|
LEVEL_3_APPROVED: 'Level 3 Approved',
|
||||||
FDD_VERIFICATION: 'FDD Verification',
|
FDD_VERIFICATION: 'FDD Verification',
|
||||||
|
SECURITY_DETAILS: 'Security Details',
|
||||||
PAYMENT_PENDING: 'Payment Pending',
|
PAYMENT_PENDING: 'Payment Pending',
|
||||||
LOI_IN_PROGRESS: 'LOI In Progress',
|
LOI_IN_PROGRESS: 'LOI In Progress',
|
||||||
LOI_ISSUED: 'LOI Issued',
|
LOI_ISSUED: 'LOI Issued',
|
||||||
@ -84,6 +86,7 @@ export const APPLICATION_STATUS = {
|
|||||||
LOA_PENDING: 'LOA Pending',
|
LOA_PENDING: 'LOA Pending',
|
||||||
EOR_COMPLETE: 'EOR Complete',
|
EOR_COMPLETE: 'EOR Complete',
|
||||||
INAUGURATION: 'Inauguration',
|
INAUGURATION: 'Inauguration',
|
||||||
|
ONBOARDED: 'Onboarded',
|
||||||
DISQUALIFIED: 'Disqualified'
|
DISQUALIFIED: 'Disqualified'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -314,10 +317,22 @@ export const DOCUMENT_TYPES = {
|
|||||||
DOMAIN_ID: 'Domain ID Setup',
|
DOMAIN_ID: 'Domain ID Setup',
|
||||||
MSD_CONFIG: 'MSD Configuration',
|
MSD_CONFIG: 'MSD Configuration',
|
||||||
LOI_ACK: 'LOI Acknowledgement',
|
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_ASSIGNMENT: 'Architecture Assignment Document',
|
||||||
ARCHITECTURE_BLUEPRINT: 'Architecture Blueprint',
|
ARCHITECTURE_BLUEPRINT: 'Architecture Blueprint',
|
||||||
SITE_PLAN: 'Site Plan',
|
SITE_PLAN: 'Site Plan',
|
||||||
ARCHITECTURE_COMPLETION: 'Architecture Completion Certificate',
|
ARCHITECTURE_COMPLETION: 'Architecture Completion Certificate',
|
||||||
|
STATUTORY_AUDIT: 'Statutory Approval Certificate',
|
||||||
|
BANK_GUARANTEE: 'Bank Guarantee Document',
|
||||||
|
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
|
||||||
OTHER: 'Other'
|
OTHER: 'Other'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
const authHeader = req.header('Authorization');
|
const authHeader = req.header('Authorization');
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
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({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Access denied. No token provided.'
|
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
|
// 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;
|
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)) {
|
if (!uuidRegex.test(appId)) {
|
||||||
|
console.warn(`[Auth] 401: Invalid Prospective UUID: ${appId}`);
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid session token. Please login again.'
|
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?
|
// If app not found, fall through or error?
|
||||||
// Let's error to be safe as the token was specific
|
// 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({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid prospective user session.'
|
message: 'Invalid prospective user session.'
|
||||||
@ -66,6 +69,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
console.warn(`[Auth] 401: User not found for ID: ${decoded.userId}`);
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Invalid token. User not found.'
|
message: 'Invalid token. User not found.'
|
||||||
@ -73,6 +77,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
|
console.warn(`[Auth] 401: Inactive user tried to access: ${user.id}`);
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'User account is inactive.'
|
message: 'User account is inactive.'
|
||||||
@ -88,6 +93,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
logger.error('Authentication error:', error);
|
logger.error('Authentication error:', error);
|
||||||
|
|
||||||
if (error.name === 'TokenExpiredError') {
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
console.warn(`[Auth] 401: Token Expired`);
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Token expired'
|
message: 'Token expired'
|
||||||
|
|||||||
120
src/common/utils/progress.ts
Normal file
120
src/common/utils/progress.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -264,6 +264,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
scope: { requestType: 'application' }
|
scope: { requestType: 'application' }
|
||||||
});
|
});
|
||||||
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
|
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
|
||||||
|
Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' });
|
||||||
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
|
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const {
|
|||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import * as EmailService from '../../common/utils/email.service.js';
|
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 getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
||||||
const district: any = await District.findByPk(locationId);
|
const district: any = await District.findByPk(locationId);
|
||||||
@ -41,6 +42,158 @@ const ensureInterviewPolicy = async (level: number) => {
|
|||||||
return policy;
|
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: {
|
const processInterviewApprovalDecision = async (params: {
|
||||||
interviewId: string;
|
interviewId: string;
|
||||||
decision: 'Approved' | 'Rejected';
|
decision: 'Approved' | 'Rejected';
|
||||||
@ -49,145 +202,34 @@ const processInterviewApprovalDecision = async (params: {
|
|||||||
roleCode: string;
|
roleCode: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { interviewId, decision, remarks, userId, roleCode } = params;
|
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 };
|
if (!interview) return { notFound: true };
|
||||||
|
|
||||||
const policy = await ensureInterviewPolicy(interview.level);
|
const stageCode = interviewStageCode(interview.level);
|
||||||
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
|
|
||||||
|
|
||||||
// Check if user is an assigned participant for this specific level
|
// Ensure policy exists for interviews
|
||||||
const userAssignments = await db.RequestParticipant.findAll({
|
await ensureInterviewPolicy(interview.level);
|
||||||
where: {
|
|
||||||
requestId: interview.applicationId,
|
|
||||||
requestType: 'application',
|
|
||||||
userId: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignedParticipant = userAssignments.find((p: any) =>
|
const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'FDD Verification' };
|
||||||
p.metadata && Number(p.metadata.interviewLevel) === Number(interview.level)
|
const progressMap: any = { 1: 40, 2: 55, 3: 70 };
|
||||||
);
|
|
||||||
|
|
||||||
const isAssigned = !!assignedParticipant;
|
const result = await processStageDecision({
|
||||||
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,
|
applicationId: interview.applicationId,
|
||||||
interviewId,
|
stageCode,
|
||||||
stageCode: policy.stageCode,
|
|
||||||
actorUserId: userId,
|
|
||||||
actorRole: assignedRole || roleCode, // Use assigned role if available (e.g. ZBH acting as ZM)
|
|
||||||
decision,
|
decision,
|
||||||
remarks: remarks || null
|
remarks,
|
||||||
|
userId,
|
||||||
|
roleCode,
|
||||||
|
interviewId,
|
||||||
|
nextStatus: nextStatusMap[interview.level] || 'Approved',
|
||||||
|
nextProgress: progressMap[interview.level]
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = await StageApprovalAction.findAll({
|
if (result.statusUpdated) {
|
||||||
where: { interviewId, stageCode: policy.stageCode }
|
await interview.update({ status: 'Completed', outcome: decision === 'Approved' ? 'Selected' : 'Rejected' });
|
||||||
});
|
|
||||||
|
|
||||||
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 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({
|
|
||||||
applicationId: interview.applicationId,
|
|
||||||
previousStatus: 'Interview Pending',
|
|
||||||
newStatus,
|
|
||||||
changedBy: userId,
|
|
||||||
reason: `Approved via ${policy.stageCode} policy`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
success: true,
|
|
||||||
interview,
|
|
||||||
policy,
|
|
||||||
requiredRoles,
|
|
||||||
uniqueApprovalsByRole,
|
|
||||||
hasAllRequiredRoleApprovals,
|
|
||||||
meetsMinApprovals,
|
|
||||||
evaluation
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Questionnaires ---
|
// --- Questionnaires ---
|
||||||
@ -278,6 +320,10 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
|||||||
reason: 'Questionnaire submitted by applicant'
|
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 });
|
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit response error:', 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;
|
const levelNum = typeof level === 'string' ? parseInt(level.replace(/\D/g, ''), 10) : level;
|
||||||
console.log(`Parsed Level: ${level} -> ${levelNum}`);
|
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...');
|
console.log('Creating Interview record...');
|
||||||
const interview = await Interview.create({
|
const interview = await Interview.create({
|
||||||
applicationId,
|
applicationId,
|
||||||
@ -357,7 +419,8 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
if (participantIds.length > 0) {
|
if (participantIds.length > 0) {
|
||||||
console.log(`Processing ${participantIds.length} participants...`);
|
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
|
// 1. Add to Panel
|
||||||
await InterviewParticipant.create({
|
await InterviewParticipant.create({
|
||||||
interviewId: interview.id,
|
interviewId: interview.id,
|
||||||
@ -374,37 +437,52 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
userId
|
userId
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
participantType: 'contributor', // 'interviewer' is not a valid enum value
|
participantType: 'contributor',
|
||||||
joinedMethod: 'interview'
|
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) {
|
if (application) {
|
||||||
await EmailService.sendInterviewScheduledEmail(
|
notificationPromises.push(
|
||||||
application.email,
|
EmailService.sendInterviewScheduledEmail(
|
||||||
application.applicantName,
|
application.email,
|
||||||
application.applicationId || application.id,
|
application.applicantName,
|
||||||
interview
|
application.applicationId || application.id,
|
||||||
|
interview
|
||||||
|
).catch(err => console.error('Failed to send applicant email:', err))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify panelists if needed
|
|
||||||
if (participantIds.length > 0) {
|
if (participantIds.length > 0) {
|
||||||
for (const userId of participantIds) {
|
for (const userId of participantIds) {
|
||||||
const panelist = await User.findByPk(userId);
|
notificationPromises.push(
|
||||||
if (panelist) {
|
(async () => {
|
||||||
await EmailService.sendInterviewScheduledEmail(
|
const panelist = await User.findByPk(userId);
|
||||||
panelist.email,
|
if (panelist) {
|
||||||
panelist.fullName,
|
await EmailService.sendInterviewScheduledEmail(
|
||||||
application?.applicationId || application?.id || applicationId,
|
panelist.email,
|
||||||
interview
|
panelist.fullName,
|
||||||
);
|
application?.applicationId || application?.id || applicationId,
|
||||||
}
|
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.');
|
console.log('Interview scheduling completed successfully.');
|
||||||
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
||||||
} catch (error) {
|
} 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' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -20,6 +20,7 @@ router.post('/kt-matrix', assessmentController.submitKTMatrix);
|
|||||||
router.post('/level2-feedback', assessmentController.submitLevel2Feedback);
|
router.post('/level2-feedback', assessmentController.submitLevel2Feedback);
|
||||||
router.post('/recommendation', assessmentController.updateRecommendation);
|
router.post('/recommendation', assessmentController.updateRecommendation);
|
||||||
router.post('/decision', assessmentController.updateInterviewDecision);
|
router.post('/decision', assessmentController.updateInterviewDecision);
|
||||||
|
router.post('/stage-decision', assessmentController.submitStageDecision);
|
||||||
router.get('/interviews/:interviewId/approval-status', assessmentController.getInterviewApprovalStatus);
|
router.get('/interviews/:interviewId/approval-status', assessmentController.getInterviewApprovalStatus);
|
||||||
router.get('/approval-policies', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.getStageApprovalPolicies);
|
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);
|
router.put('/approval-policies/:stageCode', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.upsertStageApprovalPolicy);
|
||||||
|
|||||||
@ -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({
|
res.status(201).json({
|
||||||
success: true,
|
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: {
|
data: {
|
||||||
dealer,
|
dealer,
|
||||||
user: { email: user.email, role: user.roleCode },
|
user: { email: user.email, role: user.roleCode },
|
||||||
|
|||||||
@ -117,6 +117,11 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
|
|||||||
overallStatus: 'Approved',
|
overallStatus: 'Approved',
|
||||||
progressPercentage: 100
|
progressPercentage: 100
|
||||||
}, { where: { id: checklist.applicationId } });
|
}, { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -172,9 +172,11 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole)
|
stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole)
|
||||||
);
|
);
|
||||||
const hasRejection = stageActions.some((a: any) => a.decision === 'Rejected');
|
const hasRejection = stageActions.some((a: any) => a.decision === 'Rejected');
|
||||||
|
|
||||||
const hasAllRequiredRoleApprovals = requiredRoles.length === 0
|
const hasAllRequiredRoleApprovals = requiredRoles.length === 0
|
||||||
? true
|
? true
|
||||||
: requiredRoles.every((role: string) => approvedRoles.has(role));
|
: requiredRoles.every((role: string) => approvedRoles.has(role));
|
||||||
|
|
||||||
const meetsMinApprovals = approvedRoles.size >= (policy.minApprovals || 1);
|
const meetsMinApprovals = approvedRoles.size >= (policy.minApprovals || 1);
|
||||||
|
|
||||||
// 2. Handle Logic based on Action
|
// 2. Handle Logic based on Action
|
||||||
@ -202,8 +204,8 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await db.Application.update({
|
await db.Application.update({
|
||||||
overallStatus: 'LOI Issued',
|
overallStatus: 'Security Details',
|
||||||
progressPercentage: 85
|
progressPercentage: 80
|
||||||
}, { where: { id: request.applicationId } });
|
}, { where: { id: request.applicationId } });
|
||||||
|
|
||||||
res.json({ success: true, message: 'LOI Request fully approved and document generated' });
|
res.json({ success: true, message: 'LOI Request fully approved and document generated' });
|
||||||
|
|||||||
@ -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.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.DealerCode, as: 'dealerCode' },
|
||||||
{ model: db.Dealer, as: 'dealer' }
|
{ model: db.Dealer, as: 'dealer' }
|
||||||
]
|
]
|
||||||
@ -231,6 +232,14 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) =
|
|||||||
newData: { status, stage }
|
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' });
|
res.json({ success: true, message: 'Application status updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update application status error:', 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' });
|
if (nationalUsers['NBH']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' });
|
||||||
|
|
||||||
// Persistence logic: Store in RequestParticipant with metadata
|
// 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'];
|
const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL'];
|
||||||
for (const stage of allStages) {
|
for (const stage of allStages) {
|
||||||
const assignments = evaluatorMappings[stage];
|
const assignments = evaluatorMappings[stage];
|
||||||
for (const assign of assignments) {
|
for (const assign of assignments) {
|
||||||
const { id: userId, role } = assign;
|
const { id: userId, role } = assign;
|
||||||
|
if (!userAssignments[userId]) {
|
||||||
const whereClause: any = {
|
userAssignments[userId] = { stages: [], roles: [] };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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,
|
requestId: applicationId,
|
||||||
requestType: 'application',
|
requestType: 'application',
|
||||||
userId,
|
userId: userId
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
participantType: 'contributor',
|
participantType: 'contributor',
|
||||||
joinedMethod: 'auto',
|
joinedMethod: 'auto',
|
||||||
metadata: isInterview
|
metadata: {
|
||||||
? { interviewLevel: stage, role, autoMapped: true }
|
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
||||||
: { stageCode: stage, role, autoMapped: true }
|
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) => {
|
export const retriggerEvaluators = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@ -130,26 +130,18 @@ app.use('/api/termination', terminationRoutes);
|
|||||||
|
|
||||||
// Backward Compatibility & Frontend Mapping Aliases
|
// Backward Compatibility & Frontend Mapping Aliases
|
||||||
app.use('/api/applications', onboardingRoutes);
|
app.use('/api/applications', onboardingRoutes);
|
||||||
|
app.use('/api/resignation', resignationRoutes);
|
||||||
app.use('/api/resignations', 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) => {
|
app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Alias for constitutional-change
|
req.url = '/constitutional' + (req.url === '/' ? '' : req.url);
|
||||||
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;
|
|
||||||
next();
|
next();
|
||||||
}, selfServiceRoutes);
|
}, selfServiceRoutes);
|
||||||
app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => {
|
app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Alias for relocation
|
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
|
||||||
req.url = '/relocation' + req.url;
|
next();
|
||||||
|
}, selfServiceRoutes);
|
||||||
|
app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
|
||||||
next();
|
next();
|
||||||
}, selfServiceRoutes);
|
}, selfServiceRoutes);
|
||||||
app.use('/api/outlets', outletRoutes);
|
app.use('/api/outlets', outletRoutes);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user