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';
|
||||
const { Application, ApplicationStatusHistory } = db;
|
||||
const { ApplicationStatusHistory } = (db as any).default || db;
|
||||
|
||||
async function checkApp() {
|
||||
try {
|
||||
const app = await Application.findOne({ order: [['updatedAt', 'DESC']] });
|
||||
if (!app) {
|
||||
console.log('No apps found');
|
||||
return;
|
||||
const applicationId = '6139d6f9-f3c1-4e55-903b-3516d3a08955';
|
||||
|
||||
async function checkStatusHistory() {
|
||||
try {
|
||||
const count = await ApplicationStatusHistory.count({
|
||||
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';
|
||||
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
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)
|
||||
|
||||
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
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: '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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
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' }
|
||||
});
|
||||
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
|
||||
Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' });
|
||||
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
|
||||
};
|
||||
|
||||
|
||||
@ -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 : [];
|
||||
|
||||
// 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
|
||||
}
|
||||
});
|
||||
|
||||
const assignedParticipant = userAssignments.find((p: any) =>
|
||||
p.metadata && Number(p.metadata.interviewLevel) === Number(interview.level)
|
||||
);
|
||||
|
||||
const isAssigned = !!assignedParticipant;
|
||||
const assignedRole = assignedParticipant?.metadata?.role;
|
||||
const stageCode = interviewStageCode(interview.level);
|
||||
|
||||
console.log(`[debug] User ID: ${userId}, Role: ${roleCode}, isAssigned: ${isAssigned}, assignedRole: ${assignedRole}`);
|
||||
if (isAssigned) {
|
||||
console.log(`[debug] Assigned Participant Metadata: ${JSON.stringify(assignedParticipant.metadata)}`);
|
||||
}
|
||||
// Ensure policy exists for interviews
|
||||
await ensureInterviewPolicy(interview.level);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'FDD Verification' };
|
||||
const progressMap: any = { 1: 40, 2: 55, 3: 70 };
|
||||
|
||||
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({
|
||||
const result = await processStageDecision({
|
||||
applicationId: interview.applicationId,
|
||||
interviewId,
|
||||
stageCode: policy.stageCode,
|
||||
actorUserId: userId,
|
||||
actorRole: assignedRole || roleCode, // Use assigned role if available (e.g. ZBH acting as ZM)
|
||||
stageCode,
|
||||
decision,
|
||||
remarks: remarks || null
|
||||
remarks,
|
||||
userId,
|
||||
roleCode,
|
||||
interviewId,
|
||||
nextStatus: nextStatusMap[interview.level] || 'Approved',
|
||||
nextProgress: progressMap[interview.level]
|
||||
});
|
||||
|
||||
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 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`
|
||||
});
|
||||
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,37 +437,52 @@ 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(
|
||||
application.email,
|
||||
application.applicantName,
|
||||
application.applicationId || application.id,
|
||||
interview
|
||||
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) {
|
||||
const panelist = await User.findByPk(userId);
|
||||
if (panelist) {
|
||||
await EmailService.sendInterviewScheduledEmail(
|
||||
panelist.email,
|
||||
panelist.fullName,
|
||||
application?.applicationId || application?.id || applicationId,
|
||||
interview
|
||||
);
|
||||
}
|
||||
notificationPromises.push(
|
||||
(async () => {
|
||||
const panelist = await User.findByPk(userId);
|
||||
if (panelist) {
|
||||
await EmailService.sendInterviewScheduledEmail(
|
||||
panelist.email,
|
||||
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.');
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user