hirrachy enhanced nd creatded distric as centric and added dealer location stable ui made for the hirarchy

This commit is contained in:
laxmanhalaki 2026-03-30 02:59:34 +05:30
parent 2e1e96cc54
commit 9b645b0480
31 changed files with 1632 additions and 448 deletions

37
.env.example Normal file
View File

@ -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 <noreply@royalenfield.com>"
# 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

32
all_perms.json Normal file
View File

@ -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"
]

32
audit_db.mjs Normal file
View File

@ -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();

32
audit_db.ts Normal file
View File

@ -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();

33
check_zms_v2.ts Normal file
View File

@ -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();

67
db_audit.json Normal file
View File

@ -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": []
}
]

View File

@ -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",

View File

@ -1,8 +1,9 @@
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 ---');
@ -35,7 +36,7 @@ async function seed() {
}
console.log('Roles seeded.');
// 2. Create Locations (Hierarchy)
// 2. Create Districts (Hierarchy)
const [zone1] = await Zone.findOrCreate({
where: { name: 'North Zone' },
defaults: { name: 'North Zone', code: 'ZONE-N' }
@ -61,14 +62,14 @@ async function seed() {
defaults: { name: 'Bangalore Region', zoneId: zone2.id }
});
const [district1] = await Location.findOrCreate({
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.');
const mapUserRole = async (userRec: any, roleCode: string, assignment: { zoneId?: string | null, regionId?: string | null, locationId?: string | null } = {}) => {
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({
@ -89,7 +90,6 @@ async function seed() {
};
// 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 }
@ -98,27 +98,27 @@ async function seed() {
const zbhResult = await User.findOrCreate({
where: { email: 'zbh.north@example.com' },
defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword }
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 }
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 }
defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword, employeeId: 'ASM001' }
});
await mapUserRole(asmResult[0], 'ASM', { locationId: district1.id });
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: { locationId: district1.id }, isExt: true },
{ 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 } },
@ -140,7 +140,18 @@ async function seed() {
}
console.log('Users and Mappings seeded.');
console.log('--- Seeding Complete ---');
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 zones = await Zone.findAll({ attributes: ['id'] });
for (const z of zones) await syncZoneManager(z.id);
console.log('--- Seeding & Synchronization Complete ---');
}
seed().catch(err => {

View File

@ -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,7 +31,9 @@ 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
@ -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++;
}
}
console.log(`✅ Successfully seeded Real Geo Data! Created/Verified ${districtCount} districts.`);
// 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('--- 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) {

View File

@ -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();

View File

@ -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, {

View File

@ -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' });

View File

@ -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>, DistrictAttributes { }
export default (sequelize: Sequelize) => {
const District = sequelize.define<DistrictInstance>('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;
};

View File

@ -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;

View File

@ -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' });
};

View File

@ -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>, 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',

View File

@ -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>, 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;

View File

@ -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' });

View File

@ -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'

View File

@ -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>, 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;

View File

@ -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);

View File

@ -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,60 +215,49 @@ 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 {
// territories mapping — provide fallbacks to the nested location fields
const territories = assignments.map((a: any) => ({
role: a.role?.roleName,
roleCode: a.role?.roleCode,
locationId: a.locationId,
locationName: a.location?.name,
locationType: a.location?.type,
districtId: a.districtId,
districtName: a.district?.name,
locationType: 'district',
managerCode: a.managerCode,
zone: zone?.name,
zoneId: zone?.id,
region: region?.name,
regionId: region?.id,
state: state?.name,
stateId: state?.id,
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([
@ -264,8 +265,12 @@ export const getAllUsers = async (req: Request, res: Response) => {
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) {
@ -382,6 +420,11 @@ export const updateUserStatus = async (req: AuthRequest, res: Response) => {
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,
action: AUDIT_ACTIONS.UPDATED,
@ -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);
}
}
}

View File

@ -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<string[]> => {
const ancestors: string[] = [];
const visited = new Set<string>();
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',

View File

@ -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
}

View File

@ -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}.`);

View File

@ -1,18 +1,118 @@
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.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'] },
{ model: db.User, as: 'asm', attributes: ['id', 'fullName', 'email'] }
{ 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) => ({
@ -20,35 +120,67 @@ export const getDistricts = async (req: Request, res: Response) => {
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
});
res.status(201).json({ success: true, data: district });
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: 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;
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' });
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,
code,
stateId,
regionId,
zoneId,
asmId,
isActive,
description
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
});
res.json({ success: true, message: 'District updated' });
// 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<string>();
const stateSet = new Set<string>();
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;

View File

@ -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;

View File

@ -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);
}
};

View File

@ -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 (req.body.locationId && normalizedType === 'district') {
const selectedDistrict = await District.findByPk(req.body.locationId);
if (selectedDistrict) {
locationId = selectedDistrict.id;
isOpportunityAvailable = true;
}
});
if (selectedLocation) {
locationId = selectedLocation.id;
} 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']
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 (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'
}
});
}
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

View File

@ -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
});

View File

@ -3,4 +3,5 @@ export interface TokenPayload {
email: string;
role: string;
locationId: string | null;
districtId: string | null;
}