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:
laxman h 2026-04-24 19:29:51 +05:30
parent c6b7b79021
commit 71d9f9dbba
41 changed files with 2345 additions and 201 deletions

1
.gitignore vendored
View File

@ -135,3 +135,4 @@ uploads/
# GCP Service Account Key # GCP Service Account Key
config/gcp-key.json config/gcp-key.json
src/database/models/index.ts

20
check_db.js Normal file
View 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();

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

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

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

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

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

View File

@ -43,7 +43,7 @@ const policies = [
stageCode: 'FDD_VERIFICATION', stageCode: 'FDD_VERIFICATION',
minApprovals: 1, minApprovals: 1,
approvalMode: 'ROLE_MANDATORY', approvalMode: 'ROLE_MANDATORY',
requiredRoles: ['DD Admin', 'Super Admin'], requiredRoles: ['DD Admin', 'Super Admin', 'DD Lead', 'DD Head'],
isActive: true isActive: true
}, },
{ {

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

View 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);
});

View File

@ -22,6 +22,42 @@ const seedSystemConfigs = async () => {
value: { amount: 1500000, currency: 'INR' }, value: { amount: 1500000, currency: 'INR' },
category: 'SECURITY_DEPOSIT', category: 'SECURITY_DEPOSIT',
description: 'Default First Fill amount for new dealer onboarding' description: 'Default First Fill amount for new dealer onboarding'
},
{
key: 'AUTO_ASSIGN_ONBOARDING',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for Onboarding module'
},
{
key: 'AUTO_ASSIGN_RELOCATION',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for Relocation module'
},
{
key: 'AUTO_ASSIGN_TERMINATION',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for Termination module'
},
{
key: 'AUTO_ASSIGN_CONSTITUTIONAL',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for Constitutional module'
},
{
key: 'AUTO_ASSIGN_RESIGNATION',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for Resignation module'
},
{
key: 'AUTO_ASSIGN_FNF',
value: { enabled: true },
category: 'ASSIGNMENT',
description: 'Enable/Disable auto-assignment of participants for F&F module'
} }
]; ];

View 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
}
};

View File

