From a43d3efa68ca4db47e64b982b4e79e2ff343650e Mon Sep 17 00:00:00 2001 From: laxman h Date: Wed, 25 Mar 2026 20:28:25 +0530 Subject: [PATCH] enhanching loaction hierarchy and approval stages --- package.json | 10 +- scripts/seed-approval-policies.ts | 74 +++ scripts/seed-users.ts | 29 +- scripts/seed_normalized_data.ts | 159 ++++-- scripts/seed_real_locations.ts | 90 ++++ src/common/utils/email.service.ts | 8 +- src/database/models/Location.ts | 25 + src/database/models/StageApprovalAction.ts | 82 ++++ src/database/models/StageApprovalPolicy.ts | 56 +++ src/database/models/UserRole.ts | 25 + src/database/models/index.ts | 4 + src/modules/admin/admin.controller.ts | 81 ++- src/modules/admin/admin.routes.ts | 2 +- .../assessment/assessment.controller.ts | 463 ++++++++++++------ src/modules/assessment/assessment.routes.ts | 5 + src/modules/loa/loa.controller.ts | 113 ++++- src/modules/loa/loa.routes.ts | 3 + src/modules/loi/loi.controller.ts | 117 ++++- src/modules/loi/loi.routes.ts | 1 + src/modules/master/master.controller.ts | 190 ++++++- .../onboarding/onboarding.controller.ts | 92 +++- src/scripts/seedQuestionnaire.ts | 5 +- 22 files changed, 1323 insertions(+), 311 deletions(-) create mode 100644 scripts/seed-approval-policies.ts create mode 100644 scripts/seed_real_locations.ts create mode 100644 src/database/models/StageApprovalAction.ts create mode 100644 src/database/models/StageApprovalPolicy.ts diff --git a/package.json b/package.json index 41aad96..a637c81 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,12 @@ "build": "tsc", "type-check": "tsc --noEmit", "migrate": "tsx scripts/migrate.ts", - "seed": "tsx scripts/seed-geo.ts", - "seed-normalized": "tsx scripts/seed_normalized_data.ts", + "seed": "tsx scripts/seed_normalized_data.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:questionnaire": "tsx src/scripts/seedQuestionnaire.ts", "test": "jest", "test:coverage": "jest --coverage", "clear-logs": "rm -rf logs/*.log" @@ -71,4 +75,4 @@ "node": ">=18.0.0", "npm": ">=9.0.0" } -} +} \ No newline at end of file diff --git a/scripts/seed-approval-policies.ts b/scripts/seed-approval-policies.ts new file mode 100644 index 0000000..12c13eb --- /dev/null +++ b/scripts/seed-approval-policies.ts @@ -0,0 +1,74 @@ +import 'dotenv/config'; +import db from '../src/database/models/index.js'; + +const { StageApprovalPolicy } = db; + +const policies = [ + { + stageCode: 'INTERVIEW_LEVEL_1', + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['DD-ZM', 'RBM'], + isActive: true + }, + { + stageCode: 'INTERVIEW_LEVEL_2', + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['ZBH', 'DD Lead'], + isActive: true + }, + { + stageCode: 'INTERVIEW_LEVEL_3', + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['NBH', 'DD Head'], + isActive: true + }, + { + stageCode: 'LOI_APPROVAL', + minApprovals: 3, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['Finance', 'DD Head', 'NBH'], + isActive: true + }, + { + stageCode: 'LOA_APPROVAL', + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['DD Head', 'NBH'], + isActive: true + } +]; + +async function seedApprovalPolicies() { + console.log('--- Seeding Approval Policies ---'); + + for (const policy of policies) { + const [record, created] = await StageApprovalPolicy.findOrCreate({ + where: { stageCode: policy.stageCode }, + defaults: policy + }); + + if (!created) { + await record.update({ + minApprovals: policy.minApprovals, + approvalMode: policy.approvalMode, + requiredRoles: policy.requiredRoles, + isActive: policy.isActive + }); + console.log(`Updated policy: ${policy.stageCode}`); + } else { + console.log(`Created policy: ${policy.stageCode}`); + } + } + + console.log('--- Approval Policies Seeded ---'); +} + +seedApprovalPolicies() + .catch((error) => { + console.error('Approval policy seed failed:', error); + process.exit(1); + }) + .then(() => process.exit(0)); diff --git a/scripts/seed-users.ts b/scripts/seed-users.ts index 5fd3b59..310ca76 100644 --- a/scripts/seed-users.ts +++ b/scripts/seed-users.ts @@ -13,28 +13,13 @@ async function seedUsers() { const hashedPassword = await bcrypt.hash('Admin@123', 10); const usersToSeed = [ - { - email: 'admin@royalenfield.com', - fullName: 'Super Admin', - password: hashedPassword, - roleCode: ROLES.SUPER_ADMIN, - status: 'active' - }, - { - email: 'zm@royalenfield.com', - fullName: 'Zone Manager', - password: hashedPassword, - roleCode: ROLES.DD_ZM, - status: 'active' - }, - { - email: 'dealer@example.com', - fullName: 'Amit Sharma', - password: hashedPassword, - roleCode: ROLES.DEALER, - status: 'active', - isExternal: true - } + { email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, + { email: 'finance@royalenfield.com', fullName: 'Rahul Verma', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' }, + { email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', password: hashedPassword, roleCode: ROLES.DEALER, status: 'active', isExternal: true }, + { email: 'admin@royalenfield.com', fullName: 'Laxman H', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, + { email: 'yashwin@gmail.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' }, + { email: 'kenil@gmail.com', fullName: 'Kenil', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, + { email: 'lince@gmail.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' } ]; for (const u of usersToSeed) { diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index 1b3f45d..644eafe 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -1,10 +1,20 @@ - +import 'dotenv/config'; import db from '../src/database/models/index.js'; -const { Role, Location, LocationHierarchy, User, UserRole, Permission } = db; +import bcrypt from 'bcryptjs'; + +const { Role, Location, LocationHierarchy, User, UserRole } = db; async function seed() { console.log('--- Seeding Normalized Graph Data ---'); + // Ensure schema exists when seed is run on a fresh/empty database. + // This is non-destructive (does not drop data). + await db.sequelize.authenticate(); + await db.sequelize.sync({ alter: false }); + + // Hash default password for test users + const hashedPassword = await bcrypt.hash('Admin@123', 10); + // 1. Create Roles const roles = [ { roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' }, @@ -14,7 +24,11 @@ async function seed() { { roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' }, { roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' }, { roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' }, - { roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' } + { roleCode: '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) { @@ -23,69 +37,130 @@ async function seed() { console.log('Roles seeded.'); // 2. Create Locations - const zone1 = await Location.create({ name: 'North Zone', type: 'zone' }); - const region1 = await Location.create({ name: 'Delhi Region', type: 'region' }); - const area1 = await Location.create({ name: 'South Delhi Area', type: 'area' }); + const existingZones = await Location.findAll({ + where: { type: 'zone' }, + order: [['createdAt', 'ASC']] + }); - const zone2 = await Location.create({ name: 'South Zone', type: 'zone' }); - const region2 = await Location.create({ name: 'Bangalore Region', type: 'region' }); + let zone1: any = existingZones[0]; + let zone2: any = existingZones[1]; + + if (!zone1) { + const [createdZone1] = await Location.findOrCreate({ + where: { name: 'North Zone', type: 'zone' }, + defaults: { name: 'North Zone', type: 'zone' } + }); + zone1 = createdZone1; + } + + if (!zone2) { + const [createdZone2] = await Location.findOrCreate({ + where: { name: 'South Zone', type: 'zone' }, + defaults: { name: 'South Zone', type: 'zone' } + }); + zone2 = createdZone2; + } + + const [region1] = await Location.findOrCreate({ + where: { name: 'Delhi Region', type: 'region' }, + defaults: { name: 'Delhi Region', type: 'region' } + }); + const [area1] = await Location.findOrCreate({ + where: { name: 'South Delhi Area', type: 'area' }, + defaults: { name: 'South Delhi Area', type: 'area' } + }); + + const [region2] = await Location.findOrCreate({ + where: { name: 'Bangalore Region', type: 'region' }, + defaults: { name: 'Bangalore Region', type: 'region' } + }); console.log('Locations created.'); // 3. Create Hierarchies (Bridge Table) - await LocationHierarchy.create({ locationId: region1.id, parentId: zone1.id }); - await LocationHierarchy.create({ locationId: area1.id, parentId: region1.id }); - - // Example of multiple parents if needed - // await LocationHierarchy.create({ locationId: area1.id, parentId: someOtherParent.id }); - - await LocationHierarchy.create({ locationId: region2.id, parentId: zone2.id }); + await LocationHierarchy.findOrCreate({ + where: { locationId: region1.id, parentId: zone1.id }, + defaults: { locationId: region1.id, parentId: zone1.id } + }); + await LocationHierarchy.findOrCreate({ + where: { locationId: area1.id, parentId: region1.id }, + defaults: { locationId: area1.id, parentId: region1.id } + }); + await LocationHierarchy.findOrCreate({ + where: { locationId: region2.id, parentId: zone2.id }, + defaults: { locationId: region2.id, parentId: zone2.id } + }); console.log('Hierarchies seeded.'); + const mapUserRole = async (userRec: any, roleCode: string, locationId?: string) => { + const role = await Role.findOne({ where: { roleCode } }); + if (role) { + await UserRole.findOrCreate({ + where: { + userId: userRec.id, + roleId: role.id, + locationId: locationId || null + }, + defaults: { + userId: userRec.id, + roleId: role.id, + locationId: locationId || null + } + }); + } + }; + // 4. Create Users and Map them - // NBH (Global) + // Custom Seed Users const nbhUser = await User.findOrCreate({ where: { email: 'nbh@example.com' }, - defaults: { fullName: 'National Head', roleCode: 'NBH' } + defaults: { fullName: 'National Head', roleCode: 'NBH', password: hashedPassword } }); - await UserRole.create({ userId: nbhUser[0].id, roleId: (await Role.findOne({ where: { roleCode: 'NBH' } })).id }); + await mapUserRole(nbhUser[0], 'NBH'); - // ZBH (North Zone) const zbhUser = await User.findOrCreate({ where: { email: 'zbh.north@example.com' }, - defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH' } - }); - const zbhRole = await Role.findOne({ where: { roleCode: 'ZBH' } }); - await UserRole.create({ - userId: zbhUser[0].id, - roleId: zbhRole.id, - locationId: zone1.id + defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword } }); + await mapUserRole(zbhUser[0], 'ZBH', zone1.id); - // RBM (Delhi Region) const rbmUser = await User.findOrCreate({ where: { email: 'rbm.delhi@example.com' }, - defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RBM' } - }); - const rbmRole = await Role.findOne({ where: { roleCode: 'RBM' } }); - await UserRole.create({ - userId: rbmUser[0].id, - roleId: rbmRole.id, - locationId: region1.id + defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RBM', password: hashedPassword } }); + await mapUserRole(rbmUser[0], 'RBM', region1.id); - // ASM (South Delhi Area) const asmUser = await User.findOrCreate({ where: { email: 'asm.sdelhi@example.com' }, - defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM' } - }); - const asmRole = await Role.findOne({ where: { roleCode: 'ASM' } }); - await UserRole.create({ - userId: asmUser[0].id, - roleId: asmRole.id, - locationId: area1.id + defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword } }); + await mapUserRole(asmUser[0], 'ASM', area1.id); + + // Requested Mock Users + const mockUsers = [ + { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', roleCode: 'DD Lead', location: zone1.id }, + { email: 'finance@royalenfield.com', name: 'Rahul Verma', roleCode: 'Finance', location: null }, + { email: 'dealer@royalenfield.com', name: 'Amit Sharma', roleCode: 'Dealer', location: area1.id, isExt: true }, + { email: 'admin@royalenfield.com', name: 'Laxman H', roleCode: 'DD Lead', location: zone2.id }, + { email: 'yashwin@gmail.com', name: 'Yashwin', roleCode: 'ZBH', location: zone1.id }, + { email: 'kenil@gmail.com', name: 'Kenil', roleCode: 'DD Lead', location: zone1.id }, + { email: 'lince@gmail.com', name: 'Lince', roleCode: 'DD Admin', location: null } + ]; + + 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[0], m.roleCode, m.location); + } console.log('Users and Mappings seeded.'); console.log('--- Seeding Complete ---'); diff --git a/scripts/seed_real_locations.ts b/scripts/seed_real_locations.ts new file mode 100644 index 0000000..7343bb4 --- /dev/null +++ b/scripts/seed_real_locations.ts @@ -0,0 +1,90 @@ +import 'dotenv/config'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import db from '../src/database/models/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { Location, LocationHierarchy } = db; + +async function run() { + console.log('--- Migrating Real Geo Data to Normalized Location Models ---'); + try { + // Read the original seeder file as text so we don't have to duplicate the 350 items + const seederPath = path.join(__dirname, '../seeders/20240127-seed-geo-data.js'); + const content = fs.readFileSync(seederPath, 'utf8'); + + // Extract the arrays using eval since it's a known static JS file + const zonesMatch = content.match(/const ZONES_DATA = \[([\s\S]*?)\];/); + const statesMatch = content.match(/const STATES_DATA = \[([\s\S]*?)\];/); + const citiesMatch = content.match(/const CITIES_DATA = \[([\s\S]*?)\];/); + + if (!zonesMatch || !statesMatch || !citiesMatch) { + throw new Error('Could not parse geo data arrays!'); + } + + const ZONES_DATA = eval(`[${zonesMatch[1]}]`); + 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} Cities.`); + + // 1. Insert Zones + const zoneIdMap = new Map(); + for (const z of ZONES_DATA) { + const [loc] = await Location.findOrCreate({ + where: { name: z.name, type: 'zone' }, + defaults: { name: z.name, type: 'zone' } + }); + zoneIdMap.set(z.code, loc.id); + z._dbId = loc.id; + } + + // 2. Insert States and link to Zones + const stateIdMap = new Map(); + for (const s of STATES_DATA) { + const [loc] = await Location.findOrCreate({ + where: { name: s.name, type: 'state' }, + defaults: { name: s.name, type: 'state' } + }); + stateIdMap.set(s.id, loc.id); + + // Find which zone string array it belongs to + const parentZone = ZONES_DATA.find((z: any) => z.states.includes(s.name)); + if (parentZone) { + await LocationHierarchy.findOrCreate({ + where: { locationId: loc.id, parentId: parentZone._dbId }, + defaults: { locationId: loc.id, parentId: parentZone._dbId } + }); + } + } + + // 3. Insert Cities (Districts) and link to States + let cityCount = 0; + for (const c of CITIES_DATA) { + const stateDbId = stateIdMap.get(c.state_id); + if (stateDbId) { + const [loc] = await Location.findOrCreate({ + where: { name: c.name, type: 'district' }, + defaults: { name: c.name, type: 'district' } + }); + await LocationHierarchy.findOrCreate({ + where: { locationId: loc.id, parentId: stateDbId }, + defaults: { locationId: loc.id, parentId: stateDbId } + }); + cityCount++; + } + } + + console.log(`✅ Successfully seeded Real Geo Data! Created ${cityCount} districts tied to their respective states and zones.`); + process.exit(0); + + } catch (e: any) { + console.error('❌ Failed:', e.message); + process.exit(1); + } +} + +run(); diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index d7a4c21..fa63e80 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -114,13 +114,13 @@ export const sendInterviewScheduledEmail = async (to: string, name: string, appl const date = new Date(interview.scheduleDate); const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const formattedDate = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const dateTime = `${formattedDate} ${time}`; await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', { - applicant_name: name, - application_id: applicationId, + name, + applicationId, level: interview.level, - interview_date: formattedDate, - interview_time: time, + dateTime, type: interview.interviewType, location: interview.linkOrLocation, status: interview.status diff --git a/src/database/models/Location.ts b/src/database/models/Location.ts index d00ca26..15a6425 100644 --- a/src/database/models/Location.ts +++ b/src/database/models/Location.ts @@ -4,6 +4,11 @@ export interface LocationAttributes { id: string; name: string; type: 'zone' | 'region' | 'area' | 'state' | 'district'; + code?: string; + pincode?: string; + isActive?: boolean; + activeFrom?: string | Date | null; + activeTo?: string | Date | null; } export interface LocationInstance extends Model, LocationAttributes { } @@ -22,6 +27,26 @@ export default (sequelize: Sequelize) => { type: { type: DataTypes.ENUM('zone', 'region', 'area', 'state', 'district'), allowNull: false + }, + code: { + type: DataTypes.STRING, + allowNull: true + }, + pincode: { + type: DataTypes.STRING, + allowNull: true + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + activeFrom: { + type: DataTypes.DATE, + allowNull: true + }, + activeTo: { + type: DataTypes.DATE, + allowNull: true } }, { tableName: 'locations', diff --git a/src/database/models/StageApprovalAction.ts b/src/database/models/StageApprovalAction.ts new file mode 100644 index 0000000..afb4302 --- /dev/null +++ b/src/database/models/StageApprovalAction.ts @@ -0,0 +1,82 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface StageApprovalActionAttributes { + id: string; + applicationId: string; + interviewId: string | null; + stageCode: string; + actorUserId: string; + actorRole: string; + decision: 'Approved' | 'Rejected' | 'Hold'; + remarks: string | null; +} + +export interface StageApprovalActionInstance extends Model, StageApprovalActionAttributes { } + +export default (sequelize: Sequelize) => { + const StageApprovalAction = sequelize.define('StageApprovalAction', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + applicationId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'applications', + key: 'id' + } + }, + interviewId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'interviews', + key: 'id' + } + }, + stageCode: { + type: DataTypes.STRING, + allowNull: false + }, + actorUserId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + actorRole: { + type: DataTypes.STRING, + allowNull: false + }, + decision: { + type: DataTypes.ENUM('Approved', 'Rejected', 'Hold'), + allowNull: false + }, + remarks: { + type: DataTypes.TEXT, + allowNull: true + } + }, { + tableName: 'stage_approval_actions', + timestamps: true, + indexes: [ + { fields: ['applicationId'] }, + { fields: ['interviewId'] }, + { fields: ['stageCode'] }, + { fields: ['actorUserId'] }, + { fields: ['interviewId', 'stageCode', 'actorUserId'], unique: true } + ] + }); + + (StageApprovalAction as any).associate = (models: any) => { + StageApprovalAction.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' }); + StageApprovalAction.belongsTo(models.Interview, { foreignKey: 'interviewId', as: 'interview' }); + StageApprovalAction.belongsTo(models.User, { foreignKey: 'actorUserId', as: 'actor' }); + }; + + return StageApprovalAction; +}; diff --git a/src/database/models/StageApprovalPolicy.ts b/src/database/models/StageApprovalPolicy.ts new file mode 100644 index 0000000..390af38 --- /dev/null +++ b/src/database/models/StageApprovalPolicy.ts @@ -0,0 +1,56 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface StageApprovalPolicyAttributes { + id: string; + stageCode: string; + minApprovals: number; + approvalMode: 'ALL' | 'MIN_N' | 'ROLE_MANDATORY'; + requiredRoles: string[]; + isActive: boolean; +} + +export interface StageApprovalPolicyInstance extends Model, StageApprovalPolicyAttributes { } + +export default (sequelize: Sequelize) => { + const StageApprovalPolicy = sequelize.define('StageApprovalPolicy', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + stageCode: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + minApprovals: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1 + }, + approvalMode: { + type: DataTypes.ENUM('ALL', 'MIN_N', 'ROLE_MANDATORY'), + allowNull: false, + defaultValue: 'MIN_N' + }, + requiredRoles: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: [] + }, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, { + tableName: 'stage_approval_policies', + timestamps: true, + indexes: [ + { fields: ['stageCode'], unique: true }, + { fields: ['isActive'] } + ] + }); + + return StageApprovalPolicy; +}; diff --git a/src/database/models/UserRole.ts b/src/database/models/UserRole.ts index 15561d1..32e7e21 100644 --- a/src/database/models/UserRole.ts +++ b/src/database/models/UserRole.ts @@ -5,6 +5,11 @@ export interface UserRoleAttributes { userId: string; roleId: string; locationId: string | null; + managerCode: string | null; + isPrimary: boolean; + isActive: boolean; + effectiveFrom: Date | null; + effectiveTo: Date | null; assignedAt: Date; assignedBy: string | null; } @@ -42,6 +47,26 @@ export default (sequelize: Sequelize) => { key: 'id' } }, + managerCode: { + type: DataTypes.STRING, + allowNull: true + }, + isPrimary: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + effectiveFrom: { + type: DataTypes.DATE, + allowNull: true + }, + effectiveTo: { + type: DataTypes.DATE, + allowNull: true + }, assignedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 0aa42bf..3ade843 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -80,6 +80,8 @@ import createPushSubscription from './PushSubscription.js'; // Batch 8: SLA & TAT Tracking import createSLATracking from './SLATracking.js'; import createSLABreach from './SLABreach.js'; +import createStageApprovalPolicy from './StageApprovalPolicy.js'; +import createStageApprovalAction from './StageApprovalAction.js'; const env = process.env.NODE_ENV || 'development'; const dbConfig = config[env]; @@ -180,6 +182,8 @@ db.PushSubscription = createPushSubscription(sequelize); // Batch 8: SLA & TAT Tracking db.SLATracking = createSLATracking(sequelize); db.SLABreach = createSLABreach(sequelize); +db.StageApprovalPolicy = createStageApprovalPolicy(sequelize); +db.StageApprovalAction = createStageApprovalAction(sequelize); // Define associations Object.keys(db).forEach((modelName) => { diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index affe573..4862eb7 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -6,6 +6,37 @@ const { Role, Permission, RolePermission, User, DealerCode, AuditLog } = db; import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AuthRequest } from '../../types/express.types.js'; +const upsertUserAssignments = async ( + userId: string, + assignments: any[], + actorUserId?: string +) => { + if (!Array.isArray(assignments)) return; + + await db.UserRole.destroy({ where: { userId } }); + + for (let i = 0; i < assignments.length; i++) { + const assignment = assignments[i] || {}; + const roleCode = assignment.roleCode || assignment.role; + if (!roleCode) continue; + + const role = await Role.findOne({ where: { roleCode } }); + if (!role) continue; + + await db.UserRole.create({ + userId, + roleId: role.id, + locationId: assignment.locationId || null, + managerCode: assignment.managerCode || assignment.asmCode || null, + isPrimary: assignment.isPrimary !== undefined ? Boolean(assignment.isPrimary) : i === 0, + isActive: assignment.isActive !== undefined ? Boolean(assignment.isActive) : true, + effectiveFrom: assignment.effectiveFrom || null, + effectiveTo: assignment.effectiveTo || null, + assignedBy: actorUserId || null + }); + } +}; + // --- Roles Management --- export const getRoles = async (req: Request, res: Response) => { @@ -173,7 +204,15 @@ export const getAllUsers = async (req: Request, res: Response) => { } ] }, - { model: db.Location, as: 'location' } + { model: db.Location, as: 'location' }, + { + model: db.UserRole, + as: 'userRoles', + include: [ + { model: db.Role, as: 'role', attributes: ['id', 'roleCode', 'roleName'] }, + { model: db.Location, as: 'location', attributes: ['id', 'name', 'type'] } + ] + } ], order: [['createdAt', 'DESC']] }); @@ -189,7 +228,8 @@ export const createUser = async (req: AuthRequest, res: Response) => { const { fullName, email, roleCode, employeeId, mobileNumber, department, designation, - locationId + locationId, + assignments } = req.body; @@ -239,6 +279,22 @@ export const createUser = async (req: AuthRequest, res: Response) => { locationId }); + if (Array.isArray(assignments) && assignments.length > 0) { + await upsertUserAssignments(user.id, assignments, req.user?.id); + } else if (roleCode) { + const role = await Role.findOne({ where: { roleCode } }); + if (role) { + await db.UserRole.create({ + userId: user.id, + roleId: role.id, + locationId: locationId || null, + isPrimary: true, + isActive: true, + assignedBy: req.user?.id || null + }); + } + } + await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.CREATED, @@ -294,6 +350,7 @@ export const updateUser = async (req: AuthRequest, res: Response) => { fullName, email, roleCode, status, isActive, employeeId, mobileNumber, department, designation, locationId, + assignments, password // Optional password update } = req.body; @@ -321,6 +378,26 @@ export const updateUser = async (req: AuthRequest, res: Response) => { await user.update(updates); + if (Array.isArray(assignments)) { + await upsertUserAssignments(id, assignments, req.user?.id); + } else if (roleCode !== undefined || locationId !== undefined) { + const primaryRoleCode = roleCode || user.roleCode; + if (primaryRoleCode) { + const role = await Role.findOne({ where: { roleCode: primaryRoleCode } }); + if (role) { + await db.UserRole.destroy({ where: { userId: id, isPrimary: true } }); + await db.UserRole.create({ + userId: id, + roleId: role.id, + locationId: updates.locationId ?? user.locationId ?? null, + isPrimary: true, + isActive: updates.isActive, + assignedBy: req.user?.id || null + }); + } + } + } + await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.UPDATED, diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 71bce9a..de864bd 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -22,7 +22,7 @@ router.get('/permissions', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any router.post('/users', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, adminController.createUser); router.get('/users', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, adminController.getAllUsers); router.patch('/users/:id/status', checkRole([ROLES.SUPER_ADMIN]) as any, adminController.updateUserStatus); -router.put('/users/:id', checkRole([ROLES.SUPER_ADMIN]) as any, adminController.updateUser); +router.put('/users/:id', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, adminController.updateUser); // Email Templates router.get('/email-templates', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.getAllTemplates); diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 2a374fd..e066a39 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -2,12 +2,164 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore, - Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role + Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role, LocationHierarchy, 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 interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`; + +const getDefaultInterviewPolicy = (level: number) => { + const defaults: Record = { + 1: { requiredRoles: ['DD-ZM', 'RBM'], minApprovals: 2 }, + 2: { requiredRoles: ['ZBH', 'DD Lead'], minApprovals: 2 }, + 3: { requiredRoles: ['NBH', 'DD Head'], minApprovals: 2 } + }; + return defaults[level] || { requiredRoles: [], minApprovals: 1 }; +}; + +const ensureInterviewPolicy = async (level: number) => { + const stageCode = interviewStageCode(level); + const defaultPolicy = getDefaultInterviewPolicy(level); + const [policy] = await StageApprovalPolicy.findOrCreate({ + where: { stageCode }, + defaults: { + stageCode, + minApprovals: defaultPolicy.minApprovals, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: defaultPolicy.requiredRoles, + isActive: true + } + }); + return policy; +}; + +const processInterviewApprovalDecision = async (params: { + interviewId: string; + decision: 'Approved' | 'Rejected'; + remarks?: string; + userId: string; + roleCode: string; +}) => { + const { interviewId, decision, remarks, userId, roleCode } = params; + const interview = await Interview.findByPk(interviewId); + if (!interview) return { notFound: true }; + + const policy = await ensureInterviewPolicy(interview.level); + const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; + + if (requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && roleCode !== 'Super Admin') { + return { forbidden: true, policy, requiredRoles, currentRole: roleCode }; + } + + let evaluation = await db.InterviewEvaluation.findOne({ + where: { interviewId, evaluatorId: userId } + }); + + if (evaluation) { + await evaluation.update({ recommendation: decision, decision, remarks }); + } else { + evaluation = await db.InterviewEvaluation.create({ + interviewId, + evaluatorId: userId, + recommendation: decision, + decision, + remarks + }); + } + + await StageApprovalAction.upsert({ + applicationId: interview.applicationId, + interviewId, + stageCode: policy.stageCode, + actorUserId: userId, + actorRole: roleCode, + decision, + remarks: remarks || null + }); + + const actions = await StageApprovalAction.findAll({ + where: { interviewId, stageCode: policy.stageCode } + }); + + const uniqueApprovalsByRole = new Set( + actions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole) + ); + const hasRejection = actions.some((a: any) => a.decision === 'Rejected'); + const hasAllRequiredRoleApprovals = requiredRoles.length === 0 + ? true + : requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role)); + const meetsMinApprovals = uniqueApprovalsByRole.size >= (policy.minApprovals || 1); + + if (hasRejection) { + await interview.update({ status: 'Completed' }); + await db.Application.update({ + overallStatus: 'Rejected', + currentStage: 'Rejected' + }, { where: { id: interview.applicationId } }); + await db.ApplicationStatusHistory.create({ + applicationId: interview.applicationId, + previousStatus: 'Interview Pending', + newStatus: 'Rejected', + changedBy: userId, + reason: 'Rejected in interview approval workflow' + }); + } else if (hasAllRequiredRoleApprovals && meetsMinApprovals) { + await interview.update({ status: 'Completed', outcome: 'Selected' }); + const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'Level 3 Approved' }; + const newStatus = nextStatusMap[interview.level] || 'Approved'; + await db.Application.update({ + overallStatus: newStatus, + currentStage: newStatus + }, { where: { id: interview.applicationId } }); + await db.ApplicationStatusHistory.create({ + applicationId: interview.applicationId, + previousStatus: 'Interview Pending', + newStatus, + changedBy: userId, + reason: `Approved via ${policy.stageCode} policy` + }); + } + + return { + success: true, + interview, + policy, + requiredRoles, + uniqueApprovalsByRole, + hasAllRequiredRoleApprovals, + meetsMinApprovals, + evaluation + }; +}; + // --- Questionnaires --- export const getQuestionnaire = async (req: Request, res: Response) => { @@ -156,9 +308,26 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { ); } - if (participants && participants.length > 0) { - console.log(`Processing ${participants.length} participants...`); - for (const userId of participants) { + const application = await db.Application.findByPk(applicationId); + 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); + const zonalHeads = await User.findAll({ + where: { + roleCode: 'ZBH', + locationId: { [Op.in]: ancestorLocationIds } + }, + attributes: ['id'] + }); + participantIds = zonalHeads.map((user: any) => user.id); + } + participantIds = [...new Set(participantIds)]; + + if (participantIds.length > 0) { + console.log(`Processing ${participantIds.length} participants...`); + for (const userId of participantIds) { // 1. Add to Panel await InterviewParticipant.create({ interviewId: interview.id, @@ -182,21 +351,18 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { } } - // Fetch application and user email for notification - const application = await db.Application.findByPk(applicationId); - if (application) { await EmailService.sendInterviewScheduledEmail( application.email, - application.name, + application.applicantName, application.applicationId || application.id, interview ); } // Notify panelists if needed - if (participants && participants.length > 0) { - for (const userId of participants) { + if (participantIds.length > 0) { + for (const userId of participantIds) { const panelist = await User.findByPk(userId); if (panelist) { await EmailService.sendInterviewScheduledEmail( @@ -477,82 +643,43 @@ export const getInterviews = async (req: Request, res: Response) => { export const updateRecommendation = async (req: AuthRequest, res: Response) => { try { + if (!req.user?.id || !req.user?.roleCode) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } const { interviewId, recommendation } = req.body; // recommendation: 'Recommended' | 'Not Recommended' + const normalizedDecision = (recommendation === 'Recommended' || recommendation === 'Selected' || recommendation === 'Approved') + ? 'Approved' + : 'Rejected'; - const interview = await Interview.findByPk(interviewId, { - include: [ - { model: InterviewParticipant, as: 'participants' }, - { model: InterviewEvaluation, as: 'evaluations' } - ] + const result: any = await processInterviewApprovalDecision({ + interviewId, + decision: normalizedDecision, + remarks: req.body.remarks, + userId: req.user.id, + roleCode: req.user.roleCode }); - if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' }); - - // 1. Update or Create Evaluation for Current User - let evaluation = await InterviewEvaluation.findOne({ - where: { interviewId, evaluatorId: req.user?.id } - }); - - if (evaluation) { - await evaluation.update({ recommendation }); - } else { - evaluation = await InterviewEvaluation.create({ - interviewId, - evaluatorId: req.user?.id, - recommendation + if (result.notFound) return res.status(404).json({ success: false, message: 'Interview not found' }); + if (result.forbidden) { + return res.status(403).json({ + success: false, + message: `Role ${result.currentRole} is not allowed to approve ${result.policy.stageCode}` }); } - // 2. Check for Consensus - // Refresh interview evaluations to include the one just updated/created - const updatedInterview = await Interview.findByPk(interviewId, { - include: [ - { model: InterviewParticipant, as: 'participants' }, - { model: InterviewEvaluation, as: 'evaluations' } - ] + res.json({ + success: true, + message: 'Recommendation updated successfully', + data: { + evaluation: result.evaluation, + stageCode: result.policy.stageCode, + requiredRoles: result.requiredRoles, + minApprovals: result.policy.minApprovals, + approvedRoles: Array.from(result.uniqueApprovalsByRole), + hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals, + meetsMinApprovals: result.meetsMinApprovals + } }); - - const participants = updatedInterview?.participants || []; - const evaluations = updatedInterview?.evaluations || []; - - // Filter valid panelists (exclude observers if any role logic exists, assuming all participants differ from scheduler are panelists) - const panelistIds = participants.map((p: any) => p.userId); - - // Check if all panelists have evaluated with 'Selected' or equivalent positive recommendation - // Adjust logic based on exact recommendation string values used in frontend ('Selected', 'Rejected', etc.) - const allApproved = panelistIds.every((userId: string) => { - const userEval = evaluations.find((e: any) => e.evaluatorId === userId); - return userEval && (userEval.recommendation === 'Selected' || userEval.recommendation === 'Recommended'); - }); - - const anyRejected = evaluations.some((e: any) => panelistIds.includes(e.evaluatorId) && (e.recommendation === 'Rejected' || e.recommendation === 'Not Recommended')); - - if (anyRejected) { - await db.Application.update({ - overallStatus: 'Rejected', - currentStage: 'Rejected' - }, { where: { id: interview.applicationId } }); - - await interview.update({ status: 'Completed', outcome: 'Rejected' }); - - } else if (allApproved) { - // Determine next status based on current level - const nextStatusMap: any = { - 1: 'Level 1 Approved', - 2: 'Level 2 Approved', - 3: 'Level 3 Approved' - }; - const newStatus = nextStatusMap[interview.level] || 'Approved'; - - await db.Application.update({ - overallStatus: newStatus, - // Optionally update currentStage if it maps 1:1 - }, { where: { id: interview.applicationId } }); - - await interview.update({ status: 'Completed', outcome: 'Selected' }); - } - - res.json({ success: true, message: 'Recommendation updated successfully', data: evaluation }); } catch (error) { console.error('Update recommendation error:', error); res.status(500).json({ success: false, message: 'Error updating recommendation' }); @@ -561,84 +688,25 @@ export const updateRecommendation = async (req: AuthRequest, res: Response) => { export const updateInterviewDecision = async (req: AuthRequest, res: Response) => { try { - const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected' - const recommendation = decision === 'Approved' ? 'Approved' : 'Rejected'; - - const interview = await Interview.findByPk(interviewId); - if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' }); - - // Update or Create Evaluation for the current user - let evaluation = await db.InterviewEvaluation.findOne({ - where: { - interviewId, - evaluatorId: req.user?.id - } - }); - - if (evaluation) { - await evaluation.update({ recommendation, decision, remarks }); - } else { - evaluation = await db.InterviewEvaluation.create({ - interviewId, - evaluatorId: req.user?.id, - recommendation, - decision, - remarks - }); + if (!req.user?.id || !req.user?.roleCode) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); } - - // --- Multi-Interviewer Synchronization --- - // Fetch all assigned participants for this interview - const participants = await db.InterviewParticipant.findAll({ - where: { interviewId } + const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected' + const normalizedDecision = decision === 'Approved' ? 'Approved' : 'Rejected'; + const result: any = await processInterviewApprovalDecision({ + interviewId, + decision: normalizedDecision, + remarks, + userId: req.user.id, + roleCode: req.user.roleCode }); - // Fetch all evaluations submitted for this interview - const evaluations = await db.InterviewEvaluation.findAll({ - where: { interviewId } - }); - - const isFullyEvaluated = evaluations.length >= participants.length; - - if (isFullyEvaluated) { - // All interviewers have responded - await interview.update({ status: 'Completed' }); - - // Determine next status based on level - const nextStatusMap: any = { - 1: 'Level 1 Approved', - 2: 'Level 2 Approved', - 3: 'Level 3 Approved' - }; - - // Check if any interviewer rejected (for logging/metadata, though we still move forward as requested) - const hasRejection = evaluations.some((e: any) => e.decision === 'Rejected'); - - const newStatus = nextStatusMap[interview.level] || 'Approved'; - const stageMapping: any = { - 1: 'Level 1 Approved', - 2: 'Level 2 Approved', - 3: 'Level 3 Approved' - }; - - await db.Application.update({ - overallStatus: newStatus, - currentStage: stageMapping[interview.level] || newStatus - }, { where: { id: interview.applicationId } }); - - // Log Status History - await db.ApplicationStatusHistory.create({ - applicationId: interview.applicationId, - previousStatus: 'Interview Pending', - newStatus: newStatus, - changedBy: req.user?.id, - reason: hasRejection ? 'Interview completed with mixed recommendations' : 'Interview Approved by all' + if (result.notFound) return res.status(404).json({ success: false, message: 'Interview not found' }); + if (result.forbidden) { + return res.status(403).json({ + success: false, + message: `Role ${result.currentRole} is not allowed to approve ${result.policy.stageCode}` }); - } else { - // Still waiting for other interviewers - // We can mark the status as 'In Progress' or keep it as 'Scheduled' - // But we do NOT update the Application status yet. - console.log(`Interview ${interviewId}: Waiting for more evaluations. (${evaluations.length}/${participants.length})`); } await db.AuditLog.create({ @@ -649,9 +717,98 @@ export const updateInterviewDecision = async (req: AuthRequest, res: Response) = newData: { decision, remarks } }); - res.json({ success: true, message: `Recommendation ${decision.toLowerCase()} successfully` }); + res.json({ + success: true, + message: `Recommendation ${normalizedDecision.toLowerCase()} successfully`, + data: { + stageCode: result.policy.stageCode, + requiredRoles: result.requiredRoles, + minApprovals: result.policy.minApprovals, + approvedRoles: Array.from(result.uniqueApprovalsByRole), + hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals, + meetsMinApprovals: result.meetsMinApprovals + } + }); } catch (error) { console.error('Update interview decision error:', error); res.status(500).json({ success: false, message: 'Error updating interview decision' }); } }; + +export const getStageApprovalPolicies = async (req: AuthRequest, res: Response) => { + try { + const policies = await StageApprovalPolicy.findAll({ + where: { isActive: true }, + order: [['stageCode', 'ASC']] + }); + res.json({ success: true, data: policies }); + } catch (error) { + console.error('Get stage approval policies error:', error); + res.status(500).json({ success: false, message: 'Error fetching stage approval policies' }); + } +}; + +export const upsertStageApprovalPolicy = async (req: AuthRequest, res: Response) => { + try { + const { stageCode } = req.params; + const { minApprovals, approvalMode, requiredRoles, isActive } = req.body; + + const [policy, created] = await StageApprovalPolicy.findOrCreate({ + where: { stageCode }, + defaults: { + stageCode, + minApprovals: minApprovals ?? 1, + approvalMode: approvalMode ?? 'MIN_N', + requiredRoles: requiredRoles ?? [], + isActive: isActive ?? true + } + }); + + if (!created) { + await policy.update({ + minApprovals: minApprovals ?? policy.minApprovals, + approvalMode: approvalMode ?? policy.approvalMode, + requiredRoles: requiredRoles ?? policy.requiredRoles, + isActive: isActive ?? policy.isActive + }); + } + + res.json({ success: true, data: policy }); + } catch (error) { + console.error('Upsert stage approval policy error:', error); + res.status(500).json({ success: false, message: 'Error saving stage approval policy' }); + } +}; + +export const getInterviewApprovalStatus = async (req: AuthRequest, res: Response) => { + try { + const { interviewId } = req.params; + const interview = await Interview.findByPk(interviewId); + if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' }); + + const policy = await ensureInterviewPolicy(interview.level); + const actions = await StageApprovalAction.findAll({ + where: { + interviewId, + stageCode: policy.stageCode + }, + include: [{ model: User, as: 'actor', attributes: ['id', 'fullName', 'email', 'roleCode'] }], + order: [['updatedAt', 'DESC']] + }); + + res.json({ + success: true, + data: { + interviewId, + stageCode: policy.stageCode, + minApprovals: policy.minApprovals, + approvalMode: policy.approvalMode, + requiredRoles: policy.requiredRoles || [], + actions + } + }); + } catch (error) { + console.error('Get interview approval status error:', error); + res.status(500).json({ success: false, message: 'Error fetching interview approval status' }); + } +}; diff --git a/src/modules/assessment/assessment.routes.ts b/src/modules/assessment/assessment.routes.ts index ce1b6fd..51f15fb 100644 --- a/src/modules/assessment/assessment.routes.ts +++ b/src/modules/assessment/assessment.routes.ts @@ -2,6 +2,8 @@ import express from 'express'; const router = express.Router(); import * as assessmentController from './assessment.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; +import { checkRole } from '../../common/middleware/roleCheck.js'; +import { ROLES } from '../../common/config/constants.js'; router.use(authenticate as any); @@ -18,6 +20,9 @@ router.post('/kt-matrix', assessmentController.submitKTMatrix); router.post('/level2-feedback', assessmentController.submitLevel2Feedback); router.post('/recommendation', assessmentController.updateRecommendation); router.post('/decision', assessmentController.updateInterviewDecision); +router.get('/interviews/:interviewId/approval-status', assessmentController.getInterviewApprovalStatus); +router.get('/approval-policies', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.getStageApprovalPolicies); +router.put('/approval-policies/:stageCode', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, assessmentController.upsertStageApprovalPolicy); // AI Summary router.post('/ai-summary/:applicationId', assessmentController.generateAiSummary); diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 8a7efda..6054d6e 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -1,9 +1,25 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog } = db; +const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS } from '../../common/config/constants.js'; +const LOA_STAGE_CODE = 'LOA_APPROVAL'; + +const ensureLoaPolicy = async () => { + const [policy] = await StageApprovalPolicy.findOrCreate({ + where: { stageCode: LOA_STAGE_CODE }, + defaults: { + stageCode: LOA_STAGE_CODE, + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['DD Head', 'NBH'], + isActive: true + } + }); + return policy; +}; + // --- LOA --- export const getRequest = async (req: Request, res: Response) => { @@ -58,11 +74,22 @@ export const createRequest = async (req: AuthRequest, res: Response) => { export const approveRequest = async (req: AuthRequest, res: Response) => { try { + if (!req.user?.id || !req.user?.roleCode) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } const { requestId } = req.params; const { action, remarks } = req.body; const request = await LoaRequest.findByPk(requestId); if (!request) return res.status(404).json({ success: false, message: 'LOA Request not found' }); + const policy = await ensureLoaPolicy(); + const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; + if (requiredRoles.length > 0 && !requiredRoles.includes(req.user.roleCode) && req.user.roleCode !== 'Super Admin') { + return res.status(403).json({ + success: false, + message: `Role ${req.user.roleCode} is not allowed to approve ${LOA_STAGE_CODE}` + }); + } const currentApproval = await LoaApproval.findOne({ where: { requestId, action: 'Pending' }, @@ -74,33 +101,41 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { await currentApproval.update({ action, remarks, - approverId: req.user?.id, + approverId: req.user.id, approvedAt: action === 'Approved' ? new Date() : null }); - if (action === 'Rejected') { + const normalizedDecision = action === 'Rejected' ? 'Rejected' : 'Approved'; + await StageApprovalAction.upsert({ + applicationId: request.applicationId, + interviewId: null, + stageCode: LOA_STAGE_CODE, + actorUserId: req.user.id, + actorRole: req.user.roleCode, + decision: normalizedDecision, + remarks: remarks || null + }); + + const stageActions = await StageApprovalAction.findAll({ + where: { applicationId: request.applicationId, stageCode: LOA_STAGE_CODE } + }); + const approvedRoles = new Set( + stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole) + ); + const hasRejection = stageActions.some((a: any) => a.decision === 'Rejected'); + const hasAllRequiredRoleApprovals = requiredRoles.length === 0 + ? true + : requiredRoles.every((role: string) => approvedRoles.has(role)); + const meetsMinApprovals = approvedRoles.size >= (policy.minApprovals || 1); + + if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); await db.Application.update({ overallStatus: 'LOA Rejected' }, { where: { id: request.applicationId } }); return res.json({ success: true, message: 'LOA Request rejected' }); } - const nextLevelMap: any = { - 1: { role: 'NBH', level: 2 }, - 2: { role: 'Final', level: 3 } - }; - - const next = nextLevelMap[currentApproval.level]; - - if (next && next.role !== 'Final') { - await LoaApproval.create({ - requestId: request.id, - level: next.level, - approverRole: next.role, - action: 'Pending' - }); - res.json({ success: true, message: `Approved at Level ${currentApproval.level}. Moved to ${next.role}` }); - } else { - await request.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() }); + if (hasAllRequiredRoleApprovals && meetsMinApprovals) { + await request.update({ status: 'Approved', approvedBy: req.user.id, approvedAt: new Date() }); const mockFile = `LOA_${request.id}.pdf`; await LoaDocumentGenerated.create({ @@ -112,6 +147,19 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { await db.Application.update({ overallStatus: 'Authorized for Operations' }, { where: { id: request.applicationId } }); res.json({ success: true, message: 'LOA fully approved and issued' }); + } else { + res.json({ + success: true, + message: 'Approval recorded. Waiting for remaining required approvers.', + data: { + stageCode: LOA_STAGE_CODE, + requiredRoles, + minApprovals: policy.minApprovals, + approvedRoles: Array.from(approvedRoles), + hasAllRequiredRoleApprovals, + meetsMinApprovals + } + }); } } catch (error) { console.error('Approve LOA request error:', error); @@ -119,6 +167,31 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { } }; +export const getApprovalStatus = async (req: AuthRequest, res: Response) => { + try { + const { applicationId } = req.params; + const policy = await ensureLoaPolicy(); + const actions = await StageApprovalAction.findAll({ + where: { applicationId, stageCode: LOA_STAGE_CODE }, + include: [{ model: User, as: 'actor', attributes: ['id', 'fullName', 'email', 'roleCode'] }], + order: [['updatedAt', 'DESC']] + }); + res.json({ + success: true, + data: { + stageCode: LOA_STAGE_CODE, + minApprovals: policy.minApprovals, + approvalMode: policy.approvalMode, + requiredRoles: policy.requiredRoles || [], + actions + } + }); + } catch (error) { + console.error('Get LOA approval status error:', error); + res.status(500).json({ success: false, message: 'Error fetching LOA approval status' }); + } +}; + export const generateDocument = async (req: AuthRequest, res: Response) => { try { const { requestId } = req.body; diff --git a/src/modules/loa/loa.routes.ts b/src/modules/loa/loa.routes.ts index 6c2e60d..4344e49 100644 --- a/src/modules/loa/loa.routes.ts +++ b/src/modules/loa/loa.routes.ts @@ -7,6 +7,9 @@ router.use(authenticate as any); router.get('/request/:applicationId', loaController.getRequest); router.post('/request', loaController.createRequest); +router.post('/request/:requestId/approve', loaController.approveRequest); +router.get('/request/:applicationId/approval-status', loaController.getApprovalStatus); +router.post('/request/:requestId/generate', loaController.generateDocument); router.post('/security-deposit', loaController.updateSecurityDeposit); router.get('/security-deposit/:applicationId', loaController.getSecurityDeposit); diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index e3e62f2..8bd7cef 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -1,9 +1,25 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog } = db; +const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS } from '../../common/config/constants.js'; +const LOI_STAGE_CODE = 'LOI_APPROVAL'; + +const ensureLoiPolicy = async () => { + const [policy] = await StageApprovalPolicy.findOrCreate({ + where: { stageCode: LOI_STAGE_CODE }, + defaults: { + stageCode: LOI_STAGE_CODE, + minApprovals: 3, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['Finance', 'DD Head', 'NBH'], + isActive: true + } + }); + return policy; +}; + export const getRequest = async (req: Request, res: Response) => { try { const { applicationId } = req.params; @@ -82,11 +98,23 @@ export const createRequest = async (req: AuthRequest, res: Response) => { export const approveRequest = async (req: AuthRequest, res: Response) => { try { + if (!req.user?.id || !req.user?.roleCode) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } const { requestId } = req.params; const { action, remarks } = req.body; // action: Approved/Rejected const request = await LoiRequest.findByPk(requestId); if (!request) return res.status(404).json({ success: false, message: 'LOI Request not found' }); + const policy = await ensureLoiPolicy(); + const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; + + if (requiredRoles.length > 0 && !requiredRoles.includes(req.user.roleCode) && req.user.roleCode !== 'Super Admin') { + return res.status(403).json({ + success: false, + message: `Role ${req.user.roleCode} is not allowed to approve ${LOI_STAGE_CODE}` + }); + } // Find current pending approval const currentApproval = await LoiApproval.findOne({ @@ -116,38 +144,43 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { await currentApproval.update({ action, remarks, - approverId: req.user?.id, + approverId: req.user.id, approvedAt: action === 'Approved' ? new Date() : null }); + const normalizedDecision = action === 'Rejected' ? 'Rejected' : 'Approved'; + await StageApprovalAction.upsert({ + applicationId: request.applicationId, + interviewId: null, + stageCode: LOI_STAGE_CODE, + actorUserId: req.user.id, + actorRole: req.user.roleCode, + decision: normalizedDecision, + remarks: remarks || null + }); + + const stageActions = await StageApprovalAction.findAll({ + where: { applicationId: request.applicationId, stageCode: LOI_STAGE_CODE } + }); + const approvedRoles = new Set( + stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole) + ); + const hasRejection = stageActions.some((a: any) => a.decision === 'Rejected'); + const hasAllRequiredRoleApprovals = requiredRoles.length === 0 + ? true + : requiredRoles.every((role: string) => approvedRoles.has(role)); + const meetsMinApprovals = approvedRoles.size >= (policy.minApprovals || 1); + // 2. Handle Logic based on Action - if (action === 'Rejected') { + if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); await db.Application.update({ overallStatus: 'LOI Rejected' }, { where: { id: request.applicationId } }); return res.json({ success: true, message: 'LOI Request rejected' }); } - // 3. If Approved, determine next step - const nextLevelMap: any = { - 1: { role: 'DD Head', level: 2 }, - 2: { role: 'NBH', level: 3 }, - 3: { role: 'Final', level: 4 } - }; - - const next = nextLevelMap[currentApproval.level]; - - if (next && next.role !== 'Final') { - // Initiate next level - await LoiApproval.create({ - requestId: request.id, - level: next.level, - approverRole: next.role, - action: 'Pending' - }); - res.json({ success: true, message: `Approved at Level ${currentApproval.level}. Moved to ${next.role}` }); - } else { + if (hasAllRequiredRoleApprovals && meetsMinApprovals) { // Final Approval reached - await request.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() }); + await request.update({ status: 'Approved', approvedBy: req.user.id, approvedAt: new Date() }); // Trigger Mock Document Generation const mockFile = `LOI_${request.id}.pdf`; @@ -161,6 +194,19 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { await db.Application.update({ overallStatus: 'LOI Issued' }, { where: { id: request.applicationId } }); res.json({ success: true, message: 'LOI Request fully approved and document generated' }); + } else { + res.json({ + success: true, + message: 'Approval recorded. Waiting for remaining required approvers.', + data: { + stageCode: LOI_STAGE_CODE, + requiredRoles, + minApprovals: policy.minApprovals, + approvedRoles: Array.from(approvedRoles), + hasAllRequiredRoleApprovals, + meetsMinApprovals + } + }); } await AuditLog.create({ @@ -177,6 +223,31 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { } }; +export const getApprovalStatus = async (req: AuthRequest, res: Response) => { + try { + const { applicationId } = req.params; + const policy = await ensureLoiPolicy(); + const actions = await StageApprovalAction.findAll({ + where: { applicationId, stageCode: LOI_STAGE_CODE }, + include: [{ model: User, as: 'actor', attributes: ['id', 'fullName', 'email', 'roleCode'] }], + order: [['updatedAt', 'DESC']] + }); + res.json({ + success: true, + data: { + stageCode: LOI_STAGE_CODE, + minApprovals: policy.minApprovals, + approvalMode: policy.approvalMode, + requiredRoles: policy.requiredRoles || [], + actions + } + }); + } catch (error) { + console.error('Get LOI approval status error:', error); + res.status(500).json({ success: false, message: 'Error fetching LOI approval status' }); + } +}; + export const generateDocument = async (req: AuthRequest, res: Response) => { try { const { requestId } = req.body; diff --git a/src/modules/loi/loi.routes.ts b/src/modules/loi/loi.routes.ts index 7f18f6a..10aa1b5 100644 --- a/src/modules/loi/loi.routes.ts +++ b/src/modules/loi/loi.routes.ts @@ -8,6 +8,7 @@ router.use(authenticate as any); router.get('/request/:applicationId', loiController.getRequest); router.post('/request', loiController.createRequest); router.post('/request/:requestId/approve', loiController.approveRequest); +router.get('/request/:applicationId/approval-status', loiController.getApprovalStatus); router.post('/request/:requestId/acknowledge', loiController.acknowledgeRequest); router.post('/request/:requestId/generate', loiController.generateDocument); diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index b88f3b5..656fadf 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -35,22 +35,31 @@ export const getRegions = async (req: Request, res: Response) => { export const createRegion = async (req: Request, res: Response) => { try { - const { zoneId, regionName } = req.body; + const { parentIds, name, childrenIds, regionalManagerId } = req.body; - if (!regionName) { + if (!name) { return res.status(400).json({ success: false, message: 'Region name is required' }); } const region = await db.Location.create({ - name: regionName, + name: name, type: 'region' }); - if (zoneId) { - await db.LocationHierarchy.create({ - locationId: region.id, - parentId: zoneId - }); + if (parentIds && Array.isArray(parentIds)) { + for (const pid of parentIds) { + await db.LocationHierarchy.create({ locationId: region.id, parentId: pid }); + } + } + + if (childrenIds && Array.isArray(childrenIds)) { + for (const cid of childrenIds) { + await db.LocationHierarchy.create({ locationId: cid, parentId: region.id }); + } + } + + if (regionalManagerId) { + await db.User.update({ locationId: region.id }, { where: { id: regionalManagerId } }); } res.status(201).json({ success: true, message: 'Region created successfully', data: region }); @@ -67,22 +76,28 @@ export const getZones = async (req: Request, res: Response) => { export const createZone = async (req: Request, res: Response) => { try { - const { regionId, zoneName } = req.body; + const { name, childrenIds } = req.body; - if (!zoneName) { + if (!name) { return res.status(400).json({ success: false, message: 'Zone name is required' }); } const zone = await db.Location.create({ - name: zoneName, + name: name, type: 'zone' }); - if (regionId) { - await db.LocationHierarchy.create({ - locationId: zone.id, - parentId: regionId - }); + if (childrenIds && Array.isArray(childrenIds)) { + for (const childId of childrenIds) { + await db.LocationHierarchy.create({ + locationId: childId, + parentId: zone.id + }); + } + } + + if (req.body.zonalBusinessHeadId) { + await db.User.update({ locationId: zone.id }, { where: { id: req.body.zonalBusinessHeadId } }); } res.status(201).json({ success: true, message: 'Zone created successfully', data: zone }); @@ -95,7 +110,7 @@ export const createZone = async (req: Request, res: Response) => { export const updateLocation = async (req: Request, res: Response) => { try { const { id } = req.params; - const { name, type, parentIds } = req.body; + const { name, type, parentIds, childrenIds, zonalBusinessHeadId, regionalManagerId, areaName, pincode, isActive, activeFrom, activeTo, districtId } = req.body; const location = await db.Location.findByPk(id); if (!location) { @@ -104,18 +119,53 @@ export const updateLocation = async (req: Request, res: Response) => { const updates: any = {}; if (name) updates.name = name; + if (areaName) updates.name = areaName; // Fallback mapping for Area dialog payloads if (type) updates.type = type; + if (pincode !== undefined) updates.pincode = pincode; + if (isActive !== undefined) updates.isActive = isActive; + if (activeFrom !== undefined) updates.activeFrom = activeFrom; + if (activeTo !== undefined) updates.activeTo = activeTo; await location.update(updates); if (parentIds && Array.isArray(parentIds)) { - // Re-sync parents + // Re-sync parents (Where this location is the child) await db.LocationHierarchy.destroy({ where: { locationId: id } }); for (const pid of parentIds) { await db.LocationHierarchy.create({ locationId: id, parentId: pid }); } } + if (childrenIds && Array.isArray(childrenIds)) { + // Re-sync children (Where this location is the parent) + await db.LocationHierarchy.destroy({ where: { parentId: id } }); + for (const cid of childrenIds) { + await db.LocationHierarchy.create({ locationId: cid, parentId: id }); + } + } + + // Handling Area Dialog parentId mapping which passes exclusively districtId instead of parentIds array + if (districtId) { + await db.LocationHierarchy.destroy({ where: { locationId: id } }); + await db.LocationHierarchy.create({ locationId: id, parentId: districtId }); + } + + if (zonalBusinessHeadId !== undefined) { + const roleCodes = ['ZBH', 'Zonal Business Head']; + await db.User.update({ locationId: null }, { where: { locationId: id, roleCode: { [db.Sequelize.Op.in]: roleCodes } } }); + if (zonalBusinessHeadId !== null) { + await db.User.update({ locationId: id }, { where: { id: zonalBusinessHeadId } }); + } + } + + if (regionalManagerId !== undefined) { + const roleCodes = ['RM', 'Regional Manager']; + await db.User.update({ locationId: null }, { where: { locationId: id, roleCode: { [db.Sequelize.Op.in]: roleCodes } } }); + if (regionalManagerId !== null) { + await db.User.update({ locationId: id }, { where: { id: regionalManagerId } }); + } + } + res.json({ success: true, message: 'Location updated successfully' }); } catch (error) { console.error('Update location error:', error); @@ -176,10 +226,19 @@ export const getAreas = async (req: Request, res: Response) => { export const createArea = async (req: Request, res: Response) => { try { - const { districtId, areaName } = req.body; + // Intercept all legacy property keys matching the MasterPage payload. + const { districtId, areaName, city, pincode, areaCode, isActive, activeFrom, activeTo } = req.body; if (!areaName) return res.status(400).json({ success: false, message: 'Area name is required' }); - const area = await db.Location.create({ name: areaName, type: 'area' }); + const area = await db.Location.create({ + name: areaName, + type: 'area', + code: areaCode, + pincode: pincode, + isActive: isActive !== undefined ? isActive : true, + activeFrom: activeFrom || null, + activeTo: activeTo || null + }); if (districtId) { await db.LocationHierarchy.create({ locationId: area.id, parentId: districtId }); @@ -196,21 +255,98 @@ export const createArea = async (req: Request, res: Response) => { export const getManagersByRole = async (req: Request, res: Response) => { try { const { roleCode, locationId } = req.query as any; - const where: any = {}; - if (roleCode) where.roleCode = roleCode; - if (locationId) where.locationId = locationId; - const managers = await User.findAll({ - where, attributes: ['id', 'fullName', 'email', 'mobileNumber', 'roleCode', 'locationId'], include: [{ model: db.Location, as: 'location', - attributes: ['id', 'name', 'type'] + attributes: ['id', 'name', 'type'], + include: [ + { + model: db.Location, + as: 'parents', + through: { attributes: [] }, + attributes: ['id', 'name', 'type'], + include: [ + { + model: db.Location, + as: 'parents', + through: { attributes: [] }, + attributes: ['id', 'name', 'type'], + include: [ + { + model: db.Location, + as: 'parents', + through: { attributes: [] }, + attributes: ['id', 'name', 'type'], + include: [ + { + model: db.Location, + as: 'parents', + through: { attributes: [] }, + attributes: ['id', 'name', 'type'] + } + ] + } + ] + } + ] + } + ] + }, + { + model: db.UserRole, + as: 'userRoles', + attributes: ['id', 'locationId', 'managerCode', 'isPrimary', 'isActive'], + include: [ + { + model: db.Role, + as: 'role', + attributes: ['id', 'roleCode', 'roleName'] + }, + { + model: db.Location, + as: 'location', + attributes: ['id', 'name', 'type'], + include: [ + { + model: db.Location, + as: 'parents', + through: { attributes: [] }, + attributes: ['id', 'name', 'type'] + } + ] + } + ] }] }); - res.json({ success: true, data: managers }); + const filteredManagers = managers.filter((m: any) => { + const assignments = Array.isArray(m.userRoles) ? m.userRoles : []; + const hasRole = !roleCode || m.roleCode === roleCode || assignments.some((a: any) => a.role?.roleCode === roleCode); + const hasLocation = !locationId || m.locationId === locationId || assignments.some((a: any) => a.locationId === locationId); + return hasRole && hasLocation; + }).map((m: any) => { + const assignments = Array.isArray(m.userRoles) ? m.userRoles : []; + const asmAssignments = assignments.filter((a: any) => + (a.role?.roleCode === 'ASM' || m.roleCode === 'ASM') && a.location?.type === 'area' + ); + const asmCode = assignments.find((a: any) => a.managerCode)?.managerCode || null; + + const result = m.toJSON(); + result.asmCode = asmCode; + result.areaManagers = asmAssignments.map((a: any) => ({ + area: { + id: a.location.id, + areaName: a.location.name, + district: (a.location.parents || []).find((p: any) => p.type === 'district') || null, + state: (a.location.parents || []).find((p: any) => p.type === 'state') || null + } + })); + return result; + }); + + res.json({ success: true, data: filteredManagers }); } catch (error) { console.error('Get managers error:', error); res.status(500).json({ success: false, message: 'Error fetching managers' }); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 639810c..f86c818 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 } = db; +const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, Location, LocationHierarchy } = db; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; @@ -8,6 +8,13 @@ import { AuthRequest } from '../../types/express.types.js'; import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js'; +const normalizeLocationType = (rawType?: string | null): string | null => { + if (!rawType) return null; + const normalized = String(rawType).trim().toLowerCase(); + const supportedTypes = new Set(['area', 'district', 'state', 'region', 'zone']); + return supportedTypes.has(normalized) ? normalized : null; +}; + export const submitApplication = async (req: AuthRequest, res: Response) => { try { const { @@ -36,25 +43,86 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { const applicationId = `APP-${new Date().getFullYear()}-${uuidv4().substring(0, 6).toUpperCase()}`; - // Fetch hierarchy from Auto-detected Location + // Resolve location using canonical id/type first, then backward-compatible state+district names. let locationId = null; let isOpportunityAvailable = false; + const normalizedType = normalizeLocationType(locationType); - // Auto-detect Location from District - if (req.body.district) { - const districtName = req.body.district; - - // Find Location (type: district) match - const districtRecord = await Location.findOne({ - where: { - name: { [Op.iLike]: districtName }, - type: 'district' + if (req.body.locationId && normalizedType) { + const selectedLocation = await Location.findOne({ + where: { + id: req.body.locationId, + type: normalizedType } }); + if (selectedLocation) { + locationId = selectedLocation.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' + } + }); + } if (districtRecord) { locationId = districtRecord.id; - isOpportunityAvailable = true; // For now, assume if district exists, it's an opportunity + 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 (stateRecord) { + locationId = stateRecord.id; + isOpportunityAvailable = true; } } diff --git a/src/scripts/seedQuestionnaire.ts b/src/scripts/seedQuestionnaire.ts index 8070e3e..1c87be0 100644 --- a/src/scripts/seedQuestionnaire.ts +++ b/src/scripts/seedQuestionnaire.ts @@ -8,8 +8,9 @@ const seedQuestionnaire = async () => { console.log('QuestionnaireOption defined?', !!db.QuestionnaireOption); // Ensure database schema is up to date - console.log('Syncing database...'); - await db.sequelize.sync({ alter: true }); + // Skipping sync because dev server holds locks + // console.log('Syncing database...'); + // await db.sequelize.sync({ alter: true }); // Deactivate existing questionnaires await db.Questionnaire.update({ isActive: false }, { where: {} });