enhanching loaction hierarchy and approval stages

This commit is contained in:
laxman h 2026-03-25 20:28:25 +05:30
parent d20e573d69
commit a43d3efa68
22 changed files with 1323 additions and 311 deletions

View File

@ -10,8 +10,12 @@
"build": "tsc", "build": "tsc",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"migrate": "tsx scripts/migrate.ts", "migrate": "tsx scripts/migrate.ts",
"seed": "tsx scripts/seed-geo.ts", "seed": "tsx scripts/seed_normalized_data.ts",
"seed-normalized": "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": "jest",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"clear-logs": "rm -rf logs/*.log" "clear-logs": "rm -rf logs/*.log"

View File

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

View File

@ -13,28 +13,13 @@ async function seedUsers() {
const hashedPassword = await bcrypt.hash('Admin@123', 10); const hashedPassword = await bcrypt.hash('Admin@123', 10);
const usersToSeed = [ const usersToSeed = [
{ { email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
email: 'admin@royalenfield.com', { email: 'finance@royalenfield.com', fullName: 'Rahul Verma', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' },
fullName: 'Super Admin', { email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', password: hashedPassword, roleCode: ROLES.DEALER, status: 'active', isExternal: true },
password: hashedPassword, { email: 'admin@royalenfield.com', fullName: 'Laxman H', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
roleCode: ROLES.SUPER_ADMIN, { email: 'yashwin@gmail.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' },
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' }
{
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
}
]; ];
for (const u of usersToSeed) { for (const u of usersToSeed) {

View File

@ -1,10 +1,20 @@
import 'dotenv/config';
import db from '../src/database/models/index.js'; 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() { async function seed() {
console.log('--- Seeding Normalized Graph Data ---'); 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 // 1. Create Roles
const roles = [ const roles = [
{ roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' }, { roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' },
@ -14,7 +24,11 @@ async function seed() {
{ roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' }, { roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' },
{ roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' }, { roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' },
{ roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' }, { 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) { for (const r of roles) {
@ -23,69 +37,130 @@ async function seed() {
console.log('Roles seeded.'); console.log('Roles seeded.');
// 2. Create Locations // 2. Create Locations
const zone1 = await Location.create({ name: 'North Zone', type: 'zone' }); const existingZones = await Location.findAll({
const region1 = await Location.create({ name: 'Delhi Region', type: 'region' }); where: { type: 'zone' },
const area1 = await Location.create({ name: 'South Delhi Area', type: 'area' }); order: [['createdAt', 'ASC']]
});
const zone2 = await Location.create({ name: 'South Zone', type: 'zone' }); let zone1: any = existingZones[0];
const region2 = await Location.create({ name: 'Bangalore Region', type: 'region' }); 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.'); console.log('Locations created.');
// 3. Create Hierarchies (Bridge Table) // 3. Create Hierarchies (Bridge Table)
await LocationHierarchy.create({ locationId: region1.id, parentId: zone1.id }); await LocationHierarchy.findOrCreate({
await LocationHierarchy.create({ locationId: area1.id, parentId: region1.id }); where: { locationId: region1.id, parentId: zone1.id },
defaults: { locationId: region1.id, parentId: zone1.id }
// Example of multiple parents if needed });
// await LocationHierarchy.create({ locationId: area1.id, parentId: someOtherParent.id }); await LocationHierarchy.findOrCreate({
where: { locationId: area1.id, parentId: region1.id },
await LocationHierarchy.create({ locationId: region2.id, parentId: zone2.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.'); 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 // 4. Create Users and Map them
// NBH (Global) // Custom Seed Users
const nbhUser = await User.findOrCreate({ const nbhUser = await User.findOrCreate({
where: { email: 'nbh@example.com' }, 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({ const zbhUser = await User.findOrCreate({
where: { email: 'zbh.north@example.com' }, where: { email: 'zbh.north@example.com' },
defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH' } defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword }
});
const zbhRole = await Role.findOne({ where: { roleCode: 'ZBH' } });
await UserRole.create({
userId: zbhUser[0].id,
roleId: zbhRole.id,
locationId: zone1.id
}); });
await mapUserRole(zbhUser[0], 'ZBH', zone1.id);
// RBM (Delhi Region)
const rbmUser = await User.findOrCreate({ const rbmUser = await User.findOrCreate({
where: { email: 'rbm.delhi@example.com' }, where: { email: 'rbm.delhi@example.com' },
defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RBM' } defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RBM', password: hashedPassword }
});
const rbmRole = await Role.findOne({ where: { roleCode: 'RBM' } });
await UserRole.create({
userId: rbmUser[0].id,
roleId: rbmRole.id,
locationId: region1.id
}); });
await mapUserRole(rbmUser[0], 'RBM', region1.id);
// ASM (South Delhi Area)
const asmUser = await User.findOrCreate({ const asmUser = await User.findOrCreate({
where: { email: 'asm.sdelhi@example.com' }, where: { email: 'asm.sdelhi@example.com' },
defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM' } defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword }
}); });
const asmRole = await Role.findOne({ where: { roleCode: 'ASM' } }); await mapUserRole(asmUser[0], 'ASM', area1.id);
await UserRole.create({
userId: asmUser[0].id, // Requested Mock Users
roleId: asmRole.id, const mockUsers = [
locationId: area1.id { 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('Users and Mappings seeded.');
console.log('--- Seeding Complete ---'); console.log('--- Seeding Complete ---');

View File

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

View File

@ -114,13 +114,13 @@ export const sendInterviewScheduledEmail = async (to: string, name: string, appl
const date = new Date(interview.scheduleDate); const date = new Date(interview.scheduleDate);
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); 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 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', { await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', {
applicant_name: name, name,
application_id: applicationId, applicationId,
level: interview.level, level: interview.level,
interview_date: formattedDate, dateTime,
interview_time: time,
type: interview.interviewType, type: interview.interviewType,
location: interview.linkOrLocation, location: interview.linkOrLocation,
status: interview.status status: interview.status

View File

@ -4,6 +4,11 @@ export interface LocationAttributes {
id: string; id: string;
name: string; name: string;
type: 'zone' | 'region' | 'area' | 'state' | 'district'; 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>, LocationAttributes { } export interface LocationInstance extends Model<LocationAttributes>, LocationAttributes { }
@ -22,6 +27,26 @@ export default (sequelize: Sequelize) => {
type: { type: {
type: DataTypes.ENUM('zone', 'region', 'area', 'state', 'district'), type: DataTypes.ENUM('zone', 'region', 'area', 'state', 'district'),
allowNull: false 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', tableName: 'locations',

View File

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

View File

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

View File

@ -5,6 +5,11 @@ export interface UserRoleAttributes {
userId: string; userId: string;
roleId: string; roleId: string;
locationId: string | null; locationId: string | null;
managerCode: string | null;
isPrimary: boolean;
isActive: boolean;
effectiveFrom: Date | null;
effectiveTo: Date | null;
assignedAt: Date; assignedAt: Date;
assignedBy: string | null; assignedBy: string | null;
} }
@ -42,6 +47,26 @@ export default (sequelize: Sequelize) => {
key: 'id' 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: { assignedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
defaultValue: DataTypes.NOW defaultValue: DataTypes.NOW

View File

@ -80,6 +80,8 @@ import createPushSubscription from './PushSubscription.js';
// Batch 8: SLA & TAT Tracking // Batch 8: SLA & TAT Tracking
import createSLATracking from './SLATracking.js'; import createSLATracking from './SLATracking.js';
import createSLABreach from './SLABreach.js'; import createSLABreach from './SLABreach.js';
import createStageApprovalPolicy from './StageApprovalPolicy.js';
import createStageApprovalAction from './StageApprovalAction.js';
const env = process.env.NODE_ENV || 'development'; const env = process.env.NODE_ENV || 'development';
const dbConfig = config[env]; const dbConfig = config[env];
@ -180,6 +182,8 @@ db.PushSubscription = createPushSubscription(sequelize);
// Batch 8: SLA & TAT Tracking // Batch 8: SLA & TAT Tracking
db.SLATracking = createSLATracking(sequelize); db.SLATracking = createSLATracking(sequelize);
db.SLABreach = createSLABreach(sequelize); db.SLABreach = createSLABreach(sequelize);
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
db.StageApprovalAction = createStageApprovalAction(sequelize);
// Define associations // Define associations
Object.keys(db).forEach((modelName) => { Object.keys(db).forEach((modelName) => {

View File

@ -6,6 +6,37 @@ const { Role, Permission, RolePermission, User, DealerCode, AuditLog } = db;
import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS } from '../../common/config/constants.js';
import { AuthRequest } from '../../types/express.types.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 --- // --- Roles Management ---
export const getRoles = async (req: Request, res: Response) => { 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']] order: [['createdAt', 'DESC']]
}); });
@ -189,7 +228,8 @@ export const createUser = async (req: AuthRequest, res: Response) => {
const { const {
fullName, email, roleCode, fullName, email, roleCode,
employeeId, mobileNumber, department, designation, employeeId, mobileNumber, department, designation,
locationId locationId,
assignments
} = req.body; } = req.body;
@ -239,6 +279,22 @@ export const createUser = async (req: AuthRequest, res: Response) => {
locationId 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({ await AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.CREATED, action: AUDIT_ACTIONS.CREATED,
@ -294,6 +350,7 @@ export const updateUser = async (req: AuthRequest, res: Response) => {
fullName, email, roleCode, status, isActive, employeeId, fullName, email, roleCode, status, isActive, employeeId,
mobileNumber, department, designation, mobileNumber, department, designation,
locationId, locationId,
assignments,
password // Optional password update password // Optional password update
} = req.body; } = req.body;
@ -321,6 +378,26 @@ export const updateUser = async (req: AuthRequest, res: Response) => {
await user.update(updates); 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({ await AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.UPDATED, action: AUDIT_ACTIONS.UPDATED,

View File

@ -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.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.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.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 // Email Templates
router.get('/email-templates', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.getAllTemplates); router.get('/email-templates', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.getAllTemplates);

View File

@ -2,12 +2,164 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { const {
Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore, Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore,
Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role, LocationHierarchy, StageApprovalPolicy, StageApprovalAction
} = db; } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import * as EmailService from '../../common/utils/email.service.js'; 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 interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
const getDefaultInterviewPolicy = (level: number) => {
const defaults: Record<number, { requiredRoles: string[]; minApprovals: number }> = {
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 --- // --- Questionnaires ---
export const getQuestionnaire = async (req: Request, res: Response) => { 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) { const application = await db.Application.findByPk(applicationId);
console.log(`Processing ${participants.length} participants...`); let participantIds: string[] = Array.isArray(participants) ? participants : [];
for (const userId of 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 // 1. Add to Panel
await InterviewParticipant.create({ await InterviewParticipant.create({
interviewId: interview.id, 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) { if (application) {
await EmailService.sendInterviewScheduledEmail( await EmailService.sendInterviewScheduledEmail(
application.email, application.email,
application.name, application.applicantName,
application.applicationId || application.id, application.applicationId || application.id,
interview interview
); );
} }
// Notify panelists if needed // Notify panelists if needed
if (participants && participants.length > 0) { if (participantIds.length > 0) {
for (const userId of participants) { for (const userId of participantIds) {
const panelist = await User.findByPk(userId); const panelist = await User.findByPk(userId);
if (panelist) { if (panelist) {
await EmailService.sendInterviewScheduledEmail( await EmailService.sendInterviewScheduledEmail(
@ -477,82 +643,43 @@ export const getInterviews = async (req: Request, res: Response) => {
export const updateRecommendation = async (req: AuthRequest, res: Response) => { export const updateRecommendation = async (req: AuthRequest, res: Response) => {
try { 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 { interviewId, recommendation } = req.body; // recommendation: 'Recommended' | 'Not Recommended'
const normalizedDecision = (recommendation === 'Recommended' || recommendation === 'Selected' || recommendation === 'Approved')
? 'Approved'
: 'Rejected';
const interview = await Interview.findByPk(interviewId, { const result: any = await processInterviewApprovalDecision({
include: [
{ model: InterviewParticipant, as: 'participants' },
{ model: InterviewEvaluation, as: 'evaluations' }
]
});
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, interviewId,
evaluatorId: req.user?.id, decision: normalizedDecision,
recommendation remarks: req.body.remarks,
userId: req.user.id,
roleCode: req.user.roleCode
});
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 res.json({
// Refresh interview evaluations to include the one just updated/created success: true,
const updatedInterview = await Interview.findByPk(interviewId, { message: 'Recommendation updated successfully',
include: [ data: {
{ model: InterviewParticipant, as: 'participants' }, evaluation: result.evaluation,
{ model: InterviewEvaluation, as: 'evaluations' } stageCode: result.policy.stageCode,
] requiredRoles: result.requiredRoles,
}); minApprovals: result.policy.minApprovals,
approvedRoles: Array.from(result.uniqueApprovalsByRole),
const participants = updatedInterview?.participants || []; hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals,
const evaluations = updatedInterview?.evaluations || []; meetsMinApprovals: result.meetsMinApprovals
// 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) { } catch (error) {
console.error('Update recommendation error:', error); console.error('Update recommendation error:', error);
res.status(500).json({ success: false, message: 'Error updating recommendation' }); res.status(500).json({ success: false, message: 'Error updating recommendation' });
@ -561,86 +688,27 @@ export const updateRecommendation = async (req: AuthRequest, res: Response) => {
export const updateInterviewDecision = async (req: AuthRequest, res: Response) => { export const updateInterviewDecision = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user?.id || !req.user?.roleCode) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected' const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected'
const recommendation = decision === 'Approved' ? 'Approved' : 'Rejected'; const normalizedDecision = decision === 'Approved' ? 'Approved' : 'Rejected';
const result: any = await processInterviewApprovalDecision({
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, interviewId,
evaluatorId: req.user?.id decision: normalizedDecision,
} remarks,
userId: req.user.id,
roleCode: req.user.roleCode
}); });
if (evaluation) { if (result.notFound) return res.status(404).json({ success: false, message: 'Interview not found' });
await evaluation.update({ recommendation, decision, remarks }); if (result.forbidden) {
} else { return res.status(403).json({
evaluation = await db.InterviewEvaluation.create({ success: false,
interviewId, message: `Role ${result.currentRole} is not allowed to approve ${result.policy.stageCode}`
evaluatorId: req.user?.id,
recommendation,
decision,
remarks
}); });
} }
// --- Multi-Interviewer Synchronization ---
// Fetch all assigned participants for this interview
const participants = await db.InterviewParticipant.findAll({
where: { interviewId }
});
// 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'
});
} 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({ await db.AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: 'UPDATED', action: 'UPDATED',
@ -649,9 +717,98 @@ export const updateInterviewDecision = async (req: AuthRequest, res: Response) =
newData: { decision, remarks } 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) { } catch (error) {
console.error('Update interview decision error:', error); console.error('Update interview decision error:', error);
res.status(500).json({ success: false, message: 'Error updating interview decision' }); 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' });
}
};

View File

@ -2,6 +2,8 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
import * as assessmentController from './assessment.controller.js'; import * as assessmentController from './assessment.controller.js';
import { authenticate } from '../../common/middleware/auth.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); router.use(authenticate as any);
@ -18,6 +20,9 @@ router.post('/kt-matrix', assessmentController.submitKTMatrix);
router.post('/level2-feedback', assessmentController.submitLevel2Feedback); router.post('/level2-feedback', assessmentController.submitLevel2Feedback);
router.post('/recommendation', assessmentController.updateRecommendation); router.post('/recommendation', assessmentController.updateRecommendation);
router.post('/decision', assessmentController.updateInterviewDecision); 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 // AI Summary
router.post('/ai-summary/:applicationId', assessmentController.generateAiSummary); router.post('/ai-summary/:applicationId', assessmentController.generateAiSummary);

View File

@ -1,9 +1,25 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; 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 { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.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 --- // --- LOA ---
export const getRequest = async (req: Request, res: Response) => { 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) => { export const approveRequest = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user?.id || !req.user?.roleCode) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const { requestId } = req.params; const { requestId } = req.params;
const { action, remarks } = req.body; const { action, remarks } = req.body;
const request = await LoaRequest.findByPk(requestId); const request = await LoaRequest.findByPk(requestId);
if (!request) return res.status(404).json({ success: false, message: 'LOA Request not found' }); 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({ const currentApproval = await LoaApproval.findOne({
where: { requestId, action: 'Pending' }, where: { requestId, action: 'Pending' },
@ -74,33 +101,41 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
await currentApproval.update({ await currentApproval.update({
action, action,
remarks, remarks,
approverId: req.user?.id, approverId: req.user.id,
approvedAt: action === 'Approved' ? new Date() : null 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 request.update({ status: 'Rejected' });
await db.Application.update({ overallStatus: 'LOA Rejected' }, { where: { id: request.applicationId } }); await db.Application.update({ overallStatus: 'LOA Rejected' }, { where: { id: request.applicationId } });
return res.json({ success: true, message: 'LOA Request rejected' }); return res.json({ success: true, message: 'LOA Request rejected' });
} }
const nextLevelMap: any = { if (hasAllRequiredRoleApprovals && meetsMinApprovals) {
1: { role: 'NBH', level: 2 }, await request.update({ status: 'Approved', approvedBy: req.user.id, approvedAt: new Date() });
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() });
const mockFile = `LOA_${request.id}.pdf`; const mockFile = `LOA_${request.id}.pdf`;
await LoaDocumentGenerated.create({ 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 } }); await db.Application.update({ overallStatus: 'Authorized for Operations' }, { where: { id: request.applicationId } });
res.json({ success: true, message: 'LOA fully approved and issued' }); 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) { } catch (error) {
console.error('Approve LOA request error:', 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) => { export const generateDocument = async (req: AuthRequest, res: Response) => {
try { try {
const { requestId } = req.body; const { requestId } = req.body;

View File

@ -7,6 +7,9 @@ router.use(authenticate as any);
router.get('/request/:applicationId', loaController.getRequest); router.get('/request/:applicationId', loaController.getRequest);
router.post('/request', loaController.createRequest); 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.post('/security-deposit', loaController.updateSecurityDeposit);
router.get('/security-deposit/:applicationId', loaController.getSecurityDeposit); router.get('/security-deposit/:applicationId', loaController.getSecurityDeposit);

View File

@ -1,9 +1,25 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; 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 { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.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) => { export const getRequest = async (req: Request, res: Response) => {
try { try {
const { applicationId } = req.params; const { applicationId } = req.params;
@ -82,11 +98,23 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
export const approveRequest = async (req: AuthRequest, res: Response) => { export const approveRequest = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user?.id || !req.user?.roleCode) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const { requestId } = req.params; const { requestId } = req.params;
const { action, remarks } = req.body; // action: Approved/Rejected const { action, remarks } = req.body; // action: Approved/Rejected
const request = await LoiRequest.findByPk(requestId); const request = await LoiRequest.findByPk(requestId);
if (!request) return res.status(404).json({ success: false, message: 'LOI Request not found' }); 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 // Find current pending approval
const currentApproval = await LoiApproval.findOne({ const currentApproval = await LoiApproval.findOne({
@ -116,38 +144,43 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
await currentApproval.update({ await currentApproval.update({
action, action,
remarks, remarks,
approverId: req.user?.id, approverId: req.user.id,
approvedAt: action === 'Approved' ? new Date() : null 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 // 2. Handle Logic based on Action
if (action === 'Rejected') { if (action === 'Rejected' || hasRejection) {
await request.update({ status: 'Rejected' }); await request.update({ status: 'Rejected' });
await db.Application.update({ overallStatus: 'LOI Rejected' }, { where: { id: request.applicationId } }); await db.Application.update({ overallStatus: 'LOI Rejected' }, { where: { id: request.applicationId } });
return res.json({ success: true, message: 'LOI Request rejected' }); return res.json({ success: true, message: 'LOI Request rejected' });
} }
// 3. If Approved, determine next step if (hasAllRequiredRoleApprovals && meetsMinApprovals) {
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 {
// Final Approval reached // 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 // Trigger Mock Document Generation
const mockFile = `LOI_${request.id}.pdf`; 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 } }); await db.Application.update({ overallStatus: 'LOI Issued' }, { where: { id: request.applicationId } });
res.json({ success: true, message: 'LOI Request fully approved and document generated' }); 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({ 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) => { export const generateDocument = async (req: AuthRequest, res: Response) => {
try { try {
const { requestId } = req.body; const { requestId } = req.body;

View File

@ -8,6 +8,7 @@ router.use(authenticate as any);
router.get('/request/:applicationId', loiController.getRequest); router.get('/request/:applicationId', loiController.getRequest);
router.post('/request', loiController.createRequest); router.post('/request', loiController.createRequest);
router.post('/request/:requestId/approve', loiController.approveRequest); 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/acknowledge', loiController.acknowledgeRequest);
router.post('/request/:requestId/generate', loiController.generateDocument); router.post('/request/:requestId/generate', loiController.generateDocument);

View File

@ -35,22 +35,31 @@ export const getRegions = async (req: Request, res: Response) => {
export const createRegion = async (req: Request, res: Response) => { export const createRegion = async (req: Request, res: Response) => {
try { 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' }); return res.status(400).json({ success: false, message: 'Region name is required' });
} }
const region = await db.Location.create({ const region = await db.Location.create({
name: regionName, name: name,
type: 'region' type: 'region'
}); });
if (zoneId) { if (parentIds && Array.isArray(parentIds)) {
await db.LocationHierarchy.create({ for (const pid of parentIds) {
locationId: region.id, await db.LocationHierarchy.create({ locationId: region.id, parentId: pid });
parentId: zoneId }
}); }
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 }); res.status(201).json({ success: true, message: 'Region created successfully', data: region });
@ -67,23 +76,29 @@ export const getZones = async (req: Request, res: Response) => {
export const createZone = async (req: Request, res: Response) => { export const createZone = async (req: Request, res: Response) => {
try { 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' }); return res.status(400).json({ success: false, message: 'Zone name is required' });
} }
const zone = await db.Location.create({ const zone = await db.Location.create({
name: zoneName, name: name,
type: 'zone' type: 'zone'
}); });
if (regionId) { if (childrenIds && Array.isArray(childrenIds)) {
for (const childId of childrenIds) {
await db.LocationHierarchy.create({ await db.LocationHierarchy.create({
locationId: zone.id, locationId: childId,
parentId: regionId 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 }); res.status(201).json({ success: true, message: 'Zone created successfully', data: zone });
} catch (error) { } catch (error) {
@ -95,7 +110,7 @@ export const createZone = async (req: Request, res: Response) => {
export const updateLocation = async (req: Request, res: Response) => { export const updateLocation = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; 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); const location = await db.Location.findByPk(id);
if (!location) { if (!location) {
@ -104,18 +119,53 @@ export const updateLocation = async (req: Request, res: Response) => {
const updates: any = {}; const updates: any = {};
if (name) updates.name = name; if (name) updates.name = name;
if (areaName) updates.name = areaName; // Fallback mapping for Area dialog payloads
if (type) updates.type = type; 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); await location.update(updates);
if (parentIds && Array.isArray(parentIds)) { if (parentIds && Array.isArray(parentIds)) {
// Re-sync parents // Re-sync parents (Where this location is the child)
await db.LocationHierarchy.destroy({ where: { locationId: id } }); await db.LocationHierarchy.destroy({ where: { locationId: id } });
for (const pid of parentIds) { for (const pid of parentIds) {
await db.LocationHierarchy.create({ locationId: id, parentId: pid }); 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' }); res.json({ success: true, message: 'Location updated successfully' });
} catch (error) { } catch (error) {
console.error('Update location error:', 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) => { export const createArea = async (req: Request, res: Response) => {
try { 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' }); 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) { if (districtId) {
await db.LocationHierarchy.create({ locationId: area.id, parentId: 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) => { export const getManagersByRole = async (req: Request, res: Response) => {
try { try {
const { roleCode, locationId } = req.query as any; 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({ const managers = await User.findAll({
where,
attributes: ['id', 'fullName', 'email', 'mobileNumber', 'roleCode', 'locationId'], attributes: ['id', 'fullName', 'email', 'mobileNumber', 'roleCode', 'locationId'],
include: [{ include: [{
model: db.Location, model: db.Location,
as: 'location', as: 'location',
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'] 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) { } catch (error) {
console.error('Get managers error:', error); console.error('Get managers error:', error);
res.status(500).json({ success: false, message: 'Error fetching managers' }); res.status(500).json({ success: false, message: 'Error fetching managers' });

View File

@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; 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 { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
@ -8,6 +8,13 @@ import { AuthRequest } from '../../types/express.types.js';
import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.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) => { export const submitApplication = async (req: AuthRequest, res: Response) => {
try { try {
const { const {
@ -36,25 +43,86 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
const applicationId = `APP-${new Date().getFullYear()}-${uuidv4().substring(0, 6).toUpperCase()}`; 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 locationId = null;
let isOpportunityAvailable = false; let isOpportunityAvailable = false;
const normalizedType = normalizeLocationType(locationType);
// Auto-detect Location from District if (req.body.locationId && normalizedType) {
if (req.body.district) { 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 districtName = req.body.district;
const stateName = req.body.state;
// Find Location (type: district) match // If state is available, disambiguate district by hierarchy parent.
const districtRecord = await Location.findOne({ 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: { where: {
name: { [Op.iLike]: districtName }, name: { [Op.iLike]: districtName },
type: 'district' type: 'district'
} }
}); });
}
if (districtRecord) { if (districtRecord) {
locationId = districtRecord.id; 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;
} }
} }

View File

@ -8,8 +8,9 @@ const seedQuestionnaire = async () => {
console.log('QuestionnaireOption defined?', !!db.QuestionnaireOption); console.log('QuestionnaireOption defined?', !!db.QuestionnaireOption);
// Ensure database schema is up to date // Ensure database schema is up to date
console.log('Syncing database...'); // Skipping sync because dev server holds locks
await db.sequelize.sync({ alter: true }); // console.log('Syncing database...');
// await db.sequelize.sync({ alter: true });
// Deactivate existing questionnaires // Deactivate existing questionnaires
await db.Questionnaire.update({ isActive: false }, { where: {} }); await db.Questionnaire.update({ isActive: false }, { where: {} });