@ -1,5 +1,5 @@
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize';
/** /**
* Centralized utility for ID generation and nomenclature across all modules. * Centralized utility for ID generation and nomenclature across all modules.
@ -7,46 +7,89 @@ import { v4 as uuidv4 } from 'uuid';
*/ */
export class NomenclatureService { export class NomenclatureService {
/** /**
* Generates a Resignation ID (e.g., RES-2026-1234) * Generic helper for generating sequential IDs with Month/Year prefix
*/ */
static generateResignationId() { private static async generateSequentialId(modelName: string, fieldName: string, modulePrefix: string) {
return `RES-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; const now = new Date();
const year = now.getFullYear();
const monthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const month = monthNames[now.getMonth()];
const prefix = `${modulePrefix}-${year}-${month}-`;
try {
const Model = (db as any)[modelName];
if (!Model) {
throw new Error(`Model ${modelName} not found in database indexed models.`);
}
// Find the last ID with this prefix to increment
const lastRecord = await Model.findOne({
where: {
[fieldName]: {
[Op.like]: `${prefix}%`
}
},
order: [['createdAt', 'DESC']],
attributes: [fieldName]
});
let nextNumber = 1;
if (lastRecord && lastRecord[fieldName]) {
const parts = lastRecord[fieldName].split('-');
const lastNumStr = parts[parts.length - 1];
const lastNum = parseInt(lastNumStr, 10);
if (!isNaN(lastNum)) {
nextNumber = lastNum + 1;
}
}
return `${prefix}${nextNumber.toString().padStart(5, '0')}`;
} catch (error) {
console.error(`Error generating sequential ID for ${modulePrefix}:`, error);
// Fallback to random if DB fetch fails
return `${prefix}${Math.floor(10000 + Math.random() * 90000)}`;
}
} }
/** /**
* Generates a Termination Request ID (e.g., TRM-2026-1234) * Generates an Onboarding Application ID (e.g., DD-2026-FEB-00001)
*/ */
static generateTerminationId() { static async generateApplicationId() {
return `TRM-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; return this.generateSequentialId('Application', 'applicationId', 'DD');
} }
/** /**
* Generates a Constitutional Change ID (e.g., CC-2026-1234) * Generates a Resignation ID (e.g., RES-2026-FEB-00001)
*/ */
static generateConstitutionalChangeId() { static async generateResignationId() {
return `CC-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; return this.generateSequentialId('Resignation', 'resignationId', 'RES');
} }
/** /**
* Generates an Onboarding Application ID (e.g., APP-2026-5678) * Generates a Termination Request ID (e.g., TER-2026-FEB-00001)
*/ */
static generateApplicationId() { static async generateTerminationId() {
return `APP-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; return this.generateSequentialId('TerminationRequest', 'requestId', 'TER');
} }
/** /**
* Generates a Settlement/FnF ID (e.g., FNF-2025-001) * Generates a Constitutional Change ID (e.g., CC-2026-FEB-00001)
*/ */
static generateFnFId() { static async generateConstitutionalChangeId() {
const year = new Date().getFullYear(); return this.generateSequentialId('ConstitutionalChange', 'requestId', 'CC');
const rand = Math.floor(1 + Math.random() * 999);
return `FNF-${year}-${rand.toString().padStart(3, '0')}`;
} }
/** /**
* Generates a Relocation Request ID (e.g., REL-2026-1234) * Generates a Relocation Request ID (e.g., REL-2026-FEB-00001)
*/ */
static generateRelocationId() { static async generateRelocationId() {
return `REL-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; return this.generateSequentialId('RelocationRequest', 'requestId', 'REL');
}
/**
* Generates a Settlement/FnF ID (e.g., FNF-2026-FEB-00001)
*/
static async generateFnFId() {
return this.generateSequentialId('FnF', 'settlementId', 'FNF');
} }
} }

View File

@ -14,7 +14,7 @@ export interface DistrictAttributes {
zmId?: string | null; zmId?: string | null;
zmCode?: string | null; zmCode?: string | null;
city?: string | null; city?: string | null;
isActive?: boolean; isOpportunity?: boolean;
description?: string | null; description?: string | null;
} }
@ -91,7 +91,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true allowNull: true
}, },
isActive: { isOpportunity: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: true defaultValue: true
}, },

View File

@ -5,7 +5,7 @@ export interface LocationAttributes {
name: string; name: string;
districtId: string | null; districtId: string | null;
city?: string | null; city?: string | null;
isActive?: boolean; isOpportunity?: boolean;
openFrom?: Date | null; openFrom?: Date | null;
openTo?: Date | null; openTo?: Date | null;
description?: string | null; description?: string | null;
@ -36,7 +36,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true allowNull: true
}, },
isActive: { isOpportunity: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: true defaultValue: true
}, },

View File

@ -44,6 +44,9 @@ import createQuestionnaireQuestion from './verification/QuestionnaireQuestion.js
import createQuestionnaireResponse from './verification/QuestionnaireResponse.js'; import createQuestionnaireResponse from './verification/QuestionnaireResponse.js';
import createQuestionnaireScore from './verification/QuestionnaireScore.js'; import createQuestionnaireScore from './verification/QuestionnaireScore.js';
import createKTMatrixScore from './verification/KTMatrixScore.js'; import createKTMatrixScore from './verification/KTMatrixScore.js';
import createInterviewConfig from './verification/InterviewConfig.js';
import createInterviewConfigItem from './verification/InterviewConfigItem.js';
import createInterviewConfigItemOption from './verification/InterviewConfigItemOption.js';
import createAiSummary from './verification/AiSummary.js'; import createAiSummary from './verification/AiSummary.js';
import createFddAssignment from './verification/FddAssignment.js'; import createFddAssignment from './verification/FddAssignment.js';
import createFddReport from './verification/FddReport.js'; import createFddReport from './verification/FddReport.js';
@ -182,6 +185,9 @@ db.InterviewParticipant = createInterviewParticipant(sequelize);
db.InterviewEvaluation = createInterviewEvaluation(sequelize); db.InterviewEvaluation = createInterviewEvaluation(sequelize);
db.KTMatrixScore = createKTMatrixScore(sequelize); db.KTMatrixScore = createKTMatrixScore(sequelize);
db.InterviewFeedback = createInterviewFeedback(sequelize); db.InterviewFeedback = createInterviewFeedback(sequelize);
db.InterviewConfig = createInterviewConfig(sequelize);
db.InterviewConfigItem = createInterviewConfigItem(sequelize);
db.InterviewConfigItemOption = createInterviewConfigItemOption(sequelize);
db.AiSummary = createAiSummary(sequelize); db.AiSummary = createAiSummary(sequelize);
// Batch 4: Dealer Entity, Documents & Work Notes // Batch 4: Dealer Entity, Documents & Work Notes

View 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;
};

View 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;
};

View File

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

View File

@ -308,7 +308,16 @@ export const getAllUsers = async (req: Request, res: Response) => {
return userJson; return userJson;
}); });
res.json({ success: true, data: result, total: count }); res.json({
success: true,
data: result,
meta: {
total: count,
totalPages: Math.ceil(count / Number(limit)),
currentPage: Number(page),
limit: Number(limit)
}
});
} catch (error) { } catch (error) {
console.error('Get users error:', error); console.error('Get users error:', error);
res.status(500).json({ success: false, message: 'Error fetching users' }); res.status(500).json({ success: false, message: 'Error fetching users' });

View File

@ -5,7 +5,7 @@ const {
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
} = db; } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, REQUEST_TYPES, ROLES } from '../../common/config/constants.js';
import { import {
resolveEntityUuidByType, resolveEntityUuidByType,
requestTypeQueryVariants, requestTypeQueryVariants,
@ -482,22 +482,44 @@ export const addParticipant = async (req: AuthRequest, res: Response) => {
export const removeParticipant = async (req: AuthRequest, res: Response) => { export const removeParticipant = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const participant = await RequestParticipant.findByPk(id); const { reason } = req.body || {}; // Safe destructuring
await RequestParticipant.destroy({ where: { id } });
// Audit log for participant removed const participant = await RequestParticipant.findByPk(id);
if (participant) { if (!participant) {
return res.status(404).json({ success: false, message: 'Participant not found' });
}
// 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({ await AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, // Using existing constant
entityType: (participant as any).requestType || 'application', entityType: (participant as any).requestType || 'application',
entityId: (participant as any).requestId, entityId: (participant as any).requestId,
newData: { removedUserId: (participant as any).userId } newData: {
}); revokedUserId: (participant as any).userId,
revocationReason: reason || 'Manual revocation'
} }
});
res.json({ success: true, message: 'Participant removed' }); res.json({ success: true, message: 'Participant access revoked successfully' });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, message: 'Error removing participant' }); console.error('Revoke participant error:', error);
res.status(500).json({ success: false, message: 'Error revoking participant' });
} }
}; };

View File

@ -2,14 +2,15 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
import * as collaborationController from './collaboration.controller.js'; import * as collaborationController from './collaboration.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js'; import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js';
router.use(authenticate as any); router.use(authenticate as any);
// Worknotes // Worknotes
router.get('/worknotes', collaborationController.getWorknotes); router.get('/worknotes', collaborationController.getWorknotes);
router.post('/worknotes', collaborationController.addWorknote); router.post('/worknotes', checkRevocation, collaborationController.addWorknote);
router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment); router.post('/upload', checkRevocation, uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment);
// Participants // Participants
router.post('/participants', collaborationController.addParticipant); router.post('/participants', collaborationController.addParticipant);

View 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' });
}
};

View File

@ -81,12 +81,15 @@ export const getAreas = async (req: Request, res: Response) => {
const stateId = req.query.stateId as string; const stateId = req.query.stateId as string;
const zoneId = req.query.zoneId as string; const zoneId = req.query.zoneId as string;
const regionId = req.query.regionId as string; const regionId = req.query.regionId as string;
const isActive = req.query.isActive as string; const isOpportunity = req.query.isOpportunity as string || req.query.isActive as string;
if (stateId) where['$district.stateId$'] = stateId; if (stateId && stateId !== 'all') where['$district.stateId$'] = stateId;
if (zoneId) where['$district.zoneId$'] = zoneId; if (zoneId && zoneId !== 'all') where['$district.zoneId$'] = zoneId;
if (regionId) where['$district.regionId$'] = regionId; if (regionId && regionId !== 'all') where['$district.regionId$'] = regionId;
if (isActive !== undefined) where.isActive = isActive === 'true';
if (isOpportunity === 'true' || isOpportunity === 'false') {
where.isOpportunity = isOpportunity === 'true';
}
const { count, rows: areas } = await db.Location.findAndCountAll({ const { count, rows: areas } = await db.Location.findAndCountAll({
where, where,
@ -138,21 +141,18 @@ export const getAreas = async (req: Request, res: Response) => {
// --- Districts (Territory Entities) --- // --- Districts (Territory Entities) ---
export const getDistricts = async (req: Request, res: Response) => { export const getDistricts = async (req: Request, res: Response) => {
try { try {
let search = req.query.search as string; const { search, page = 1, limit = 10, stateId, zoneId, regionId, isActive, isOpportunity } = req.query as any;
let limit = (req.query.limit || 10) as any;
const stateId = req.query.stateId as string;
const zoneId = req.query.zoneId as string;
const regionId = req.query.regionId as string;
const isAll = limit === 'all' || limit === -1 || limit === '-1'; const isAll = limit === 'all' || limit === -1 || limit === '-1';
const offset = isAll ? null : (Number(page) - 1) * Number(limit);
const where: any = {}; const where: any = {};
if (search) { const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive;
where.name = { [Op.iLike]: `%${search}%` };
} if (search) where.name = { [Op.iLike]: `%${search}%` };
if (stateId) where.stateId = stateId; if (stateId) where.stateId = stateId;
if (zoneId) where.zoneId = zoneId; if (zoneId) where.zoneId = zoneId;
if (regionId) where.regionId = regionId; if (regionId) where.regionId = regionId;
if (finalIsOpportunity !== undefined) where.isOpportunity = finalIsOpportunity === 'true';
const { count, rows: districts } = await db.District.findAndCountAll({ const { count, rows: districts } = await db.District.findAndCountAll({
where, where,
@ -184,10 +184,10 @@ export const getDistricts = async (req: Request, res: Response) => {
} }
}; };
export const createDistrict = async (req: Request, res: Response) => { export const createDistrict = async (req: Request, res: Response) => {
try { try {
const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body; const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body;
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : (isActive !== undefined ? isActive : true);
// Preferred path: create location against an existing district // Preferred path: create location against an existing district
if (districtId) { if (districtId) {
@ -201,7 +201,7 @@ export const createDistrict = async (req: Request, res: Response) => {
name: areaName, name: areaName,
districtId: district.id, districtId: district.id,
city: city || areaName, city: city || areaName,
isActive: isActive !== undefined ? isActive : true, isOpportunity: finalIsOpportunity,
openFrom: openFrom || null, openFrom: openFrom || null,
openTo: openTo || null, openTo: openTo || null,
description: description || null description: description || null
@ -238,14 +238,14 @@ export const createDistrict = async (req: Request, res: Response) => {
name, name,
code, code,
stateId, stateId,
isActive: isActive !== undefined ? isActive : true isOpportunity: finalIsOpportunity
}); });
const area = await db.Location.create({ const area = await db.Location.create({
name, name,
districtId: district.id, districtId: district.id,
city: city || name, city: city || name,
isActive: true, isOpportunity: true,
openFrom: openFrom || null, openFrom: openFrom || null,
openTo: openTo || null, openTo: openTo || null,
description: description || null description: description || null
@ -810,7 +810,8 @@ export const deleteLocation = async (req: Request, res: Response) => {
export const updateLocation = async (req: Request, res: Response) => { export const updateLocation = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; // This is the Area ID const { id } = req.params; // This is the Area ID
const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body; const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body;
const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive;
const area = await db.Location.findByPk(id, { const area = await db.Location.findByPk(id, {
include: [{ model: db.District, as: 'district' }] include: [{ model: db.District, as: 'district' }]
@ -842,7 +843,7 @@ export const updateLocation = async (req: Request, res: Response) => {
name: name || district.name, name: name || district.name,
code: code || district.code, code: code || district.code,
stateId: stateId || district.stateId, stateId: stateId || district.stateId,
isActive: isActive !== undefined ? isActive : district.isActive isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : district.isOpportunity
}); });
} }
@ -851,9 +852,9 @@ export const updateLocation = async (req: Request, res: Response) => {
name: name || area.name, name: name || area.name,
districtId: district?.id || area.districtId, districtId: district?.id || area.districtId,
city: city || area.city, city: city || area.city,
isActive: isActive !== undefined ? isActive : area.isActive, isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : area.isOpportunity,
openFrom: openFrom !== undefined ? (openFrom || null) : area.openFrom, openFrom: openFrom || area.openFrom,
openTo: openTo !== undefined ? (openTo || null) : area.openTo, openTo: openTo || area.openTo,
description: description || area.description description: description || area.description
}); });
@ -885,35 +886,32 @@ export const updateLocation = async (req: Request, res: Response) => {
// --- Managers --- // --- Managers ---
export const getASMs = async (req: Request, res: Response) => { export const getASMs = async (req: Request, res: Response) => {
try { try {
const asms = await db.User.findAll({ const asms = await User.findAll({
where: { where: {
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] }, roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] },
isActive: true isActive: true
}, },
include: [ include: [
{ {
model: db.UserRole, association: 'userRoles',
as: 'userRoles',
where: { isActive: true }, where: { isActive: true },
required: false, required: false,
include: [{ model: db.Role, as: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }] include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }]
}, },
{ {
model: db.District, association: 'managedAsmDistricts',
as: 'managedAsmDistricts',
include: [ include: [
{ model: db.State, as: 'state', attributes: ['id', 'name'] }, { association: 'state', attributes: ['id', 'name'] },
{ model: db.Region, as: 'region', attributes: ['id', 'name'] }, { association: 'region', attributes: ['id', 'name'] },
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] } { association: 'zone', attributes: ['id', 'name'] }
] ]
}, },
{ {
model: db.District, association: 'managedAreaDistricts',
as: 'managedDdAmDistricts',
include: [ include: [
{ model: db.State, as: 'state', attributes: ['id', 'name'] }, { association: 'state', attributes: ['id', 'name'] },
{ model: db.Region, as: 'region', attributes: ['id', 'name'] }, { association: 'region', attributes: ['id', 'name'] },
{ model: db.Zone, as: 'zone', attributes: ['id', 'name'] } { association: 'zone', attributes: ['id', 'name'] }
] ]
} }
], ],
@ -922,7 +920,7 @@ export const getASMs = async (req: Request, res: Response) => {
const result = (asms || []).map((u: any) => { const result = (asms || []).map((u: any) => {
const asmDistricts = u.managedAsmDistricts || []; const asmDistricts = u.managedAsmDistricts || [];
const ddAmDistricts = u.managedDdAmDistricts || []; const ddAmDistricts = u.managedAreaDistricts || [];
const districts = [...asmDistricts, ...ddAmDistricts]; const districts = [...asmDistricts, ...ddAmDistricts];
const roleAssignment = (u.userRoles || []).find((r: any) => const roleAssignment = (u.userRoles || []).find((r: any) =>

View File

@ -32,6 +32,16 @@ import {
initializeDefaultSlas initializeDefaultSlas
} from './master.controller.js'; } from './master.controller.js';
import {
getInterviewConfigByType,
getInterviewConfigs,
getInterviewConfigById,
createInterviewConfig,
updateInterviewConfig,
deleteInterviewConfig,
initializeDefaultInterviewConfigs
} from './interviewConfig.controller.js';
const router = Router(); const router = Router();
@ -77,4 +87,13 @@ router.get('/sla-configs', getSlaConfigs);
router.post('/sla-configs', saveSlaConfig); router.post('/sla-configs', saveSlaConfig);
router.post('/sla-configs/initialize', initializeDefaultSlas); router.post('/sla-configs/initialize', initializeDefaultSlas);
// --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) ---
router.get('/interview-configs', getInterviewConfigs);
router.get('/interview-configs/active/:configType', getInterviewConfigByType);
router.get('/interview-configs/:id', getInterviewConfigById);
router.post('/interview-configs', createInterviewConfig);
router.put('/interview-configs/:id', updateInterviewConfig);
router.delete('/interview-configs/:id', deleteInterviewConfig);
router.post('/interview-configs/initialize', initializeDefaultInterviewConfigs);
export default router; export default router;

View File

@ -12,7 +12,11 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} }
const outlets = await Outlet.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: outlets } = await Outlet.findAndCountAll({
where, where,
include: [ include: [
{ {
@ -29,12 +33,20 @@ export const getOutlets = async (req: AuthRequest, res: Response) => {
} }
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset
}); });
res.json({ res.json({
success: true, success: true,
outlets outlets,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit
}
}); });
} catch (error) { } catch (error) {
console.error('Get outlets error:', error); console.error('Get outlets error:', error);

View File

@ -36,11 +36,11 @@ export const syncLocationManagers = async (districtId: string) => {
// Find primary/last assigned manager for each type // Find primary/last assigned manager for each type
// ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion // ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion
const asm = activeAssignments.find((a: any) => const asm = activeAssignments.find((a: any) =>
((a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER') && ['ASM', 'AREA SALES MANAGER'].includes((a.role as any)?.roleCode) &&
a.districtId === districtId a.districtId === districtId
); );
const ddAm = activeAssignments.find((a: any) => const ddAm = activeAssignments.find((a: any) =>
((a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER') && ['DD-AM', 'DD AM', 'AREA MANAGER', ROLES.DD_AM].includes((a.role as any)?.roleCode) &&
a.districtId === districtId a.districtId === districtId
); );

View File

@ -12,6 +12,7 @@ import { WorkflowService } from '../../services/WorkflowService.js';
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js'; import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
const { DocumentStageConfig } = db; const { DocumentStageConfig } = db;
@ -60,7 +61,7 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
}); });
} }
const applicationId = NomenclatureService.generateApplicationId(); const applicationId = await NomenclatureService.generateApplicationId();
let districtId = null; let districtId = null;
// Normalize incoming ID sources for robustness // Normalize incoming ID sources for robustness
const incomingLocationId = req.body.locationId || req.body.districtId; const incomingLocationId = req.body.locationId || req.body.districtId;
@ -163,10 +164,28 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
if (isOpportunityAvailable) { if (isOpportunityAvailable) {
sendOpportunityEmail(email, displayApplicantName, displayLocation, applicationId) sendOpportunityEmail(email, displayApplicantName, displayLocation, applicationId)
.catch(err => console.error('Error sending opportunity email', err)); .then(() => {
db.AuditLog.create({
userId: req.user?.id || null,
action: 'EMAIL_SENT',
entityType: 'application',
entityId: application.id,
newData: { email, type: 'OPPORTUNITY', location: displayLocation }
}).catch((err: any) => console.error('AuditLog error for email:', err));
})
.catch((err: any) => console.error('Error sending opportunity email', err));
} else { } else {
sendNonOpportunityEmail(email, displayApplicantName, displayLocation) sendNonOpportunityEmail(email, displayApplicantName, displayLocation)
.catch(err => console.error('Error sending non-opportunity email', err)); .then(() => {
db.AuditLog.create({
userId: req.user?.id || null,
action: 'EMAIL_SENT',
entityType: 'application',
entityId: application.id,
newData: { email, type: 'NON_OPPORTUNITY', location: displayLocation }
}).catch((err: any) => console.error('AuditLog error for email:', err));
})
.catch((err: any) => console.error('Error sending non-opportunity email', err));
} }
await AuditLog.create({ await AuditLog.create({
@ -191,32 +210,143 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
try { try {
const whereClause: any = {}; const whereClause: any = {};
// Security Check: If prospective dealer, only show their own application // Determine if user has national-level visibility
if (req.user?.roleCode === 'Prospective Dealer') { const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'Admin', 'Super Admin'];
// Filter by phone instead of email to show all applications from same user const isNationalUser = nationalRoles.includes(req.user?.roleCode || '');
whereClause.phone = (req.user as any).phone || req.user.email; const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer';
}
// Security Check: If FDD user, only show applications where they are a participant if (isProspectiveDealer) {
if (req.user?.roleCode === 'FDD') { whereClause.phone = (req.user as any).phone || req.user?.email;
} else if (!isNationalUser) {
// Restriction: Only show applications where the user is a participant
const participantApps = await db.RequestParticipant.findAll({ const participantApps = await db.RequestParticipant.findAll({
where: { userId: req.user.id, requestType: 'application' }, where: { userId: req.user?.id, requestType: 'application' },
attributes: ['requestId'] attributes: ['requestId']
}); });
const appIds = participantApps.map((p: any) => p.requestId); const appIds = participantApps.map((p: any) => p.requestId);
whereClause.id = { [Op.in]: appIds }; whereClause.id = { [Op.in]: appIds };
} }
const applications = await Application.findAll({ // Apply Filters
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
if (fromDate || toDate) {
whereClause.createdAt = {};
if (fromDate) {
const start = new Date(fromDate as string);
start.setHours(0, 0, 0, 0);
whereClause.createdAt[Op.gte] = start;
}
if (toDate) {
const end = new Date(toDate as string);
end.setHours(23, 59, 59, 999);
whereClause.createdAt[Op.lte] = end;
}
}
if (search) {
whereClause[Op.or] = [
{ applicantName: { [Op.iLike]: `%${search}%` } },
{ applicationId: { [Op.iLike]: `%${search}%` } },
{ email: { [Op.iLike]: `%${search}%` } },
{ phone: { [Op.iLike]: `%${search}%` } }
];
}
const applyStatusFilter = (val: any) => {
if (!val || val === 'all') return;
if (Array.isArray(val)) {
whereClause.overallStatus = { [Op.in]: val };
} else if (typeof val === 'string' && val.includes(',')) {
whereClause.overallStatus = { [Op.in]: val.split(',') };
} else {
whereClause.overallStatus = val;
}
};
// Pipeline Logic - Forced strict filtering by lifecycle stage
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
if (isShortlistedStr === 'false') {
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
whereClause.overallStatus = 'Submitted';
whereClause.isShortlisted = { [Op.ne]: true };
whereClause.ddLeadShortlisted = { [Op.ne]: true };
whereClause.opportunityId = null; // Strictly lead-gen records only
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
// Opportunities (Prospects) MUST be shortlisted but NOT yet in workflow
whereClause.isShortlisted = true;
whereClause.ddLeadShortlisted = { [Op.ne]: true };
if (status && status !== 'all') {
applyStatusFilter(status);
} else {
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed'] };
}
} else if (ddLeadShortlistedStr === 'true') {
// Workflow strictly shows shortlisted by DD Lead
whereClause.ddLeadShortlisted = true;
applyStatusFilter(status);
} else {
// 'All Requests' or other general views
applyStatusFilter(status);
}
if (location && location !== 'all') {
whereClause.preferredLocation = location;
}
if (state && state !== 'all') {
whereClause.state = state;
}
if (assignedTo) {
whereClause.assignedTo = assignedTo;
}
// Apply Pagination
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: applications } = await Application.findAndCountAll({
where: whereClause, where: whereClause,
include: [ include: [
{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }, { model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] },
{ model: SecurityDeposit, as: 'securityDeposits' } { model: SecurityDeposit, as: 'securityDeposits' }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true,
col: 'id'
}); });
res.json({ success: true, data: applications }); // Get additional stats for the header
const stats = {
total: count,
uniqueLocations: await Application.count({
where: whereClause,
distinct: true,
col: 'preferredLocation'
}),
withExperience: await Application.count({
where: {
...whereClause,
experienceYears: { [Op.gt]: 0 }
}
})
};
res.json({
success: true,
data: applications,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats
}
});
} catch (error) { } catch (error) {
console.error('Get applications error:', error); console.error('Get applications error:', error);
res.status(500).json({ success: false, message: 'Error fetching applications' }); res.status(500).json({ success: false, message: 'Error fetching applications' });
@ -573,13 +703,9 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
return res.status(400).json({ success: false, message: 'No applications selected' }); return res.status(400).json({ success: false, message: 'No applications selected' });
} }
if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) { // assignedTo is now optional as auto-assignment is handled via location
return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' }); const assignedToArr = Array.isArray(assignedTo) ? assignedTo : [];
} const primaryManualAssigneeId = assignedToArr.length > 0 ? assignedToArr[0] : null;
// Strategy: Assign the first user as primary assignee for the single FK field,
// but add ALL as participants to enforce dual-responsibility.
const primaryAssigneeId = assignedTo[0];
// Update Applications sequentially via WorkflowService for consistency // Update Applications sequentially via WorkflowService for consistency
for (const appId of applicationIds) { for (const appId of applicationIds) {
@ -587,11 +713,23 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
const application = await Application.findOne({ const application = await Application.findOne({
where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId } where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId }
}); });
if (application) { if (application) {
let resolvedAssigneeId = primaryManualAssigneeId;
// If no manual assignee provided, auto-resolve from District mapping
if (!resolvedAssigneeId && application.districtId) {
const district = await db.District.findByPk(application.districtId);
if (district) {
// Prioritize DD-AM as per user request
resolvedAssigneeId = district.ddAmId || district.zmId || null;
}
}
await application.update({ await application.update({
ddLeadShortlisted: true, ddLeadShortlisted: true,
isShortlisted: true, isShortlisted: true,
assignedTo: primaryAssigneeId, assignedTo: resolvedAssigneeId,
updatedAt: new Date(), updatedAt: new Date(),
}); });
@ -608,22 +746,24 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
application.applicationId application.applicationId
).catch(err => console.error('Failed to send shortlist email:', err)); ).catch(err => console.error('Failed to send shortlist email:', err));
// Add all assigned users as participants // Add manual assignees as participants if provided
for (const userId of assignedTo) { if (assignedToArr.length > 0) {
for (const userId of assignedToArr) {
await db.RequestParticipant.findOrCreate({ await db.RequestParticipant.findOrCreate({
where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' }, where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' },
defaults: { joinedMethod: 'auto' } defaults: { joinedMethod: 'auto' }
}); });
} }
}
// AUTO-FILL Interview Evaluators // TRIGGER AUTO-ASSIGNMENT for all stages based on location
await assignStageEvaluators(application.id); await assignStageEvaluators(application.id);
} }
} }
res.json({ res.json({
success: true, success: true,
message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.` message: `Successfully shortlisted ${applicationIds.length} application(s). Assignments processed automatically based on location.`
}); });
} catch (error) { } catch (error) {
console.error('Bulk shortlist error:', error); console.error('Bulk shortlist error:', error);
@ -640,6 +780,10 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
*/ */
const assignStageEvaluators = async (appIdOrId: string) => { const assignStageEvaluators = async (appIdOrId: string) => {
try { try {
if (!await isAutoAssignmentEnabled('ONBOARDING')) {
console.log(`[debug] Auto-assignment disabled for ONBOARDING. Skipping for App: ${appIdOrId}`);
return;
}
console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`); console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`);
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(appIdOrId); const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(appIdOrId);
const application = await Application.findOne({ const application = await Application.findOne({
@ -752,7 +896,6 @@ const assignStageEvaluators = async (appIdOrId: string) => {
} }
for (const [userId, assignment] of Object.entries(userAssignments)) { for (const [userId, assignment] of Object.entries(userAssignments)) {
const isInterview = assignment.stages.some(s => typeof s === 'number');
const primaryStage = assignment.stages[0]; const primaryStage = assignment.stages[0];
const primaryRole = assignment.roles[0]; const primaryRole = assignment.roles[0];
@ -769,29 +912,37 @@ const assignStageEvaluators = async (appIdOrId: string) => {
interviewLevel: typeof primaryStage === 'number' ? primaryStage : null, interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
stageCode: typeof primaryStage === 'string' ? primaryStage : null, stageCode: typeof primaryStage === 'string' ? primaryStage : null,
role: primaryRole, role: primaryRole,
allAssignments: assignment.stages, // Store all assignments allAssignments: assignment.stages,
autoMapped: true autoMapped: true
} }
} }
}); });
if (!created) { if (!created) {
// Update metadata if it exists to include the new assignments
const meta = participant.metadata || {};
const currentAssignments = meta.allAssignments || [];
const mergedAssignments = [...new Set([...currentAssignments, ...assignment.stages])];
await participant.update({ await participant.update({
joinedMethod: 'auto', // Ensure it's marked as auto if it wasn't
metadata: { metadata: {
...meta, ...(participant.metadata || {}),
allAssignments: mergedAssignments, interviewLevel: typeof primaryStage === 'number' ? primaryStage : null,
// Maintain legacy fields for compatibility if they don't exist stageCode: typeof primaryStage === 'string' ? primaryStage : null,
interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null), role: primaryRole,
stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null) allAssignments: assignment.stages,
autoMapped: true
} }
}); });
} }
} }
// --- Cleanup stale auto-assignments ---
const currentParticipantIds = Object.keys(userAssignments);
await db.RequestParticipant.destroy({
where: {
requestId: application.id,
requestType: 'application',
joinedMethod: 'auto',
userId: { [db.Sequelize.Op.notIn]: currentParticipantIds }
}
});
} catch (error) { } catch (error) {
console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error); console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error);
} }
@ -1019,8 +1170,7 @@ export const getDocumentConfigMetadata = async (_req: AuthRequest, res: Response
// Fetch Document Configurations based on Role and Stage // Fetch Document Configurations based on Role and Stage
export const getDocumentConfigs = async (req: AuthRequest, res: Response) => { export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
try { try { const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query;
const roleCode = (roleFilter as string) || req.user?.role; const roleCode = (roleFilter as string) || req.user?.role;
const where: any = { module }; const where: any = { module };
@ -1028,6 +1178,7 @@ export const getDocumentConfigs = async (req: AuthRequest, res: Response) => {
where.stageCode = { [Op.or]: [stageCode, 'General'] }; where.stageCode = { [Op.or]: [stageCode, 'General'] };
} }
if (search) { if (search) {
where[Op.or] = [ where[Op.or] = [
{ documentType: { [Op.iLike]: `%${search}%` } }, { documentType: { [Op.iLike]: `%${search}%` } },
@ -1190,8 +1341,225 @@ export const exportApplicationResponses = async (req: AuthRequest, res: Response
}); });
res.json({ success: true, data: rows }); res.json({ success: true, data: rows });
} catch (error) { } catch (error: any) {
console.error('Export error:', error); console.error('Export error:', error);
res.status(500).json({ success: false, message: 'Error exporting data' }); res.status(500).json({ success: false, message: 'Error exporting data' });
} }
}; };
/**
* Handle the scenario when an applicant has applied for a location where
* the opportunity was unavailable (Non-Opportunity Application) but now
* an opportunity has opened up.
*/
export const convertToOpportunity = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { opportunityId, remarks } = req.body;
// 1. Resolve Application
const application = await Application.findByPk(id);
if (!application) {
return res.status(404).json({ success: false, message: 'Application not found' });
}
// 2. Guardians: Ensure it's not already an opportunity application
if (application.opportunityId) {
return res.status(400).json({
success: false,
message: 'Application is already linked to an opportunity.'
});
}
let targetOpportunityId = opportunityId;
// 3. Auto-discovery fallback: If no ID provided, look for active opportunity in same district
if (!targetOpportunityId && application.districtId) {
const activeOpp = await Opportunity.findOne({
where: {
districtId: application.districtId,
status: 'active',
[Op.or]: [
{ openTo: null },
{ openTo: { [Op.gte]: new Date() } }
]
}
});
if (activeOpp) {
targetOpportunityId = activeOpp.id;
}
}
if (!targetOpportunityId) {
return res.status(400).json({
success: false,
message: 'No active opportunity found for this location. Please create an opportunity for this district first.'
});
}
// 4. Update Application Record
await application.update({
opportunityId: targetOpportunityId,
isShortlisted: true,
updatedAt: new Date()
});
// 5. Trigger Workflow Transition (Moves to Questionnaire Pending)
// This handles status history, audit logs, and progress percentage sync (10%)
await WorkflowService.transitionApplication(
application,
APPLICATION_STATUS.QUESTIONNAIRE_PENDING,
req.user?.id || null,
{
reason: 'Application processing initiated.',
source: 'Lead Conversion',
skipNotification: true
}
);
// 6. Trigger Opportunity Email (Contains portal link + questionnaire invitation)
const displayApplicantName = toTitleCase(application.applicantName);
const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location');
sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId)
.then(() => {
db.AuditLog.create({
userId: req.user?.id || null,
action: 'EMAIL_SENT',
entityType: 'application',
entityId: application.id,
newData: {
email: application.email,
type: 'OPPORTUNITY_CONVERSION',
status: 'SUCCESS'
}
}).catch((err: any) => console.error('[onboarding] Conversion email audit failed:', err));
})
.catch((err: any) => console.error('[onboarding] Failed to send conversion email:', err));
// 7. Log specialized Audit record
await AuditLog.create({
userId: req.user?.id,
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: application.id,
newData: {
action: 'CONVERT_TO_OPPORTUNITY',
opportunityId: targetOpportunityId,
remarks: remarks || 'N/A'
}
});
res.json({
success: true,
message: 'Application successfully converted. Questionnaire link sent to applicant.',
data: application
});
} catch (error) {
console.error('Convert to opportunity error:', error);
res.status(500).json({ success: false, message: 'Error converting application' });
}
};
/**
* Bulk conversion of lead-generation applications to active opportunities.
*/
export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response) => {
try {
const { ids, remarks } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: 'No application IDs provided' });
}
const results = {
success: 0,
failed: 0,
errors: [] as string[]
};
for (const id of ids) {
try {
const application = await Application.findByPk(id);
if (!application) {
results.failed++;
results.errors.push(`Application ${id} not found.`);
continue;
}
if (application.opportunityId) {
results.failed++;
results.errors.push(`Application ${application.applicationId} is already linked to an opportunity.`);
continue;
}
let targetOpportunityId = null;
// Auto-discovery logic (matches the single conversion logic)
if (application.districtId) {
const activeOpp = await Opportunity.findOne({
where: {
districtId: application.districtId,
status: 'active',
[Op.or]: [
{ openTo: null },
{ openTo: { [Op.gte]: new Date() } }
]
}
});
if (activeOpp) {
targetOpportunityId = activeOpp.id;
}
}
if (!targetOpportunityId) {
results.failed++;
results.errors.push(`No active opportunity found for ${application.applicantName} (${application.preferredLocation}).`);
continue;
}
// Update Application
await application.update({
opportunityId: targetOpportunityId,
isShortlisted: true,
updatedAt: new Date()
});
// Transition Workflow
await WorkflowService.transitionApplication(
application,
APPLICATION_STATUS.QUESTIONNAIRE_PENDING,
req.user?.id || null,
{
reason: 'Application processing initiated.',
source: 'Lead Conversion',
skipNotification: true
}
);
// Send Email Notification
const displayApplicantName = toTitleCase(application.applicantName);
const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location');
sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId)
.catch((err: any) => console.error(`[bulk-convert] Mail failed for ${id}:`, err));
results.success++;
} catch (err: any) {
results.failed++;
results.errors.push(`Error converting ID ${id}: ${err.message}`);
}
}
res.json({
success: true,
message: `Batch processed ${ids.length} records. ${results.success} succeeded, ${results.failed} failed.`,
data: results
});
} catch (error) {
console.error('Bulk convert error:', error);
res.status(500).json({ success: false, message: 'Internal error during batch conversion' });
}
};

