enhanching loaction hierarchy and approval stages
This commit is contained in:
parent
d20e573d69
commit
a43d3efa68
@ -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"
|
||||
|
||||
74
scripts/seed-approval-policies.ts
Normal file
74
scripts/seed-approval-policies.ts
Normal 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));
|
||||
@ -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) {
|
||||
|
||||
@ -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 ---');
|
||||
|
||||
90
scripts/seed_real_locations.ts
Normal file
90
scripts/seed_real_locations.ts
Normal 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();
|
||||
@ -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
|
||||
|
||||
@ -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>, 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',
|
||||
|
||||
82
src/database/models/StageApprovalAction.ts
Normal file
82
src/database/models/StageApprovalAction.ts
Normal 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;
|
||||
};
|
||||
56
src/database/models/StageApprovalPolicy.ts
Normal file
56
src/database/models/StageApprovalPolicy.ts
Normal 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;
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<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 ---
|
||||
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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({
|
||||
if (req.body.locationId && normalizedType) {
|
||||
const selectedLocation = await Location.findOne({
|
||||
where: {
|
||||
name: { [Op.iLike]: districtName },
|
||||
type: 'district'
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: {} });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user