From 9b645b0480e6fd603f2431638651003b18458721 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Mon, 30 Mar 2026 02:59:34 +0530 Subject: [PATCH] hirrachy enhanced nd creatded distric as centric and added dealer location stable ui made for the hirarchy --- .env.example | 37 ++ all_perms.json | 32 + audit_db.mjs | 32 + audit_db.ts | 32 + check_zms_v2.ts | 33 + db_audit.json | 67 ++ package.json | 9 +- scripts/seed_normalized_data.ts | 249 ++++---- scripts/seed_real_locations.ts | 61 +- scripts/sync-all-hierarchy.ts | 46 ++ src/common/config/auth.ts | 3 +- src/database/models/Application.ts | 8 +- src/database/models/District.ts | 128 ++++ src/database/models/Location.ts | 79 +-- src/database/models/Opportunity.ts | 16 +- src/database/models/Outlet.ts | 14 +- src/database/models/Region.ts | 17 +- src/database/models/User.ts | 44 +- src/database/models/UserRole.ts | 28 +- src/database/models/Zone.ts | 17 +- src/database/models/index.ts | 2 + src/modules/admin/admin.controller.ts | 232 ++++--- .../assessment/assessment.controller.ts | 32 +- src/modules/auth/auth.controller.ts | 8 +- src/modules/dealer/dealer.controller.ts | 4 +- src/modules/master/master.controller.ts | 582 ++++++++++++++++-- src/modules/master/master.routes.ts | 63 ++ src/modules/master/syncHierarchy.service.ts | 105 ++++ .../onboarding/onboarding.controller.ts | 85 +-- .../opportunity/opportunity.controller.ts | 14 +- src/types/auth.types.ts | 1 + 31 files changed, 1632 insertions(+), 448 deletions(-) create mode 100644 .env.example create mode 100644 all_perms.json create mode 100644 audit_db.mjs create mode 100644 audit_db.ts create mode 100644 check_zms_v2.ts create mode 100644 db_audit.json create mode 100644 scripts/sync-all-hierarchy.ts create mode 100644 src/database/models/District.ts create mode 100644 src/modules/master/syncHierarchy.service.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f409a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Environment configuration +NODE_ENV=development +PORT=5000 +FRONTEND_URL=http://localhost:5173 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=1000 + +# Authentication +JWT_SECRET=your-secret-key-change-in-production +JWT_EXPIRE=7d + +# Database Configuration +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=royal_enfield_onboarding +DB_HOST=localhost +DB_PORT=5432 +DB_SSL=false + +# Email Configuration +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_SECURE=true +EMAIL_USER=your-email@gmail.com +EMAIL_PASSWORD=your-app-password +EMAIL_FROM="Royal Enfield " + +# Web Push Notifications (VAPID) +VAPID_PUBLIC_KEY=your_vapid_public_key +VAPID_PRIVATE_KEY=your_vapid_private_key +VAPID_EMAIL=admin@royalenfield.com + +# File Uploads +UPLOAD_DIR=./uploads +MAX_FILE_SIZE=10485760 diff --git a/all_perms.json b/all_perms.json new file mode 100644 index 0000000..16d514c --- /dev/null +++ b/all_perms.json @@ -0,0 +1,32 @@ +[ + "action:approve", + "action:reject", + "action:upload_docs", + "action:request_changes", + "action:forward", + "action:reassign", + "action:schedule_interview", + "action:add_comments", + "action:rank_applicants", + "action:final_approval", + "view:view_details", + "view:view_financial", + "view:view_discussions", + "view:view_progress", + "view:view_audit", + "view:view_documents", + "view:view_personal", + "view:view_business", + "view:view_reports", + "view:view_history", + "stage:initial_review", + "stage:field_verification", + "stage:level1_interview", + "stage:level2_interview", + "stage:ranking", + "stage:legal_review", + "stage:financial_review", + "stage:final_approval", + "stage:payment", + "stage:onboarding" +] \ No newline at end of file diff --git a/audit_db.mjs b/audit_db.mjs new file mode 100644 index 0000000..0f7d547 --- /dev/null +++ b/audit_db.mjs @@ -0,0 +1,32 @@ +import db from './src/database/models/index.js'; +import fs from 'fs'; + +async function dump() { + try { + const roles = await db.Role.findAll({ + include: [{ + model: db.Permission, + as: 'permissions', + through: { attributes: [] } + }] + }); + const data = roles.map(r => ({ + id: r.id, + name: r.roleName, + permissions: r.permissions.map(p => p.permissionCode) + })); + fs.writeFileSync('db_audit.json', JSON.stringify(data, null, 2)); + + const perms = await db.Permission.findAll(); + fs.writeFileSync('all_perms.json', JSON.stringify(perms.map(p => p.permissionCode), null, 2)); + + console.log('Audit complete'); + } catch (e) { + fs.writeFileSync('db_error.txt', e.stack); + console.error(e); + } finally { + process.exit(); + } +} + +dump(); diff --git a/audit_db.ts b/audit_db.ts new file mode 100644 index 0000000..4e4b537 --- /dev/null +++ b/audit_db.ts @@ -0,0 +1,32 @@ +import db from './src/database/models/index.js'; +import fs from 'fs'; + +async function dump() { + try { + const roles = await (db as any).Role.findAll({ + include: [{ + model: (db as any).Permission, + as: 'permissions', + through: { attributes: [] } + }] + }); + const data = roles.map((r: any) => ({ + id: r.id, + name: r.roleName, + permissions: r.permissions.map((p: any) => p.permissionCode) + })); + fs.writeFileSync('db_audit.json', JSON.stringify(data, null, 2)); + + const perms = await (db as any).Permission.findAll(); + fs.writeFileSync('all_perms.json', JSON.stringify(perms.map((p: any) => p.permissionCode), null, 2)); + + console.log('Audit complete'); + } catch (e: any) { + fs.writeFileSync('db_error.txt', e.stack || e.toString()); + console.error(e); + } finally { + process.exit(); + } +} + +dump(); diff --git a/check_zms_v2.ts b/check_zms_v2.ts new file mode 100644 index 0000000..2d627dd --- /dev/null +++ b/check_zms_v2.ts @@ -0,0 +1,33 @@ +import db from './src/database/models/index.js'; + +async function checkZMs() { + try { + const users = await db.User.findAll({ + include: [ + { + model: db.UserRole, + as: 'userRoles', + where: { isActive: true }, + include: [{ model: db.Role, as: 'role' }] + } + ] + }); + + console.log('--- Active Zonal Manager Roles ---'); + users.forEach((u: any) => { + const zms = (u.userRoles || []).filter((ur: any) => ['ZM', 'DD-ZM', 'ZBH'].includes(ur.role?.roleCode)); + if (zms.length > 0) { + console.log(`User: ${u.fullName} (ID: ${u.id}, EmployeeID: ${u.employeeId})`); + zms.forEach((zm: any) => { + console.log(` Role: ${zm.role.roleCode}, managerCode: ${zm.managerCode}, isActive: ${zm.isActive}`); + }); + } + }); + process.exit(0); + } catch (err) { + console.error('Error checking ZMs:', err); + process.exit(1); + } +} + +checkZMs(); diff --git a/db_audit.json b/db_audit.json new file mode 100644 index 0000000..6680e87 --- /dev/null +++ b/db_audit.json @@ -0,0 +1,67 @@ +[ + { + "id": "c5a8042f-47f1-4a83-a508-271fdc2774c4", + "name": "DD Zonal Manager", + "permissions": [] + }, + { + "id": "0f4ecb63-49ff-45d9-a66c-a5bd99429aba", + "name": "DD Admin", + "permissions": [] + }, + { + "id": "2c045771-c6a2-41f4-9ca2-29523ae9f03a", + "name": "Dealer", + "permissions": [] + }, + { + "id": "1a740765-0a1f-4f36-bffd-e97a8d2609e2", + "name": "Finance", + "permissions": [] + }, + { + "id": "da14a401-44f5-471c-a564-224d6c935898", + "name": "Regional Business Manager", + "permissions": [] + }, + { + "id": "815c1ede-da74-4240-92a9-f5473cf933f7", + "name": "DD Head", + "permissions": [] + }, + { + "id": "188ce0bc-f845-4f4b-abc5-82ef4be18b68", + "name": "Regional Manager", + "permissions": [] + }, + { + "id": "1cb698cc-3ee2-466b-8e9f-03b71ca4dbc3", + "name": "Area Sales Manager", + "permissions": [] + }, + { + "id": "9ccfec23-aa11-4031-8cbb-1f4ddf3a91e2", + "name": "Legal Admin", + "permissions": [] + }, + { + "id": "dfb978d4-8556-470f-86e4-2b75044d37f9", + "name": "DD Lead", + "permissions": [] + }, + { + "id": "1a1d91a0-c15e-477d-9381-17131a2a9966", + "name": "Super Admin", + "permissions": [] + }, + { + "id": "27c9e905-ab9d-42de-8dda-60a1c35d54b7", + "name": "Zonal Business Head", + "permissions": [] + }, + { + "id": "ce872857-200f-4a0d-b46a-a46f8022e4dc", + "name": "National Business Head", + "permissions": [] + } +] \ No newline at end of file diff --git a/package.json b/package.json index 64037ae..831b325 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,13 @@ "migrate": "tsx scripts/migrate.ts", "reset:stable": "tsx scripts/reset_db_stable.ts", "seed": "tsx scripts/seed_normalized_data.ts", + "seed:permissions": "tsx scripts/seed-permissions.ts", "seed:approval-policies": "tsx scripts/seed-approval-policies.ts", - "seed:all": "npm run seed && npm run seed:approval-policies && npm run seed:questionnaire", - "setup:fresh": "npm run migrate && npm run seed:all", - "seed:real-geo": "tsx scripts/seed_real_locations.ts", + "seed:email-templates": "tsx src/scripts/seed-master-emails.ts", + "seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates", + "setup:fresh": "npm run migrate && npm run seed:all && npm run sync:hierarchy", + "seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy", + "sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts", "seed:questionnaire": "tsx src/scripts/seedQuestionnaire.ts", "test": "jest", "test:coverage": "jest --coverage", diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index 0e6bc72..4a8c48b 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -1,148 +1,159 @@ 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, Location, User, UserRole } = db; + const { Role, Zone, Region, State, District, User, UserRole } = db; -async function seed() { - console.log('--- Seeding Normalized Denormalized Data ---'); + async function seed() { + console.log('--- Seeding Normalized Denormalized Data ---'); - await db.sequelize.authenticate(); - // Use sync with alter false to match main app behavior - await db.sequelize.sync({ alter: false }); + await db.sequelize.authenticate(); + // Use sync with alter false to match main app behavior + await db.sequelize.sync({ alter: false }); - const hashedPassword = await bcrypt.hash('Admin@123', 10); + const hashedPassword = await bcrypt.hash('Admin@123', 10); - // 1. Create 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: 'RM', roleName: 'Regional 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: 'Legal Admin', roleName: 'Legal Admin', category: 'DEPARTMENT' } - ]; + // 1. Create 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: 'RM', roleName: 'Regional 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: 'Legal Admin', roleName: 'Legal Admin', category: 'DEPARTMENT' } + ]; - for (const r of roles) { - await Role.findOrCreate({ where: { roleCode: r.roleCode }, defaults: r }); - } - console.log('Roles seeded.'); + for (const r of roles) { + await Role.findOrCreate({ where: { roleCode: r.roleCode }, defaults: r }); + } + console.log('Roles seeded.'); - // 2. Create Locations (Hierarchy) - const [zone1] = await Zone.findOrCreate({ - where: { name: 'North Zone' }, - defaults: { name: 'North Zone', code: 'ZONE-N' } - }); + // 2. Create Districts (Hierarchy) + const [zone1] = await Zone.findOrCreate({ + where: { name: 'North Zone' }, + defaults: { name: 'North Zone', code: 'ZONE-N' } + }); - const [zone2] = await Zone.findOrCreate({ - where: { name: 'South Zone' }, - defaults: { name: 'South Zone', code: 'ZONE-S' } - }); + const [zone2] = await Zone.findOrCreate({ + where: { name: 'South Zone' }, + defaults: { name: 'South Zone', code: 'ZONE-S' } + }); - const [state1] = await State.findOrCreate({ - where: { name: 'Delhi' }, - defaults: { name: 'Delhi', zoneId: zone1.id } - }); + const [state1] = await State.findOrCreate({ + where: { name: 'Delhi' }, + defaults: { name: 'Delhi', zoneId: zone1.id } + }); - const [region1] = await Region.findOrCreate({ - where: { name: 'NCR Region' }, - defaults: { name: 'NCR Region', zoneId: zone1.id } - }); + const [region1] = await Region.findOrCreate({ + where: { name: 'NCR Region' }, + defaults: { name: 'NCR Region', zoneId: zone1.id } + }); - const [region2] = await Region.findOrCreate({ - where: { name: 'Bangalore Region' }, - defaults: { name: 'Bangalore Region', zoneId: zone2.id } - }); + const [region2] = await Region.findOrCreate({ + where: { name: 'Bangalore Region' }, + defaults: { name: 'Bangalore Region', zoneId: zone2.id } + }); - const [district1] = await Location.findOrCreate({ - where: { name: 'South Delhi District' }, - defaults: { name: 'South Delhi District', stateId: state1.id, regionId: region1.id, zoneId: zone1.id } - }); + const [district1] = await District.findOrCreate({ + where: { name: 'South Delhi District' }, + defaults: { name: 'South Delhi District', stateId: state1.id, regionId: region1.id, zoneId: zone1.id } + }); - console.log('Geographical Hierarchy seeded.'); + console.log('Geographical Hierarchy seeded.'); - const mapUserRole = async (userRec: any, roleCode: string, assignment: { zoneId?: string | null, regionId?: string | null, locationId?: string | null } = {}) => { - const role = await Role.findOne({ where: { roleCode } }); - if (role) { - await UserRole.findOrCreate({ - where: { - userId: userRec.id, - roleId: role.id, - ...assignment - }, - defaults: { - userId: userRec.id, - roleId: role.id, - ...assignment, - isActive: true, - isPrimary: true + const mapUserRole = async (userRec: any, roleCode: string, assignment: { zoneId?: string | null, regionId?: string | null, districtId?: string | null } = {}) => { + const role = await Role.findOne({ where: { roleCode } }); + if (role) { + await UserRole.findOrCreate({ + where: { + userId: userRec.id, + roleId: role.id, + ...assignment + }, + defaults: { + userId: userRec.id, + roleId: role.id, + ...assignment, + isActive: true, + isPrimary: true + } + }); + } + }; + + // 4. Create Users and Map them + const nbhResult = await User.findOrCreate({ + where: { email: 'nbh@example.com' }, + defaults: { fullName: 'National Head', roleCode: 'NBH', password: hashedPassword } + }); + await mapUserRole(nbhResult[0], 'NBH'); + + const zbhResult = await User.findOrCreate({ + where: { email: 'zbh.north@example.com' }, + defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword, employeeId: 'ZBH001' } + }); + await mapUserRole(zbhResult[0], 'ZBH', { zoneId: zone1.id }); + + const rmResult = await User.findOrCreate({ + where: { email: 'rbm.delhi@example.com' }, + defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RM', password: hashedPassword, employeeId: 'RBM001' } + }); + await mapUserRole(rmResult[0], 'RM', { regionId: region1.id }); + + const asmResult = await User.findOrCreate({ + where: { email: 'asm.sdelhi@example.com' }, + defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword, employeeId: 'ASM001' } + }); + await mapUserRole(asmResult[0], 'ASM', { districtId: district1.id }); + + // Mock Users alignment + const mockUsers = [ + { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, + { email: 'finance@royalenfield.com', name: 'Rahul Verma', roleCode: 'Finance', assignment: {} }, + { email: 'dealer@royalenfield.com', name: 'Amit Sharma', roleCode: 'Dealer', assignment: { districtId: district1.id }, isExt: true }, + { email: 'admin@royalenfield.com', name: 'Laxman H', roleCode: 'DD Lead', assignment: { zoneId: zone2.id } }, + { email: 'yashwin@gmail.com', name: 'Yashwin', roleCode: 'ZBH', assignment: { zoneId: zone1.id } }, + { email: 'kenil@gmail.com', name: 'Kenil', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, + { email: 'lince@gmail.com', name: 'Lince', roleCode: 'DD Admin', assignment: {} } + ]; + + for (const m of mockUsers) { + const [u] = await User.findOrCreate({ + where: { email: m.email }, + defaults: { + fullName: m.name, + roleCode: m.roleCode, + password: hashedPassword, + isExternal: m.isExt || false, + status: 'active' } }); + await mapUserRole(u, m.roleCode, m.assignment); } - }; - // 4. Create Users and Map them - // Custom Seed Users - const nbhResult = await User.findOrCreate({ - where: { email: 'nbh@example.com' }, - defaults: { fullName: 'National Head', roleCode: 'NBH', password: hashedPassword } - }); - await mapUserRole(nbhResult[0], 'NBH'); + console.log('Users and Mappings seeded.'); - const zbhResult = await User.findOrCreate({ - where: { email: 'zbh.north@example.com' }, - defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword } - }); - await mapUserRole(zbhResult[0], 'ZBH', { zoneId: zone1.id }); + console.log('--- Triggering Hierarchy Synchronization ---'); + const districts = await District.findAll({ attributes: ['id'] }); + for (const d of districts) await syncLocationManagers(d.id); + + const regions = await Region.findAll({ attributes: ['id'] }); + for (const r of regions) await syncRegionManager(r.id); - const rmResult = await User.findOrCreate({ - where: { email: 'rbm.delhi@example.com' }, - defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RM', password: hashedPassword } - }); - await mapUserRole(rmResult[0], 'RM', { regionId: region1.id }); + const zones = await Zone.findAll({ attributes: ['id'] }); + for (const z of zones) await syncZoneManager(z.id); - const asmResult = await User.findOrCreate({ - where: { email: 'asm.sdelhi@example.com' }, - defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword } - }); - await mapUserRole(asmResult[0], 'ASM', { locationId: district1.id }); - - // Mock Users alignment - const mockUsers = [ - { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, - { email: 'finance@royalenfield.com', name: 'Rahul Verma', roleCode: 'Finance', assignment: {} }, - { email: 'dealer@royalenfield.com', name: 'Amit Sharma', roleCode: 'Dealer', assignment: { locationId: district1.id }, isExt: true }, - { email: 'admin@royalenfield.com', name: 'Laxman H', roleCode: 'DD Lead', assignment: { zoneId: zone2.id } }, - { email: 'yashwin@gmail.com', name: 'Yashwin', roleCode: 'ZBH', assignment: { zoneId: zone1.id } }, - { email: 'kenil@gmail.com', name: 'Kenil', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, - { email: 'lince@gmail.com', name: 'Lince', roleCode: 'DD Admin', assignment: {} } - ]; - - for (const m of mockUsers) { - const [u] = await User.findOrCreate({ - where: { email: m.email }, - defaults: { - fullName: m.name, - roleCode: m.roleCode, - password: hashedPassword, - isExternal: m.isExt || false, - status: 'active' - } - }); - await mapUserRole(u, m.roleCode, m.assignment); + console.log('--- Seeding & Synchronization Complete ---'); } - console.log('Users and Mappings seeded.'); - console.log('--- Seeding Complete ---'); -} - seed().catch(err => { console.error(err); process.exit(1); diff --git a/scripts/seed_real_locations.ts b/scripts/seed_real_locations.ts index 32931b0..6ae3ca9 100644 --- a/scripts/seed_real_locations.ts +++ b/scripts/seed_real_locations.ts @@ -3,14 +3,13 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import db from '../src/database/models/index.js'; +import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const { Zone, State, Location } = db; - async function run() { - console.log('--- Seeding Real Geo Data (Denormalized Model) ---'); + console.log('--- Seeding Real Geo Data (District -> Area Hierarchy) ---'); try { await db.sequelize.authenticate(); @@ -32,8 +31,10 @@ async function run() { const STATES_DATA = eval(`[${statesMatch[1]}]`); const CITIES_DATA = eval(`[${citiesMatch[1]}]`); - console.log(`Extracted ${ZONES_DATA.length} Zones, ${STATES_DATA.length} States, and ${CITIES_DATA.length} Districts.`); + console.log(`Extracted ${ZONES_DATA.length} Zones, ${STATES_DATA.length} States, and ${CITIES_DATA.length} Cities.`); + const { Zone, State, District, Location, Opportunity } = db; + // 1. Seed Zones const zoneIdMap = new Map(); // Name -> UUID for (const z of ZONES_DATA) { @@ -42,15 +43,12 @@ async function run() { defaults: { name: z.name, code: z.code } }); zoneIdMap.set(z.name, zoneRecord.id); - // Attach states list for later lookup - z._dbId = zoneRecord.id; } console.log('Zones seeded.'); // 2. Seed States and link to Zones const stateIdMap = new Map(); // Legacy ID -> { id, zoneId } for (const s of STATES_DATA) { - // Find parent zone by checking which zone's states array contains this state name const parentZoneData = ZONES_DATA.find((z: any) => z.states.includes(s.name)); const zoneId = parentZoneData ? zoneIdMap.get(parentZoneData.name) : null; @@ -63,12 +61,16 @@ async function run() { } console.log('States seeded.'); - // 3. Seed Districts (Locations) + // 3. Seed Districts and Areas (Locations) let districtCount = 0; + let areaCount = 0; + let opportunityCount = 0; + for (const c of CITIES_DATA) { const parentStateData = stateIdMap.get(c.state_id); if (parentStateData) { - await Location.findOrCreate({ + // a. Create District (Primary territory entity) + const [districtRecord] = await District.findOrCreate({ where: { name: c.name, stateId: parentStateData.id }, defaults: { name: c.name, @@ -77,10 +79,49 @@ async function run() { } }); districtCount++; + + // b. Create Area (Granular Location record) + const [areaRecord] = await Location.findOrCreate({ + where: { name: c.name, districtId: districtRecord.id }, + defaults: { + name: c.name, + city: c.name, + districtId: districtRecord.id, + isActive: true + } + }); + areaCount++; + + // c. Create associated Opportunity + const [oppRecord, created] = await Opportunity.findOrCreate({ + where: { areaId: areaRecord.id }, + defaults: { + districtId: districtRecord.id, + areaId: areaRecord.id, // Linking to Area! + city: c.name, + status: 'inactive', + opportunityType: 'New Dealership', + capacity: 'Standard', + priority: 'Medium', + notes: 'Automatically generated from district seed' + } + }); + if (created) opportunityCount++; } } + console.log(`✅ Seeded ${districtCount} Districts, ${areaCount} Areas, and ${opportunityCount} Opportunities.`); - console.log(`✅ Successfully seeded Real Geo Data! Created/Verified ${districtCount} districts.`); + console.log('--- Triggering Hierarchy Synchronization ---'); + const districts = await District.findAll({ attributes: ['id'] }); + for (const d of districts) await syncLocationManagers(d.id); + + const regions = await db.Region.findAll({ attributes: ['id'] }); + for (const r of regions) await syncRegionManager(r.id); + + const zonesArr = await Zone.findAll({ attributes: ['id'] }); + for (const z of zonesArr) await syncZoneManager(z.id); + + console.log('--- Synchronization Complete ---'); process.exit(0); } catch (e: any) { diff --git a/scripts/sync-all-hierarchy.ts b/scripts/sync-all-hierarchy.ts new file mode 100644 index 0000000..4a14b81 --- /dev/null +++ b/scripts/sync-all-hierarchy.ts @@ -0,0 +1,46 @@ +import 'dotenv/config'; +import db from '../src/database/models/index.js'; +import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js'; + +async function syncAll() { + console.log('--- Starting Master Hierarchy Synchronization ---'); + try { + await db.sequelize.authenticate(); + console.log('✓ Database connected.'); + + // 1. Sync all Zones + console.log('Syncing Zones...'); + const zones = await db.Zone.findAll({ attributes: ['id', 'name'] }); + for (const zone of zones) { + await syncZoneManager(zone.id); + console.log(` - Synced Zone: ${zone.name}`); + } + + // 2. Sync all Regions + console.log('Syncing Regions...'); + const regions = await db.Region.findAll({ attributes: ['id', 'name'] }); + for (const region of regions) { + await syncRegionManager(region.id); + console.log(` - Synced Region: ${region.name}`); + } + + // 3. Sync all Districts + console.log('Syncing Districts (This may take a moment)...'); + const locations = await db.District.findAll({ attributes: ['id', 'name'] }); + let count = 0; + for (const loc of locations) { + await syncLocationManagers(loc.id); + count++; + if (count % 50 === 0) console.log(` - Synced ${count} districts...`); + } + console.log(`✓ Total Districts Synced: ${count}`); + + console.log('\n--- Synchronization Complete ---'); + process.exit(0); + } catch (error) { + console.error('❌ Synchronization failed:', error); + process.exit(1); + } +} + +syncAll(); diff --git a/src/common/config/auth.ts b/src/common/config/auth.ts index c9ebee8..95ba32d 100644 --- a/src/common/config/auth.ts +++ b/src/common/config/auth.ts @@ -10,7 +10,8 @@ export const generateToken = (user: any): string => { userId: user.id, email: user.email, role: user.roleCode, - locationId: user.locationId + locationId: user.locationId, + districtId: user.districtId }; return jwt.sign(payload, JWT_SECRET, { diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index 3d2d116..f5a51cd 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -34,7 +34,7 @@ export interface ApplicationAttributes { architectureAssignedTo: string | null; architectureStatus: string | null; submittedBy: string | null; - locationId: string | null; + districtId: string | null; architectureAssignedDate: Date | null; architectureDocumentDate: Date | null; architectureCompletionDate: Date | null; @@ -200,11 +200,11 @@ export default (sequelize: Sequelize) => { key: 'id' } }, - locationId: { + districtId: { type: DataTypes.UUID, allowNull: true, references: { - model: 'locations', + model: 'districts', key: 'id' } }, @@ -245,7 +245,7 @@ export default (sequelize: Sequelize) => { Application.belongsTo(models.User, { foreignKey: 'assignedTo', as: 'assignee' }); Application.belongsTo(models.User, { foreignKey: 'architectureAssignedTo', as: 'architectureAssignee' }); Application.belongsTo(models.Opportunity, { foreignKey: 'opportunityId', as: 'opportunity' }); - Application.belongsTo(models.Location, { foreignKey: 'locationId', as: 'location' }); + Application.belongsTo(models.District, { foreignKey: 'districtId', as: 'district' }); Application.hasMany(models.ApplicationStatusHistory, { foreignKey: 'applicationId', as: 'statusHistory' }); Application.hasMany(models.ApplicationProgress, { foreignKey: 'applicationId', as: 'progressTracking' }); diff --git a/src/database/models/District.ts b/src/database/models/District.ts new file mode 100644 index 0000000..4a17ffa --- /dev/null +++ b/src/database/models/District.ts @@ -0,0 +1,128 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface DistrictAttributes { + id: string; + name: string; + code?: string; + stateId?: string | null; + regionId?: string | null; + zoneId?: string | null; + asmId?: string | null; + asmCode?: string | null; + ddAmId?: string | null; + ddAmCode?: string | null; + zmId?: string | null; + zmCode?: string | null; + city?: string | null; + isActive?: boolean; + description?: string | null; +} + +export interface DistrictInstance extends Model, DistrictAttributes { } + +export default (sequelize: Sequelize) => { + const District = sequelize.define('District', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + code: { + type: DataTypes.STRING, + allowNull: true, + unique: true + }, + stateId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'states', + key: 'id' + } + }, + regionId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'regions', + key: 'id' + } + }, + zoneId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'zones', + key: 'id' + } + }, + asmId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'id' } + }, + asmCode: { + type: DataTypes.STRING, + allowNull: true + }, + ddAmId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'id' } + }, + ddAmCode: { + type: DataTypes.STRING, + allowNull: true + }, + zmId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'users', key: 'id' } + }, + zmCode: { + type: DataTypes.STRING, + allowNull: true + }, + city: { + type: DataTypes.STRING, + allowNull: true + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + description: { + type: DataTypes.TEXT, + allowNull: true + } + }, { + tableName: 'districts', + timestamps: true, + indexes: [ + { fields: ['stateId'] }, + { fields: ['regionId'] }, + { fields: ['zoneId'] }, + { unique: true, fields: ['name', 'stateId'] } + ] + }); + + (District as any).associate = (models: any) => { + District.belongsTo(models.State, { foreignKey: 'stateId', as: 'state' }); + District.belongsTo(models.Region, { foreignKey: 'regionId', as: 'region' }); + District.belongsTo(models.Zone, { foreignKey: 'zoneId', as: 'zone' }); + District.belongsTo(models.User, { foreignKey: 'asmId', as: 'asm' }); + District.belongsTo(models.User, { foreignKey: 'ddAmId', as: 'ddAm' }); + District.belongsTo(models.User, { foreignKey: 'zmId', as: 'zonalManager' }); + District.hasMany(models.User, { foreignKey: 'districtId', as: 'users' }); + District.hasMany(models.UserRole, { foreignKey: 'districtId', as: 'userRoles' }); + District.hasMany(models.Application, { foreignKey: 'districtId', as: 'applications' }); + District.hasMany(models.Opportunity, { foreignKey: 'districtId', as: 'opportunities' }); + District.hasMany(models.Location, { foreignKey: 'districtId', as: 'locations' }); // Will be added soon + }; + + return District; +}; diff --git a/src/database/models/Location.ts b/src/database/models/Location.ts index bb2e2c9..94fc8a5 100644 --- a/src/database/models/Location.ts +++ b/src/database/models/Location.ts @@ -3,12 +3,11 @@ import { Model, DataTypes, Sequelize } from 'sequelize'; export interface LocationAttributes { id: string; name: string; - code?: string; - stateId?: string | null; - regionId?: string | null; - zoneId?: string | null; - asmId?: string | null; + districtId: string | null; + city?: string | null; isActive?: boolean; + openFrom?: Date | null; + openTo?: Date | null; description?: string | null; } @@ -25,47 +24,30 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: false }, - code: { + districtId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'districts', + key: 'id' + } + }, + city: { type: DataTypes.STRING, - allowNull: true, - unique: true - }, - stateId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'states', - key: 'id' - } - }, - regionId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'regions', - key: 'id' - } - }, - zoneId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'zones', - key: 'id' - } - }, - asmId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'users', - key: 'id' - } + allowNull: true }, isActive: { type: DataTypes.BOOLEAN, defaultValue: true }, + openFrom: { + type: DataTypes.DATE, + allowNull: true + }, + openTo: { + type: DataTypes.DATE, + allowNull: true + }, description: { type: DataTypes.TEXT, allowNull: true @@ -74,21 +56,16 @@ export default (sequelize: Sequelize) => { tableName: 'locations', timestamps: true, indexes: [ - { fields: ['stateId'] }, - { fields: ['regionId'] }, - { fields: ['zoneId'] }, - { unique: true, fields: ['name', 'stateId'] } + { fields: ['districtId'] }, + { unique: true, fields: ['name', 'districtId'] } ] }); (Location as any).associate = (models: any) => { - Location.belongsTo(models.State, { foreignKey: 'stateId', as: 'state' }); - Location.belongsTo(models.Region, { foreignKey: 'regionId', as: 'region' }); - Location.belongsTo(models.Zone, { foreignKey: 'zoneId', as: 'zone' }); - Location.belongsTo(models.User, { foreignKey: 'asmId', as: 'asm' }); - Location.hasMany(models.User, { foreignKey: 'locationId', as: 'users' }); - Location.hasMany(models.UserRole, { foreignKey: 'locationId', as: 'userRoles' }); - Location.hasMany(models.Application, { foreignKey: 'locationId', as: 'applications' }); + Location.belongsTo(models.District, { foreignKey: 'districtId', as: 'district' }); + + // These can have detailed opportunities if needed in future + Location.hasMany(models.Opportunity, { foreignKey: 'areaId', as: 'opportunities' }); }; return Location; diff --git a/src/database/models/Opportunity.ts b/src/database/models/Opportunity.ts index fecc012..87c2651 100644 --- a/src/database/models/Opportunity.ts +++ b/src/database/models/Opportunity.ts @@ -2,7 +2,8 @@ import { Model, DataTypes, Sequelize } from 'sequelize'; export interface OpportunityAttributes { id: string; - locationId: string; + districtId: string; + areaId?: string | null; city: string; opportunityType: string; capacity: string; @@ -23,9 +24,17 @@ export default (sequelize: Sequelize) => { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - locationId: { + districtId: { type: DataTypes.UUID, allowNull: false, + references: { + model: 'districts', + key: 'id' + } + }, + areaId: { + type: DataTypes.UUID, + allowNull: true, references: { model: 'locations', key: 'id' @@ -77,7 +86,8 @@ export default (sequelize: Sequelize) => { }); (Opportunity as any).associate = (models: any) => { - Opportunity.belongsTo(models.Location, { foreignKey: 'locationId', as: 'location' }); + Opportunity.belongsTo(models.District, { foreignKey: 'districtId', as: 'district' }); + Opportunity.belongsTo(models.Location, { foreignKey: 'areaId', as: 'area' }); Opportunity.belongsTo(models.User, { foreignKey: 'createdBy', as: 'creator' }); Opportunity.hasMany(models.Application, { foreignKey: 'opportunityId', as: 'applications' }); }; diff --git a/src/database/models/Outlet.ts b/src/database/models/Outlet.ts index e33887a..e808709 100644 --- a/src/database/models/Outlet.ts +++ b/src/database/models/Outlet.ts @@ -15,7 +15,7 @@ export interface OutletAttributes { status: typeof OUTLET_STATUS[keyof typeof OUTLET_STATUS]; establishedDate: string; dealerId: string; - locationId: string; + districtId: string; } export interface OutletInstance extends Model, OutletAttributes { } @@ -80,11 +80,11 @@ export default (sequelize: Sequelize) => { key: 'id' } }, - locationId: { + districtId: { type: DataTypes.UUID, allowNull: false, references: { - model: 'locations', + model: 'districts', key: 'id' } } @@ -96,7 +96,7 @@ export default (sequelize: Sequelize) => { { fields: ['dealerId'] }, { fields: ['type'] }, { fields: ['status'] }, - { fields: ['locationId'] } + { fields: ['districtId'] } ] }); @@ -105,9 +105,9 @@ export default (sequelize: Sequelize) => { foreignKey: 'dealerId', as: 'dealer' }); - Outlet.belongsTo(models.Location, { - foreignKey: 'locationId', - as: 'location' + Outlet.belongsTo(models.District, { + foreignKey: 'districtId', + as: 'district' }); Outlet.hasMany(models.Resignation, { foreignKey: 'outletId', diff --git a/src/database/models/Region.ts b/src/database/models/Region.ts index 323dc80..c3846a5 100644 --- a/src/database/models/Region.ts +++ b/src/database/models/Region.ts @@ -6,6 +6,8 @@ export interface RegionAttributes { code: string; description?: string | null; zoneId?: string | null; + rbmId?: string | null; // Regional Business Manager + rbmCode?: string | null; } export interface RegionInstance extends Model, RegionAttributes { } @@ -38,6 +40,18 @@ export default (sequelize: Sequelize) => { model: 'zones', key: 'id' } + }, + rbmId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id' + } + }, + rbmCode: { + type: DataTypes.STRING, + allowNull: true } }, { tableName: 'regions', @@ -46,7 +60,8 @@ export default (sequelize: Sequelize) => { (Region as any).associate = (models: any) => { Region.belongsTo(models.Zone, { foreignKey: 'zoneId', as: 'zone' }); - Region.hasMany(models.Location, { foreignKey: 'regionId', as: 'districts' }); + Region.belongsTo(models.User, { foreignKey: 'rbmId', as: 'regionalManager' }); + Region.hasMany(models.District, { foreignKey: 'regionId', as: 'districts' }); }; return Region; diff --git a/src/database/models/User.ts b/src/database/models/User.ts index 600c04d..f8a8209 100644 --- a/src/database/models/User.ts +++ b/src/database/models/User.ts @@ -11,7 +11,10 @@ export interface UserAttributes { department: string | null; designation: string | null; roleCode: string | null; - locationId: string | null; + districtId: string | null; + zoneId: string | null; + regionId: string | null; + stateId: string | null; dealerId: string | null; isActive: boolean; isExternal: boolean; @@ -44,7 +47,7 @@ export default (sequelize: Sequelize) => { }, password: { type: DataTypes.STRING, - allowNull: true // SSO might not need passwords + allowNull: true }, fullName: { type: DataTypes.STRING, @@ -66,16 +69,28 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true }, - locationId: { + districtId: { type: DataTypes.UUID, allowNull: true, - references: { - model: 'locations', - key: 'id' - } + references: { model: 'districts', key: 'id' } + }, + zoneId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'zones', key: 'id' } + }, + regionId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'regions', key: 'id' } + }, + stateId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: 'states', key: 'id' } }, dealerId: { - type: DataTypes.UUID, // Link to Dealer entity if applicable + type: DataTypes.UUID, allowNull: true }, isActive: { @@ -116,7 +131,18 @@ export default (sequelize: Sequelize) => { }); User.hasMany(models.UserRole, { foreignKey: 'userId', as: 'userRoles' }); User.hasMany(models.UserRole, { foreignKey: 'assignedBy', as: 'assignedRoles' }); - User.belongsTo(models.Location, { foreignKey: 'locationId', as: 'location' }); + + // Link to District (Parent Territory) + User.belongsTo(models.District, { foreignKey: 'districtId', as: 'district' }); + + // Role-specific managed districts (pointing to District model now) + User.hasMany(models.District, { foreignKey: 'asmId', as: 'managedAsmDistricts' }); + User.hasMany(models.District, { foreignKey: 'ddAmId', as: 'managedAreaDistricts' }); + User.hasMany(models.District, { foreignKey: 'zmId', as: 'managedZmDistricts' }); + + User.belongsTo(models.Zone, { foreignKey: 'zoneId', as: 'zone' }); + User.belongsTo(models.Region, { foreignKey: 'regionId', as: 'region' }); + User.belongsTo(models.State, { foreignKey: 'stateId', as: 'state' }); User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' }); User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' }); diff --git a/src/database/models/UserRole.ts b/src/database/models/UserRole.ts index 81e237e..3cf47a9 100644 --- a/src/database/models/UserRole.ts +++ b/src/database/models/UserRole.ts @@ -4,9 +4,10 @@ export interface UserRoleAttributes { id: string; userId: string; roleId: string; - locationId: string | null; // District + districtId: string | null; zoneId: string | null; regionId: string | null; + stateId?: string | null; managerCode: string | null; isPrimary: boolean; isActive: boolean; @@ -41,11 +42,11 @@ export default (sequelize: Sequelize) => { key: 'id' } }, - locationId: { + districtId: { type: DataTypes.UUID, allowNull: true, references: { - model: 'locations', + model: 'districts', key: 'id' } }, @@ -65,6 +66,14 @@ export default (sequelize: Sequelize) => { key: 'id' } }, + stateId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'states', + key: 'id' + } + }, managerCode: { type: DataTypes.STRING, allowNull: true @@ -99,8 +108,7 @@ export default (sequelize: Sequelize) => { } }, { tableName: 'user_roles', - timestamps: true, - updatedAt: false + timestamps: true }); (UserRole as any).associate = (models: any) => { @@ -112,9 +120,9 @@ export default (sequelize: Sequelize) => { foreignKey: 'roleId', as: 'role' }); - UserRole.belongsTo(models.Location, { - foreignKey: 'locationId', - as: 'location' + UserRole.belongsTo(models.District, { + foreignKey: 'districtId', + as: 'district' }); UserRole.belongsTo(models.Zone, { foreignKey: 'zoneId', @@ -124,6 +132,10 @@ export default (sequelize: Sequelize) => { foreignKey: 'regionId', as: 'region' }); + UserRole.belongsTo(models.State, { + foreignKey: 'stateId', + as: 'state' + }); UserRole.belongsTo(models.User, { foreignKey: 'assignedBy', as: 'assigner' diff --git a/src/database/models/Zone.ts b/src/database/models/Zone.ts index b130ba6..b2a9ce2 100644 --- a/src/database/models/Zone.ts +++ b/src/database/models/Zone.ts @@ -5,6 +5,8 @@ export interface ZoneAttributes { name: string; code: string; description?: string | null; + zbhId?: string | null; // Zonal Business Head + zbhCode?: string | null; } export interface ZoneInstance extends Model, ZoneAttributes { } @@ -29,6 +31,18 @@ export default (sequelize: Sequelize) => { description: { type: DataTypes.TEXT, allowNull: true + }, + zbhId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id' + } + }, + zbhCode: { + type: DataTypes.STRING, + allowNull: true } }, { tableName: 'zones', @@ -38,7 +52,8 @@ export default (sequelize: Sequelize) => { (Zone as any).associate = (models: any) => { Zone.hasMany(models.Region, { foreignKey: 'zoneId', as: 'regions' }); Zone.hasMany(models.State, { foreignKey: 'zoneId', as: 'states' }); - Zone.hasMany(models.Location, { foreignKey: 'zoneId', as: 'districts' }); + Zone.hasMany(models.District, { foreignKey: 'zoneId', as: 'districts' }); + Zone.belongsTo(models.User, { foreignKey: 'zbhId', as: 'zonalBusinessHead' }); }; return Zone; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index fc50cf8..b93ee14 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -19,6 +19,7 @@ import createSLAReminder from './SLAReminder.js'; import createSLAEscalationConfig from './SLAEscalationConfig.js'; import createWorkflowStageConfig from './WorkflowStageConfig.js'; import createNotification from './Notification.js'; +import createDistrict from './District.js'; import createLocation from './Location.js'; import createZone from './Zone.js'; import createRegion from './Region.js'; @@ -123,6 +124,7 @@ db.SLAReminder = createSLAReminder(sequelize); db.SLAEscalationConfig = createSLAEscalationConfig(sequelize); db.WorkflowStageConfig = createWorkflowStageConfig(sequelize); db.Notification = createNotification(sequelize); +db.District = createDistrict(sequelize); db.Location = createLocation(sequelize); db.Zone = createZone(sequelize); db.Region = createRegion(sequelize); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index a8b595d..661db27 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -5,6 +5,7 @@ import db from '../../database/models/index.js'; const { Role, Permission, RolePermission, User, DealerCode, AuditLog } = db; import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AuthRequest } from '../../types/express.types.js'; +import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../master/syncHierarchy.service.js'; const upsertUserAssignments = async ( userId: string, @@ -23,10 +24,12 @@ const upsertUserAssignments = async ( const role = await Role.findOne({ where: { roleCode } }); if (!role) continue; - await db.UserRole.create({ + const createdRole = await db.UserRole.create({ userId, roleId: role.id, - locationId: assignment.locationId || null, + districtId: assignment.locationId || assignment.districtId || null, + zoneId: assignment.zoneId || null, + regionId: assignment.regionId || null, managerCode: assignment.managerCode || assignment.asmCode || null, isPrimary: assignment.isPrimary !== undefined ? Boolean(assignment.isPrimary) : i === 0, isActive: assignment.isActive !== undefined ? Boolean(assignment.isActive) : true, @@ -34,6 +37,12 @@ const upsertUserAssignments = async ( effectiveTo: assignment.effectiveTo || null, assignedBy: actorUserId || null }); + + // Trigger Sync + const targetId = assignment.locationId || assignment.districtId; + if (targetId) await syncLocationManagers(targetId); + if (assignment.regionId) await syncRegionManager(assignment.regionId); + if (assignment.zoneId) await syncZoneManager(assignment.zoneId); } }; @@ -103,20 +112,32 @@ export const createRole = async (req: AuthRequest, res: Response) => { export const updateRole = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const { roleName, description, permissionIds, isActive } = req.body; + const { roleName, description, permissionIds, permissions, isActive } = req.body; + const permsToUpdate = permissionIds || permissions; const role = await Role.findByPk(id); if (!role) return res.status(404).json({ success: false, message: 'Role not found' }); await role.update({ roleName, description, isActive }); - if (permissionIds) { + if (permsToUpdate && Array.isArray(permsToUpdate)) { + // Resolve IDs if they are passed as codes + const resolvedIds: string[] = []; + for (const pid of permsToUpdate) { + if (pid.includes(':') || /^[A-Z_]+$/.test(pid)) { + const perm = await Permission.findOne({ where: { permissionCode: pid } }); + if (perm) resolvedIds.push(perm.id); + } else { + resolvedIds.push(pid); + } + } + // Remove existing permissions and re-add new ones await RolePermission.destroy({ where: { roleId: id } }); - for (const pid of permissionIds) { + for (const resolvedId of resolvedIds) { await RolePermission.create({ roleId: id, - permissionId: pid + permissionId: resolvedId }); } } @@ -169,25 +190,16 @@ export const getAllUsers = async (req: Request, res: Response) => { (Array.isArray(roleCode) && roleCode.some(r => typeof r === 'string' && nationalRoles.includes(r))); if (!isNationalRole && locationId) { - const district: any = await db.Location.findByPk(locationId as string, { + const district: any = await db.District.findByPk(locationId as string, { attributes: ['id', 'zoneId', 'regionId', 'stateId'] }); if (district) { const relevantIds = [district.id, district.zoneId, district.regionId, district.stateId].filter(Boolean); - whereClause.locationId = { [Op.in]: relevantIds }; + whereClause.districtId = { [Op.in]: relevantIds }; } } - // Fetch all locations to build a map if needed - const allLocations = await db.Location.findAll({ - include: [ - { model: db.Zone, as: 'zone', attributes: ['name'] }, - { model: db.Region, as: 'region', attributes: ['name'] }, - { model: db.State, as: 'state', attributes: ['name'] } - ] - }); - const users = await User.findAll({ where: whereClause, attributes: { exclude: ['password'] }, @@ -203,70 +215,63 @@ export const getAllUsers = async (req: Request, res: Response) => { } ] }, - { model: db.Location, as: 'location' }, + { model: db.District, as: 'district' }, { model: db.UserRole, as: 'userRoles', include: [ - { model: db.Role, as: 'role', attributes: ['id', 'roleCode', 'roleName'] }, - { model: db.Location, as: 'location', attributes: ['id', 'name', 'type'] } + { + model: db.District, + as: 'district', + attributes: ['id', 'name', 'stateId', 'regionId', 'zoneId'], + include: [ + { model: db.State, as: 'state', attributes: ['id', 'name'] }, + { model: db.Region, as: 'region', attributes: ['id', 'name'] }, + { model: db.Zone, as: 'zone', attributes: ['id', 'name'] } + ] + }, + { model: db.Zone, as: 'zone', attributes: ['id', 'name'] }, + { model: db.Region, as: 'region', attributes: ['id', 'name'] } ] } ], order: [['createdAt', 'DESC']] }); - const findAncestor = (locId: string, targetType: string): any => { - const queue = [locId]; - const visited = new Set(); - while (queue.length > 0) { - const id = queue.shift(); - if (visited.has(id)) continue; - visited.add(id); - const loc = allLocations.find((l: any) => l.id === id); - if (!loc) continue; - if (loc.type.toLowerCase() === targetType.toLowerCase()) return loc; - if (loc.parents) queue.push(...loc.parents.map((p: any) => p.id)); - } - return null; - }; - const result = users.map((u: any) => { const userJson = u.toJSON(); const assignments = userJson.userRoles || []; - - // Consolidate roles and territories with DEEP resolution - const territories = assignments.map((a: any) => { - const zone = findAncestor(a.locationId, 'zone'); - const region = findAncestor(a.locationId, 'region'); - const state = findAncestor(a.locationId, 'state'); - return { - role: a.role?.roleName, - roleCode: a.role?.roleCode, - locationId: a.locationId, - locationName: a.location?.name, - locationType: a.location?.type, - managerCode: a.managerCode, - zone: zone?.name, - zoneId: zone?.id, - region: region?.name, - regionId: region?.id, - state: state?.name, - stateId: state?.id, - isActive: a.isActive - }; - }); + // territories mapping — provide fallbacks to the nested location fields + const territories = assignments.map((a: any) => ({ + role: a.role?.roleName, + roleCode: a.role?.roleCode, + districtId: a.districtId, + districtName: a.district?.name, + locationType: 'district', + managerCode: a.managerCode, + zoneId: a.zoneId || a.district?.zoneId, + zone: a.zone?.name || a.district?.zone?.name, + regionId: a.regionId || a.district?.regionId, + region: a.region?.name || a.district?.region?.name, + stateId: a.district?.state?.id || a.district?.stateId, + state: a.district?.state?.name, + isActive: a.isActive + })); userJson.territoryProfile = territories; userJson.allRoles = Array.from(new Set([ - u.role?.roleCode, + u.role?.roleCode, u.role?.roleName, ...assignments.flatMap((a: any) => [a.role?.roleCode, a.role?.roleName]) ].filter(Boolean))); - userJson.allZones = Array.from(new Set([u.location?.zone?.name, ...territories.map((t: any) => t.zone)].filter(Boolean).map(z => z.toUpperCase()))); - userJson.allRegions = Array.from(new Set([u.location?.region?.name, ...territories.map((t: any) => t.region)].filter(Boolean).map(r => r.toUpperCase()))); - + userJson.allZones = Array.from(new Set( + territories.map((t: any) => t.zone).filter(Boolean).map((z: string) => z.toUpperCase()) + )); + userJson.allRegions = Array.from(new Set( + territories.map((t: any) => t.region).filter(Boolean).map((r: string) => r.toUpperCase()) + )); + return userJson; }); @@ -283,7 +288,10 @@ export const createUser = async (req: AuthRequest, res: Response) => { fullName, email, roleCode, employeeId, mobileNumber, department, designation, locationId, - assignments + assignments, + districts, // New: ASM managed areas + asmCode, // New: ASM code + zmCode // New: ZM code } = req.body; @@ -330,11 +338,41 @@ export const createUser = async (req: AuthRequest, res: Response) => { mobileNumber, department, designation, - locationId + districtId: locationId }); if (Array.isArray(assignments) && assignments.length > 0) { await upsertUserAssignments(user.id, assignments, req.user?.id); + } else if (districts && Array.isArray(districts) && (roleCode === 'ASM' || roleCode === 'ZM')) { + const targetRole = await Role.findOne({ where: { roleCode: roleCode } }); + if (targetRole) { + // Resolve Zone and Region from the districts + let targetZoneId = null; + let targetRegionId = null; + if (districts.length > 0) { + const sampleDistrict = await db.District.findByPk(districts[0]); + if (sampleDistrict) { + targetZoneId = sampleDistrict.zoneId; + targetRegionId = sampleDistrict.regionId; + } + } + + for (const distId of districts) { + await db.UserRole.create({ + userId: user.id, + roleId: targetRole.id, + districtId: distId, + zoneId: targetZoneId, + regionId: targetRegionId, + managerCode: asmCode || zmCode || null, + isPrimary: false, + isActive: true, + assignedBy: req.user?.id || null + }); + // Atomic Sync + await syncLocationManagers(distId); + } + } } else if (roleCode) { const role = await Role.findOne({ where: { roleCode } }); if (role) { @@ -381,6 +419,11 @@ export const updateUserStatus = async (req: AuthRequest, res: Response) => { if (!user) return res.status(404).json({ success: false, message: 'User not found' }); await user.update({ status, isActive }); + + // If user is deactivated, clear their ASM assignments in District table + if (isActive === false) { + await db.District.update({ asmId: null }, { where: { asmId: id } }); + } await AuditLog.create({ userId: req.user?.id, @@ -407,6 +450,7 @@ export const updateUser = async (req: AuthRequest, res: Response) => { assignments, districts, // New: ASM managed areas/districts asmCode, // New: ASM code to store in managerCode + zmCode, // New: ZM code password // Optional password update } = req.body; @@ -423,10 +467,19 @@ export const updateUser = async (req: AuthRequest, res: Response) => { employeeId: employeeId || user.employeeId, mobileNumber: mobileNumber || user.mobileNumber, department: department || user.department, - designation: designation || user.designation, - locationId: (locationId === '' ? null : (locationId !== undefined ? locationId : user.locationId)) + designation: designation || user.designation }; + // NEW: Validate locationId if provided (must exist in districts table, otherwise set to null on User record) + if (locationId !== undefined) { + if (locationId === '' || locationId === null) { + updates.districtId = null; + } else { + const districtExists = await db.District.findByPk(locationId); + updates.districtId = districtExists ? locationId : null; + } + } + // If password is provided, hash it and update if (password && password.trim() !== '') { updates.password = await bcrypt.hash(password, 10); @@ -445,7 +498,7 @@ export const updateUser = async (req: AuthRequest, res: Response) => { const duplicate = await db.UserRole.findOne({ where: { roleId: targetRole.id, - locationId: { [Op.in]: districts }, + districtId: { [Op.in]: districts }, userId: { [Op.ne]: id }, isActive: true }, @@ -453,7 +506,7 @@ export const updateUser = async (req: AuthRequest, res: Response) => { }); if (duplicate) { - const location = await db.Location.findByPk(duplicate.locationId); + const location = await db.District.findByPk(duplicate.districtId); return res.status(400).json({ success: false, message: `Territory "${location?.name}" is already assigned to ${duplicate.user?.fullName}. Duplicate assignments for ${roleCode} are restricted.` @@ -463,16 +516,49 @@ export const updateUser = async (req: AuthRequest, res: Response) => { // 2. Transactional Update: Clear old assignments for this role and add new ones await db.UserRole.destroy({ where: { userId: id, roleId: targetRole.id } }); + // Clear old asmId/managerId assignments in District table for this specific user + // (The sync service will handle the new ones) + if (roleCode === 'ASM') await db.District.update({ asmId: null, asmCode: null }, { where: { asmId: id } }); + if (roleCode === 'ZM') await db.District.update({ zmId: null, zmCode: null }, { where: { zmId: id } }); + + // 3. TRANSFER LOGIC: If any of these districts are currently assigned to ANOTHER manager for this role, + // deactivate those assignments to prevent duplication. + await db.UserRole.update({ isActive: false }, { + where: { + roleId: targetRole.id, + districtId: { [Op.in]: districts }, + userId: { [Op.ne]: id }, + isActive: true + } + }); + + // 4. Resolve Zone and Region from the districts + let targetZoneId = null; + let targetRegionId = null; + if (districts.length > 0) { + const sampleDistrict = await db.District.findByPk(districts[0]); + if (sampleDistrict) { + targetZoneId = sampleDistrict.zoneId; + targetRegionId = sampleDistrict.regionId; + } + } + for (const distId of districts) { + // Update UserRole table await db.UserRole.create({ userId: id, roleId: targetRole.id, - locationId: distId, - managerCode: asmCode || (req.body as any).zmCode || null, + districtId: distId, + zoneId: targetZoneId, + regionId: targetRegionId, + managerCode: asmCode || zmCode || null, isPrimary: false, isActive: true, assignedBy: req.user?.id || null }); + + // Atomic Sync (handles Location table asmId / asmCode / etc) + await syncLocationManagers(distId); } } else if (roleCode !== undefined || locationId !== undefined) { @@ -481,14 +567,18 @@ export const updateUser = async (req: AuthRequest, res: Response) => { const role = await Role.findOne({ where: { roleCode: primaryRoleCode } }); if (role) { await db.UserRole.destroy({ where: { userId: id, isPrimary: true } }); - await db.UserRole.create({ + const created = await db.UserRole.create({ userId: id, roleId: role.id, - locationId: updates.locationId ?? user.locationId ?? null, + districtId: updates.districtId ?? user.districtId ?? null, isPrimary: true, isActive: updates.isActive, assignedBy: req.user?.id || null }); + // Sync primary location if exists + if (created.districtId) await syncLocationManagers(created.districtId); + if (created.regionId) await syncRegionManager(created.regionId); + if (created.zoneId) await syncZoneManager(created.zoneId); } } } diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index e066a39..8381103 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -2,36 +2,16 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore, - Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role, LocationHierarchy, StageApprovalPolicy, StageApprovalAction + Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role, District, StageApprovalPolicy, StageApprovalAction } = db; import { AuthRequest } from '../../types/express.types.js'; import { Op } from 'sequelize'; import * as EmailService from '../../common/utils/email.service.js'; const getLocationAncestors = async (locationId: string): Promise => { - const ancestors: string[] = []; - const visited = new Set(); - const queue: string[] = [locationId]; - - while (queue.length > 0) { - const currentId = queue.shift() as string; - if (visited.has(currentId)) continue; - visited.add(currentId); - ancestors.push(currentId); - - const parentLinks = await LocationHierarchy.findAll({ - where: { locationId: currentId }, - attributes: ['parentId'] - }); - - for (const link of parentLinks as any[]) { - if (link.parentId && !visited.has(link.parentId)) { - queue.push(link.parentId); - } - } - } - - return ancestors; + const district: any = await District.findByPk(locationId); + if (!district) return [locationId]; + return [district.id, district.stateId, district.regionId, district.zoneId].filter(Boolean); }; const interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`; @@ -312,8 +292,8 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { let participantIds: string[] = Array.isArray(participants) ? participants : []; // Auto-include relevant ZBH by location hierarchy when interviewer list is omitted. - if (participantIds.length === 0 && application?.locationId) { - const ancestorLocationIds = await getLocationAncestors(application.locationId); + if (participantIds.length === 0 && (application?.districtId || application?.locationId)) { + const ancestorLocationIds = await getLocationAncestors(application.districtId || application.locationId); const zonalHeads = await User.findAll({ where: { roleCode: 'ZBH', diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index b05499e..3487662 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -38,7 +38,7 @@ export const register = async (req: Request, res: Response) => { fullName, roleCode: role, mobileNumber: phone, - locationId, + districtId: locationId, status: 'active' }); @@ -126,7 +126,7 @@ export const login = async (req: Request, res: Response) => { email: user.email, fullName: user.fullName, role: user.roleCode, - locationId: user.locationId + districtId: user.districtId } }); } catch (error) { @@ -161,7 +161,7 @@ export const getProfile = async (req: AuthRequest, res: Response) => { } const user = await User.findByPk(req.user.id, { - attributes: ['id', 'email', 'fullName', 'roleCode', 'locationId', 'mobileNumber', 'createdAt'] + attributes: ['id', 'email', 'fullName', 'roleCode', 'districtId', 'mobileNumber', 'createdAt'] }); if (!user) { @@ -178,7 +178,7 @@ export const getProfile = async (req: AuthRequest, res: Response) => { email: user.email, fullName: user.fullName, role: user.roleCode, - locationId: user.locationId, + districtId: user.districtId, phone: user.mobileNumber, createdAt: (user as any).createdAt } diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 1be25d1..7163456 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -111,7 +111,7 @@ export const createDealer = async (req: AuthRequest, res: Response) => { status: 'active', isActive: true, isExternal: true, // Dealers are external users - locationId: application.locationId + districtId: application.districtId || application.locationId }); console.log(`[Dealer Onboarding] Created new Dealer user account for ${user.email}.`); } @@ -150,7 +150,7 @@ export const createDealer = async (req: AuthRequest, res: Response) => { status: 'Active', establishedDate: new Date(), dealerId: user.id, - locationId: application.locationId + districtId: application.districtId || application.locationId }); console.log(`[Dealer Onboarding] Created outlet ${outlet.code} for application ${application.applicationId} linked to user ${user.email}.`); diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 026e982..6035375 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1,54 +1,186 @@ import { Request, Response } from 'express'; +import { Op } from 'sequelize'; +import { syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.js'; import db from '../../database/models/index.js'; const { User } = db; -// --- Districts (Locations) --- -export const getDistricts = async (req: Request, res: Response) => { +// --- Areas (Granular Locations) --- +export const getAreas = async (req: Request, res: Response) => { try { - const districts = await db.Location.findAll({ + let search = req.query.search as string; + let page = (req.query.page || 1) as any; + let limit = (req.query.limit || 10) as any; + + const checkNested = (obj: any) => { + if (!obj || typeof obj !== 'object') return; + if (!search && obj.search) search = obj.search; + if (page === 1 && obj.page) page = obj.page; + if (limit === 10 && obj.limit) limit = obj.limit; + }; + + checkNested(req.query.params); + + const isAll = limit === 'all' || limit === -1 || limit === '-1'; + + page = Number(page || 1); + limit = isAll ? null : Number(limit || 10); + const offset = isAll ? null : (page - 1) * limit; + + const where: any = {}; + if (search) { + where[Op.or] = [ + { name: { [Op.iLike]: `%${search}%` } }, + { city: { [Op.iLike]: `%${search}%` } }, + { '$district.name$': { [Op.iLike]: `%${search}%` } } + ]; + } + + const { count, rows: areas } = await db.Location.findAndCountAll({ + where, include: [ - { model: db.Zone, as: 'zone', attributes: ['name'] }, - { model: db.Region, as: 'region', attributes: ['name'] }, - { model: db.State, as: 'state', attributes: ['name'] }, - { model: db.User, as: 'asm', attributes: ['id', 'fullName', 'email'] } + { + model: db.District, + as: 'district', + include: [ + { model: db.Zone, as: 'zone', attributes: ['name'] }, + { model: db.Region, as: 'region', attributes: ['name'] }, + { model: db.State, as: 'state', attributes: ['name'] } + ] + } ], - order: [['name', 'ASC']] + order: [['name', 'ASC']], + limit: limit === null ? undefined : Number(limit), + offset: offset === null ? undefined : Number(offset), + distinct: true, + subQuery: false }); + const result = areas.map((a: any) => { + const d = a.district || {}; + return { + ...a.toJSON(), + districtName: d.name || 'N/A', + zoneName: d.zone?.name || 'UNKNOWN', + regionName: d.region?.name || 'UNKNOWN', + stateName: d.state?.name || 'UNKNOWN' + }; + }); + + res.json({ + success: true, + data: result, + pagination: { + total: count, + page: Number(page), + limit: isAll ? count : Number(limit), + totalPages: isAll ? 1 : Math.ceil(count / Number(limit)) + } + }); + } catch (error) { + console.error('Get areas error:', error); + res.status(500).json({ success: false, message: 'Error fetching areas' }); + } +}; + +// --- Districts (Territory Entities) --- +export const getDistricts = async (req: Request, res: Response) => { + try { + let search = req.query.search as string; + let limit = (req.query.limit || 10) as any; + const stateId = req.query.stateId as string; + const zoneId = req.query.zoneId as string; + const regionId = req.query.regionId as string; + + const isAll = limit === 'all' || limit === -1 || limit === '-1'; + + const where: any = {}; + if (search) { + where.name = { [Op.iLike]: `%${search}%` }; + } + if (stateId) where.stateId = stateId; + if (zoneId) where.zoneId = zoneId; + if (regionId) where.regionId = regionId; + + const { count, rows: districts } = await db.District.findAndCountAll({ + where, + include: [ + { model: db.Zone, as: 'zone', attributes: ['id', 'name'] }, + { model: db.Region, as: 'region', attributes: ['id', 'name'] }, + { model: db.State, as: 'state', attributes: ['id', 'name'] }, + { model: db.User, as: 'asm', attributes: ['id', 'fullName', 'email', 'employeeId'] }, + { model: db.User, as: 'zonalManager', attributes: ['id', 'fullName', 'email', 'employeeId'] } + ], + order: [['name', 'ASC']], + limit: isAll ? undefined : Number(limit), + distinct: true + }); + const result = districts.map((d: any) => ({ ...d.toJSON(), zoneName: d.zone?.name || 'UNKNOWN', regionName: d.region?.name || 'UNKNOWN', stateName: d.state?.name || 'UNKNOWN', - asmName: d.asm?.fullName || 'UNASSIGNED' + asmName: d.asm?.fullName || 'UNASSIGNED', + zmName: d.zonalManager?.fullName || 'UNASSIGNED' })); - res.json({ success: true, data: result }); + res.json({ success: true, data: result, total: count }); } catch (error) { console.error('Get districts error:', error); res.status(500).json({ success: false, message: 'Error fetching districts' }); } }; + export const createDistrict = async (req: Request, res: Response) => { try { - const { name, code, stateId, regionId, zoneId, asmId, description } = req.body; + const { name, code, stateName, city, openFrom, openTo, status, isActive, description } = req.body; if (!name) return res.status(400).json({ success: false, message: 'District name is required' }); - const district = await db.Location.create({ + // Find or Create state if stateName provided + let stateId = req.body.stateId; + if (stateName && !stateId) { + const [state] = await db.State.findOrCreate({ + where: { name: stateName }, + defaults: { name: stateName } + }); + stateId = state.id; + } + + const district = await db.District.create({ name, code, stateId, - regionId, - zoneId, - asmId, - description + isActive: isActive !== undefined ? isActive : true + }); + + const area = await db.Location.create({ + name, + districtId: district.id, + city: city || name, + isActive: true, + openFrom: openFrom || null, + openTo: openTo || null, + description: description || null + }); + + // Create associated Opportunity for "Active Period" and "City" + await db.Opportunity.create({ + districtId: district.id, + areaId: area.id, + city: city || name, + openFrom: openFrom || null, + openTo: openTo || null, + status: status || 'inactive', + opportunityType: 'New Dealership', + capacity: 'Standard', + priority: 'Medium' }); - res.status(201).json({ success: true, data: district }); + res.status(201).json({ success: true, data: area }); } catch (error) { - console.error('Create district error:', error); - res.status(500).json({ success: false, message: 'Error creating district' }); + console.error('Create area error:', error); + res.status(500).json({ success: false, message: 'Error creating area' }); } }; @@ -58,7 +190,12 @@ export const getRegions = async (req: Request, res: Response) => { const regions = await db.Region.findAll({ include: [ { model: db.Zone, as: 'zone', attributes: ['name'] }, - { model: db.Location, as: 'districts', attributes: ['id', 'name'] } + { + model: db.District, + as: 'districts', + attributes: ['id', 'name', 'stateId'], + include: [{ model: db.State, as: 'state', attributes: ['id', 'name'] }] + } ], order: [['name', 'ASC']] }); @@ -78,7 +215,7 @@ export const getRegions = async (req: Request, res: Response) => { const districtIds = (region.districts || []).map((d: any) => d.id); const [asmCount, rmCount, rmAssignment] = await Promise.all([ - db.UserRole.count({ where: { roleId: { [db.Sequelize.Op.in]: asmRoleIds }, locationId: { [db.Sequelize.Op.in]: districtIds }, isActive: true }, distinct: true, col: 'userId' }), + db.UserRole.count({ where: { roleId: { [db.Sequelize.Op.in]: asmRoleIds }, districtId: { [db.Sequelize.Op.in]: districtIds }, isActive: true }, distinct: true, col: 'userId' }), db.UserRole.count({ where: { roleId: { [db.Sequelize.Op.in]: rmRoleIds }, regionId: region.id, isActive: true }, distinct: true, col: 'userId' }), db.UserRole.findOne({ where: { roleId: { [db.Sequelize.Op.in]: rmRoleIds }, regionId: region.id, isActive: true }, @@ -89,7 +226,22 @@ export const getRegions = async (req: Request, res: Response) => { regionJson.asmCount = asmCount; regionJson.regionalOfficerCount = rmCount; regionJson.regionalManager = rmAssignment?.user || null; - regionJson.districts = (region.districts || []).map((d: any) => ({ id: d.id, name: d.name.toUpperCase() })); + regionJson.rbmCode = region.rbmCode || 'N/A'; + + // Extract unique states for this region + const statesMap = new Map(); + (region.districts || []).forEach((d: any) => { + if (d.state) { + statesMap.set(d.state.id, d.state.name); + } + }); + regionJson.states = Array.from(statesMap.values()); + + regionJson.districts = (region.districts || []).map((d: any) => ({ + id: d.id, + name: d.name.toUpperCase(), + stateId: d.stateId + })); return regionJson; })); @@ -124,18 +276,38 @@ export const createRegion = async (req: Request, res: Response) => { } } - // 2. Assign Districts + // 2. Assign Districts (with conflict check) const targetDistrictIds = districts || districtIds; if (Array.isArray(targetDistrictIds) && targetDistrictIds.length > 0) { - await db.Location.update( + const conflicts = await db.District.findAll({ + where: { + id: { [db.Sequelize.Op.in]: targetDistrictIds }, + regionId: { [db.Sequelize.Op.and]: [{ [db.Sequelize.Op.ne]: null }, { [db.Sequelize.Op.ne]: region.id }] } + } + }); + if (conflicts.length > 0) { + return res.status(409).json({ + success: false, + message: `Districts already assigned to another region: ${conflicts.map((c: any) => c.name).join(', ')}` + }); + } + await db.District.update( { regionId: region.id, zoneId: targetZoneId }, { where: { id: { [db.Sequelize.Op.in]: targetDistrictIds } } } ); } res.status(201).json({ success: true, message: 'Region created', data: region }); - } catch (error) { + } catch (error: any) { console.error('Create region error:', error); + if (error.name === 'SequelizeUniqueConstraintError') { + const field = error.errors?.[0]?.path || 'field'; + const value = error.errors?.[0]?.value || ''; + return res.status(409).json({ + success: false, + message: `A region with this ${field} "${value}" already exists. Please use a different name.` + }); + } res.status(500).json({ success: false, message: 'Error creating region' }); } }; @@ -175,12 +347,26 @@ export const updateRegion = async (req: Request, res: Response) => { } } - // 2. Update Districts + // 2. Update Districts (with conflict check) const targetDistrictIds = districts || districtIds; if (Array.isArray(targetDistrictIds)) { - await db.Location.update({ regionId: null }, { where: { regionId: id } }); if (targetDistrictIds.length > 0) { - await db.Location.update( + const conflicts = await db.District.findAll({ + where: { + id: { [db.Sequelize.Op.in]: targetDistrictIds }, + regionId: { [db.Sequelize.Op.and]: [{ [db.Sequelize.Op.ne]: null }, { [db.Sequelize.Op.ne]: id }] } + } + }); + if (conflicts.length > 0) { + return res.status(409).json({ + success: false, + message: `Districts already assigned to another region: ${conflicts.map((c: any) => c.name).join(', ')}` + }); + } + } + await db.District.update({ regionId: null }, { where: { regionId: id } }); + if (targetDistrictIds.length > 0) { + await db.District.update( { regionId: id, zoneId: region.zoneId }, { where: { id: { [db.Sequelize.Op.in]: targetDistrictIds } } } ); @@ -188,8 +374,16 @@ export const updateRegion = async (req: Request, res: Response) => { } res.json({ success: true, message: 'Region updated' }); - } catch (error) { + } catch (error: any) { console.error('Update region error:', error); + if (error.name === 'SequelizeUniqueConstraintError') { + const field = error.errors?.[0]?.path || 'field'; + const value = error.errors?.[0]?.value || ''; + return res.status(409).json({ + success: false, + message: `A region with this ${field} "${value}" already exists. Please use a different name.` + }); + } res.status(500).json({ success: false, message: 'Error updating region' }); } }; @@ -201,7 +395,7 @@ export const getZones = async (req: Request, res: Response) => { include: [ { model: db.Region, as: 'regions', attributes: ['id', 'name'] }, { model: db.State, as: 'states', attributes: ['id', 'name'] }, - { model: db.Location, as: 'districts', attributes: ['id'] } + { model: db.District, as: 'districts', attributes: ['id'] } ], order: [['name', 'ASC']] }); @@ -228,8 +422,8 @@ export const getZones = async (req: Request, res: Response) => { // For each ZM, fetch their assigned districts in this zone const zonalManagers = await Promise.all(zms.map(async (zmRole: any) => { - const districts = await db.Location.findAll({ - where: { zoneId: zone.id, regionId: zmRole.regionId || null }, // ZMs usually managed regions or specific district sets + const districts = await db.District.findAll({ + where: { zmId: zmRole.user.id, zoneId: zone.id }, // ZMs usually managed regions or specific district sets attributes: ['name'] }); return { @@ -237,6 +431,7 @@ export const getZones = async (req: Request, res: Response) => { name: zmRole.user.fullName || zmRole.user.name, email: zmRole.user.email, phone: zmRole.user.mobileNumber || 'N/A', + code: zmRole.managerCode || zmRole.user.employeeId || 'N/A', districts: districts.map((d: any) => d.name) }; })); @@ -249,7 +444,8 @@ export const getZones = async (req: Request, res: Response) => { id: zbhAssignment.user.id, name: zbhAssignment.user.fullName || zbhAssignment.user.name, email: zbhAssignment.user.email, - phone: zbhAssignment.user.mobileNumber || 'N/A' + phone: zbhAssignment.user.mobileNumber || 'N/A', + code: zone.zbhCode || 'N/A' } : null; zoneJson.zonalManagers = zonalManagers; return zoneJson; @@ -285,7 +481,7 @@ export const createZone = async (req: Request, res: Response) => { if (Array.isArray(stateIds) && stateIds.length > 0) { await db.State.update({ zoneId: zone.id }, { where: { id: { [db.Sequelize.Op.in]: stateIds } } }); - await db.Location.update({ zoneId: zone.id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); + await db.District.update({ zoneId: zone.id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); } res.status(201).json({ success: true, message: 'Zone created', data: zone }); @@ -325,11 +521,11 @@ export const updateZone = async (req: Request, res: Response) => { if (Array.isArray(stateIds)) { await db.State.update({ zoneId: null }, { where: { zoneId: id } }); - await db.Location.update({ zoneId: null }, { where: { zoneId: id } }); + await db.District.update({ zoneId: null }, { where: { zoneId: id } }); if (stateIds.length > 0) { await db.State.update({ zoneId: id }, { where: { id: { [db.Sequelize.Op.in]: stateIds } } }); - await db.Location.update({ zoneId: id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); + await db.District.update({ zoneId: id }, { where: { stateId: { [db.Sequelize.Op.in]: stateIds } } }); } } @@ -361,7 +557,7 @@ export const createState = async (req: Request, res: Response) => { const state = await db.State.create({ name, zoneId }); if (zoneId) { - await db.Location.update({ zoneId }, { where: { stateId: state.id } }); + await db.District.update({ zoneId }, { where: { stateId: state.id } }); } res.status(201).json({ success: true, data: state }); @@ -388,7 +584,7 @@ export const getManagersByRole = async (req: Request, res: Response) => { const filteredManagers = managers.filter((m: any) => { const hasRole = !roleCode || m.roleCode === roleCode || (m.userRoles || []).some((a: any) => a.role?.roleCode === roleCode); const hasLocation = !locationId || (m.userRoles || []).some((a: any) => - a.locationId === locationId || + a.districtId === locationId || a.zoneId === locationId || a.regionId === locationId ); @@ -406,45 +602,307 @@ export const getAreaManagers = async (req: Request, res: Response) => { return getManagersByRole(req, res); }; -// --- Delete --- +// --- Delete Area (Location) --- export const deleteLocation = async (req: Request, res: Response) => { try { const { id } = req.params; - await db.Location.destroy({ where: { id } }); - res.json({ success: true, message: 'District deleted' }); + const area = await db.Location.findByPk(id); + if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); + + // Delete associated opportunities if they belong to this granular location + await db.Opportunity.destroy({ where: { areaId: id } }); + + // Delete the location itself + await area.destroy(); + + res.json({ success: true, message: 'Area deleted successfully' }); } catch (error) { - console.error('Delete district error:', error); - res.status(500).json({ success: false, message: 'Error deleting district' }); + console.error('Delete area error:', error); + res.status(500).json({ success: false, message: 'Error deleting area' }); } }; export const updateLocation = async (req: Request, res: Response) => { try { - const { id } = req.params; - const { name, code, stateId, regionId, zoneId, asmId, isActive, description } = req.body; - const district = await db.Location.findByPk(id); - if (!district) return res.status(404).json({ success: false, message: 'District not found' }); + const { id } = req.params; // This is the Area ID + const { name, code, stateName, city, openFrom, openTo, status, isActive, description } = req.body; - await district.update({ - name, - code, - stateId, - regionId, - zoneId, - asmId, - isActive, - description + const area = await db.Location.findByPk(id, { + include: [{ model: db.District, as: 'district' }] }); + if (!area) return res.status(404).json({ success: false, message: 'Area not found' }); - res.json({ success: true, message: 'District updated' }); + const district = area.district; + + // 1. Update District + if (district) { + let stateId = req.body.stateId; + if (stateName && !stateId) { + const [state] = await db.State.findOrCreate({ + where: { name: stateName }, + defaults: { name: stateName } + }); + stateId = state.id; + } + + await district.update({ + name: name || district.name, + code: code || district.code, + stateId: stateId || district.stateId, + isActive: isActive !== undefined ? isActive : district.isActive + }); + } + + // 2. Update Area + await area.update({ + name: name || area.name, + city: city || area.city, + isActive: isActive !== undefined ? isActive : area.isActive, + openFrom: openFrom !== undefined ? (openFrom || null) : area.openFrom, + openTo: openTo !== undefined ? (openTo || null) : area.openTo, + description: description || area.description + }); + + // 3. Update or Create associated Opportunity + const [opportunity] = await db.Opportunity.findOrBuild({ + where: { areaId: id } + }); + + opportunity.set({ + districtId: district?.id || opportunity.districtId, + city: city || opportunity.city || name || area.name, + openFrom: openFrom !== undefined ? (openFrom || null) : opportunity.openFrom, + openTo: openTo !== undefined ? (openTo || null) : opportunity.openTo, + status: status || opportunity.status || 'inactive' + }); + await opportunity.save(); + + res.json({ success: true, message: 'Area updated' }); + + if (district && typeof district.id === 'string') { + await syncLocationManagers(district.id); + } } catch (error) { - console.error('Update district error:', error); - res.status(500).json({ success: false, message: 'Error updating district' }); + console.error('Update area error:', error); + res.status(500).json({ success: false, message: 'Error updating area' }); + } +}; + +// --- Managers --- +export const getASMs = async (req: Request, res: Response) => { + try { + const asms = await db.User.findAll({ + where: { + roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER'] }, + isActive: true + }, + include: [ + { + model: db.UserRole, + as: 'userRoles', + where: { isActive: true }, + required: false, + include: [{ model: db.Role, as: 'role', where: { roleCode: 'ASM' } }] + }, + { + model: db.District, + as: 'managedAsmDistricts', + include: [ + { model: db.State, as: 'state', attributes: ['id', 'name'] }, + { model: db.Region, as: 'region', attributes: ['id', 'name'] }, + { model: db.Zone, as: 'zone', attributes: ['id', 'name'] } + ] + } + ], + order: [['fullName', 'ASC']] + }); + + const result = (asms || []).map((u: any) => { + const districts = u.managedAsmDistricts || []; + const asmRoleAssignment = (u.userRoles || []).find((r: any) => r.role?.roleCode === 'ASM'); + const asmCode = asmRoleAssignment?.managerCode || u.employeeId; + + const zoneSet = new Set(); + const regionSet = new Set(); + const stateSet = new Set(); + + const territoryInfo = districts.map((d: any) => { + if (d.zone) zoneSet.add(JSON.stringify({ id: d.zone.id, name: d.zone.name })); + if (d.region) regionSet.add(JSON.stringify({ id: d.region.id, name: d.region.name })); + if (d.state) stateSet.add(d.state.name); + + return { + id: d.id, + name: d.name, + stateId: d.stateId, + regionId: d.regionId, + zoneId: d.zoneId + }; + }); + + const zones = Array.from(zoneSet).map((s: any) => JSON.parse(s)); + const regions = Array.from(regionSet).map((s: any) => JSON.parse(s)); + + return { + id: u.id, + name: u.fullName, + email: u.email, + phone: u.mobileNumber, + employeeId: u.employeeId, + asmCode: asmCode || 'N/A', + status: u.status, + zoneId: zones[0]?.id || '', + zoneName: zones[0]?.name || 'Unassigned', + regionId: regions[0]?.id || '', + regionName: regions[0]?.name || 'Unassigned', + areasManaged: territoryInfo, + stateNames: Array.from(stateSet), + totalDistricts: territoryInfo.length + }; + }); + + res.json({ success: true, data: result }); + } catch (error) { + console.error('Get ASMs error:', error); + res.status(500).json({ success: false, message: 'Error fetching ASMs' }); + } +}; + +export const getZonalManagers = async (req: Request, res: Response) => { + try { + const zms = await db.User.findAll({ + attributes: ['id', 'fullName', 'email', 'employeeId', 'status'], + include: [ + { + model: db.UserRole, + as: 'userRoles', + where: { isActive: true }, + required: true, + include: [{ + model: db.Role, + as: 'role', + where: { roleCode: { [Op.in]: ['ZM', 'DD-ZM', 'ZBH'] } } + }] + }, + { + model: db.District, + as: 'managedZmDistricts', + include: [ + { model: db.Zone, as: 'zone', attributes: ['id', 'name'] }, + { model: db.Region, as: 'region', attributes: ['id', 'name'] }, + { model: db.State, as: 'state', attributes: ['id', 'name'] } + ] + } + ], + order: [['fullName', 'ASC']] + }); + + const result = (zms || []).map((u: any) => { + const rolePriority = ['DD-ZM', 'ZM', 'ZBH']; + const roleAssignment = (u.userRoles || []).sort((a: any, b: any) => { + const aIndex = rolePriority.indexOf(a.role?.roleCode || ''); + const bIndex = rolePriority.indexOf(b.role?.roleCode || ''); + if (aIndex !== bIndex) return aIndex - bIndex; + // If same role type, prefer the one with a code + if (a.managerCode && !b.managerCode) return -1; + if (!a.managerCode && b.managerCode) return 1; + return 0; + })[0]; + const zmCode = roleAssignment?.managerCode || u.employeeId || 'N/A'; + + // Collect unique zones and states + const zoneSet = new Set(); + const stateSet = new Set(); + let inferredZoneId = roleAssignment?.zoneId || null; + + (u.managedZmDistricts || []).forEach((d: any) => { + if (d.zone) { + zoneSet.add(d.zone.name); + if (!inferredZoneId) inferredZoneId = d.zone.id; // Fallback to first district's zone if role zone is missing + } + if (d.state) stateSet.add(d.state.name); + }); + + return { + id: u.id, + name: u.fullName, + email: u.email, + employeeId: u.employeeId, + zmCode: zmCode, + status: u.status, + zoneId: inferredZoneId, + zones: Array.from(zoneSet).length > 0 ? Array.from(zoneSet) : ["Assigned Zone"], + stateNames: Array.from(stateSet), + districts: (u.managedZmDistricts || []).map((d: any) => ({ + id: d.id, + name: d.name, + state: d.state?.name + })) + }; + }); + + res.json({ success: true, data: result }); + } catch (error) { + console.error('Get ZMs error:', error); + res.status(500).json({ success: false, message: 'Error fetching Zonal Managers' }); + } +}; + +export const saveZM = async (req: Request, res: Response) => { + try { + const { userId, zmCode, zoneId, districts, status } = req.body; + if (!userId) return res.status(400).json({ success: false, message: 'userId is required' }); + + // Find the ZM role (DD-ZM) + const zmRole = await db.Role.findOne({ where: { roleCode: 'DD-ZM' } }); + if (!zmRole) return res.status(404).json({ success: false, message: 'ZM role (DD-ZM) not found in roles table' }); + + // Update User status if provided + if (status) { + await db.User.update({ status }, { where: { id: userId } }); + } + + // Deactivate existing ZM role assignments for this user + await db.UserRole.update({ isActive: false }, { + where: { userId, roleId: zmRole.id } + }); + + // Create new active UserRole with managerCode = zmCode + await db.UserRole.create({ + userId, + roleId: zmRole.id, + zoneId: zoneId || null, + managerCode: zmCode || null, + isActive: true, + isPrimary: true + }); + + // Assign districts to this user if provided + // First, clear this ZM from any other districts they might have had + await db.District.update({ zmId: null, zmCode: null }, { where: { zmId: userId } }); + + if (Array.isArray(districts) && districts.length > 0) { + // Then assign new ones + const updateProps: any = { + zmId: userId, + zmCode: zmCode || null + }; + if (zoneId) updateProps.zoneId = zoneId; + + await db.District.update( + updateProps, + { where: { id: { [db.Sequelize.Op.in]: districts } } } + ); + } + + res.json({ success: true, message: 'Zonal Manager saved successfully' }); + } catch (error) { + console.error('Save ZM error:', error); + res.status(500).json({ success: false, message: 'Error saving Zonal Manager' }); } }; -// --- Semantic Aliases for Backward Compatibility --- -export const getAreas = getDistricts; export const createArea = createDistrict; export const deleteArea = deleteLocation; -export const createDistrictLegacy = createDistrict; // Just in case +export const createDistrictLegacy = createDistrict; + diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index e69de29..657edbd 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { + // Districts + getDistricts, + getAreas, + createDistrict, + updateLocation, + deleteLocation, + // Regions + getRegions, + createRegion, + updateRegion, + // Zones + getZones, + createZone, + updateZone, + // States + getStates, + createState, + // Managers + getManagersByRole, + getAreaManagers, + getASMs, + getZonalManagers, + saveZM +} from './master.controller.js'; + +const router = Router(); + +// --- Districts --- +router.get('/districts', getDistricts); +router.post('/districts', createDistrict); +router.put('/districts/:id', updateLocation); +router.delete('/districts/:id', deleteLocation); + +// --- Areas --- +router.get('/areas', getAreas); +router.post('/areas', createDistrict); +router.put('/areas/:id', updateLocation); +router.delete('/areas/:id', deleteLocation); + +// --- Regions --- +router.get('/regions', getRegions); +router.post('/regions', createRegion); +router.put('/regions/:id', updateRegion); + +// --- Zones --- +router.get('/zones', getZones); +router.post('/zones', createZone); +router.put('/zones/:id', updateZone); + +// --- States --- +router.get('/states', getStates); +router.post('/states', createState); + +// --- Managers --- +router.get('/managers', getManagersByRole); +router.get('/area-managers', getAreaManagers); +router.get('/asms', getASMs); +router.get('/zonal-managers', getZonalManagers); +router.post('/zonal-managers', saveZM); + +export default router; diff --git a/src/modules/master/syncHierarchy.service.ts b/src/modules/master/syncHierarchy.service.ts new file mode 100644 index 0000000..24e9a83 --- /dev/null +++ b/src/modules/master/syncHierarchy.service.ts @@ -0,0 +1,105 @@ +import db from '../../database/models/index.js'; + +/** + * Synchronizes the Location (District) table's manager IDs with the UserRole table. + * This ensures the Location table always reflects the "Active" managers for each role type. + */ +export const syncLocationManagers = async (districtId: string) => { + try { + const UserRole = db.UserRole; + const Role = db.Role; + const Op = db.Sequelize.Op; + + // Fetch active assignments for this district + const activeAssignments = await UserRole.findAll({ + where: { districtId, isActive: true }, + include: [ + { model: Role, as: 'role', attributes: ['roleCode'] }, + { model: db.User, as: 'user', attributes: ['employeeId'] } + ] + }); + + // Find primary/last assigned manager for each type + const asm = activeAssignments.find((a: any) => (a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER'); + const ddAm = activeAssignments.find((a: any) => (a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER'); + const zm = activeAssignments.find((a: any) => (a.role as any)?.roleCode === 'DD-ZM' || (a.role as any)?.roleCode === 'ZM' || (a.role as any)?.roleCode === 'ZONAL MANAGER'); + + // Update District table with IDs and Codes + await db.District.update({ + asmId: asm?.userId || null, + asmCode: asm?.managerCode || asm?.user?.employeeId || null, + ddAmId: ddAm?.userId || null, + ddAmCode: ddAm?.managerCode || ddAm?.user?.employeeId || null, + zmId: zm?.userId || null, + zmCode: zm?.managerCode || zm?.user?.employeeId || null + }, { + where: { id: districtId } + }); + + console.log(`[Sync] District ${districtId} synchronized successfully`); + } catch (error) { + console.error(`[Sync] Error synchronizing District ${districtId}:`, error); + } +}; + +/** + * Synchronizes the Region table's manager ID and Code. + */ +export const syncRegionManager = async (regionId: string) => { + try { + const activeAssignment = await db.UserRole.findOne({ + where: { regionId, isActive: true }, + include: [ + { + model: db.Role, + as: 'role', + where: { roleCode: { [db.Sequelize.Op.in]: ['RM', 'RBM', 'REGIONAL MANAGER'] } } + }, + { model: db.User, as: 'user', attributes: ['employeeId'] } + ], + order: [['assignedAt', 'DESC']] + }); + + await db.Region.update({ + rbmId: activeAssignment?.userId || null, + rbmCode: activeAssignment?.managerCode || activeAssignment?.user?.employeeId || null + }, { + where: { id: regionId } + }); + + console.log(`[Sync] Region ${regionId} synchronized successfully`); + } catch (error) { + console.error(`[Sync] Error synchronizing Region ${regionId}:`, error); + } +}; + +/** + * Synchronizes the Zone table's manager ID and Code. + */ +export const syncZoneManager = async (zoneId: string) => { + try { + const activeAssignment = await db.UserRole.findOne({ + where: { zoneId, isActive: true }, + include: [ + { + model: db.Role, + as: 'role', + where: { roleCode: { [db.Sequelize.Op.in]: ['ZBH', 'ZONE BUSINESS HEAD', 'ZONAL BUSINESS HEAD'] } } + }, + { model: db.User, as: 'user', attributes: ['employeeId'] } + ], + order: [['assignedAt', 'DESC']] + }); + + await db.Zone.update({ + zbhId: activeAssignment?.userId || null, + zbhCode: activeAssignment?.managerCode || activeAssignment?.user?.employeeId || null + }, { + where: { id: zoneId } + }); + + console.log(`[Sync] Zone ${zoneId} synchronized successfully`); + } catch (error) { + console.error(`[Sync] Error synchronizing Zone ${zoneId}:`, error); + } +}; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index f86c818..b2ea6c2 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, Location, LocationHierarchy } = db; +const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region } = db; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; @@ -48,77 +48,39 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { let isOpportunityAvailable = false; const normalizedType = normalizeLocationType(locationType); - if (req.body.locationId && normalizedType) { - const selectedLocation = await Location.findOne({ - where: { - id: req.body.locationId, - type: normalizedType - } - }); - if (selectedLocation) { - locationId = selectedLocation.id; + if (req.body.locationId && normalizedType === 'district') { + const selectedDistrict = await District.findByPk(req.body.locationId); + if (selectedDistrict) { + locationId = selectedDistrict.id; + isOpportunityAvailable = true; + } + } else if (req.body.locationId && normalizedType === 'state') { + const selectedState = await State.findByPk(req.body.locationId); + if (selectedState) { + locationId = selectedState.id; isOpportunityAvailable = true; } } // Backward-compatible fallback path for older payloads that send only names. if (!locationId && req.body.district) { - const districtName = req.body.district; - const stateName = req.body.state; - - // If state is available, disambiguate district by hierarchy parent. - let districtRecord: any = null; - if (stateName) { - const matchedStates = await Location.findAll({ - where: { - name: { [Op.iLike]: stateName }, - type: 'state' - }, - attributes: ['id'] - }); - - if (matchedStates.length > 0) { - const stateIds = matchedStates.map((s: any) => s.id); - const districtLinks = await LocationHierarchy.findAll({ - where: { parentId: { [Op.in]: stateIds } }, - attributes: ['locationId'] - }); - const districtIds = districtLinks.map((link: any) => link.locationId); - if (districtIds.length > 0) { - districtRecord = await Location.findOne({ - where: { - id: { [Op.in]: districtIds }, - name: { [Op.iLike]: districtName }, - type: 'district' - } - }); - } - } - } - - // Final fallback to old behavior if state context was unavailable or unresolved. - if (!districtRecord) { - districtRecord = await Location.findOne({ - where: { - name: { [Op.iLike]: districtName }, - type: 'district' - } - }); - } - + const districtRecord: any = await District.findOne({ + where: { name: { [Op.iLike]: req.body.district } }, + include: req.body.state ? [{ + model: State, + as: 'state', + where: { name: { [Op.iLike]: req.body.state } } + }] : [] + }); if (districtRecord) { locationId = districtRecord.id; isOpportunityAvailable = true; } } - // Last fallback: allow state-level canonical submissions. - if (!locationId && normalizedType === 'state' && req.body.state) { - const stateRecord = await Location.findOne({ - where: { - name: { [Op.iLike]: req.body.state }, - type: 'state' - } + if (!locationId && req.body.state) { + const stateRecord = await State.findOne({ + where: { name: { [Op.iLike]: req.body.state } } }); if (stateRecord) { locationId = stateRecord.id; @@ -142,7 +104,8 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { currentStage: APPLICATION_STAGES.DD, overallStatus: isOpportunityAvailable ? APPLICATION_STATUS.QUESTIONNAIRE_PENDING : APPLICATION_STATUS.SUBMITTED, progressPercentage: isOpportunityAvailable ? 10 : 0, - locationId + locationId, + districtId: locationId }); // Log Status History diff --git a/src/modules/opportunity/opportunity.controller.ts b/src/modules/opportunity/opportunity.controller.ts index 937f71c..cf5b7ac 100644 --- a/src/modules/opportunity/opportunity.controller.ts +++ b/src/modules/opportunity/opportunity.controller.ts @@ -7,11 +7,12 @@ import { AUDIT_ACTIONS } from '../../common/config/constants.js'; export const getOpportunities = async (req: Request, res: Response) => { try { - const { status, locationId } = req.query as any; + const { status, locationId, districtId } = req.query as any; const where: any = {}; if (status) where.status = status; - if (locationId) where.locationId = locationId; + const targetDistrictId = districtId || locationId; + if (targetDistrictId) where.districtId = targetDistrictId; const opportunities = await Opportunity.findAll({ where, @@ -32,8 +33,8 @@ export const createOpportunity = async (req: AuthRequest, res: Response) => { try { const { leadSource, leadName, contactNumber, email, - locationId, - opportunityType, priority + locationId, districtId, city, + opportunityType, priority, capacity, notes } = req.body; const opportunity = await Opportunity.create({ @@ -41,9 +42,12 @@ export const createOpportunity = async (req: AuthRequest, res: Response) => { leadName, contactNumber, email, - locationId, + districtId: districtId || locationId, + city: city || 'Unknown', // Fallback opportunityType, priority, + capacity: capacity || '1', // Fallback + notes, status: 'New', assignedTo: req.user?.id }); diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 317184e..c1e3893 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -3,4 +3,5 @@ export interface TokenPayload { email: string; role: string; locationId: string | null; + districtId: string | null; }