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
|
||||
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',
|
||||
minApprovals: 1,
|
||||
approvalMode: 'ROLE_MANDATORY',
|
||||
requiredRoles: ['DD Admin', 'Super Admin'],
|
||||
requiredRoles: ['DD Admin', 'Super Admin', 'DD Lead', 'DD Head'],
|
||||
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' },
|
||||
category: 'SECURITY_DEPOSIT',
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
/**
|
||||
* Centralized utility for ID generation and nomenclature across all modules.
|
||||
@ -7,46 +7,89 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
*/
|
||||
export class NomenclatureService {
|
||||
/**
|
||||
* Generates a Resignation ID (e.g., RES-2026-1234)
|
||||
* Generic helper for generating sequential IDs with Month/Year prefix
|
||||
*/
|
||||
static generateResignationId() {
|
||||
return `RES-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
private static async generateSequentialId(modelName: string, fieldName: string, modulePrefix: string) {
|
||||
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() {
|
||||
return `TRM-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
static async generateApplicationId() {
|
||||
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() {
|
||||
return `CC-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
static async generateResignationId() {
|
||||
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() {
|
||||
return `APP-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
static async generateTerminationId() {
|
||||
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() {
|
||||
const year = new Date().getFullYear();
|
||||
const rand = Math.floor(1 + Math.random() * 999);
|
||||
return `FNF-${year}-${rand.toString().padStart(3, '0')}`;
|
||||
static async generateConstitutionalChangeId() {
|
||||
return this.generateSequentialId('ConstitutionalChange', 'requestId', 'CC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Relocation Request ID (e.g., REL-2026-1234)
|
||||
* Generates a Relocation Request ID (e.g., REL-2026-FEB-00001)
|
||||
*/
|
||||
static generateRelocationId() {
|
||||
return `REL-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
static async generateRelocationId() {
|
||||
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;
|
||||
zmCode?: string | null;
|
||||
city?: string | null;
|
||||
isActive?: boolean;
|
||||
isOpportunity?: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
isActive: {
|
||||
isOpportunity: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
|
||||
@ -5,7 +5,7 @@ export interface LocationAttributes {
|
||||
name: string;
|
||||
districtId: string | null;
|
||||
city?: string | null;
|
||||
isActive?: boolean;
|
||||
isOpportunity?: boolean;
|
||||
openFrom?: Date | null;
|
||||
openTo?: Date | null;
|
||||
description?: string | null;
|
||||
@ -36,7 +36,7 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
isActive: {
|
||||
isOpportunity: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
|
||||
@ -44,6 +44,9 @@ import createQuestionnaireQuestion from './verification/QuestionnaireQuestion.js
|
||||
import createQuestionnaireResponse from './verification/QuestionnaireResponse.js';
|
||||
import createQuestionnaireScore from './verification/QuestionnaireScore.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 createFddAssignment from './verification/FddAssignment.js';
|
||||
import createFddReport from './verification/FddReport.js';
|
||||
@ -182,6 +185,9 @@ db.InterviewParticipant = createInterviewParticipant(sequelize);
|
||||
db.InterviewEvaluation = createInterviewEvaluation(sequelize);
|
||||
db.KTMatrixScore = createKTMatrixScore(sequelize);
|
||||
db.InterviewFeedback = createInterviewFeedback(sequelize);
|
||||
db.InterviewConfig = createInterviewConfig(sequelize);
|
||||
db.InterviewConfigItem = createInterviewConfigItem(sequelize);
|
||||
db.InterviewConfigItemOption = createInterviewConfigItemOption(sequelize);
|
||||
db.AiSummary = createAiSummary(sequelize);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
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) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching users' });
|
||||
|
||||
@ -5,7 +5,7 @@ const {
|
||||
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
|
||||
} = db;
|
||||
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 {
|
||||
resolveEntityUuidByType,
|
||||
requestTypeQueryVariants,
|
||||
@ -482,22 +482,44 @@ export const addParticipant = async (req: AuthRequest, res: Response) => {
|
||||
export const removeParticipant = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const participant = await RequestParticipant.findByPk(id);
|
||||
await RequestParticipant.destroy({ where: { id } });
|
||||
const { reason } = req.body || {}; // Safe destructuring
|
||||
|
||||
// Audit log for participant removed
|
||||
if (participant) {
|
||||
await AuditLog.create({
|
||||
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 }
|
||||
});
|
||||
const participant = await RequestParticipant.findByPk(id);
|
||||
if (!participant) {
|
||||
return res.status(404).json({ success: false, message: 'Participant not found' });
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
import * as collaborationController from './collaboration.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
|
||||
import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js';
|
||||
|
||||
router.use(authenticate as any);
|
||||
|
||||
// Worknotes
|
||||
router.get('/worknotes', collaborationController.getWorknotes);
|
||||
router.post('/worknotes', collaborationController.addWorknote);
|
||||
router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
|
||||
router.post('/worknotes', checkRevocation, collaborationController.addWorknote);
|
||||
router.post('/upload', checkRevocation, uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
|
||||
|
||||
// Participants
|
||||
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 zoneId = req.query.zoneId 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 (zoneId) where['$district.zoneId$'] = zoneId;
|
||||
if (regionId) where['$district.regionId$'] = regionId;
|
||||
if (isActive !== undefined) where.isActive = isActive === 'true';
|
||||
if (stateId && stateId !== 'all') where['$district.stateId$'] = stateId;
|
||||
if (zoneId && zoneId !== 'all') where['$district.zoneId$'] = zoneId;
|
||||
if (regionId && regionId !== 'all') where['$district.regionId$'] = regionId;
|
||||
|
||||
if (isOpportunity === 'true' || isOpportunity === 'false') {
|
||||
where.isOpportunity = isOpportunity === 'true';
|
||||
}
|
||||
|
||||
const { count, rows: areas } = await db.Location.findAndCountAll({
|
||||
where,
|
||||
@ -138,21 +141,18 @@ export const getAreas = async (req: Request, res: Response) => {
|
||||
// --- Districts (Territory Entities) ---
|
||||
export const getDistricts = async (req: Request, res: Response) => {
|
||||
try {
|
||||
let search = req.query.search as string;
|
||||
let limit = (req.query.limit || 10) as any;
|
||||
const stateId = req.query.stateId as string;
|
||||
const zoneId = req.query.zoneId as string;
|
||||
const regionId = req.query.regionId as string;
|
||||
|
||||
const { search, page = 1, limit = 10, stateId, zoneId, regionId, isActive, isOpportunity } = req.query as any;
|
||||
const isAll = limit === 'all' || limit === -1 || limit === '-1';
|
||||
const offset = isAll ? null : (Number(page) - 1) * Number(limit);
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.name = { [Op.iLike]: `%${search}%` };
|
||||
}
|
||||
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive;
|
||||
|
||||
if (search) where.name = { [Op.iLike]: `%${search}%` };
|
||||
if (stateId) where.stateId = stateId;
|
||||
if (zoneId) where.zoneId = zoneId;
|
||||
if (regionId) where.regionId = regionId;
|
||||
if (finalIsOpportunity !== undefined) where.isOpportunity = finalIsOpportunity === 'true';
|
||||
|
||||
const { count, rows: districts } = await db.District.findAndCountAll({
|
||||
where,
|
||||
@ -184,10 +184,10 @@ export const getDistricts = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const createDistrict = async (req: Request, res: Response) => {
|
||||
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
|
||||
if (districtId) {
|
||||
@ -201,7 +201,7 @@ export const createDistrict = async (req: Request, res: Response) => {
|
||||
name: areaName,
|
||||
districtId: district.id,
|
||||
city: city || areaName,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
isOpportunity: finalIsOpportunity,
|
||||
openFrom: openFrom || null,
|
||||
openTo: openTo || null,
|
||||
description: description || null
|
||||
@ -238,14 +238,14 @@ export const createDistrict = async (req: Request, res: Response) => {
|
||||
name,
|
||||
code,
|
||||
stateId,
|
||||
isActive: isActive !== undefined ? isActive : true
|
||||
isOpportunity: finalIsOpportunity
|
||||
});
|
||||
|
||||
const area = await db.Location.create({
|
||||
name,
|
||||
districtId: district.id,
|
||||
city: city || name,
|
||||
isActive: true,
|
||||
isOpportunity: true,
|
||||
openFrom: openFrom || null,
|
||||
openTo: openTo || null,
|
||||
description: description || null
|
||||
@ -810,7 +810,8 @@ export const deleteLocation = async (req: Request, res: Response) => {
|
||||
export const updateLocation = async (req: Request, res: Response) => {
|
||||
try {
|
||||
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, {
|
||||
include: [{ model: db.District, as: 'district' }]
|
||||
@ -842,7 +843,7 @@ export const updateLocation = async (req: Request, res: Response) => {
|
||||
name: name || district.name,
|
||||
code: code || district.code,
|
||||
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,
|
||||
districtId: district?.id || area.districtId,
|
||||
city: city || area.city,
|
||||
isActive: isActive !== undefined ? isActive : area.isActive,
|
||||
openFrom: openFrom !== undefined ? (openFrom || null) : area.openFrom,
|
||||
openTo: openTo !== undefined ? (openTo || null) : area.openTo,
|
||||
isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : area.isOpportunity,
|
||||
openFrom: openFrom || area.openFrom,
|
||||
openTo: openTo || area.openTo,
|
||||
description: description || area.description
|
||||
});
|
||||
|
||||
@ -885,35 +886,32 @@ export const updateLocation = async (req: Request, res: Response) => {
|
||||
// --- Managers ---
|
||||
export const getASMs = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const asms = await db.User.findAll({
|
||||
const asms = await User.findAll({
|
||||
where: {
|
||||
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] },
|
||||
isActive: true
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: db.UserRole,
|
||||
as: 'userRoles',
|
||||
association: 'userRoles',
|
||||
where: { isActive: true },
|
||||
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,
|
||||
as: 'managedAsmDistricts',
|
||||
association: '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'] }
|
||||
{ association: 'state', attributes: ['id', 'name'] },
|
||||
{ association: 'region', attributes: ['id', 'name'] },
|
||||
{ association: 'zone', attributes: ['id', 'name'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
model: db.District,
|
||||
as: 'managedDdAmDistricts',
|
||||
association: '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'] }
|
||||
{ association: 'state', attributes: ['id', 'name'] },
|
||||
{ association: 'region', 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 asmDistricts = u.managedAsmDistricts || [];
|
||||
const ddAmDistricts = u.managedDdAmDistricts || [];
|
||||
const ddAmDistricts = u.managedAreaDistricts || [];
|
||||
const districts = [...asmDistricts, ...ddAmDistricts];
|
||||
|
||||
const roleAssignment = (u.userRoles || []).find((r: any) =>
|
||||
|
||||
@ -32,6 +32,16 @@ import {
|
||||
initializeDefaultSlas
|
||||
} from './master.controller.js';
|
||||
|
||||
import {
|
||||
getInterviewConfigByType,
|
||||
getInterviewConfigs,
|
||||
getInterviewConfigById,
|
||||
createInterviewConfig,
|
||||
updateInterviewConfig,
|
||||
deleteInterviewConfig,
|
||||
initializeDefaultInterviewConfigs
|
||||
} from './interviewConfig.controller.js';
|
||||
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -77,4 +87,13 @@ router.get('/sla-configs', getSlaConfigs);
|
||||
router.post('/sla-configs', saveSlaConfig);
|
||||
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;
|
||||
|
||||
@ -12,7 +12,11 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
|
||||
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,
|
||||
include: [
|
||||
{
|
||||
@ -29,12 +33,20 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
outlets
|
||||
outlets,
|
||||
meta: {
|
||||
total: count,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
currentPage: page,
|
||||
limit
|
||||
}
|
||||
});
|
||||
} catch (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
|
||||
// ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
|
||||
|
||||
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;
|
||||
// Normalize incoming ID sources for robustness
|
||||
const incomingLocationId = req.body.locationId || req.body.districtId;
|
||||
@ -163,10 +164,28 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
if (isOpportunityAvailable) {
|
||||
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 {
|
||||
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({
|
||||
@ -191,32 +210,143 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const whereClause: any = {};
|
||||
|
||||
// Security Check: If prospective dealer, only show their own application
|
||||
if (req.user?.roleCode === 'Prospective Dealer') {
|
||||
// Filter by phone instead of email to show all applications from same user
|
||||
whereClause.phone = (req.user as any).phone || req.user.email;
|
||||
}
|
||||
// Determine if user has national-level visibility
|
||||
const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'Admin', 'Super Admin'];
|
||||
const isNationalUser = nationalRoles.includes(req.user?.roleCode || '');
|
||||
const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer';
|
||||
|
||||
// Security Check: If FDD user, only show applications where they are a participant
|
||||
if (req.user?.roleCode === 'FDD') {
|
||||
if (isProspectiveDealer) {
|
||||
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({
|
||||
where: { userId: req.user.id, requestType: 'application' },
|
||||
where: { userId: req.user?.id, requestType: 'application' },
|
||||
attributes: ['requestId']
|
||||
});
|
||||
const appIds = participantApps.map((p: any) => p.requestId);
|
||||
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,
|
||||
include: [
|
||||
{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] },
|
||||
{ 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) {
|
||||
console.error('Get applications error:', error);
|
||||
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' });
|
||||
}
|
||||
|
||||
if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' });
|
||||
}
|
||||
|
||||
// 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];
|
||||
// assignedTo is now optional as auto-assignment is handled via location
|
||||
const assignedToArr = Array.isArray(assignedTo) ? assignedTo : [];
|
||||
const primaryManualAssigneeId = assignedToArr.length > 0 ? assignedToArr[0] : null;
|
||||
|
||||
// Update Applications sequentially via WorkflowService for consistency
|
||||
for (const appId of applicationIds) {
|
||||
@ -587,11 +713,23 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
||||
const application = await Application.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId }
|
||||
});
|
||||
|
||||
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({
|
||||
ddLeadShortlisted: true,
|
||||
isShortlisted: true,
|
||||
assignedTo: primaryAssigneeId,
|
||||
assignedTo: resolvedAssigneeId,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
@ -608,22 +746,24 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
||||
application.applicationId
|
||||
).catch(err => console.error('Failed to send shortlist email:', err));
|
||||
|
||||
// Add all assigned users as participants
|
||||
for (const userId of assignedTo) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' },
|
||||
defaults: { joinedMethod: 'auto' }
|
||||
});
|
||||
// Add manual assignees as participants if provided
|
||||
if (assignedToArr.length > 0) {
|
||||
for (const userId of assignedToArr) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
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) {
|
||||
console.error('Bulk shortlist error:', error);
|
||||
@ -640,6 +780,10 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
||||
*/
|
||||
const assignStageEvaluators = async (appIdOrId: string) => {
|
||||
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}`);
|
||||
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({
|
||||
@ -752,7 +896,6 @@ const assignStageEvaluators = async (appIdOrId: string) => {
|
||||
}
|
||||
|
||||
for (const [userId, assignment] of Object.entries(userAssignments)) {
|
||||
const isInterview = assignment.stages.some(s => typeof s === 'number');
|
||||
const primaryStage = assignment.stages[0];
|
||||
const primaryRole = assignment.roles[0];
|
||||
|
||||
@ -769,29 +912,37 @@ const assignStageEvaluators = async (appIdOrId: string) => {
|
||||
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
||||
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
|
||||
role: primaryRole,
|
||||
allAssignments: assignment.stages, // Store all assignments
|
||||
allAssignments: assignment.stages,
|
||||
autoMapped: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
joinedMethod: 'auto', // Ensure it's marked as auto if it wasn't
|
||||
metadata: {
|
||||
...meta,
|
||||
allAssignments: mergedAssignments,
|
||||
// Maintain legacy fields for compatibility if they don't exist
|
||||
interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null),
|
||||
stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null)
|
||||
...(participant.metadata || {}),
|
||||
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
|
||||
stageCode: typeof primaryStage === 'string' ? primaryStage : null,
|
||||
role: primaryRole,
|
||||
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) {
|
||||
console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error);
|
||||
}
|
||||
@ -1019,15 +1170,15 @@ export const getDocumentConfigMetadata = async (_req: AuthRequest, res: Response
|
||||
|
||||
// Fetch Document Configurations based on Role and Stage
|
||||
export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
|
||||
try { const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
|
||||
const roleCode = (roleFilter as string) || req.user?.role;
|
||||
|
||||
const where: any = { module };
|
||||
if (stageCode) {
|
||||
where.stageCode = { [Op.or]: [stageCode, 'General'] };
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ documentType: { [Op.iLike]: `%${search}%` } },
|
||||
@ -1190,8 +1341,225 @@ export const exportApplicationResponses = async (req: AuthRequest, res: Response
|
||||
});
|
||||
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Export error:', error);
|
||||
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,
|
||||
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
||||
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
||||
exportApplicationResponses
|
||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
||||
} from './onboarding.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
@ -33,6 +33,8 @@ router.put('/applications/:id', updateApplication);
|
||||
router.put('/applications/:id/status', updateApplicationStatus);
|
||||
router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
|
||||
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
|
||||
router.post('/applications/:id/assign-architecture', assignArchitectureTeam);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Response } from 'express';
|
||||
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 { Op } from 'sequelize';
|
||||
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;
|
||||
}
|
||||
|
||||
const requestId = NomenclatureService.generateConstitutionalChangeId();
|
||||
const requestId = await NomenclatureService.generateConstitutionalChangeId();
|
||||
|
||||
const metadata = {
|
||||
newPartnersDetails,
|
||||
@ -241,9 +241,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
const where: any = {};
|
||||
if (req.user.roleCode === 'Dealer') {
|
||||
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,
|
||||
include: [
|
||||
{ model: Outlet, as: 'outlet' },
|
||||
@ -251,25 +268,31 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName'],
|
||||
include: [
|
||||
{
|
||||
model: Dealer,
|
||||
as: 'dealerProfile',
|
||||
include: [
|
||||
{
|
||||
model: Application,
|
||||
as: 'application',
|
||||
include: [{ model: District, as: 'district' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
include: [{ model: Dealer, as: 'dealerProfile', include: [{ model: DealerCode, as: 'dealerCode' }] }]
|
||||
}
|
||||
],
|
||||
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) {
|
||||
console.error('Get constitutional changes error:', error);
|
||||
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'
|
||||
};
|
||||
|
||||
const hasRoleInConfig = (rolesArray: string[] | undefined, role: string) => {
|
||||
if (!rolesArray || !role) return false;
|
||||
return rolesArray.includes(role);
|
||||
};
|
||||
|
||||
const normalizeRoleKey = (rawRole: string) => {
|
||||
const role = String(rawRole || '').trim().toUpperCase();
|
||||
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
|
||||
};
|
||||
|
||||
const approvedRequiredRoles = requiredRoles.filter((role) => Boolean(zmRbm[role]?.approvedByUserId));
|
||||
const waitingFor = requiredRoles.filter((role) => !zmRbm[role]?.approvedByUserId);
|
||||
const approvedRequiredRoles = requiredRoles.filter((role: string) => Boolean(zmRbm[role]?.approvedByUserId));
|
||||
const waitingFor = requiredRoles.filter((role: string) => !zmRbm[role]?.approvedByUserId);
|
||||
const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length);
|
||||
|
||||
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({
|
||||
requestId,
|
||||
@ -400,9 +400,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
const where: any = {};
|
||||
if (req.user.roleCode === 'Dealer') {
|
||||
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,
|
||||
include: [
|
||||
{
|
||||
@ -427,7 +444,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
attributes: ['fullName']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
distinct: true
|
||||
});
|
||||
|
||||
// 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) {
|
||||
console.error('Get relocation requests error:', error);
|
||||
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' };
|
||||
});
|
||||
|
||||
const resignationId = NomenclatureService.generateResignationId();
|
||||
const resignationId = await NomenclatureService.generateResignationId();
|
||||
const resignation = await db.Resignation.create({
|
||||
resignationId,
|
||||
outletId,
|
||||
@ -115,9 +115,32 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
|
||||
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
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,
|
||||
include: [
|
||||
{ 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) {
|
||||
logger.error('Error fetching resignations:', 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.
|
||||
const fnf = await db.FnF.create({
|
||||
settlementId: NomenclatureService.generateFnFId(),
|
||||
settlementId: await NomenclatureService.generateFnFId(),
|
||||
resignationId: resignation.id,
|
||||
outletId: resignation.outletId,
|
||||
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 { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.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 = {
|
||||
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
||||
@ -118,15 +118,30 @@ export const uploadFnFDocument = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
export const getOnboardingPayments = async (req: AuthRequest, res: Response) => {
|
||||
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: [{
|
||||
model: Application,
|
||||
as: 'application',
|
||||
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) {
|
||||
console.error('Get onboarding payments error:', error);
|
||||
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) => {
|
||||
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: [
|
||||
{ model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] },
|
||||
{ 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: 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) {
|
||||
res.status(500).json({ success: false, message: 'Error fetching settlements' });
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import db from '../../database/models/index.js';
|
||||
import logger from '../../common/utils/logger.js';
|
||||
import {
|
||||
@ -32,7 +33,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||
|
||||
const requestId = NomenclatureService.generateTerminationId();
|
||||
const requestId = await NomenclatureService.generateTerminationId();
|
||||
const termination = await db.TerminationRequest.create({
|
||||
requestId,
|
||||
dealerId,
|
||||
@ -86,9 +87,24 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
const dealer = await db.Dealer.findOne({ where: { userId: req.user.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,
|
||||
include: [
|
||||
{
|
||||
@ -97,9 +113,26 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
|
||||
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) {
|
||||
logger.error('Error fetching terminations:', 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 { ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { isAutoAssignmentEnabled } from './AutoAssignmentConfigService.js';
|
||||
|
||||
const {
|
||||
RequestParticipant,
|
||||
@ -22,20 +23,33 @@ export class ParticipantService {
|
||||
*/
|
||||
private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) {
|
||||
try {
|
||||
await RequestParticipant.findOrCreate({
|
||||
const existing = await RequestParticipant.findOne({
|
||||
where: {
|
||||
requestId,
|
||||
requestType,
|
||||
userId
|
||||
},
|
||||
defaults: {
|
||||
participantType,
|
||||
joinedMethod: 'auto',
|
||||
metadata: {
|
||||
...metadata,
|
||||
autoMapped: true,
|
||||
assignedAt: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Requirement: Auto-assignment logic skips participants with revokedAt to prevent re-adding
|
||||
if (existing.metadata?.revokedAt) {
|
||||
console.log(`[ParticipantService] Skipping revoked participant ${userId} for ${requestType} ${requestId}`);
|
||||
return;
|
||||
}
|
||||
return; // Already exists
|
||||
}
|
||||
|
||||
await RequestParticipant.create({
|
||||
requestId,
|
||||
requestType,
|
||||
userId,
|
||||
participantType,
|
||||
joinedMethod: 'auto',
|
||||
metadata: {
|
||||
...metadata,
|
||||
autoMapped: true,
|
||||
assignedAt: new Date()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@ -80,6 +94,10 @@ export class ParticipantService {
|
||||
*/
|
||||
static async assignTerminationParticipants(requestId: string) {
|
||||
try {
|
||||
if (!await isAutoAssignmentEnabled('TERMINATION')) {
|
||||
console.log(`[ParticipantService] Auto-assignment disabled for TERMINATION. Skipping for ${requestId}`);
|
||||
return;
|
||||
}
|
||||
const termination = await db.TerminationRequest.findByPk(requestId);
|
||||
if (!termination) {
|
||||
console.error(`[ParticipantService] Termination Request not found: ${requestId}`);
|
||||
@ -154,6 +172,10 @@ export class ParticipantService {
|
||||
*/
|
||||
static async assignConstitutionalParticipants(requestId: string) {
|
||||
try {
|
||||
if (!await isAutoAssignmentEnabled('CONSTITUTIONAL')) {
|
||||
console.log(`[ParticipantService] Auto-assignment disabled for CONSTITUTIONAL. Skipping for ${requestId}`);
|
||||
return;
|
||||
}
|
||||
const request = await ConstitutionalChange.findByPk(requestId);
|
||||
if (!request) return;
|
||||
|
||||
@ -215,6 +237,10 @@ export class ParticipantService {
|
||||
*/
|
||||
static async assignResignationParticipants(requestId: string) {
|
||||
try {
|
||||
if (!await isAutoAssignmentEnabled('RESIGNATION')) {
|
||||
console.log(`[ParticipantService] Auto-assignment disabled for RESIGNATION. Skipping for ${requestId}`);
|
||||
return;
|
||||
}
|
||||
const resignation = await db.Resignation.findByPk(requestId);
|
||||
if (!resignation) {
|
||||
console.error(`[ParticipantService] Resignation not found: ${requestId}`);
|
||||
@ -286,6 +312,10 @@ export class ParticipantService {
|
||||
*/
|
||||
static async assignRelocationParticipants(requestId: string) {
|
||||
try {
|
||||
if (!await isAutoAssignmentEnabled('RELOCATION')) {
|
||||
console.log(`[ParticipantService] Auto-assignment disabled for RELOCATION. Skipping for ${requestId}`);
|
||||
return;
|
||||
}
|
||||
const relocation = await db.RelocationRequest.findByPk(requestId, {
|
||||
include: [{
|
||||
model: Outlet,
|
||||
@ -367,6 +397,10 @@ export class ParticipantService {
|
||||
*/
|
||||
static async assignFnFParticipants(fnfId: string) {
|
||||
try {
|
||||
if (!await isAutoAssignmentEnabled('FNF')) {
|
||||
console.log(`[ParticipantService] Auto-assignment disabled for FNF. Skipping for ${fnfId}`);
|
||||
return;
|
||||
}
|
||||
const fnf = await db.FnF.findByPk(fnfId);
|
||||
if (!fnf) return;
|
||||
|
||||
|
||||
@ -180,7 +180,7 @@ export class TerminationWorkflowService {
|
||||
|
||||
if (!fnf) {
|
||||
fnf = await db.FnF.create({
|
||||
settlementId: NomenclatureService.generateFnFId(),
|
||||
settlementId: await NomenclatureService.generateFnFId(),
|
||||
terminationRequestId: termination.id,
|
||||
dealerId: termination.dealerId,
|
||||
outletId: primaryOutlet?.id || null,
|
||||
|
||||
@ -121,7 +121,7 @@ export class WorkflowService {
|
||||
}
|
||||
|
||||
// 5. Notifications — non-fatal
|
||||
if (application.email) {
|
||||
if (application.email && !metadata.skipNotification) {
|
||||
try {
|
||||
const user = await User.findOne({
|
||||
where: { email: application.email },
|
||||
@ -200,10 +200,12 @@ export class WorkflowService {
|
||||
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
|
||||
*/
|
||||
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
|
||||
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } });
|
||||
if (!policy) return { policyMet: true }; // No policy means no restriction
|
||||
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
|
||||
if (!policy) return { policyMet: true }; // No active policy means no restriction
|
||||
|
||||
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
|
||||
const actions = await db.StageApprovalAction.findAll({
|
||||
@ -213,21 +215,40 @@ export class WorkflowService {
|
||||
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
|
||||
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' };
|
||||
}
|
||||
|
||||
let roleConditionMet = false;
|
||||
|
||||
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval)
|
||||
? true
|
||||
: requiredRoles.every((role: string) => approvedRoles.has(role));
|
||||
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 meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1);
|
||||
const meetsMinCount = uniqueApprovers.size >= minNeeded;
|
||||
|
||||
return {
|
||||
policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals,
|
||||
policyMet: roleConditionMet && meetsMinCount,
|
||||
policy,
|
||||
uniqueApprovers: Array.from(uniqueApprovers),
|
||||
approvedRoles: Array.from(approvedRoles),
|
||||
hasAllRequiredRoleApprovals,
|
||||
meetsMinApprovals
|
||||
roleConditionMet,
|
||||
meetsMinCount,
|
||||
mode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
|
||||
|
||||
const EMAILS = {
|
||||
DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com",
|
||||
DEALER: args.dealerEmail,
|
||||
DEALER: args.dealerEmail || "ramesh_1777038131833@gmail.com",
|
||||
ASM: args.asmEmail || "abhishek@royalenfield.com",
|
||||
RBM: args.rbmEmail || "manish@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;
|
||||
}
|
||||
|
||||
async function login(email) {
|
||||
async function login(email, password = PASSWORD) {
|
||||
if (!login.cache) login.cache = {};
|
||||
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;
|
||||
return data.token;
|
||||
}
|
||||
@ -89,7 +89,7 @@ async function approveCurrentStage(requestId, stageName) {
|
||||
}
|
||||
throw new Error(
|
||||
`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.");
|
||||
}
|
||||
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;
|
||||
if (!requestId) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user