diff --git a/check_app.ts b/check_app.ts new file mode 100644 index 0000000..c89fa13 --- /dev/null +++ b/check_app.ts @@ -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(); diff --git a/check_audit.ts b/check_audit.ts new file mode 100644 index 0000000..fab65ba --- /dev/null +++ b/check_audit.ts @@ -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(); diff --git a/check_history.ts b/check_history.ts index 5362e53..104d772 100644 --- a/check_history.ts +++ b/check_history.ts @@ -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(); diff --git a/check_json_size.ts b/check_json_size.ts new file mode 100644 index 0000000..bf44126 --- /dev/null +++ b/check_json_size.ts @@ -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(); diff --git a/check_participants.ts b/check_participants.ts new file mode 100644 index 0000000..c13a7d3 --- /dev/null +++ b/check_participants.ts @@ -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(); diff --git a/check_progress.ts b/check_progress.ts new file mode 100644 index 0000000..604767c --- /dev/null +++ b/check_progress.ts @@ -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(); diff --git a/check_questions.ts b/check_questions.ts index 3342b09..a67db95 100644 --- a/check_questions.ts +++ b/check_questions.ts @@ -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(); } } diff --git a/check_size.ts b/check_size.ts new file mode 100644 index 0000000..e4b050a --- /dev/null +++ b/check_size.ts @@ -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(); diff --git a/debug_app.ts b/debug_app.ts new file mode 100644 index 0000000..745b88b --- /dev/null +++ b/debug_app.ts @@ -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(); diff --git a/docs/Backend_Alignment_Report_v1.4.md b/docs/Backend_Alignment_Report_v1.4.md index a9d5d40..db58dfa 100644 --- a/docs/Backend_Alignment_Report_v1.4.md +++ b/docs/Backend_Alignment_Report_v1.4.md @@ -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. diff --git a/reset_db.ts b/reset_db.ts new file mode 100644 index 0000000..17d007e --- /dev/null +++ b/reset_db.ts @@ -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)); diff --git a/scripts/add-architecture-role.ts b/scripts/add-architecture-role.ts new file mode 100644 index 0000000..c893dd2 --- /dev/null +++ b/scripts/add-architecture-role.ts @@ -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(); diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index 701a66c..5cbebec 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -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) { diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 212ecd3..5fe8ed0 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -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; diff --git a/src/common/middleware/auth.ts b/src/common/middleware/auth.ts index f30554c..2b2d55a 100644 --- a/src/common/middleware/auth.ts +++ b/src/common/middleware/auth.ts @@ -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' diff --git a/src/common/utils/progress.ts b/src/common/utils/progress.ts new file mode 100644 index 0000000..50688fa --- /dev/null +++ b/src/common/utils/progress.ts @@ -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 = { + '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); + } + } +}; diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index e09fd8c..365dee6 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -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' }); }; diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 77d12d3..99747ee 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -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 => { 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[] = []; + 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' }); + } +}; diff --git a/src/modules/assessment/assessment.routes.ts b/src/modules/assessment/assessment.routes.ts index 51f15fb..c3e8572 100644 --- a/src/modules/assessment/assessment.routes.ts +++ b/src/modules/assessment/assessment.routes.ts @@ -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); diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 7163456..d94a4c4 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -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 }, diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index db9fed3..7b01ae2 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -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); } } diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index c3ef11b..c337086 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -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' }); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index d79d3c1..1923a1b 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -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 = {}; + 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; diff --git a/src/server.ts b/src/server.ts index dbece2a..e4927fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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);