View File

@ -6,7 +6,7 @@ import {
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes, assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata, retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication, createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
exportApplicationResponses exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
} from './onboarding.controller.js'; } from './onboarding.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
@ -33,6 +33,8 @@ router.put('/applications/:id', updateApplication);
router.put('/applications/:id/status', updateApplicationStatus); router.put('/applications/:id/status', updateApplicationStatus);
router.post('/applications/:id/documents', uploadSingle, uploadDocuments); router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
router.post('/applications/:id/convert-to-opportunity', convertToOpportunity);
// Architecture-related routes // Architecture-related routes
router.post('/applications/:id/assign-architecture', assignArchitectureTeam); router.post('/applications/:id/assign-architecture', assignArchitectureTeam);

View File

@ -1,6 +1,6 @@
import { Response } from 'express'; import { Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant } = db; const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant, DealerCode } = db;
import { NotificationService } from '../../services/NotificationService.js'; import { NotificationService } from '../../services/NotificationService.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
@ -164,7 +164,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null; resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null;
} }
const requestId = NomenclatureService.generateConstitutionalChangeId(); const requestId = await NomenclatureService.generateConstitutionalChangeId();
const metadata = { const metadata = {
newPartnersDetails, newPartnersDetails,
@ -241,9 +241,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
const where: any = {}; const where: any = {};
if (req.user.roleCode === 'Dealer') { if (req.user.roleCode === 'Dealer') {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} else {
const { status } = req.query;
if (status) {
if (status === 'pending') {
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] };
} else if (status === 'completed') {
where.status = { [Op.in]: ['Completed', 'Closed'] };
} else if (status === 'rejected') {
where.status = { [Op.in]: ['Rejected', 'Revoked'] };
} else {
where.status = status;
}
}
} }
const requests = await ConstitutionalChange.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: requests } = await ConstitutionalChange.findAndCountAll({
where, where,
include: [ include: [
{ model: Outlet, as: 'outlet' }, { model: Outlet, as: 'outlet' },
@ -251,25 +268,31 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
model: User, model: User,
as: 'dealer', as: 'dealer',
attributes: ['fullName'], attributes: ['fullName'],
include: [ include: [{ model: Dealer, as: 'dealerProfile', include: [{ model: DealerCode, as: 'dealerCode' }] }]
{
model: Dealer,
as: 'dealerProfile',
include: [
{
model: Application,
as: 'application',
include: [{ model: District, as: 'district' }]
}
]
}
]
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
}); });
res.json({ success: true, requests }); res.json({
success: true,
requests,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats: {
total: count,
pending: await ConstitutionalChange.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }),
completed: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }),
rejected: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } })
}
}
});
} catch (error) { } catch (error) {
console.error('Get constitutional changes error:', error); console.error('Get constitutional changes error:', error);
res.status(500).json({ success: false, message: 'Error fetching requests' }); res.status(500).json({ success: false, message: 'Error fetching requests' });
@ -377,6 +400,11 @@ const CONSTITUTIONAL_STAGE_POLICY_CODES: Record<string, string> = {
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW' [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW'
}; };
const hasRoleInConfig = (rolesArray: string[] | undefined, role: string) => {
if (!rolesArray || !role) return false;
return rolesArray.includes(role);
};
const normalizeRoleKey = (rawRole: string) => { const normalizeRoleKey = (rawRole: string) => {
const role = String(rawRole || '').trim().toUpperCase(); const role = String(rawRole || '').trim().toUpperCase();
if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM'; if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM';
@ -516,8 +544,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
zmRbm zmRbm
}; };
const approvedRequiredRoles = requiredRoles.filter((role) => Boolean(zmRbm[role]?.approvedByUserId)); const approvedRequiredRoles = requiredRoles.filter((role: string) => Boolean(zmRbm[role]?.approvedByUserId));
const waitingFor = requiredRoles.filter((role) => !zmRbm[role]?.approvedByUserId); const waitingFor = requiredRoles.filter((role: string) => !zmRbm[role]?.approvedByUserId);
const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length); const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length);
if (!approvalThresholdMet) { if (!approvalThresholdMet) {

View File

@ -330,7 +330,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
}); });
} }
const requestId = NomenclatureService.generateRelocationId(); const requestId = await NomenclatureService.generateRelocationId();
const request = await RelocationRequest.create({ const request = await RelocationRequest.create({
requestId, requestId,
@ -400,9 +400,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
const where: any = {}; const where: any = {};
if (req.user.roleCode === 'Dealer') { if (req.user.roleCode === 'Dealer') {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} else {
const { status } = req.query;
if (status) {
if (status === 'pending') {
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] };
} else if (status === 'completed') {
where.status = { [Op.in]: ['Completed', 'Closed'] };
} else if (status === 'rejected') {
where.status = { [Op.in]: ['Rejected', 'Revoked'] };
} else {
where.status = status;
}
}
} }
const requests = await RelocationRequest.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: requests } = await RelocationRequest.findAndCountAll({
where, where,
include: [ include: [
{ {
@ -427,7 +444,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
attributes: ['fullName'] attributes: ['fullName']
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
}); });
// Filter requests based on user's role and location assignments // Filter requests based on user's role and location assignments
@ -475,7 +495,22 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
}; };
}); });
res.json({ success: true, requests: enrichedRequests }); res.json({
success: true,
requests: enrichedRequests,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats: {
total: count,
pending: await RelocationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }),
completed: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }),
rejected: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } })
}
}
});
} catch (error) { } catch (error) {
console.error('Get relocation requests error:', error); console.error('Get relocation requests error:', error);
res.status(500).json({ success: false, message: 'Error fetching requests' }); res.status(500).json({ success: false, message: 'Error fetching requests' });

View File

@ -57,7 +57,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' }; initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
}); });
const resignationId = NomenclatureService.generateResignationId(); const resignationId = await NomenclatureService.generateResignationId();
const resignation = await db.Resignation.create({ const resignation = await db.Resignation.create({
resignationId, resignationId,
outletId, outletId,
@ -115,9 +115,32 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
if (req.user.roleCode === ROLES.DEALER) { if (req.user.roleCode === ROLES.DEALER) {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} else {
// For administrative users, filter by status or assignment if requested
const { status, onlyMine } = req.query;
if (status) {
if (String(status).includes(',')) {
where.status = { [Op.in]: String(status).split(',') };
} else if (status === 'open') {
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] };
} else {
where.status = status;
}
} }
const resignations = await db.Resignation.findAll({ 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 page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: resignations } = await db.Resignation.findAndCountAll({
where, where,
include: [ include: [
{ model: db.Outlet, as: 'outlet' }, { model: db.Outlet, as: 'outlet' },
@ -130,9 +153,26 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
] ]
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
});
res.json({
success: true,
resignations,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats: {
total: count,
open: await db.Resignation.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] } } }),
completed: await db.Resignation.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } })
}
}
}); });
res.json({ success: true, resignations });
} catch (error) { } catch (error) {
logger.error('Error fetching resignations:', error); logger.error('Error fetching resignations:', error);
next(error); next(error);
@ -384,7 +424,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances. // No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
const fnf = await db.FnF.create({ const fnf = await db.FnF.create({
settlementId: NomenclatureService.generateFnFId(), settlementId: await NomenclatureService.generateFnFId(),
resignationId: resignation.id, resignationId: resignation.id,
outletId: resignation.outletId, outletId: resignation.outletId,
dealerId: dealerProfileId, // Correctly using the Dealer model ID dealerId: dealerProfileId, // Correctly using the Dealer model ID

View File

@ -9,7 +9,7 @@ import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { NomenclatureService } from '../../services/NomenclatureService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
const LINE_ITEM_DESCRIPTION_PREFIX = { const LINE_ITEM_DESCRIPTION_PREFIX = {
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
@ -118,15 +118,30 @@ export const uploadFnFDocument = async (req: AuthRequest, res: Response) => {
export const getOnboardingPayments = async (req: AuthRequest, res: Response) => { export const getOnboardingPayments = async (req: AuthRequest, res: Response) => {
try { try {
const payments = await FinancePayment.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: payments } = await FinancePayment.findAndCountAll({
include: [{ include: [{
model: Application, model: Application,
as: 'application', as: 'application',
attributes: ['applicantName', 'applicationId'] attributes: ['applicantName', 'applicationId']
}], }],
order: [['createdAt', 'ASC']] order: [['createdAt', 'ASC']],
limit,
offset
});
res.json({
success: true,
payments,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit
}
}); });
res.json({ success: true, payments });
} catch (error) { } catch (error) {
console.error('Get onboarding payments error:', error); console.error('Get onboarding payments error:', error);
res.status(500).json({ success: false, message: 'Error fetching payments' }); res.status(500).json({ success: false, message: 'Error fetching payments' });
@ -254,7 +269,11 @@ export const updateFnF = async (req: AuthRequest, res: Response) => {
export const getFnFSettlements = async (req: Request, res: Response) => { export const getFnFSettlements = async (req: Request, res: Response) => {
try { try {
const settlements = await FnF.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: settlements } = await FnF.findAndCountAll({
include: [ include: [
{ model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] }, { model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] },
{ model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] }, { model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] },
@ -263,9 +282,21 @@ export const getFnFSettlements = async (req: Request, res: Response) => {
{ model: FnFLineItem, as: 'lineItems' }, { model: FnFLineItem, as: 'lineItems' },
{ model: FffClearance, as: 'clearances' } { model: FffClearance, as: 'clearances' }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
});
res.json({
success: true,
settlements,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit
}
}); });
res.json({ success: true, settlements });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, message: 'Error fetching settlements' }); res.status(500).json({ success: false, message: 'Error fetching settlements' });
} }

View File

@ -1,4 +1,5 @@
import { Response, NextFunction } from 'express'; import { Response, NextFunction } from 'express';
import { Op } from 'sequelize';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
import logger from '../../common/utils/logger.js'; import logger from '../../common/utils/logger.js';
import { import {
@ -32,7 +33,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { dealerId, category, reason, proposedLwd, comments } = req.body; const { dealerId, category, reason, proposedLwd, comments } = req.body;
const requestId = NomenclatureService.generateTerminationId(); const requestId = await NomenclatureService.generateTerminationId();
const termination = await db.TerminationRequest.create({ const termination = await db.TerminationRequest.create({
requestId, requestId,
dealerId, dealerId,
@ -86,9 +87,24 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
if (req.user.roleCode === ROLES.DEALER) { if (req.user.roleCode === ROLES.DEALER) {
const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } }); const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } });
if (dealer) where.dealerId = dealer.id; if (dealer) where.dealerId = dealer.id;
} else {
const { status } = req.query;
if (status) {
if (status === 'open') {
where.status = { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] };
} else if (status === 'completed') {
where.status = { [Op.in]: ['Terminated', 'Completed', 'Closed'] };
} else {
where.status = status;
}
}
} }
const terminations = await db.TerminationRequest.findAll({ const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: terminations } = await db.TerminationRequest.findAndCountAll({
where, where,
include: [ include: [
{ {
@ -97,9 +113,26 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
include: [{ model: db.DealerCode, as: 'dealerCode' }] include: [{ model: db.DealerCode, as: 'dealerCode' }]
} }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
});
res.json({
success: true,
terminations,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats: {
total: count,
open: await db.TerminationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] } } }),
completed: await db.TerminationRequest.count({ where: { ...where, status: { [Op.in]: ['Terminated', 'Completed', 'Closed'] } } })
}
}
}); });
res.json({ success: true, terminations });
} catch (error) { } catch (error) {
logger.error('Error fetching terminations:', error); logger.error('Error fetching terminations:', error);
next(error); next(error);

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

View 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;
}

