nomenclature changed for the requests and the changes asked in the demo Active / Inactive Opportunity will be labeled as Opportunity with value as Yes or No auto assign to DD_AM , auto assignment configuration and kt matrix configuration added
This commit is contained in:
parent
c6b7b79021
commit
71d9f9dbba
1
.gitignore
vendored
1
.gitignore
vendored
@ -135,3 +135,4 @@ uploads/
|
|||||||
|
|
||||||
# GCP Service Account Key
|
# GCP Service Account Key
|
||||||
config/gcp-key.json
|
config/gcp-key.json
|
||||||
|
src/database/models/index.ts
|
||||||
|
|||||||
20
check_db.js
Normal file
20
check_db.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
const { Application } = require('./src/database/models/application/Application');
|
||||||
|
const { Sequelize, Op } = require('sequelize');
|
||||||
|
const config = require('./src/database/config/config.json')['development'];
|
||||||
|
const sequelize = new Sequelize(config.database, config.username, config.password, config);
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const apps = await Application.findAll({
|
||||||
|
attributes: ['id', 'applicationId', 'overallStatus', 'isShortlisted', 'ddLeadShortlisted'],
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(apps, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check();
|
||||||
74
scripts/assign_south_delhi.ts
Normal file
74
scripts/assign_south_delhi.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
import { syncLocationManagers } from '../src/modules/master/syncHierarchy.service.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
// 1. Find the South Delhi district
|
||||||
|
const district = await db.District.findOne({
|
||||||
|
where: { name: { [db.Sequelize.Op.iLike]: '%South Delhi%' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!district) {
|
||||||
|
console.log('District "South Delhi" not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Found District: ${district.name} (${district.id})`);
|
||||||
|
|
||||||
|
// 2. Find a DD-AM user
|
||||||
|
// The role code might be 'DD AM' or 'DD-AM' based on constants
|
||||||
|
const user = await db.User.findOne({
|
||||||
|
where: {
|
||||||
|
[db.Sequelize.Op.or]: [
|
||||||
|
{ roleCode: 'DD AM' },
|
||||||
|
{ roleCode: 'DD-AM' }
|
||||||
|
],
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('No active DD-AM user found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Found DD-AM User: ${user.fullName} (${user.id})`);
|
||||||
|
|
||||||
|
// 3. Create/Update UserRole mapping
|
||||||
|
const [userRole, created] = await db.UserRole.findOrCreate({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
districtId: district.id,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
roleId: (await db.Role.findOne({ where: { roleCode: user.roleCode } })).id,
|
||||||
|
isPrimary: true,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
console.log('Created new UserRole assignment');
|
||||||
|
} else {
|
||||||
|
console.log('UserRole assignment already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sync Location Managers
|
||||||
|
await syncLocationManagers(district.id);
|
||||||
|
console.log('Sync completed');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
48
scripts/check_recent_app.ts
Normal file
48
scripts/check_recent_app.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const app = await db.Application.findOne({
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
include: [
|
||||||
|
{ model: db.District, as: 'district' },
|
||||||
|
{
|
||||||
|
model: db.RequestParticipant,
|
||||||
|
as: 'participants',
|
||||||
|
include: [{ model: db.User, as: 'user' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
console.log('No applications found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Application ID:', app.id);
|
||||||
|
console.log('Status:', app.status);
|
||||||
|
console.log('District:', app.district?.name);
|
||||||
|
console.log('District ddAmId:', app.district?.ddAmId);
|
||||||
|
console.log('District asmId:', app.district?.asmId);
|
||||||
|
|
||||||
|
console.log('Participants:');
|
||||||
|
app.participants?.forEach(p => {
|
||||||
|
console.log(`- ${p.user?.fullName} (${p.metadata?.role})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
76
scripts/diagnose_associations.ts
Normal file
76
scripts/diagnose_associations.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
console.log('Associations for User:');
|
||||||
|
const userAssoc = db.User.associations;
|
||||||
|
Object.keys(userAssoc).forEach(key => {
|
||||||
|
console.log(`- ${key}: ${userAssoc[key].associationType} to ${userAssoc[key].target.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nTrying findAll with managedAsmDistricts...');
|
||||||
|
await db.User.findAll({
|
||||||
|
limit: 1,
|
||||||
|
include: [{ model: db.District, as: 'managedAsmDistricts' }]
|
||||||
|
});
|
||||||
|
console.log('Success with managedAsmDistricts');
|
||||||
|
|
||||||
|
console.log('\nTrying findAll with managedAreaDistricts...');
|
||||||
|
await db.User.findAll({
|
||||||
|
limit: 1,
|
||||||
|
include: [{ model: db.District, as: 'managedAreaDistricts' }]
|
||||||
|
});
|
||||||
|
console.log('Success with managedAreaDistricts');
|
||||||
|
|
||||||
|
console.log('\nTrying FULL query from getASMs...');
|
||||||
|
await db.User.findAll({
|
||||||
|
where: {
|
||||||
|
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', 'DD AM'] },
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.UserRole,
|
||||||
|
as: 'userRoles',
|
||||||
|
where: { isActive: true },
|
||||||
|
required: false,
|
||||||
|
include: [{ model: db.Role, as: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', 'DD AM'] } } }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.District,
|
||||||
|
as: 'managedAsmDistricts',
|
||||||
|
include: [
|
||||||
|
{ model: db.State, as: 'state', attributes: ['id', 'name'] },
|
||||||
|
{ model: db.Region, as: 'region', attributes: ['id', 'name'] },
|
||||||
|
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.District,
|
||||||
|
as: 'managedAreaDistricts',
|
||||||
|
include: [
|
||||||
|
{ model: db.State, as: 'state', attributes: ['id', 'name'] },
|
||||||
|
{ model: db.Region, as: 'region', attributes: ['id', 'name'] },
|
||||||
|
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log('Success with FULL query');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
24
scripts/find_abhishek.ts
Normal file
24
scripts/find_abhishek.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const u = await db.User.findOne({
|
||||||
|
where: { fullName: { [db.Sequelize.Op.iLike]: '%abhishek%' } }
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(u, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
40
scripts/fix_south_delhi.ts
Normal file
40
scripts/fix_south_delhi.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const district = await db.District.findOne({
|
||||||
|
where: { name: { [db.Sequelize.Op.iLike]: '%South Delhi%' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!district) {
|
||||||
|
console.log('South Delhi not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Current assignment for South Delhi:');
|
||||||
|
console.log(`ddAmId: ${district.ddAmId}`);
|
||||||
|
console.log(`asmId: ${district.asmId}`);
|
||||||
|
console.log(`zmId: ${district.zmId}`);
|
||||||
|
|
||||||
|
if (district.asmId) {
|
||||||
|
console.log(`Removing ASM ${district.asmId} from South Delhi...`);
|
||||||
|
await district.update({ asmId: null, asmCode: null });
|
||||||
|
console.log('ASM removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
36
scripts/remove_abhishek_app.ts
Normal file
36
scripts/remove_abhishek_app.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const appId = '1f1fec7d-7034-4588-a4b2-0e1d4cc3f149';
|
||||||
|
const abhishekId = '9284a190-f4d2-49f3-9186-bb7c93dc9b6d';
|
||||||
|
|
||||||
|
const deleted = await db.RequestParticipant.destroy({
|
||||||
|
where: {
|
||||||
|
requestId: appId,
|
||||||
|
userId: abhishekId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
console.log('Successfully removed Abhishek from application participants.');
|
||||||
|
} else {
|
||||||
|
console.log('Abhishek was not found in participants for this application.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@ -43,7 +43,7 @@ const policies = [
|
|||||||
stageCode: 'FDD_VERIFICATION',
|
stageCode: 'FDD_VERIFICATION',
|
||||||
minApprovals: 1,
|
minApprovals: 1,
|
||||||
approvalMode: 'ROLE_MANDATORY',
|
approvalMode: 'ROLE_MANDATORY',
|
||||||
requiredRoles: ['DD Admin', 'Super Admin'],
|
requiredRoles: ['DD Admin', 'Super Admin', 'DD Lead', 'DD Head'],
|
||||||
isActive: true
|
isActive: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
83
scripts/seed-auto-assignment-configs.ts
Normal file
83
scripts/seed-auto-assignment-configs.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Seed script for per-module auto-assignment configuration settings.
|
||||||
|
* Run: npx ts-node scripts/seed-auto-assignment-configs.ts
|
||||||
|
*/
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
const { SystemConfiguration } = db;
|
||||||
|
|
||||||
|
const AUTO_ASSIGNMENT_CONFIGS = [
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_ONBOARDING',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for Onboarding module (DD, Interviews, LOI, LOA stages)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RELOCATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for Relocation requests'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_TERMINATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for Termination requests'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RESIGNATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for Resignation requests'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_CONSTITUTIONAL',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for Constitutional Change requests'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_FNF',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'WORKFLOW_SETTINGS',
|
||||||
|
description: 'Enable/disable automatic participant assignment for F&F Settlement requests'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedAutoAssignmentConfigs() {
|
||||||
|
try {
|
||||||
|
console.log('[seed-auto-assignment] Starting...');
|
||||||
|
|
||||||
|
for (const config of AUTO_ASSIGNMENT_CONFIGS) {
|
||||||
|
const [record, created] = await SystemConfiguration.findOrCreate({
|
||||||
|
where: { key: config.key },
|
||||||
|
defaults: {
|
||||||
|
...config,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
console.log(`[seed-auto-assignment] Created: ${config.key}`);
|
||||||
|
} else {
|
||||||
|
// Ensure value structure is correct (merge with defaults)
|
||||||
|
const currentValue = record.value || {};
|
||||||
|
if (typeof currentValue.enabled !== 'boolean') {
|
||||||
|
await record.update({ value: { enabled: true } });
|
||||||
|
console.log(`[seed-auto-assignment] Updated value structure: ${config.key}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[seed-auto-assignment] Already exists: ${config.key} (enabled=${currentValue.enabled})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[seed-auto-assignment] Completed successfully.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[seed-auto-assignment] Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAutoAssignmentConfigs();
|
||||||
218
scripts/seed-interview-configs.ts
Normal file
218
scripts/seed-interview-configs.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Seed script to initialize interview configurations
|
||||||
|
* with the existing system-default questions.
|
||||||
|
*
|
||||||
|
* Run: npx tsx scripts/seed-interview-configs.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function seedInterviewConfigs() {
|
||||||
|
const { sequelize, InterviewConfig, InterviewConfigItem, InterviewConfigItemOption } = db as any;
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Database connected.');
|
||||||
|
|
||||||
|
// Ensure tables exist
|
||||||
|
await sequelize.sync({ alter: true });
|
||||||
|
console.log('Tables synced.');
|
||||||
|
|
||||||
|
// --- KT Matrix ---
|
||||||
|
const ktMatrixItems = [
|
||||||
|
{ itemKey: 'age', label: 'Age', weight: 5, maxScore: 10, order: 1, options: [
|
||||||
|
{ optionLabel: '20 to 40 years old', optionValue: '20-40', score: 10, order: 1 },
|
||||||
|
{ optionLabel: '40 to 50 years old', optionValue: '40-50', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Above 50 years old', optionValue: 'above-50', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'qualification', label: 'Qualification', weight: 5, maxScore: 10, order: 2, options: [
|
||||||
|
{ optionLabel: 'Post Graduate', optionValue: 'post-graduate', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Graduate', optionValue: 'graduate', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'SSLC', optionValue: 'sslc', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'localKnowledge', label: 'Local Knowledge and Influence', weight: 5, maxScore: 10, order: 3, options: [
|
||||||
|
{ optionLabel: 'Excellent PR', optionValue: 'excellent', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Good PR', optionValue: 'good', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Poor PR', optionValue: 'poor', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'baseLocation', label: 'Base Location vs Applied Location', weight: 10, maxScore: 10, order: 4, options: [
|
||||||
|
{ optionLabel: 'Native of the Applied location', optionValue: 'native', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Willing to relocate', optionValue: 'relocate', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Will manage remotely with occasional visits', optionValue: 'remote', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'whyInterested', label: 'Why Interested in Royal Enfield Business?', weight: 10, maxScore: 10, order: 5, options: [
|
||||||
|
{ optionLabel: 'Passion', optionValue: 'passion', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Business expansion / Status symbol', optionValue: 'business', score: 5, order: 2 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'passionRe', label: 'Passion for Royal Enfield', weight: 10, maxScore: 10, order: 6, options: [
|
||||||
|
{ optionLabel: 'Currently owns a Royal Enfield', optionValue: 'owns', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Owned by Immediate Relative', optionValue: 'relative', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Does not own Royal Enfield', optionValue: 'none', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'passionRides', label: 'Passion For Rides', weight: 10, maxScore: 10, order: 7, options: [
|
||||||
|
{ optionLabel: 'Goes for long rides regularly', optionValue: 'regular', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Goes for long rides rarely', optionValue: 'rarely', score: 5, order: 2 },
|
||||||
|
{ optionLabel: "Doesn't go for rides", optionValue: 'never', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'partnering', label: 'With Whom Partnering?', weight: 5, maxScore: 10, order: 8, options: [
|
||||||
|
{ optionLabel: 'Within family', optionValue: 'family', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Outside family', optionValue: 'outside', score: 0, order: 2 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'whoManages', label: 'Who Will Manage the Firm?', weight: 10, maxScore: 10, order: 9, options: [
|
||||||
|
{ optionLabel: 'Owner managed', optionValue: 'owner', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Partly owner / partly manager model', optionValue: 'partly', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Fully manager model', optionValue: 'manager', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'businessAcumen', label: 'Business Acumen', weight: 5, maxScore: 10, order: 10, options: [
|
||||||
|
{ optionLabel: 'Has similar automobile experience', optionValue: 'automobile', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Has successful business but not automobile', optionValue: 'other-business', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'No business experience', optionValue: 'no-experience', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'timeAvailability', label: 'Time Availability', weight: 5, maxScore: 10, order: 11, options: [
|
||||||
|
{ optionLabel: 'Full Time Availability for RE Business', optionValue: 'full-time', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Part Time Availability for RE Business', optionValue: 'part-time', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Not Available personally, Manager will handle', optionValue: 'manager', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'propertyOwnership', label: 'Property Ownership', weight: 5, maxScore: 10, order: 12, options: [
|
||||||
|
{ optionLabel: 'Has own property in proposed location', optionValue: 'own', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Will rent / lease', optionValue: 'rent', score: 0, order: 2 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'investment', label: 'Investment in the Business', weight: 5, maxScore: 10, order: 13, options: [
|
||||||
|
{ optionLabel: 'Full own funds', optionValue: 'own-funds', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Partially from the bank', optionValue: 'partial-bank', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'Completely bank funded', optionValue: 'full-bank', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'expandOtherOems', label: 'Will Expand to Other 2W/4W OEMs?', weight: 5, maxScore: 10, order: 14, options: [
|
||||||
|
{ optionLabel: 'No', optionValue: 'no', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Yes', optionValue: 'yes', score: 0, order: 2 },
|
||||||
|
]},
|
||||||
|
{ itemKey: 'expansionPlans', label: 'Plans of Expansion with RE', weight: 5, maxScore: 10, order: 15, options: [
|
||||||
|
{ optionLabel: 'Immediate blood relation will join & expand', optionValue: 'blood-relation', score: 10, order: 1 },
|
||||||
|
{ optionLabel: 'Wants to expand by himself into more clusters', optionValue: 'self-expand', score: 5, order: 2 },
|
||||||
|
{ optionLabel: 'No plans for expansion', optionValue: 'no-plans', score: 0, order: 3 },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Level 2 Feedback ---
|
||||||
|
const level2Items = [
|
||||||
|
{ itemKey: 'strategicVision', label: 'Strategic Vision', type: 'textarea' as const, isRequired: true, order: 1 },
|
||||||
|
{ itemKey: 'managementCapabilities', label: 'Management Capabilities', type: 'textarea' as const, isRequired: true, order: 2 },
|
||||||
|
{ itemKey: 'operationalUnderstanding', label: 'Operational Understanding', type: 'textarea' as const, isRequired: true, order: 3 },
|
||||||
|
{ itemKey: 'keyStrengths', label: 'Key Strengths', type: 'textarea' as const, isRequired: true, order: 4 },
|
||||||
|
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', type: 'textarea' as const, isRequired: true, order: 5 },
|
||||||
|
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea' as const, isRequired: false, order: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Level 3 Feedback ---
|
||||||
|
const level3Items = [
|
||||||
|
{ itemKey: 'strategicVision', label: 'Business Vision & Strategy', type: 'textarea' as const, isRequired: true, order: 1 },
|
||||||
|
{ itemKey: 'managementCapabilities', label: 'Leadership & Decision Making', type: 'textarea' as const, isRequired: true, order: 2 },
|
||||||
|
{ itemKey: 'operationalUnderstanding', label: 'Operational & Financial Readiness', type: 'textarea' as const, isRequired: true, order: 3 },
|
||||||
|
{ itemKey: 'brandAlignment', label: 'Brand Alignment', type: 'textarea' as const, isRequired: true, order: 4 },
|
||||||
|
{ itemKey: 'keyStrengths', label: 'Key Strengths', type: 'textarea' as const, isRequired: true, order: 5 },
|
||||||
|
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', type: 'textarea' as const, isRequired: true, order: 6 },
|
||||||
|
{ itemKey: 'executiveSummary', label: 'Executive Summary', type: 'textarea' as const, isRequired: true, order: 7 },
|
||||||
|
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea' as const, isRequired: false, order: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Deactivate any existing configs
|
||||||
|
await InterviewConfig.update({ isActive: false }, { where: {}, transaction });
|
||||||
|
|
||||||
|
// Create KT Matrix Config
|
||||||
|
const ktConfig = await InterviewConfig.create({
|
||||||
|
configType: 'KT_MATRIX',
|
||||||
|
name: 'System Default KT Matrix',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
for (const item of ktMatrixItems) {
|
||||||
|
const createdItem = await InterviewConfigItem.create({
|
||||||
|
configId: ktConfig.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: 'select',
|
||||||
|
order: item.order,
|
||||||
|
isRequired: true,
|
||||||
|
weight: item.weight,
|
||||||
|
maxScore: item.maxScore,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
if (item.options) {
|
||||||
|
await InterviewConfigItemOption.bulkCreate(
|
||||||
|
item.options.map((o: any) => ({
|
||||||
|
itemId: createdItem.id,
|
||||||
|
optionLabel: o.optionLabel,
|
||||||
|
optionValue: o.optionValue,
|
||||||
|
score: o.score,
|
||||||
|
order: o.order,
|
||||||
|
})),
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Level 2 Config
|
||||||
|
const l2Config = await InterviewConfig.create({
|
||||||
|
configType: 'LEVEL2_FEEDBACK',
|
||||||
|
name: 'System Default Level 2 Feedback',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await InterviewConfigItem.bulkCreate(
|
||||||
|
level2Items.map((item) => ({
|
||||||
|
configId: l2Config.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type,
|
||||||
|
order: item.order,
|
||||||
|
isRequired: item.isRequired,
|
||||||
|
weight: null,
|
||||||
|
maxScore: null,
|
||||||
|
})),
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Level 3 Config
|
||||||
|
const l3Config = await InterviewConfig.create({
|
||||||
|
configType: 'LEVEL3_FEEDBACK',
|
||||||
|
name: 'System Default Level 3 Feedback',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true,
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await InterviewConfigItem.bulkCreate(
|
||||||
|
level3Items.map((item) => ({
|
||||||
|
configId: l3Config.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type,
|
||||||
|
order: item.order,
|
||||||
|
isRequired: item.isRequired,
|
||||||
|
weight: null,
|
||||||
|
maxScore: null,
|
||||||
|
})),
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
console.log('Interview configurations seeded successfully.');
|
||||||
|
console.log(` - KT Matrix: ${ktMatrixItems.length} criteria`);
|
||||||
|
console.log(` - Level 2 Feedback: ${level2Items.length} fields`);
|
||||||
|
console.log(` - Level 3 Feedback: ${level3Items.length} fields`);
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
console.error('Seeding failed:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await sequelize.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedInterviewConfigs().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -22,6 +22,42 @@ const seedSystemConfigs = async () => {
|
|||||||
value: { amount: 1500000, currency: 'INR' },
|
value: { amount: 1500000, currency: 'INR' },
|
||||||
category: 'SECURITY_DEPOSIT',
|
category: 'SECURITY_DEPOSIT',
|
||||||
description: 'Default First Fill amount for new dealer onboarding'
|
description: 'Default First Fill amount for new dealer onboarding'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_ONBOARDING',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Onboarding module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RELOCATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Relocation module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_TERMINATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Termination module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_CONSTITUTIONAL',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Constitutional module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RESIGNATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Resignation module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_FNF',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for F&F module'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
46
src/common/middleware/checkRevocation.ts
Normal file
46
src/common/middleware/checkRevocation.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import db from '../../database/models/index.js';
|
||||||
|
const { RequestParticipant } = db;
|
||||||
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to reject requests from participants who have been revoked.
|
||||||
|
* This should be placed AFTER the authenticate middleware.
|
||||||
|
*/
|
||||||
|
export const checkRevocation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return next();
|
||||||
|
|
||||||
|
// Try to identify requestId and requestType from various sources
|
||||||
|
const requestId = req.params.requestId || req.body.requestId || req.query.requestId;
|
||||||
|
const requestType = req.params.requestType || req.body.requestType || req.query.requestType;
|
||||||
|
|
||||||
|
// If we can't identify the request context, we can't check revocation here
|
||||||
|
if (!requestId) return next();
|
||||||
|
|
||||||
|
const participant = await RequestParticipant.findOne({
|
||||||
|
where: {
|
||||||
|
requestId,
|
||||||
|
userId,
|
||||||
|
// If requestType is provided, include it in filter
|
||||||
|
...(requestType && { requestType })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user is a participant and has a revokedAt timestamp, block them
|
||||||
|
if (participant && participant.metadata && participant.metadata.revokedAt) {
|
||||||
|
console.warn(`[Revocation] User ${userId} blocked from ${req.method} ${req.path} for request ${requestId}`);
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Your access to this request has been revoked. You can no longer perform any actions or post messages.',
|
||||||
|
revoked: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Revocation check middleware error:', error);
|
||||||
|
next(); // Fail open for safety, but log error
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized utility for ID generation and nomenclature across all modules.
|
* Centralized utility for ID generation and nomenclature across all modules.
|
||||||
@ -7,46 +7,89 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
*/
|
*/
|
||||||
export class NomenclatureService {
|
export class NomenclatureService {
|
||||||
/**
|
/**
|
||||||
* Generates a Resignation ID (e.g., RES-2026-1234)
|
* Generic helper for generating sequential IDs with Month/Year prefix
|
||||||
*/
|
*/
|
||||||
static generateResignationId() {
|
private static async generateSequentialId(modelName: string, fieldName: string, modulePrefix: string) {
|
||||||
return `RES-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const monthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||||
|
const month = monthNames[now.getMonth()];
|
||||||
|
const prefix = `${modulePrefix}-${year}-${month}-`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Model = (db as any)[modelName];
|
||||||
|
if (!Model) {
|
||||||
|
throw new Error(`Model ${modelName} not found in database indexed models.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last ID with this prefix to increment
|
||||||
|
const lastRecord = await Model.findOne({
|
||||||
|
where: {
|
||||||
|
[fieldName]: {
|
||||||
|
[Op.like]: `${prefix}%`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
attributes: [fieldName]
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextNumber = 1;
|
||||||
|
if (lastRecord && lastRecord[fieldName]) {
|
||||||
|
const parts = lastRecord[fieldName].split('-');
|
||||||
|
const lastNumStr = parts[parts.length - 1];
|
||||||
|
const lastNum = parseInt(lastNumStr, 10);
|
||||||
|
if (!isNaN(lastNum)) {
|
||||||
|
nextNumber = lastNum + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${nextNumber.toString().padStart(5, '0')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating sequential ID for ${modulePrefix}:`, error);
|
||||||
|
// Fallback to random if DB fetch fails
|
||||||
|
return `${prefix}${Math.floor(10000 + Math.random() * 90000)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a Termination Request ID (e.g., TRM-2026-1234)
|
* Generates an Onboarding Application ID (e.g., DD-2026-FEB-00001)
|
||||||
*/
|
*/
|
||||||
static generateTerminationId() {
|
static async generateApplicationId() {
|
||||||
return `TRM-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
return this.generateSequentialId('Application', 'applicationId', 'DD');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a Constitutional Change ID (e.g., CC-2026-1234)
|
* Generates a Resignation ID (e.g., RES-2026-FEB-00001)
|
||||||
*/
|
*/
|
||||||
static generateConstitutionalChangeId() {
|
static async generateResignationId() {
|
||||||
return `CC-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
return this.generateSequentialId('Resignation', 'resignationId', 'RES');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an Onboarding Application ID (e.g., APP-2026-5678)
|
* Generates a Termination Request ID (e.g., TER-2026-FEB-00001)
|
||||||
*/
|
*/
|
||||||
static generateApplicationId() {
|
static async generateTerminationId() {
|
||||||
return `APP-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
return this.generateSequentialId('TerminationRequest', 'requestId', 'TER');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a Settlement/FnF ID (e.g., FNF-2025-001)
|
* Generates a Constitutional Change ID (e.g., CC-2026-FEB-00001)
|
||||||
*/
|
*/
|
||||||
static generateFnFId() {
|
static async generateConstitutionalChangeId() {
|
||||||
const year = new Date().getFullYear();
|
return this.generateSequentialId('ConstitutionalChange', 'requestId', 'CC');
|
||||||
const rand = Math.floor(1 + Math.random() * 999);
|
|
||||||
return `FNF-${year}-${rand.toString().padStart(3, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a Relocation Request ID (e.g., REL-2026-1234)
|
* Generates a Relocation Request ID (e.g., REL-2026-FEB-00001)
|
||||||
*/
|
*/
|
||||||
static generateRelocationId() {
|
static async generateRelocationId() {
|
||||||
return `REL-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
return this.generateSequentialId('RelocationRequest', 'requestId', 'REL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Settlement/FnF ID (e.g., FNF-2026-FEB-00001)
|
||||||
|
*/
|
||||||
|
static async generateFnFId() {
|
||||||
|
return this.generateSequentialId('FnF', 'settlementId', 'FNF');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export interface DistrictAttributes {
|
|||||||
zmId?: string | null;
|
zmId?: string | null;
|
||||||
zmCode?: string | null;
|
zmCode?: string | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
isActive?: boolean;
|
isOpportunity?: boolean;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +91,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
isActive: {
|
isOpportunity: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true
|
defaultValue: true
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export interface LocationAttributes {
|
|||||||
name: string;
|
name: string;
|
||||||
districtId: string | null;
|
districtId: string | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
isActive?: boolean;
|
isOpportunity?: boolean;
|
||||||
openFrom?: Date | null;
|
openFrom?: Date | null;
|
||||||
openTo?: Date | null;
|
openTo?: Date | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@ -36,7 +36,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
isActive: {
|
isOpportunity: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true
|
defaultValue: true
|
||||||
},
|
},
|
||||||
|
|||||||
@ -44,6 +44,9 @@ import createQuestionnaireQuestion from './verification/QuestionnaireQuestion.js
|
|||||||
import createQuestionnaireResponse from './verification/QuestionnaireResponse.js';
|
import createQuestionnaireResponse from './verification/QuestionnaireResponse.js';
|
||||||
import createQuestionnaireScore from './verification/QuestionnaireScore.js';
|
import createQuestionnaireScore from './verification/QuestionnaireScore.js';
|
||||||
import createKTMatrixScore from './verification/KTMatrixScore.js';
|
import createKTMatrixScore from './verification/KTMatrixScore.js';
|
||||||
|
import createInterviewConfig from './verification/InterviewConfig.js';
|
||||||
|
import createInterviewConfigItem from './verification/InterviewConfigItem.js';
|
||||||
|
import createInterviewConfigItemOption from './verification/InterviewConfigItemOption.js';
|
||||||
import createAiSummary from './verification/AiSummary.js';
|
import createAiSummary from './verification/AiSummary.js';
|
||||||
import createFddAssignment from './verification/FddAssignment.js';
|
import createFddAssignment from './verification/FddAssignment.js';
|
||||||
import createFddReport from './verification/FddReport.js';
|
import createFddReport from './verification/FddReport.js';
|
||||||
@ -182,6 +185,9 @@ db.InterviewParticipant = createInterviewParticipant(sequelize);
|
|||||||
db.InterviewEvaluation = createInterviewEvaluation(sequelize);
|
db.InterviewEvaluation = createInterviewEvaluation(sequelize);
|
||||||
db.KTMatrixScore = createKTMatrixScore(sequelize);
|
db.KTMatrixScore = createKTMatrixScore(sequelize);
|
||||||
db.InterviewFeedback = createInterviewFeedback(sequelize);
|
db.InterviewFeedback = createInterviewFeedback(sequelize);
|
||||||
|
db.InterviewConfig = createInterviewConfig(sequelize);
|
||||||
|
db.InterviewConfigItem = createInterviewConfigItem(sequelize);
|
||||||
|
db.InterviewConfigItemOption = createInterviewConfigItemOption(sequelize);
|
||||||
db.AiSummary = createAiSummary(sequelize);
|
db.AiSummary = createAiSummary(sequelize);
|
||||||
|
|
||||||
// Batch 4: Dealer Entity, Documents & Work Notes
|
// Batch 4: Dealer Entity, Documents & Work Notes
|
||||||
|
|||||||
49
src/database/models/verification/InterviewConfig.ts
Normal file
49
src/database/models/verification/InterviewConfig.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
export interface InterviewConfigAttributes {
|
||||||
|
id: string;
|
||||||
|
configType: 'KT_MATRIX' | 'LEVEL2_FEEDBACK' | 'LEVEL3_FEEDBACK';
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewConfigInstance extends Model<InterviewConfigAttributes>, InterviewConfigAttributes { }
|
||||||
|
|
||||||
|
export default (sequelize: Sequelize) => {
|
||||||
|
const InterviewConfig = sequelize.define<InterviewConfigInstance>('InterviewConfig', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
configType: {
|
||||||
|
type: DataTypes.ENUM('KT_MATRIX', 'LEVEL2_FEEDBACK', 'LEVEL3_FEEDBACK'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'interview_configs',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['configType', 'isActive'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
(InterviewConfig as any).associate = (models: any) => {
|
||||||
|
InterviewConfig.hasMany(models.InterviewConfigItem, { foreignKey: 'configId', as: 'items' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return InterviewConfig;
|
||||||
|
};
|
||||||
73
src/database/models/verification/InterviewConfigItem.ts
Normal file
73
src/database/models/verification/InterviewConfigItem.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
export interface InterviewConfigItemAttributes {
|
||||||
|
id: string;
|
||||||
|
configId: string;
|
||||||
|
itemKey: string;
|
||||||
|
label: string;
|
||||||
|
type: 'select' | 'text' | 'textarea' | 'number';
|
||||||
|
order: number;
|
||||||
|
isRequired: boolean;
|
||||||
|
weight: number | null;
|
||||||
|
maxScore: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewConfigItemInstance extends Model<InterviewConfigItemAttributes>, InterviewConfigItemAttributes { }
|
||||||
|
|
||||||
|
export default (sequelize: Sequelize) => {
|
||||||
|
const InterviewConfigItem = sequelize.define<InterviewConfigItemInstance>('InterviewConfigItem', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
configId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'interview_configs',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemKey: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.ENUM('select', 'text', 'textarea', 'number'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'text'
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
isRequired: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
maxScore: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'interview_config_items',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
(InterviewConfigItem as any).associate = (models: any) => {
|
||||||
|
InterviewConfigItem.belongsTo(models.InterviewConfig, { foreignKey: 'configId', as: 'config' });
|
||||||
|
InterviewConfigItem.hasMany(models.InterviewConfigItemOption, { foreignKey: 'itemId', as: 'options' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return InterviewConfigItem;
|
||||||
|
};
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
export interface InterviewConfigItemOptionAttributes {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
optionLabel: string;
|
||||||
|
optionValue: string;
|
||||||
|
score: number;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewConfigItemOptionInstance extends Model<InterviewConfigItemOptionAttributes>, InterviewConfigItemOptionAttributes { }
|
||||||
|
|
||||||
|
export default (sequelize: Sequelize) => {
|
||||||
|
const InterviewConfigItemOption = sequelize.define<InterviewConfigItemOptionInstance>('InterviewConfigItemOption', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
itemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'interview_config_items',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
optionValue: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'interview_config_item_options',
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
(InterviewConfigItemOption as any).associate = (models: any) => {
|
||||||
|
InterviewConfigItemOption.belongsTo(models.InterviewConfigItem, { foreignKey: 'itemId', as: 'item' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return InterviewConfigItemOption;
|
||||||
|
};
|
||||||
@ -308,7 +308,16 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
return userJson;
|
return userJson;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: result, total: count });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / Number(limit)),
|
||||||
|
currentPage: Number(page),
|
||||||
|
limit: Number(limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get users error:', error);
|
console.error('Get users error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching users' });
|
res.status(500).json({ success: false, message: 'Error fetching users' });
|
||||||
|
|||||||
@ -5,7 +5,7 @@ const {
|
|||||||
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
|
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
|
||||||
} = db;
|
} = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
import { AUDIT_ACTIONS, REQUEST_TYPES, ROLES } from '../../common/config/constants.js';
|
||||||
import {
|
import {
|
||||||
resolveEntityUuidByType,
|
resolveEntityUuidByType,
|
||||||
requestTypeQueryVariants,
|
requestTypeQueryVariants,
|
||||||
@ -482,22 +482,44 @@ export const addParticipant = async (req: AuthRequest, res: Response) => {
|
|||||||
export const removeParticipant = async (req: AuthRequest, res: Response) => {
|
export const removeParticipant = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const participant = await RequestParticipant.findByPk(id);
|
const { reason } = req.body || {}; // Safe destructuring
|
||||||
await RequestParticipant.destroy({ where: { id } });
|
|
||||||
|
|
||||||
// Audit log for participant removed
|
const participant = await RequestParticipant.findByPk(id);
|
||||||
if (participant) {
|
if (!participant) {
|
||||||
await AuditLog.create({
|
return res.status(404).json({ success: false, message: 'Participant not found' });
|
||||||
userId: req.user?.id,
|
|
||||||
action: AUDIT_ACTIONS.PARTICIPANT_REMOVED,
|
|
||||||
entityType: (participant as any).requestType || 'application',
|
|
||||||
entityId: (participant as any).requestId,
|
|
||||||
newData: { removedUserId: (participant as any).userId }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: 'Participant removed' });
|
// ROLE SECURITY: DD Lead, DD Head, NBH, DD Admin, Super Admin
|
||||||
|
const authorizedRoles: string[] = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN];
|
||||||
|
if (!authorizedRoles.includes(req.user?.roleCode || '')) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Unauthorized: Only designated roles can revoke participants.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOFT DELETE: Update metadata instead of destroying record
|
||||||
|
const metadata = {
|
||||||
|
...(participant.metadata || {}),
|
||||||
|
revokedAt: new Date(),
|
||||||
|
revokedBy: req.user?.id,
|
||||||
|
revocationReason: reason || 'Manual revocation'
|
||||||
|
};
|
||||||
|
|
||||||
|
await participant.update({ metadata });
|
||||||
|
|
||||||
|
// Audit log for participant revoked
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id,
|
||||||
|
action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, // Using existing constant
|
||||||
|
entityType: (participant as any).requestType || 'application',
|
||||||
|
entityId: (participant as any).requestId,
|
||||||
|
newData: {
|
||||||
|
revokedUserId: (participant as any).userId,
|
||||||
|
revocationReason: reason || 'Manual revocation'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Participant access revoked successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error removing participant' });
|
console.error('Revoke participant error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error revoking participant' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,14 +2,15 @@ import express from 'express';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import * as collaborationController from './collaboration.controller.js';
|
import * as collaborationController from './collaboration.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
|
||||||
import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js';
|
import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js';
|
||||||
|
|
||||||
router.use(authenticate as any);
|
router.use(authenticate as any);
|
||||||
|
|
||||||
// Worknotes
|
// Worknotes
|
||||||
router.get('/worknotes', collaborationController.getWorknotes);
|
router.get('/worknotes', collaborationController.getWorknotes);
|
||||||
router.post('/worknotes', collaborationController.addWorknote);
|
router.post('/worknotes', checkRevocation, collaborationController.addWorknote);
|
||||||
router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
|
router.post('/upload', checkRevocation, uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
|
||||||
|
|
||||||
// Participants
|
// Participants
|
||||||
router.post('/participants', collaborationController.addParticipant);
|
router.post('/participants', collaborationController.addParticipant);
|
||||||
|
|||||||
407
src/modules/master/interviewConfig.controller.ts
Normal file
407
src/modules/master/interviewConfig.controller.ts
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import db from '../../database/models/index.js';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
const { InterviewConfig, InterviewConfigItem, InterviewConfigItemOption } = db;
|
||||||
|
|
||||||
|
// --- GET active config by type ---
|
||||||
|
export const getInterviewConfigByType = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { configType } = req.params;
|
||||||
|
const config = await InterviewConfig.findOne({
|
||||||
|
where: { configType, isActive: true },
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItem,
|
||||||
|
as: 'items',
|
||||||
|
order: [['order', 'ASC']],
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItemOption,
|
||||||
|
as: 'options',
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(404).json({ success: false, message: `No active config found for ${configType}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get interview config error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching interview configuration' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- GET all configs (optionally filtered by type) ---
|
||||||
|
export const getInterviewConfigs = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { configType } = req.query;
|
||||||
|
const where: any = {};
|
||||||
|
if (configType) where.configType = configType;
|
||||||
|
|
||||||
|
const configs = await InterviewConfig.findAll({
|
||||||
|
where,
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
attributes: ['id', 'configType', 'name', 'version', 'isActive', 'createdAt']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: configs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get interview configs error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching interview configurations' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- GET config by ID (with full items/options) ---
|
||||||
|
export const getInterviewConfigById = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const config = await InterviewConfig.findByPk(id, {
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItem,
|
||||||
|
as: 'items',
|
||||||
|
order: [['order', 'ASC']],
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItemOption,
|
||||||
|
as: 'options',
|
||||||
|
order: [['order', 'ASC']]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Configuration not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get interview config by id error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching configuration' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- CREATE / PUBLISH new config version ---
|
||||||
|
export const createInterviewConfig = async (req: Request, res: Response) => {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { configType, name, version, items } = req.body;
|
||||||
|
|
||||||
|
// Deactivate old versions for this configType
|
||||||
|
await InterviewConfig.update({ isActive: false }, { where: { configType }, transaction });
|
||||||
|
|
||||||
|
const config = await InterviewConfig.create({
|
||||||
|
configType,
|
||||||
|
name: name || `${configType} Config`,
|
||||||
|
version: version || 'v1.0',
|
||||||
|
isActive: true
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
if (items && Array.isArray(items) && items.length > 0) {
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const createdItem = await InterviewConfigItem.create({
|
||||||
|
configId: config.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type || 'text',
|
||||||
|
order: item.order || index + 1,
|
||||||
|
isRequired: item.isRequired !== false,
|
||||||
|
weight: item.weight || null,
|
||||||
|
maxScore: item.maxScore || null
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
if (item.options && Array.isArray(item.options) && item.options.length > 0) {
|
||||||
|
const optionRecords = item.options.map((opt: any, idx: number) => ({
|
||||||
|
itemId: createdItem.id,
|
||||||
|
optionLabel: opt.optionLabel || opt.label,
|
||||||
|
optionValue: opt.optionValue || opt.value,
|
||||||
|
score: opt.score || 0,
|
||||||
|
order: opt.order || idx + 1
|
||||||
|
}));
|
||||||
|
await InterviewConfigItemOption.bulkCreate(optionRecords, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
const fullConfig = await InterviewConfig.findByPk(config.id, {
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [{ model: InterviewConfigItemOption, as: 'options' }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: fullConfig });
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
console.error('Create interview config error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error creating interview configuration' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- UPDATE config (only non-active versions, or deactivate + recreate) ---
|
||||||
|
export const updateInterviewConfig = async (req: Request, res: Response) => {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, version, isActive, items } = req.body;
|
||||||
|
|
||||||
|
const config = await InterviewConfig.findByPk(id, { transaction });
|
||||||
|
if (!config) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(404).json({ success: false, message: 'Configuration not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.update({ name, version, isActive }, { transaction });
|
||||||
|
|
||||||
|
// If items are provided, replace them
|
||||||
|
if (items && Array.isArray(items)) {
|
||||||
|
// Delete old items (cascade will handle options if DB has onDelete CASCADE)
|
||||||
|
await InterviewConfigItem.destroy({ where: { configId: id }, transaction });
|
||||||
|
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const createdItem = await InterviewConfigItem.create({
|
||||||
|
configId: id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type || 'text',
|
||||||
|
order: item.order || index + 1,
|
||||||
|
isRequired: item.isRequired !== false,
|
||||||
|
weight: item.weight || null,
|
||||||
|
maxScore: item.maxScore || null
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
if (item.options && Array.isArray(item.options) && item.options.length > 0) {
|
||||||
|
const optionRecords = item.options.map((opt: any, idx: number) => ({
|
||||||
|
itemId: createdItem.id,
|
||||||
|
optionLabel: opt.optionLabel || opt.label,
|
||||||
|
optionValue: opt.optionValue || opt.value,
|
||||||
|
score: opt.score || 0,
|
||||||
|
order: opt.order || idx + 1
|
||||||
|
}));
|
||||||
|
await InterviewConfigItemOption.bulkCreate(optionRecords, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
const fullConfig = await InterviewConfig.findByPk(id, {
|
||||||
|
include: [{
|
||||||
|
model: InterviewConfigItem,
|
||||||
|
as: 'items',
|
||||||
|
include: [{ model: InterviewConfigItemOption, as: 'options' }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: fullConfig });
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
console.error('Update interview config error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error updating interview configuration' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DELETE config ---
|
||||||
|
export const deleteInterviewConfig = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const config = await InterviewConfig.findByPk(id);
|
||||||
|
if (!config) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Configuration not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.destroy();
|
||||||
|
res.json({ success: true, message: 'Configuration deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete interview config error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error deleting configuration' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Seed / Initialize with defaults ---
|
||||||
|
export const initializeDefaultInterviewConfigs = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
// Helper to deactivate existing
|
||||||
|
const deactivate = async (configType: string) => {
|
||||||
|
await InterviewConfig.update({ isActive: false }, { where: { configType }, transaction });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- KT Matrix Defaults ---
|
||||||
|
await deactivate('KT_MATRIX');
|
||||||
|
const ktConfig = await InterviewConfig.create({
|
||||||
|
configType: 'KT_MATRIX',
|
||||||
|
name: 'Default KT Matrix',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
const ktDefaults = [
|
||||||
|
{ itemKey: 'age', label: 'Age', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: '20 to 40 years old', optionValue: '20-40', score: 10 },
|
||||||
|
{ optionLabel: '40 to 50 years old', optionValue: '40-50', score: 5 },
|
||||||
|
{ optionLabel: 'Above 50 years old', optionValue: 'above-50', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'qualification', label: 'Qualification', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Post Graduate', optionValue: 'post-graduate', score: 10 },
|
||||||
|
{ optionLabel: 'Graduate', optionValue: 'graduate', score: 5 },
|
||||||
|
{ optionLabel: 'SSLC', optionValue: 'sslc', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'local_knowledge', label: 'Local Knowledge and Influence', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Excellent PR', optionValue: 'excellent', score: 10 },
|
||||||
|
{ optionLabel: 'Good PR', optionValue: 'good', score: 5 },
|
||||||
|
{ optionLabel: 'Poor PR', optionValue: 'poor', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'base_location', label: 'Base Location vs Applied Location', type: 'select', weight: 10, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Native of the Applied location', optionValue: 'native', score: 10 },
|
||||||
|
{ optionLabel: 'Willing to relocate', optionValue: 'relocate', score: 5 },
|
||||||
|
{ optionLabel: 'Will manage remotely with occasional visits', optionValue: 'remote', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'interest_reason', label: 'Why Interested in Royal Enfield Business?', type: 'select', weight: 10, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Passion', optionValue: 'passion', score: 10 },
|
||||||
|
{ optionLabel: 'Business expansion / Status symbol', optionValue: 'business', score: 5 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'passion_re', label: 'Passion for Royal Enfield', type: 'select', weight: 10, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Currently owns a Royal Enfield', optionValue: 'owns', score: 10 },
|
||||||
|
{ optionLabel: 'Owned by Immediate Relative', optionValue: 'relative', score: 5 },
|
||||||
|
{ optionLabel: 'Does not own Royal Enfield', optionValue: 'none', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'passion_rides', label: 'Passion For Rides', type: 'select', weight: 10, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Goes for long rides regularly', optionValue: 'regular', score: 10 },
|
||||||
|
{ optionLabel: 'Goes for long rides rarely', optionValue: 'rarely', score: 5 },
|
||||||
|
{ optionLabel: "Doesn't go for rides", optionValue: 'never', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'partnering', label: 'With Whom Partnering?', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Within family', optionValue: 'family', score: 10 },
|
||||||
|
{ optionLabel: 'Outside family', optionValue: 'outside', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'who_manages', label: 'Who Will Manage the Firm?', type: 'select', weight: 10, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Owner managed', optionValue: 'owner', score: 10 },
|
||||||
|
{ optionLabel: 'Partly owner / partly manager model', optionValue: 'partly', score: 5 },
|
||||||
|
{ optionLabel: 'Fully manager model', optionValue: 'manager', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'business_acumen', label: 'Business Acumen', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Has similar automobile experience', optionValue: 'automobile', score: 10 },
|
||||||
|
{ optionLabel: 'Has successful business but not automobile', optionValue: 'other-business', score: 5 },
|
||||||
|
{ optionLabel: 'No business experience', optionValue: 'no-experience', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'time_availability', label: 'Time Availability', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Full Time Availability for RE Business', optionValue: 'full-time', score: 10 },
|
||||||
|
{ optionLabel: 'Part Time Availability for RE Business', optionValue: 'part-time', score: 5 },
|
||||||
|
{ optionLabel: 'Not Available personally, Manager will handle', optionValue: 'manager', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'property_ownership', label: 'Property Ownership', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Has own property in proposed location', optionValue: 'own', score: 10 },
|
||||||
|
{ optionLabel: 'Will rent / lease', optionValue: 'rent', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'investment', label: 'Investment in the Business', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Full own funds', optionValue: 'own-funds', score: 10 },
|
||||||
|
{ optionLabel: 'Partially from the bank', optionValue: 'partial-bank', score: 5 },
|
||||||
|
{ optionLabel: 'Completely bank funded', optionValue: 'full-bank', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'expand_oems', label: 'Will Expand to Other 2W/4W OEMs?', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'No', optionValue: 'no', score: 10 },
|
||||||
|
{ optionLabel: 'Yes', optionValue: 'yes', score: 0 }
|
||||||
|
]},
|
||||||
|
{ itemKey: 'expansion_plans', label: 'Plans of Expansion with RE', type: 'select', weight: 5, maxScore: 10, options: [
|
||||||
|
{ optionLabel: 'Immediate blood relation will join & expand', optionValue: 'blood-relation', score: 10 },
|
||||||
|
{ optionLabel: 'Wants to expand by himself into more clusters', optionValue: 'self-expand', score: 5 },
|
||||||
|
{ optionLabel: 'No plans for expansion', optionValue: 'no-plans', score: 0 }
|
||||||
|
]}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, item] of ktDefaults.entries()) {
|
||||||
|
const createdItem = await InterviewConfigItem.create({
|
||||||
|
configId: ktConfig.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type,
|
||||||
|
order: index + 1,
|
||||||
|
isRequired: true,
|
||||||
|
weight: item.weight,
|
||||||
|
maxScore: item.maxScore
|
||||||
|
}, { transaction });
|
||||||
|
if (item.options) {
|
||||||
|
await InterviewConfigItemOption.bulkCreate(
|
||||||
|
item.options.map((o: any, idx: number) => ({ ...o, itemId: createdItem.id, order: idx + 1 })),
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Level 2 Feedback Defaults ---
|
||||||
|
await deactivate('LEVEL2_FEEDBACK');
|
||||||
|
const l2Config = await InterviewConfig.create({
|
||||||
|
configType: 'LEVEL2_FEEDBACK',
|
||||||
|
name: 'Default Level 2 Feedback',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
const l2Defaults = [
|
||||||
|
{ itemKey: 'strategicVision', label: 'Strategic Vision', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'managementCapabilities', label: 'Management Capabilities', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'operationalUnderstanding', label: 'Operational Understanding', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'keyStrengths', label: 'Key Strengths', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea', isRequired: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, item] of l2Defaults.entries()) {
|
||||||
|
await InterviewConfigItem.create({
|
||||||
|
configId: l2Config.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type,
|
||||||
|
order: index + 1,
|
||||||
|
isRequired: item.isRequired,
|
||||||
|
weight: null,
|
||||||
|
maxScore: null
|
||||||
|
}, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Level 3 Feedback Defaults ---
|
||||||
|
await deactivate('LEVEL3_FEEDBACK');
|
||||||
|
const l3Config = await InterviewConfig.create({
|
||||||
|
configType: 'LEVEL3_FEEDBACK',
|
||||||
|
name: 'Default Level 3 Feedback',
|
||||||
|
version: 'v1.0',
|
||||||
|
isActive: true
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
const l3Defaults = [
|
||||||
|
{ itemKey: 'strategicVision', label: 'Business Vision & Strategy', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'managementCapabilities', label: 'Leadership & Decision Making', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'operationalUnderstanding', label: 'Operational & Financial Readiness', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'brandAlignment', label: 'Brand Alignment', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'keyStrengths', label: 'Key Strengths', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', type: 'textarea', isRequired: true },
|
||||||
|
{ itemKey: 'executiveSummary', label: 'Executive Summary', type: 'textarea', isRequired: false },
|
||||||
|
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea', isRequired: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, item] of l3Defaults.entries()) {
|
||||||
|
await InterviewConfigItem.create({
|
||||||
|
configId: l3Config.id,
|
||||||
|
itemKey: item.itemKey,
|
||||||
|
label: item.label,
|
||||||
|
type: item.type,
|
||||||
|
order: index + 1,
|
||||||
|
isRequired: item.isRequired,
|
||||||
|
weight: null,
|
||||||
|
maxScore: null
|
||||||
|
}, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json({ success: true, message: 'Default interview configurations initialized successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Initialize defaults error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error initializing default configurations' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -81,12 +81,15 @@ export const getAreas = async (req: Request, res: Response) => {
|
|||||||
const stateId = req.query.stateId as string;
|
const stateId = req.query.stateId as string;
|
||||||
const zoneId = req.query.zoneId as string;
|
const zoneId = req.query.zoneId as string;
|
||||||
const regionId = req.query.regionId as string;
|
const regionId = req.query.regionId as string;
|
||||||
const isActive = req.query.isActive as string;
|
const isOpportunity = req.query.isOpportunity as string || req.query.isActive as string;
|
||||||
|
|
||||||
if (stateId) where['$district.stateId$'] = stateId;
|
if (stateId && stateId !== 'all') where['$district.stateId$'] = stateId;
|
||||||
if (zoneId) where['$district.zoneId$'] = zoneId;
|
if (zoneId && zoneId !== 'all') where['$district.zoneId$'] = zoneId;
|
||||||
if (regionId) where['$district.regionId$'] = regionId;
|
if (regionId && regionId !== 'all') where['$district.regionId$'] = regionId;
|
||||||
if (isActive !== undefined) where.isActive = isActive === 'true';
|
|
||||||
|
if (isOpportunity === 'true' || isOpportunity === 'false') {
|
||||||
|
where.isOpportunity = isOpportunity === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
const { count, rows: areas } = await db.Location.findAndCountAll({
|
const { count, rows: areas } = await db.Location.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
@ -138,21 +141,18 @@ export const getAreas = async (req: Request, res: Response) => {
|
|||||||
// --- Districts (Territory Entities) ---
|
// --- Districts (Territory Entities) ---
|
||||||
export const getDistricts = async (req: Request, res: Response) => {
|
export const getDistricts = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
let search = req.query.search as string;
|
const { search, page = 1, limit = 10, stateId, zoneId, regionId, isActive, isOpportunity } = req.query as any;
|
||||||
let limit = (req.query.limit || 10) as any;
|
|
||||||
const stateId = req.query.stateId as string;
|
|
||||||
const zoneId = req.query.zoneId as string;
|
|
||||||
const regionId = req.query.regionId as string;
|
|
||||||
|
|
||||||
const isAll = limit === 'all' || limit === -1 || limit === '-1';
|
const isAll = limit === 'all' || limit === -1 || limit === '-1';
|
||||||
|
const offset = isAll ? null : (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (search) {
|
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive;
|
||||||
where.name = { [Op.iLike]: `%${search}%` };
|
|
||||||
}
|
if (search) where.name = { [Op.iLike]: `%${search}%` };
|
||||||
if (stateId) where.stateId = stateId;
|
if (stateId) where.stateId = stateId;
|
||||||
if (zoneId) where.zoneId = zoneId;
|
if (zoneId) where.zoneId = zoneId;
|
||||||
if (regionId) where.regionId = regionId;
|
if (regionId) where.regionId = regionId;
|
||||||
|
if (finalIsOpportunity !== undefined) where.isOpportunity = finalIsOpportunity === 'true';
|
||||||
|
|
||||||
const { count, rows: districts } = await db.District.findAndCountAll({
|
const { count, rows: districts } = await db.District.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
@ -184,10 +184,10 @@ export const getDistricts = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const createDistrict = async (req: Request, res: Response) => {
|
export const createDistrict = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body;
|
const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body;
|
||||||
|
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : (isActive !== undefined ? isActive : true);
|
||||||
|
|
||||||
// Preferred path: create location against an existing district
|
// Preferred path: create location against an existing district
|
||||||
if (districtId) {
|
if (districtId) {
|
||||||
@ -201,7 +201,7 @@ export const createDistrict = async (req: Request, res: Response) => {
|
|||||||
name: areaName,
|
name: areaName,
|
||||||
districtId: district.id,
|
districtId: district.id,
|
||||||
city: city || areaName,
|
city: city || areaName,
|
||||||
isActive: isActive !== undefined ? isActive : true,
|
isOpportunity: finalIsOpportunity,
|
||||||
openFrom: openFrom || null,
|
openFrom: openFrom || null,
|
||||||
openTo: openTo || null,
|
openTo: openTo || null,
|
||||||
description: description || null
|
description: description || null
|
||||||
@ -238,14 +238,14 @@ export const createDistrict = async (req: Request, res: Response) => {
|
|||||||
name,
|
name,
|
||||||
code,
|
code,
|
||||||
stateId,
|
stateId,
|
||||||
isActive: isActive !== undefined ? isActive : true
|
isOpportunity: finalIsOpportunity
|
||||||
});
|
});
|
||||||
|
|
||||||
const area = await db.Location.create({
|
const area = await db.Location.create({
|
||||||
name,
|
name,
|
||||||
districtId: district.id,
|
districtId: district.id,
|
||||||
city: city || name,
|
city: city || name,
|
||||||
isActive: true,
|
isOpportunity: true,
|
||||||
openFrom: openFrom || null,
|
openFrom: openFrom || null,
|
||||||
openTo: openTo || null,
|
openTo: openTo || null,
|
||||||
description: description || null
|
description: description || null
|
||||||
@ -810,7 +810,8 @@ export const deleteLocation = 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; // This is the Area ID
|
const { id } = req.params; // This is the Area ID
|
||||||
const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body;
|
const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body;
|
||||||
|
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive;
|
||||||
|
|
||||||
const area = await db.Location.findByPk(id, {
|
const area = await db.Location.findByPk(id, {
|
||||||
include: [{ model: db.District, as: 'district' }]
|
include: [{ model: db.District, as: 'district' }]
|
||||||
@ -842,7 +843,7 @@ export const updateLocation = async (req: Request, res: Response) => {
|
|||||||
name: name || district.name,
|
name: name || district.name,
|
||||||
code: code || district.code,
|
code: code || district.code,
|
||||||
stateId: stateId || district.stateId,
|
stateId: stateId || district.stateId,
|
||||||
isActive: isActive !== undefined ? isActive : district.isActive
|
isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : district.isOpportunity
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,9 +852,9 @@ export const updateLocation = async (req: Request, res: Response) => {
|
|||||||
name: name || area.name,
|
name: name || area.name,
|
||||||
districtId: district?.id || area.districtId,
|
districtId: district?.id || area.districtId,
|
||||||
city: city || area.city,
|
city: city || area.city,
|
||||||
isActive: isActive !== undefined ? isActive : area.isActive,
|
isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : area.isOpportunity,
|
||||||
openFrom: openFrom !== undefined ? (openFrom || null) : area.openFrom,
|
openFrom: openFrom || area.openFrom,
|
||||||
openTo: openTo !== undefined ? (openTo || null) : area.openTo,
|
openTo: openTo || area.openTo,
|
||||||
description: description || area.description
|
description: description || area.description
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -885,35 +886,32 @@ export const updateLocation = async (req: Request, res: Response) => {
|
|||||||
// --- Managers ---
|
// --- Managers ---
|
||||||
export const getASMs = async (req: Request, res: Response) => {
|
export const getASMs = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const asms = await db.User.findAll({
|
const asms = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] },
|
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] },
|
||||||
isActive: true
|
isActive: true
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.UserRole,
|
association: 'userRoles',
|
||||||
as: 'userRoles',
|
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
required: false,
|
required: false,
|
||||||
include: [{ model: db.Role, as: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }]
|
include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: db.District,
|
association: 'managedAsmDistricts',
|
||||||
as: 'managedAsmDistricts',
|
|
||||||
include: [
|
include: [
|
||||||
{ model: db.State, as: 'state', attributes: ['id', 'name'] },
|
{ association: 'state', attributes: ['id', 'name'] },
|
||||||
{ model: db.Region, as: 'region', attributes: ['id', 'name'] },
|
{ association: 'region', attributes: ['id', 'name'] },
|
||||||
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] }
|
{ association: 'zone', attributes: ['id', 'name'] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: db.District,
|
association: 'managedAreaDistricts',
|
||||||
as: 'managedDdAmDistricts',
|
|
||||||
include: [
|
include: [
|
||||||
{ model: db.State, as: 'state', attributes: ['id', 'name'] },
|
{ association: 'state', attributes: ['id', 'name'] },
|
||||||
{ model: db.Region, as: 'region', attributes: ['id', 'name'] },
|
{ association: 'region', attributes: ['id', 'name'] },
|
||||||
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] }
|
{ association: 'zone', attributes: ['id', 'name'] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -922,7 +920,7 @@ export const getASMs = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const result = (asms || []).map((u: any) => {
|
const result = (asms || []).map((u: any) => {
|
||||||
const asmDistricts = u.managedAsmDistricts || [];
|
const asmDistricts = u.managedAsmDistricts || [];
|
||||||
const ddAmDistricts = u.managedDdAmDistricts || [];
|
const ddAmDistricts = u.managedAreaDistricts || [];
|
||||||
const districts = [...asmDistricts, ...ddAmDistricts];
|
const districts = [...asmDistricts, ...ddAmDistricts];
|
||||||
|
|
||||||
const roleAssignment = (u.userRoles || []).find((r: any) =>
|
const roleAssignment = (u.userRoles || []).find((r: any) =>
|
||||||
|
|||||||
@ -32,6 +32,16 @@ import {
|
|||||||
initializeDefaultSlas
|
initializeDefaultSlas
|
||||||
} from './master.controller.js';
|
} from './master.controller.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getInterviewConfigByType,
|
||||||
|
getInterviewConfigs,
|
||||||
|
getInterviewConfigById,
|
||||||
|
createInterviewConfig,
|
||||||
|
updateInterviewConfig,
|
||||||
|
deleteInterviewConfig,
|
||||||
|
initializeDefaultInterviewConfigs
|
||||||
|
} from './interviewConfig.controller.js';
|
||||||
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -77,4 +87,13 @@ router.get('/sla-configs', getSlaConfigs);
|
|||||||
router.post('/sla-configs', saveSlaConfig);
|
router.post('/sla-configs', saveSlaConfig);
|
||||||
router.post('/sla-configs/initialize', initializeDefaultSlas);
|
router.post('/sla-configs/initialize', initializeDefaultSlas);
|
||||||
|
|
||||||
|
// --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) ---
|
||||||
|
router.get('/interview-configs', getInterviewConfigs);
|
||||||
|
router.get('/interview-configs/active/:configType', getInterviewConfigByType);
|
||||||
|
router.get('/interview-configs/:id', getInterviewConfigById);
|
||||||
|
router.post('/interview-configs', createInterviewConfig);
|
||||||
|
router.put('/interview-configs/:id', updateInterviewConfig);
|
||||||
|
router.delete('/interview-configs/:id', deleteInterviewConfig);
|
||||||
|
router.post('/interview-configs/initialize', initializeDefaultInterviewConfigs);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -12,7 +12,11 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
|
|||||||
where.dealerId = req.user.id;
|
where.dealerId = req.user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outlets = await Outlet.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: outlets } = await Outlet.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@ -29,12 +33,20 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
outlets
|
outlets,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get outlets error:', error);
|
console.error('Get outlets error:', error);
|
||||||
|
|||||||
@ -36,11 +36,11 @@ export const syncLocationManagers = async (districtId: string) => {
|
|||||||
// Find primary/last assigned manager for each type
|
// Find primary/last assigned manager for each type
|
||||||
// ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion
|
// ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion
|
||||||
const asm = activeAssignments.find((a: any) =>
|
const asm = activeAssignments.find((a: any) =>
|
||||||
((a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER') &&
|
['ASM', 'AREA SALES MANAGER'].includes((a.role as any)?.roleCode) &&
|
||||||
a.districtId === districtId
|
a.districtId === districtId
|
||||||
);
|
);
|
||||||
const ddAm = activeAssignments.find((a: any) =>
|
const ddAm = activeAssignments.find((a: any) =>
|
||||||
((a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER') &&
|
['DD-AM', 'DD AM', 'AREA MANAGER', ROLES.DD_AM].includes((a.role as any)?.roleCode) &&
|
||||||
a.districtId === districtId
|
a.districtId === districtId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { WorkflowService } from '../../services/WorkflowService.js';
|
|||||||
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
|
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||||
|
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
|
||||||
|
|
||||||
const { DocumentStageConfig } = db;
|
const { DocumentStageConfig } = db;
|
||||||
|
|
||||||
@ -60,7 +61,7 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const applicationId = NomenclatureService.generateApplicationId();
|
const applicationId = await NomenclatureService.generateApplicationId();
|
||||||
let districtId = null;
|
let districtId = null;
|
||||||
// Normalize incoming ID sources for robustness
|
// Normalize incoming ID sources for robustness
|
||||||
const incomingLocationId = req.body.locationId || req.body.districtId;
|
const incomingLocationId = req.body.locationId || req.body.districtId;
|
||||||
@ -163,10 +164,28 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
if (isOpportunityAvailable) {
|
if (isOpportunityAvailable) {
|
||||||
sendOpportunityEmail(email, displayApplicantName, displayLocation, applicationId)
|
sendOpportunityEmail(email, displayApplicantName, displayLocation, applicationId)
|
||||||
.catch(err => console.error('Error sending opportunity email', err));
|
.then(() => {
|
||||||
|
db.AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: 'EMAIL_SENT',
|
||||||
|
entityType: 'application',
|
||||||
|
entityId: application.id,
|
||||||
|
newData: { email, type: 'OPPORTUNITY', location: displayLocation }
|
||||||
|
}).catch((err: any) => console.error('AuditLog error for email:', err));
|
||||||
|
})
|
||||||
|
.catch((err: any) => console.error('Error sending opportunity email', err));
|
||||||
} else {
|
} else {
|
||||||
sendNonOpportunityEmail(email, displayApplicantName, displayLocation)
|
sendNonOpportunityEmail(email, displayApplicantName, displayLocation)
|
||||||
.catch(err => console.error('Error sending non-opportunity email', err));
|
.then(() => {
|
||||||
|
db.AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: 'EMAIL_SENT',
|
||||||
|
entityType: 'application',
|
||||||
|
entityId: application.id,
|
||||||
|
newData: { email, type: 'NON_OPPORTUNITY', location: displayLocation }
|
||||||
|
}).catch((err: any) => console.error('AuditLog error for email:', err));
|
||||||
|
})
|
||||||
|
.catch((err: any) => console.error('Error sending non-opportunity email', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditLog.create({
|
await AuditLog.create({
|
||||||
@ -191,32 +210,143 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const whereClause: any = {};
|
const whereClause: any = {};
|
||||||
|
|
||||||
// Security Check: If prospective dealer, only show their own application
|
// Determine if user has national-level visibility
|
||||||
if (req.user?.roleCode === 'Prospective Dealer') {
|
const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'Admin', 'Super Admin'];
|
||||||
// Filter by phone instead of email to show all applications from same user
|
const isNationalUser = nationalRoles.includes(req.user?.roleCode || '');
|
||||||
whereClause.phone = (req.user as any).phone || req.user.email;
|
const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer';
|
||||||
}
|
|
||||||
|
|
||||||
// Security Check: If FDD user, only show applications where they are a participant
|
if (isProspectiveDealer) {
|
||||||
if (req.user?.roleCode === 'FDD') {
|
whereClause.phone = (req.user as any).phone || req.user?.email;
|
||||||
|
} else if (!isNationalUser) {
|
||||||
|
// Restriction: Only show applications where the user is a participant
|
||||||
const participantApps = await db.RequestParticipant.findAll({
|
const participantApps = await db.RequestParticipant.findAll({
|
||||||
where: { userId: req.user.id, requestType: 'application' },
|
where: { userId: req.user?.id, requestType: 'application' },
|
||||||
attributes: ['requestId']
|
attributes: ['requestId']
|
||||||
});
|
});
|
||||||
const appIds = participantApps.map((p: any) => p.requestId);
|
const appIds = participantApps.map((p: any) => p.requestId);
|
||||||
whereClause.id = { [Op.in]: appIds };
|
whereClause.id = { [Op.in]: appIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
const applications = await Application.findAll({
|
// Apply Filters
|
||||||
|
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
||||||
|
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
whereClause.createdAt = {};
|
||||||
|
if (fromDate) {
|
||||||
|
const start = new Date(fromDate as string);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
whereClause.createdAt[Op.gte] = start;
|
||||||
|
}
|
||||||
|
if (toDate) {
|
||||||
|
const end = new Date(toDate as string);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
whereClause.createdAt[Op.lte] = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause[Op.or] = [
|
||||||
|
{ applicantName: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ applicationId: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ email: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ phone: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyStatusFilter = (val: any) => {
|
||||||
|
if (!val || val === 'all') return;
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
whereClause.overallStatus = { [Op.in]: val };
|
||||||
|
} else if (typeof val === 'string' && val.includes(',')) {
|
||||||
|
whereClause.overallStatus = { [Op.in]: val.split(',') };
|
||||||
|
} else {
|
||||||
|
whereClause.overallStatus = val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pipeline Logic - Forced strict filtering by lifecycle stage
|
||||||
|
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
||||||
|
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (isShortlistedStr === 'false') {
|
||||||
|
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
||||||
|
whereClause.overallStatus = 'Submitted';
|
||||||
|
whereClause.isShortlisted = { [Op.ne]: true };
|
||||||
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
|
whereClause.opportunityId = null; // Strictly lead-gen records only
|
||||||
|
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
||||||
|
// Opportunities (Prospects) MUST be shortlisted but NOT yet in workflow
|
||||||
|
whereClause.isShortlisted = true;
|
||||||
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
applyStatusFilter(status);
|
||||||
|
} else {
|
||||||
|
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed'] };
|
||||||
|
}
|
||||||
|
} else if (ddLeadShortlistedStr === 'true') {
|
||||||
|
// Workflow strictly shows shortlisted by DD Lead
|
||||||
|
whereClause.ddLeadShortlisted = true;
|
||||||
|
applyStatusFilter(status);
|
||||||
|
} else {
|
||||||
|
// 'All Requests' or other general views
|
||||||
|
applyStatusFilter(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location && location !== 'all') {
|
||||||
|
whereClause.preferredLocation = location;
|
||||||
|
}
|
||||||
|
if (state && state !== 'all') {
|
||||||
|
whereClause.state = state;
|
||||||
|
}
|
||||||
|
if (assignedTo) {
|
||||||
|
whereClause.assignedTo = assignedTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Pagination
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: applications } = await Application.findAndCountAll({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
include: [
|
include: [
|
||||||
{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] },
|
{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] },
|
||||||
{ model: SecurityDeposit, as: 'securityDeposits' }
|
{ model: SecurityDeposit, as: 'securityDeposits' }
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true,
|
||||||
|
col: 'id'
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: applications });
|
// Get additional stats for the header
|
||||||
|
const stats = {
|
||||||
|
total: count,
|
||||||
|
uniqueLocations: await Application.count({
|
||||||
|
where: whereClause,
|
||||||
|
distinct: true,
|
||||||
|
col: 'preferredLocation'
|
||||||
|
}),
|
||||||
|
withExperience: await Application.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
experienceYears: { [Op.gt]: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: applications,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get applications error:', error);
|
console.error('Get applications error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching applications' });
|
res.status(500).json({ success: false, message: 'Error fetching applications' });
|
||||||
@ -573,13 +703,9 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ success: false, message: 'No applications selected' });
|
return res.status(400).json({ success: false, message: 'No applications selected' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) {
|
// assignedTo is now optional as auto-assignment is handled via location
|
||||||
return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' });
|
const assignedToArr = Array.isArray(assignedTo) ? assignedTo : [];
|
||||||
}
|
const primaryManualAssigneeId = assignedToArr.length > 0 ? assignedToArr[0] : null;
|
||||||
|
|
||||||
// Strategy: Assign the first user as primary assignee for the single FK field,
|
|
||||||
// but add ALL as participants to enforce dual-responsibility.
|
|
||||||
const primaryAssigneeId = assignedTo[0];
|
|
||||||
|
|
||||||
// Update Applications sequentially via WorkflowService for consistency
|
// Update Applications sequentially via WorkflowService for consistency
|
||||||
for (const appId of applicationIds) {
|
for (const appId of applicationIds) {
|
||||||
@ -587,11 +713,23 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
|||||||
const application = await Application.findOne({
|
const application = await Application.findOne({
|
||||||
where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId }
|
where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (application) {
|
if (application) {
|
||||||
|
let resolvedAssigneeId = primaryManualAssigneeId;
|
||||||
|
|
||||||
|
// If no manual assignee provided, auto-resolve from District mapping
|
||||||
|
if (!resolvedAssigneeId && application.districtId) {
|
||||||
|
const district = await db.District.findByPk(application.districtId);
|
||||||
|
if (district) {
|
||||||
|
// Prioritize DD-AM as per user request
|
||||||
|
resolvedAssigneeId = district.ddAmId || district.zmId || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await application.update({
|
await application.update({
|
||||||
ddLeadShortlisted: true,
|
ddLeadShortlisted: true,
|
||||||
isShortlisted: true,
|
isShortlisted: true,
|
||||||
assignedTo: primaryAssigneeId,
|
assignedTo: resolvedAssigneeId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -608,22 +746,24 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
|||||||
application.applicationId
|
application.applicationId
|
||||||
).catch(err => console.error('Failed to send shortlist email:', err));
|
).catch(err => console.error('Failed to send shortlist email:', err));
|
||||||
|
|
||||||
// Add all assigned users as participants
|
// Add manual assignees as participants if provided
|
||||||
for (const userId of assignedTo) {
|
if (assignedToArr.length > 0) {
|
||||||
await db.RequestParticipant.findOrCreate({
|
for (const userId of assignedToArr) {
|
||||||
where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' },
|
await db.RequestParticipant.findOrCreate({
|
||||||
defaults: { joinedMethod: 'auto' }
|
where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' },
|
||||||
});
|
defaults: { joinedMethod: 'auto' }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUTO-FILL Interview Evaluators
|
// TRIGGER AUTO-ASSIGNMENT for all stages based on location
|
||||||
await assignStageEvaluators(application.id);
|
await assignStageEvaluators(application.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.`
|
message: `Successfully shortlisted ${applicationIds.length} application(s). Assignments processed automatically based on location.`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Bulk shortlist error:', error);
|
console.error('Bulk shortlist error:', error);
|
||||||
@ -640,6 +780,10 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
const assignStageEvaluators = async (appIdOrId: string) => {
|
const assignStageEvaluators = async (appIdOrId: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('ONBOARDING')) {
|
||||||
|
console.log(`[debug] Auto-assignment disabled for ONBOARDING. Skipping for App: ${appIdOrId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`);
|
console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`);
|
||||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(appIdOrId);
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(appIdOrId);
|
||||||
const application = await Application.findOne({
|
const application = await Application.findOne({
|
||||||
@ -752,7 +896,6 @@ const assignStageEvaluators = async (appIdOrId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [userId, assignment] of Object.entries(userAssignments)) {
|
for (const [userId, assignment] of Object.entries(userAssignments)) {
|
||||||
const isInterview = assignment.stages.some(s => typeof s === 'number');
|
|
||||||
const primaryStage = assignment.stages[0];
|
const primaryStage = assignment.stages[0];
|
||||||
const primaryRole = assignment.roles[0];
|
const primaryRole = assignment.roles[0];
|
||||||
|
|
||||||
@ -769,29 +912,37 @@ const assignStageEvaluators = async (appIdOrId: string) => {
|
|||||||
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
||||||
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
|
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
|
||||||
role: primaryRole,
|
role: primaryRole,
|
||||||
allAssignments: assignment.stages, // Store all assignments
|
allAssignments: assignment.stages,
|
||||||
autoMapped: true
|
autoMapped: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// Update metadata if it exists to include the new assignments
|
|
||||||
const meta = participant.metadata || {};
|
|
||||||
const currentAssignments = meta.allAssignments || [];
|
|
||||||
const mergedAssignments = [...new Set([...currentAssignments, ...assignment.stages])];
|
|
||||||
|
|
||||||
await participant.update({
|
await participant.update({
|
||||||
|
joinedMethod: 'auto', // Ensure it's marked as auto if it wasn't
|
||||||
metadata: {
|
metadata: {
|
||||||
...meta,
|
...(participant.metadata || {}),
|
||||||
allAssignments: mergedAssignments,
|
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
||||||
// Maintain legacy fields for compatibility if they don't exist
|
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
|
||||||
interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null),
|
role: primaryRole,
|
||||||
stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null)
|
allAssignments: assignment.stages,
|
||||||
|
autoMapped: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cleanup stale auto-assignments ---
|
||||||
|
const currentParticipantIds = Object.keys(userAssignments);
|
||||||
|
await db.RequestParticipant.destroy({
|
||||||
|
where: {
|
||||||
|
requestId: application.id,
|
||||||
|
requestType: 'application',
|
||||||
|
joinedMethod: 'auto',
|
||||||
|
userId: { [db.Sequelize.Op.notIn]: currentParticipantIds }
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error);
|
console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error);
|
||||||
}
|
}
|
||||||
@ -1019,8 +1170,7 @@ export const getDocumentConfigMetadata = async (_req: AuthRequest, res: Response
|
|||||||
|
|
||||||
// Fetch Document Configurations based on Role and Stage
|
// Fetch Document Configurations based on Role and Stage
|
||||||
export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
|
export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try { const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
|
||||||
const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
|
|
||||||
const roleCode = (roleFilter as string) || req.user?.role;
|
const roleCode = (roleFilter as string) || req.user?.role;
|
||||||
|
|
||||||
const where: any = { module };
|
const where: any = { module };
|
||||||
@ -1028,6 +1178,7 @@ export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
|
|||||||
where.stageCode = { [Op.or]: [stageCode, 'General'] };
|
where.stageCode = { [Op.or]: [stageCode, 'General'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where[Op.or] = [
|
where[Op.or] = [
|
||||||
{ documentType: { [Op.iLike]: `%${search}%` } },
|
{ documentType: { [Op.iLike]: `%${search}%` } },
|
||||||
@ -1190,8 +1341,225 @@ export const exportApplicationResponses = async (req: AuthRequest, res: Response
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: rows });
|
res.json({ success: true, data: rows });
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Export error:', error);
|
console.error('Export error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error exporting data' });
|
res.status(500).json({ success: false, message: 'Error exporting data' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the scenario when an applicant has applied for a location where
|
||||||
|
* the opportunity was unavailable (Non-Opportunity Application) but now
|
||||||
|
* an opportunity has opened up.
|
||||||
|
*/
|
||||||
|
export const convertToOpportunity = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { opportunityId, remarks } = req.body;
|
||||||
|
|
||||||
|
// 1. Resolve Application
|
||||||
|
const application = await Application.findByPk(id);
|
||||||
|
if (!application) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Application not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Guardians: Ensure it's not already an opportunity application
|
||||||
|
if (application.opportunityId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Application is already linked to an opportunity.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetOpportunityId = opportunityId;
|
||||||
|
|
||||||
|
// 3. Auto-discovery fallback: If no ID provided, look for active opportunity in same district
|
||||||
|
if (!targetOpportunityId && application.districtId) {
|
||||||
|
const activeOpp = await Opportunity.findOne({
|
||||||
|
where: {
|
||||||
|
districtId: application.districtId,
|
||||||
|
status: 'active',
|
||||||
|
[Op.or]: [
|
||||||
|
{ openTo: null },
|
||||||
|
{ openTo: { [Op.gte]: new Date() } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (activeOpp) {
|
||||||
|
targetOpportunityId = activeOpp.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetOpportunityId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No active opportunity found for this location. Please create an opportunity for this district first.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update Application Record
|
||||||
|
await application.update({
|
||||||
|
opportunityId: targetOpportunityId,
|
||||||
|
isShortlisted: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Trigger Workflow Transition (Moves to Questionnaire Pending)
|
||||||
|
// This handles status history, audit logs, and progress percentage sync (10%)
|
||||||
|
await WorkflowService.transitionApplication(
|
||||||
|
application,
|
||||||
|
APPLICATION_STATUS.QUESTIONNAIRE_PENDING,
|
||||||
|
req.user?.id || null,
|
||||||
|
{
|
||||||
|
reason: 'Application processing initiated.',
|
||||||
|
source: 'Lead Conversion',
|
||||||
|
skipNotification: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Trigger Opportunity Email (Contains portal link + questionnaire invitation)
|
||||||
|
const displayApplicantName = toTitleCase(application.applicantName);
|
||||||
|
const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location');
|
||||||
|
|
||||||
|
sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId)
|
||||||
|
.then(() => {
|
||||||
|
db.AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: 'EMAIL_SENT',
|
||||||
|
entityType: 'application',
|
||||||
|
entityId: application.id,
|
||||||
|
newData: {
|
||||||
|
email: application.email,
|
||||||
|
type: 'OPPORTUNITY_CONVERSION',
|
||||||
|
status: 'SUCCESS'
|
||||||
|
}
|
||||||
|
}).catch((err: any) => console.error('[onboarding] Conversion email audit failed:', err));
|
||||||
|
})
|
||||||
|
.catch((err: any) => console.error('[onboarding] Failed to send conversion email:', err));
|
||||||
|
|
||||||
|
// 7. Log specialized Audit record
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATED,
|
||||||
|
entityType: 'application',
|
||||||
|
entityId: application.id,
|
||||||
|
newData: {
|
||||||
|
action: 'CONVERT_TO_OPPORTUNITY',
|
||||||
|
opportunityId: targetOpportunityId,
|
||||||
|
remarks: remarks || 'N/A'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Application successfully converted. Questionnaire link sent to applicant.',
|
||||||
|
data: application
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Convert to opportunity error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error converting application' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk conversion of lead-generation applications to active opportunities.
|
||||||
|
*/
|
||||||
|
export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { ids, remarks } = req.body;
|
||||||
|
|
||||||
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'No application IDs provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const application = await Application.findByPk(id);
|
||||||
|
if (!application) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Application ${id} not found.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (application.opportunityId) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Application ${application.applicationId} is already linked to an opportunity.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetOpportunityId = null;
|
||||||
|
|
||||||
|
// Auto-discovery logic (matches the single conversion logic)
|
||||||
|
if (application.districtId) {
|
||||||
|
const activeOpp = await Opportunity.findOne({
|
||||||
|
where: {
|
||||||
|
districtId: application.districtId,
|
||||||
|
status: 'active',
|
||||||
|
[Op.or]: [
|
||||||
|
{ openTo: null },
|
||||||
|
{ openTo: { [Op.gte]: new Date() } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (activeOpp) {
|
||||||
|
targetOpportunityId = activeOpp.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetOpportunityId) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`No active opportunity found for ${application.applicantName} (${application.preferredLocation}).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Application
|
||||||
|
await application.update({
|
||||||
|
opportunityId: targetOpportunityId,
|
||||||
|
isShortlisted: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transition Workflow
|
||||||
|
await WorkflowService.transitionApplication(
|
||||||
|
application,
|
||||||
|
APPLICATION_STATUS.QUESTIONNAIRE_PENDING,
|
||||||
|
req.user?.id || null,
|
||||||
|
{
|
||||||
|
reason: 'Application processing initiated.',
|
||||||
|
source: 'Lead Conversion',
|
||||||
|
skipNotification: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send Email Notification
|
||||||
|
const displayApplicantName = toTitleCase(application.applicantName);
|
||||||
|
const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location');
|
||||||
|
|
||||||
|
sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId)
|
||||||
|
.catch((err: any) => console.error(`[bulk-convert] Mail failed for ${id}:`, err));
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (err: any) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Error converting ID ${id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Batch processed ${ids.length} records. ${results.success} succeeded, ${results.failed} failed.`,
|
||||||
|
data: results
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk convert error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Internal error during batch conversion' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
||||||
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
||||||
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
||||||
exportApplicationResponses
|
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
||||||
} from './onboarding.controller.js';
|
} from './onboarding.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
|
||||||
@ -33,6 +33,8 @@ router.put('/applications/:id', updateApplication);
|
|||||||
router.put('/applications/:id/status', updateApplicationStatus);
|
router.put('/applications/:id/status', updateApplicationStatus);
|
||||||
router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
|
router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
|
||||||
router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import
|
router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import
|
||||||
|
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
|
||||||
|
router.post('/applications/:id/convert-to-opportunity', convertToOpportunity);
|
||||||
|
|
||||||
// Architecture-related routes
|
// Architecture-related routes
|
||||||
router.post('/applications/:id/assign-architecture', assignArchitectureTeam);
|
router.post('/applications/:id/assign-architecture', assignArchitectureTeam);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant } = db;
|
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant, DealerCode } = db;
|
||||||
import { NotificationService } from '../../services/NotificationService.js';
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
@ -164,7 +164,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null;
|
resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = NomenclatureService.generateConstitutionalChangeId();
|
const requestId = await NomenclatureService.generateConstitutionalChangeId();
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
newPartnersDetails,
|
newPartnersDetails,
|
||||||
@ -241,9 +241,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (req.user.roleCode === 'Dealer') {
|
if (req.user.roleCode === 'Dealer') {
|
||||||
where.dealerId = req.user.id;
|
where.dealerId = req.user.id;
|
||||||
|
} else {
|
||||||
|
const { status } = req.query;
|
||||||
|
if (status) {
|
||||||
|
if (status === 'pending') {
|
||||||
|
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] };
|
||||||
|
} else if (status === 'completed') {
|
||||||
|
where.status = { [Op.in]: ['Completed', 'Closed'] };
|
||||||
|
} else if (status === 'rejected') {
|
||||||
|
where.status = { [Op.in]: ['Rejected', 'Revoked'] };
|
||||||
|
} else {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = await ConstitutionalChange.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: requests } = await ConstitutionalChange.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{ model: Outlet, as: 'outlet' },
|
{ model: Outlet, as: 'outlet' },
|
||||||
@ -251,25 +268,31 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
attributes: ['fullName'],
|
attributes: ['fullName'],
|
||||||
include: [
|
include: [{ model: Dealer, as: 'dealerProfile', include: [{ model: DealerCode, as: 'dealerCode' }] }]
|
||||||
{
|
|
||||||
model: Dealer,
|
|
||||||
as: 'dealerProfile',
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Application,
|
|
||||||
as: 'application',
|
|
||||||
include: [{ model: District, as: 'district' }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, requests });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
requests,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit,
|
||||||
|
stats: {
|
||||||
|
total: count,
|
||||||
|
pending: await ConstitutionalChange.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }),
|
||||||
|
completed: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }),
|
||||||
|
rejected: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get constitutional changes error:', error);
|
console.error('Get constitutional changes error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching requests' });
|
res.status(500).json({ success: false, message: 'Error fetching requests' });
|
||||||
@ -377,6 +400,11 @@ const CONSTITUTIONAL_STAGE_POLICY_CODES: Record<string, string> = {
|
|||||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW'
|
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasRoleInConfig = (rolesArray: string[] | undefined, role: string) => {
|
||||||
|
if (!rolesArray || !role) return false;
|
||||||
|
return rolesArray.includes(role);
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeRoleKey = (rawRole: string) => {
|
const normalizeRoleKey = (rawRole: string) => {
|
||||||
const role = String(rawRole || '').trim().toUpperCase();
|
const role = String(rawRole || '').trim().toUpperCase();
|
||||||
if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM';
|
if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM';
|
||||||
@ -516,8 +544,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
|||||||
zmRbm
|
zmRbm
|
||||||
};
|
};
|
||||||
|
|
||||||
const approvedRequiredRoles = requiredRoles.filter((role) => Boolean(zmRbm[role]?.approvedByUserId));
|
const approvedRequiredRoles = requiredRoles.filter((role: string) => Boolean(zmRbm[role]?.approvedByUserId));
|
||||||
const waitingFor = requiredRoles.filter((role) => !zmRbm[role]?.approvedByUserId);
|
const waitingFor = requiredRoles.filter((role: string) => !zmRbm[role]?.approvedByUserId);
|
||||||
const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length);
|
const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length);
|
||||||
|
|
||||||
if (!approvalThresholdMet) {
|
if (!approvalThresholdMet) {
|
||||||
|
|||||||
@ -330,7 +330,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = NomenclatureService.generateRelocationId();
|
const requestId = await NomenclatureService.generateRelocationId();
|
||||||
|
|
||||||
const request = await RelocationRequest.create({
|
const request = await RelocationRequest.create({
|
||||||
requestId,
|
requestId,
|
||||||
@ -400,9 +400,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (req.user.roleCode === 'Dealer') {
|
if (req.user.roleCode === 'Dealer') {
|
||||||
where.dealerId = req.user.id;
|
where.dealerId = req.user.id;
|
||||||
|
} else {
|
||||||
|
const { status } = req.query;
|
||||||
|
if (status) {
|
||||||
|
if (status === 'pending') {
|
||||||
|
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] };
|
||||||
|
} else if (status === 'completed') {
|
||||||
|
where.status = { [Op.in]: ['Completed', 'Closed'] };
|
||||||
|
} else if (status === 'rejected') {
|
||||||
|
where.status = { [Op.in]: ['Rejected', 'Revoked'] };
|
||||||
|
} else {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = await RelocationRequest.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: requests } = await RelocationRequest.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@ -427,7 +444,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
attributes: ['fullName']
|
attributes: ['fullName']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter requests based on user's role and location assignments
|
// Filter requests based on user's role and location assignments
|
||||||
@ -475,7 +495,22 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, requests: enrichedRequests });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
requests: enrichedRequests,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit,
|
||||||
|
stats: {
|
||||||
|
total: count,
|
||||||
|
pending: await RelocationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }),
|
||||||
|
completed: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }),
|
||||||
|
rejected: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get relocation requests error:', error);
|
console.error('Get relocation requests error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching requests' });
|
res.status(500).json({ success: false, message: 'Error fetching requests' });
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
|
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const resignationId = NomenclatureService.generateResignationId();
|
const resignationId = await NomenclatureService.generateResignationId();
|
||||||
const resignation = await db.Resignation.create({
|
const resignation = await db.Resignation.create({
|
||||||
resignationId,
|
resignationId,
|
||||||
outletId,
|
outletId,
|
||||||
@ -115,9 +115,32 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
|
|
||||||
if (req.user.roleCode === ROLES.DEALER) {
|
if (req.user.roleCode === ROLES.DEALER) {
|
||||||
where.dealerId = req.user.id;
|
where.dealerId = req.user.id;
|
||||||
|
} else {
|
||||||
|
// For administrative users, filter by status or assignment if requested
|
||||||
|
const { status, onlyMine } = req.query;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
if (String(status).includes(',')) {
|
||||||
|
where.status = { [Op.in]: String(status).split(',') };
|
||||||
|
} else if (status === 'open') {
|
||||||
|
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] };
|
||||||
|
} else {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyMine === 'true') {
|
||||||
|
// This would involve a subquery on RequestParticipants or assignedTo field
|
||||||
|
// Assuming currentStage context or RequestParticipants
|
||||||
|
where.currentStage = { [Op.like]: `%${req.user.roleCode}%` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resignations = await db.Resignation.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: resignations } = await db.Resignation.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{ model: db.Outlet, as: 'outlet' },
|
{ model: db.Outlet, as: 'outlet' },
|
||||||
@ -130,9 +153,26 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
resignations,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit,
|
||||||
|
stats: {
|
||||||
|
total: count,
|
||||||
|
open: await db.Resignation.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] } } }),
|
||||||
|
completed: await db.Resignation.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
res.json({ success: true, resignations });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching resignations:', error);
|
logger.error('Error fetching resignations:', error);
|
||||||
next(error);
|
next(error);
|
||||||
@ -384,7 +424,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
|
|
||||||
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
|
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
|
||||||
const fnf = await db.FnF.create({
|
const fnf = await db.FnF.create({
|
||||||
settlementId: NomenclatureService.generateFnFId(),
|
settlementId: await NomenclatureService.generateFnFId(),
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
outletId: resignation.outletId,
|
outletId: resignation.outletId,
|
||||||
dealerId: dealerProfileId, // Correctly using the Dealer model ID
|
dealerId: dealerProfileId, // Correctly using the Dealer model ID
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils
|
|||||||
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { NomenclatureService } from '../../services/NomenclatureService.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
|
|
||||||
const LINE_ITEM_DESCRIPTION_PREFIX = {
|
const LINE_ITEM_DESCRIPTION_PREFIX = {
|
||||||
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
||||||
@ -118,15 +118,30 @@ export const uploadFnFDocument = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getOnboardingPayments = async (req: AuthRequest, res: Response) => {
|
export const getOnboardingPayments = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const payments = await FinancePayment.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: payments } = await FinancePayment.findAndCountAll({
|
||||||
include: [{
|
include: [{
|
||||||
model: Application,
|
model: Application,
|
||||||
as: 'application',
|
as: 'application',
|
||||||
attributes: ['applicantName', 'applicationId']
|
attributes: ['applicantName', 'applicationId']
|
||||||
}],
|
}],
|
||||||
order: [['createdAt', 'ASC']]
|
order: [['createdAt', 'ASC']],
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
payments,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit
|
||||||
|
}
|
||||||
});
|
});
|
||||||
res.json({ success: true, payments });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get onboarding payments error:', error);
|
console.error('Get onboarding payments error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching payments' });
|
res.status(500).json({ success: false, message: 'Error fetching payments' });
|
||||||
@ -254,7 +269,11 @@ export const updateFnF = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getFnFSettlements = async (req: Request, res: Response) => {
|
export const getFnFSettlements = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const settlements = await FnF.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: settlements } = await FnF.findAndCountAll({
|
||||||
include: [
|
include: [
|
||||||
{ model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] },
|
{ model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] },
|
||||||
{ model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] },
|
{ model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] },
|
||||||
@ -263,9 +282,21 @@ export const getFnFSettlements = async (req: Request, res: Response) => {
|
|||||||
{ model: FnFLineItem, as: 'lineItems' },
|
{ model: FnFLineItem, as: 'lineItems' },
|
||||||
{ model: FffClearance, as: 'clearances' }
|
{ model: FffClearance, as: 'clearances' }
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
settlements,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit
|
||||||
|
}
|
||||||
});
|
});
|
||||||
res.json({ success: true, settlements });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error fetching settlements' });
|
res.status(500).json({ success: false, message: 'Error fetching settlements' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
import logger from '../../common/utils/logger.js';
|
import logger from '../../common/utils/logger.js';
|
||||||
import {
|
import {
|
||||||
@ -32,7 +33,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
if (!req.user) throw new Error('Unauthorized');
|
if (!req.user) throw new Error('Unauthorized');
|
||||||
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||||
|
|
||||||
const requestId = NomenclatureService.generateTerminationId();
|
const requestId = await NomenclatureService.generateTerminationId();
|
||||||
const termination = await db.TerminationRequest.create({
|
const termination = await db.TerminationRequest.create({
|
||||||
requestId,
|
requestId,
|
||||||
dealerId,
|
dealerId,
|
||||||
@ -86,9 +87,24 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
if (req.user.roleCode === ROLES.DEALER) {
|
if (req.user.roleCode === ROLES.DEALER) {
|
||||||
const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } });
|
const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } });
|
||||||
if (dealer) where.dealerId = dealer.id;
|
if (dealer) where.dealerId = dealer.id;
|
||||||
|
} else {
|
||||||
|
const { status } = req.query;
|
||||||
|
if (status) {
|
||||||
|
if (status === 'open') {
|
||||||
|
where.status = { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] };
|
||||||
|
} else if (status === 'completed') {
|
||||||
|
where.status = { [Op.in]: ['Terminated', 'Completed', 'Closed'] };
|
||||||
|
} else {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminations = await db.TerminationRequest.findAll({
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const { count, rows: terminations } = await db.TerminationRequest.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@ -97,9 +113,26 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
include: [{ model: db.DealerCode, as: 'dealerCode' }]
|
include: [{ model: db.DealerCode, as: 'dealerCode' }]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
distinct: true
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
terminations,
|
||||||
|
meta: {
|
||||||
|
total: count,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: page,
|
||||||
|
limit,
|
||||||
|
stats: {
|
||||||
|
total: count,
|
||||||
|
open: await db.TerminationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] } } }),
|
||||||
|
completed: await db.TerminationRequest.count({ where: { ...where, status: { [Op.in]: ['Terminated', 'Completed', 'Closed'] } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
res.json({ success: true, terminations });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching terminations:', error);
|
logger.error('Error fetching terminations:', error);
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
62
src/scripts/seed-auto-assignment-configs.ts
Normal file
62
src/scripts/seed-auto-assignment-configs.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import db from '../database/models/index.js';
|
||||||
|
|
||||||
|
const seedAutoAssignmentConfigs = async () => {
|
||||||
|
try {
|
||||||
|
console.log('--- Seeding Auto-Assignment Configurations ---');
|
||||||
|
|
||||||
|
const configs = [
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_ONBOARDING',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Onboarding module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RELOCATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Relocation module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_TERMINATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Termination module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_CONSTITUTIONAL',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Constitutional module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_RESIGNATION',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for Resignation module'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'AUTO_ASSIGN_FNF',
|
||||||
|
value: { enabled: true },
|
||||||
|
category: 'ASSIGNMENT',
|
||||||
|
description: 'Enable/Disable auto-assignment of participants for F&F module'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
await db.SystemConfiguration.upsert({
|
||||||
|
...config,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
console.log(`Successfully seeded/updated: ${config.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('--- Auto-Assignment Configurations Seeded Successfully ---');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding auto-assignment configs:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
seedAutoAssignmentConfigs();
|
||||||
92
src/services/AutoAssignmentConfigService.ts
Normal file
92
src/services/AutoAssignmentConfigService.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import db from '../database/models/index.js';
|
||||||
|
|
||||||
|
const { SystemConfiguration } = db;
|
||||||
|
|
||||||
|
export type AutoAssignmentModule =
|
||||||
|
| 'ONBOARDING'
|
||||||
|
| 'RELOCATION'
|
||||||
|
| 'TERMINATION'
|
||||||
|
| 'RESIGNATION'
|
||||||
|
| 'CONSTITUTIONAL'
|
||||||
|
| 'FNF';
|
||||||
|
|
||||||
|
const MODULE_CONFIG_KEYS: Record<AutoAssignmentModule, string> = {
|
||||||
|
ONBOARDING: 'AUTO_ASSIGN_ONBOARDING',
|
||||||
|
RELOCATION: 'AUTO_ASSIGN_RELOCATION',
|
||||||
|
TERMINATION: 'AUTO_ASSIGN_TERMINATION',
|
||||||
|
RESIGNATION: 'AUTO_ASSIGN_RESIGNATION',
|
||||||
|
CONSTITUTIONAL: 'AUTO_ASSIGN_CONSTITUTIONAL',
|
||||||
|
FNF: 'AUTO_ASSIGN_FNF'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if auto-assignment is enabled for a given module.
|
||||||
|
* Returns true by default if config is missing (backward compatible).
|
||||||
|
*/
|
||||||
|
export async function isAutoAssignmentEnabled(module: AutoAssignmentModule): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const key = MODULE_CONFIG_KEYS[module];
|
||||||
|
const config = await SystemConfiguration.findOne({
|
||||||
|
where: { key, isActive: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.log(`[AutoAssignmentConfig] Config ${key} not found, defaulting to enabled`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = config.value || {};
|
||||||
|
// Support both { enabled: boolean } and legacy boolean values
|
||||||
|
const enabled = typeof value.enabled === 'boolean' ? value.enabled : Boolean(value);
|
||||||
|
return enabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutoAssignmentConfig] Error checking ${module}:`, error);
|
||||||
|
return true; // Fail open for safety
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status for all modules at once (useful for admin dashboards)
|
||||||
|
*/
|
||||||
|
export async function getAllAutoAssignmentStatuses(): Promise<Record<AutoAssignmentModule, boolean>> {
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configs = await SystemConfiguration.findAll({
|
||||||
|
where: {
|
||||||
|
key: Object.values(MODULE_CONFIG_KEYS),
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const configMap = new Map(configs.map((c: any) => [c.key, c.value]));
|
||||||
|
|
||||||
|
for (const [module, key] of Object.entries(MODULE_CONFIG_KEYS)) {
|
||||||
|
const value = configMap.get(key) || {};
|
||||||
|
result[module] = typeof value.enabled === 'boolean' ? value.enabled : true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoAssignmentConfig] Error fetching all statuses:', error);
|
||||||
|
// Default all to true on error
|
||||||
|
for (const module of Object.keys(MODULE_CONFIG_KEYS)) {
|
||||||
|
result[module] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as Record<AutoAssignmentModule, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module name from requestType string (used in ParticipantService)
|
||||||
|
*/
|
||||||
|
export function getModuleFromRequestType(requestType: string): AutoAssignmentModule | null {
|
||||||
|
const mapping: Record<string, AutoAssignmentModule> = {
|
||||||
|
'application': 'ONBOARDING',
|
||||||
|
'relocation': 'RELOCATION',
|
||||||
|
'termination': 'TERMINATION',
|
||||||
|
'resignation': 'RESIGNATION',
|
||||||
|
'constitutional': 'CONSTITUTIONAL',
|
||||||
|
'fnf': 'FNF'
|
||||||
|
};
|
||||||
|
return mapping[requestType.toLowerCase()] || null;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
import { ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
import { ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
|
import { isAutoAssignmentEnabled } from './AutoAssignmentConfigService.js';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
RequestParticipant,
|
RequestParticipant,
|
||||||
@ -22,20 +23,33 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) {
|
private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) {
|
||||||
try {
|
try {
|
||||||
await RequestParticipant.findOrCreate({
|
const existing = await RequestParticipant.findOne({
|
||||||
where: {
|
where: {
|
||||||
requestId,
|
requestId,
|
||||||
requestType,
|
requestType,
|
||||||
userId
|
userId
|
||||||
},
|
}
|
||||||
defaults: {
|
});
|
||||||
participantType,
|
|
||||||
joinedMethod: 'auto',
|
if (existing) {
|
||||||
metadata: {
|
// Requirement: Auto-assignment logic skips participants with revokedAt to prevent re-adding
|
||||||
...metadata,
|
if (existing.metadata?.revokedAt) {
|
||||||
autoMapped: true,
|
console.log(`[ParticipantService] Skipping revoked participant ${userId} for ${requestType} ${requestId}`);
|
||||||
assignedAt: new Date()
|
return;
|
||||||
}
|
}
|
||||||
|
return; // Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
await RequestParticipant.create({
|
||||||
|
requestId,
|
||||||
|
requestType,
|
||||||
|
userId,
|
||||||
|
participantType,
|
||||||
|
joinedMethod: 'auto',
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
autoMapped: true,
|
||||||
|
assignedAt: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -80,6 +94,10 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
static async assignTerminationParticipants(requestId: string) {
|
static async assignTerminationParticipants(requestId: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('TERMINATION')) {
|
||||||
|
console.log(`[ParticipantService] Auto-assignment disabled for TERMINATION. Skipping for ${requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const termination = await db.TerminationRequest.findByPk(requestId);
|
const termination = await db.TerminationRequest.findByPk(requestId);
|
||||||
if (!termination) {
|
if (!termination) {
|
||||||
console.error(`[ParticipantService] Termination Request not found: ${requestId}`);
|
console.error(`[ParticipantService] Termination Request not found: ${requestId}`);
|
||||||
@ -154,6 +172,10 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
static async assignConstitutionalParticipants(requestId: string) {
|
static async assignConstitutionalParticipants(requestId: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('CONSTITUTIONAL')) {
|
||||||
|
console.log(`[ParticipantService] Auto-assignment disabled for CONSTITUTIONAL. Skipping for ${requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const request = await ConstitutionalChange.findByPk(requestId);
|
const request = await ConstitutionalChange.findByPk(requestId);
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
|
|
||||||
@ -215,6 +237,10 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
static async assignResignationParticipants(requestId: string) {
|
static async assignResignationParticipants(requestId: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('RESIGNATION')) {
|
||||||
|
console.log(`[ParticipantService] Auto-assignment disabled for RESIGNATION. Skipping for ${requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resignation = await db.Resignation.findByPk(requestId);
|
const resignation = await db.Resignation.findByPk(requestId);
|
||||||
if (!resignation) {
|
if (!resignation) {
|
||||||
console.error(`[ParticipantService] Resignation not found: ${requestId}`);
|
console.error(`[ParticipantService] Resignation not found: ${requestId}`);
|
||||||
@ -286,6 +312,10 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
static async assignRelocationParticipants(requestId: string) {
|
static async assignRelocationParticipants(requestId: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('RELOCATION')) {
|
||||||
|
console.log(`[ParticipantService] Auto-assignment disabled for RELOCATION. Skipping for ${requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relocation = await db.RelocationRequest.findByPk(requestId, {
|
const relocation = await db.RelocationRequest.findByPk(requestId, {
|
||||||
include: [{
|
include: [{
|
||||||
model: Outlet,
|
model: Outlet,
|
||||||
@ -367,6 +397,10 @@ export class ParticipantService {
|
|||||||
*/
|
*/
|
||||||
static async assignFnFParticipants(fnfId: string) {
|
static async assignFnFParticipants(fnfId: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!await isAutoAssignmentEnabled('FNF')) {
|
||||||
|
console.log(`[ParticipantService] Auto-assignment disabled for FNF. Skipping for ${fnfId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fnf = await db.FnF.findByPk(fnfId);
|
const fnf = await db.FnF.findByPk(fnfId);
|
||||||
if (!fnf) return;
|
if (!fnf) return;
|
||||||
|
|
||||||
|
|||||||
@ -180,7 +180,7 @@ export class TerminationWorkflowService {
|
|||||||
|
|
||||||
if (!fnf) {
|
if (!fnf) {
|
||||||
fnf = await db.FnF.create({
|
fnf = await db.FnF.create({
|
||||||
settlementId: NomenclatureService.generateFnFId(),
|
settlementId: await NomenclatureService.generateFnFId(),
|
||||||
terminationRequestId: termination.id,
|
terminationRequestId: termination.id,
|
||||||
dealerId: termination.dealerId,
|
dealerId: termination.dealerId,
|
||||||
outletId: primaryOutlet?.id || null,
|
outletId: primaryOutlet?.id || null,
|
||||||
|
|||||||
@ -121,7 +121,7 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Notifications — non-fatal
|
// 5. Notifications — non-fatal
|
||||||
if (application.email) {
|
if (application.email && !metadata.skipNotification) {
|
||||||
try {
|
try {
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
where: { email: application.email },
|
where: { email: application.email },
|
||||||
@ -200,10 +200,12 @@ export class WorkflowService {
|
|||||||
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
|
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
|
||||||
*/
|
*/
|
||||||
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
|
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
|
||||||
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } });
|
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
|
||||||
if (!policy) return { policyMet: true }; // No policy means no restriction
|
if (!policy) return { policyMet: true }; // No active policy means no restriction
|
||||||
|
|
||||||
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
|
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
|
||||||
|
const mode = policy.approvalMode || 'MIN_N';
|
||||||
|
const minNeeded = policy.minApprovals || 1;
|
||||||
|
|
||||||
// Fetch all approved actions for this stage
|
// Fetch all approved actions for this stage
|
||||||
const actions = await db.StageApprovalAction.findAll({
|
const actions = await db.StageApprovalAction.findAll({
|
||||||
@ -213,21 +215,40 @@ export class WorkflowService {
|
|||||||
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
|
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
|
||||||
const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
|
const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
|
||||||
|
|
||||||
const isSuperAdminApproval = Array.from(approvedRoles).includes('Super Admin');
|
// 1. Initial Gate: Super Admin bypass
|
||||||
|
if (approvedRoles.has('Super Admin')) {
|
||||||
|
return { policyMet: true, policy, overriddenBy: 'Super Admin' };
|
||||||
|
}
|
||||||
|
|
||||||
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval)
|
let roleConditionMet = false;
|
||||||
? true
|
|
||||||
: requiredRoles.every((role: string) => approvedRoles.has(role));
|
|
||||||
|
|
||||||
const meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1);
|
switch (mode) {
|
||||||
|
case 'ALL':
|
||||||
|
case 'ROLE_MANDATORY':
|
||||||
|
// Every role in the required list MUST be present in the approved roles
|
||||||
|
roleConditionMet = requiredRoles.length === 0 ||
|
||||||
|
requiredRoles.every(role => approvedRoles.has(role));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'MIN_N':
|
||||||
|
default:
|
||||||
|
// If there are required roles, at least one approval must come from THAT list
|
||||||
|
// If the list is empty, any approval counts
|
||||||
|
roleConditionMet = requiredRoles.length === 0 ||
|
||||||
|
requiredRoles.some(role => approvedRoles.has(role));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetsMinCount = uniqueApprovers.size >= minNeeded;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals,
|
policyMet: roleConditionMet && meetsMinCount,
|
||||||
policy,
|
policy,
|
||||||
uniqueApprovers: Array.from(uniqueApprovers),
|
uniqueApprovers: Array.from(uniqueApprovers),
|
||||||
approvedRoles: Array.from(approvedRoles),
|
approvedRoles: Array.from(approvedRoles),
|
||||||
hasAllRequiredRoleApprovals,
|
roleConditionMet,
|
||||||
meetsMinApprovals
|
meetsMinCount,
|
||||||
|
mode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
|
|||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com",
|
DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com",
|
||||||
DEALER: args.dealerEmail,
|
DEALER: args.dealerEmail || "ramesh_1777038131833@gmail.com",
|
||||||
ASM: args.asmEmail || "abhishek@royalenfield.com",
|
ASM: args.asmEmail || "abhishek@royalenfield.com",
|
||||||
RBM: args.rbmEmail || "manish@royalenfield.com",
|
RBM: args.rbmEmail || "manish@royalenfield.com",
|
||||||
DD_ZM: args.ddZmEmail || "piyush@royalenfield.com",
|
DD_ZM: args.ddZmEmail || "piyush@royalenfield.com",
|
||||||
@ -49,10 +49,10 @@ async function apiRequest(endpoint, method = "GET", body = null, token = null) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(email) {
|
async function login(email, password = PASSWORD) {
|
||||||
if (!login.cache) login.cache = {};
|
if (!login.cache) login.cache = {};
|
||||||
if (login.cache[email]) return login.cache[email];
|
if (login.cache[email]) return login.cache[email];
|
||||||
const data = await apiRequest("/auth/login", "POST", { email, password: PASSWORD });
|
const data = await apiRequest("/auth/login", "POST", { email, password });
|
||||||
login.cache[email] = data.token;
|
login.cache[email] = data.token;
|
||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
@ -89,7 +89,7 @@ async function approveCurrentStage(requestId, stageName) {
|
|||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Approval failed for stage: ${stageName}. Attempts -> ${attempts.join(" | ")}`
|
`Approval failed for stage: ${stageName}. Attempts -> ${attempts.join(" | ")}`
|
||||||
+ (lastError ? ` | Last error: ${lastError.message}` : "")
|
+ (lastError ? ` | Last error: ${lastError.message}` : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ async function run() {
|
|||||||
throw new Error("Missing --dealerEmail. This script requires an existing dealer user email.");
|
throw new Error("Missing --dealerEmail. This script requires an existing dealer user email.");
|
||||||
}
|
}
|
||||||
const adminToken = await login(EMAILS.DD_ADMIN);
|
const adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
const dealerToken = await login(EMAILS.DEALER);
|
const dealerToken = await login(EMAILS.DEALER, "Dealer@123");
|
||||||
|
|
||||||
let requestId = args.requestId;
|
let requestId = args.requestId;
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user