View File

@ -1,6 +1,7 @@
import db from '../database/models/index.js'; import db from '../database/models/index.js';
import { ROLES, REQUEST_TYPES } from '../common/config/constants.js'; import { ROLES, REQUEST_TYPES } from '../common/config/constants.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { isAutoAssignmentEnabled } from './AutoAssignmentConfigService.js';
const { const {
RequestParticipant, RequestParticipant,
@ -22,13 +23,27 @@ export class ParticipantService {
*/ */
private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) { private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) {
try { try {
await RequestParticipant.findOrCreate({ const existing = await RequestParticipant.findOne({
where: { where: {
requestId, requestId,
requestType, requestType,
userId userId
}, }
defaults: { });
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, participantType,
joinedMethod: 'auto', joinedMethod: 'auto',
metadata: { metadata: {
@ -36,7 +51,6 @@ export class ParticipantService {
autoMapped: true, autoMapped: true,
assignedAt: new Date() assignedAt: new Date()
} }
}
}); });
} catch (error) { } catch (error) {
console.error(`Error adding participant ${userId} to ${requestType} ${requestId}:`, error); console.error(`Error adding participant ${userId} to ${requestType} ${requestId}:`, error);
@ -80,6 +94,10 @@ export class ParticipantService {
*/ */
static async assignTerminationParticipants(requestId: string) { static async assignTerminationParticipants(requestId: string) {
try { try {
if (!await isAutoAssignmentEnabled('TERMINATION')) {
console.log(`[ParticipantService] Auto-assignment disabled for TERMINATION. Skipping for ${requestId}`);
return;
}
const termination = await db.TerminationRequest.findByPk(requestId); const termination = await db.TerminationRequest.findByPk(requestId);
if (!termination) { if (!termination) {
console.error(`[ParticipantService] Termination Request not found: ${requestId}`); console.error(`[ParticipantService] Termination Request not found: ${requestId}`);
@ -154,6 +172,10 @@ export class ParticipantService {
*/ */
static async assignConstitutionalParticipants(requestId: string) { static async assignConstitutionalParticipants(requestId: string) {
try { try {
if (!await isAutoAssignmentEnabled('CONSTITUTIONAL')) {
console.log(`[ParticipantService] Auto-assignment disabled for CONSTITUTIONAL. Skipping for ${requestId}`);
return;
}
const request = await ConstitutionalChange.findByPk(requestId); const request = await ConstitutionalChange.findByPk(requestId);
if (!request) return; if (!request) return;
@ -215,6 +237,10 @@ export class ParticipantService {
*/ */
static async assignResignationParticipants(requestId: string) { static async assignResignationParticipants(requestId: string) {
try { try {
if (!await isAutoAssignmentEnabled('RESIGNATION')) {
console.log(`[ParticipantService] Auto-assignment disabled for RESIGNATION. Skipping for ${requestId}`);
return;
}
const resignation = await db.Resignation.findByPk(requestId); const resignation = await db.Resignation.findByPk(requestId);
if (!resignation) { if (!resignation) {
console.error(`[ParticipantService] Resignation not found: ${requestId}`); console.error(`[ParticipantService] Resignation not found: ${requestId}`);
@ -286,6 +312,10 @@ export class ParticipantService {
*/ */
static async assignRelocationParticipants(requestId: string) { static async assignRelocationParticipants(requestId: string) {
try { try {
if (!await isAutoAssignmentEnabled('RELOCATION')) {
console.log(`[ParticipantService] Auto-assignment disabled for RELOCATION. Skipping for ${requestId}`);
return;
}
const relocation = await db.RelocationRequest.findByPk(requestId, { const relocation = await db.RelocationRequest.findByPk(requestId, {
include: [{ include: [{
model: Outlet, model: Outlet,
@ -367,6 +397,10 @@ export class ParticipantService {
*/ */
static async assignFnFParticipants(fnfId: string) { static async assignFnFParticipants(fnfId: string) {
try { try {
if (!await isAutoAssignmentEnabled('FNF')) {
console.log(`[ParticipantService] Auto-assignment disabled for FNF. Skipping for ${fnfId}`);
return;
}
const fnf = await db.FnF.findByPk(fnfId); const fnf = await db.FnF.findByPk(fnfId);
if (!fnf) return; if (!fnf) return;

View File

@ -180,7 +180,7 @@ export class TerminationWorkflowService {
if (!fnf) { if (!fnf) {
fnf = await db.FnF.create({ fnf = await db.FnF.create({
settlementId: NomenclatureService.generateFnFId(), settlementId: await NomenclatureService.generateFnFId(),
terminationRequestId: termination.id, terminationRequestId: termination.id,
dealerId: termination.dealerId, dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null, outletId: primaryOutlet?.id || null,

View File

@ -121,7 +121,7 @@ export class WorkflowService {
} }
// 5. Notifications — non-fatal // 5. Notifications — non-fatal
if (application.email) { if (application.email && !metadata.skipNotification) {
try { try {
const user = await User.findOne({ const user = await User.findOne({
where: { email: application.email }, where: { email: application.email },
@ -200,10 +200,12 @@ export class WorkflowService {
* FIXED: Counts unique users instead of unique roles to allow same-role approvals * FIXED: Counts unique users instead of unique roles to allow same-role approvals
*/ */
static async evaluateStagePolicy(applicationId: string, stageCode: string) { static async evaluateStagePolicy(applicationId: string, stageCode: string) {
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } }); const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
if (!policy) return { policyMet: true }; // No policy means no restriction if (!policy) return { policyMet: true }; // No active policy means no restriction
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
const mode = policy.approvalMode || 'MIN_N';
const minNeeded = policy.minApprovals || 1;
// Fetch all approved actions for this stage // Fetch all approved actions for this stage
const actions = await db.StageApprovalAction.findAll({ const actions = await db.StageApprovalAction.findAll({
@ -213,21 +215,40 @@ export class WorkflowService {
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId)); const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
const approvedRoles = new Set(actions.map((a: any) => a.actorRole)); const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
const isSuperAdminApproval = Array.from(approvedRoles).includes('Super Admin'); // 1. Initial Gate: Super Admin bypass
if (approvedRoles.has('Super Admin')) {
return { policyMet: true, policy, overriddenBy: 'Super Admin' };
}
const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval) let roleConditionMet = false;
? true
: requiredRoles.every((role: string) => approvedRoles.has(role));
const meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1); switch (mode) {
case 'ALL':
case 'ROLE_MANDATORY':
// Every role in the required list MUST be present in the approved roles
roleConditionMet = requiredRoles.length === 0 ||
requiredRoles.every(role => approvedRoles.has(role));
break;
case 'MIN_N':
default:
// If there are required roles, at least one approval must come from THAT list
// If the list is empty, any approval counts
roleConditionMet = requiredRoles.length === 0 ||
requiredRoles.some(role => approvedRoles.has(role));
break;
}
const meetsMinCount = uniqueApprovers.size >= minNeeded;
return { return {
policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals, policyMet: roleConditionMet && meetsMinCount,
policy, policy,
uniqueApprovers: Array.from(uniqueApprovers), uniqueApprovers: Array.from(uniqueApprovers),
approvedRoles: Array.from(approvedRoles), approvedRoles: Array.from(approvedRoles),
hasAllRequiredRoleApprovals, roleConditionMet,
meetsMinApprovals meetsMinCount,
mode
}; };
} }
} }

View File

@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
const EMAILS = { const EMAILS = {
DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com", DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com",
DEALER: args.dealerEmail, DEALER: args.dealerEmail || "ramesh_1777038131833@gmail.com",
ASM: args.asmEmail || "abhishek@royalenfield.com", ASM: args.asmEmail || "abhishek@royalenfield.com",
RBM: args.rbmEmail || "manish@royalenfield.com", RBM: args.rbmEmail || "manish@royalenfield.com",
DD_ZM: args.ddZmEmail || "piyush@royalenfield.com", DD_ZM: args.ddZmEmail || "piyush@royalenfield.com",
@ -49,10 +49,10 @@ async function apiRequest(endpoint, method = "GET", body = null, token = null) {
return data; return data;
} }
async function login(email) { async function login(email, password = PASSWORD) {
if (!login.cache) login.cache = {}; if (!login.cache) login.cache = {};
if (login.cache[email]) return login.cache[email]; if (login.cache[email]) return login.cache[email];
const data = await apiRequest("/auth/login", "POST", { email, password: PASSWORD }); const data = await apiRequest("/auth/login", "POST", { email, password });
login.cache[email] = data.token; login.cache[email] = data.token;
return data.token; return data.token;
} }
@ -107,7 +107,7 @@ async function run() {
throw new Error("Missing --dealerEmail. This script requires an existing dealer user email."); throw new Error("Missing --dealerEmail. This script requires an existing dealer user email.");
} }
const adminToken = await login(EMAILS.DD_ADMIN); const adminToken = await login(EMAILS.DD_ADMIN);
const dealerToken = await login(EMAILS.DEALER); const dealerToken = await login(EMAILS.DEALER, "Dealer@123");
let requestId = args.requestId; let requestId = args.requestId;
if (!requestId) { if (!requestId) {