From 71d9f9dbba02f19dfd8579c231bec3f149a40fee Mon Sep 17 00:00:00 2001 From: laxman h Date: Fri, 24 Apr 2026 19:29:51 +0530 Subject: [PATCH] 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 --- .gitignore | 1 + check_db.js | 20 + scripts/assign_south_delhi.ts | 74 +++ scripts/check_recent_app.ts | 48 ++ scripts/diagnose_associations.ts | 76 +++ scripts/find_abhishek.ts | 24 + scripts/fix_south_delhi.ts | 40 ++ scripts/remove_abhishek_app.ts | 36 ++ scripts/seed-approval-policies.ts | 2 +- scripts/seed-auto-assignment-configs.ts | 83 ++++ scripts/seed-interview-configs.ts | 218 +++++++++ scripts/seed-system-configs.ts | 36 ++ src/common/middleware/checkRevocation.ts | 46 ++ src/common/utils/nomenclature.ts | 85 +++- src/database/models/core/District.ts | 4 +- src/database/models/core/Location.ts | 4 +- src/database/models/index.ts | 6 + .../models/verification/InterviewConfig.ts | 49 ++ .../verification/InterviewConfigItem.ts | 73 +++ .../verification/InterviewConfigItemOption.ts | 57 +++ src/modules/admin/admin.controller.ts | 11 +- .../collaboration/collaboration.controller.ts | 50 +- .../collaboration/collaboration.routes.ts | 5 +- .../master/interviewConfig.controller.ts | 407 ++++++++++++++++ src/modules/master/master.controller.ts | 76 ++- src/modules/master/master.routes.ts | 19 + src/modules/master/outlet.controller.ts | 18 +- src/modules/master/syncHierarchy.service.ts | 4 +- .../onboarding/onboarding.controller.ts | 460 ++++++++++++++++-- src/modules/onboarding/onboarding.routes.ts | 4 +- .../self-service/constitutional.controller.ts | 68 ++- .../self-service/relocation.controller.ts | 43 +- .../self-service/resignation.controller.ts | 50 +- .../settlement/settlement.controller.ts | 45 +- .../termination/termination.controller.ts | 41 +- src/scripts/seed-auto-assignment-configs.ts | 62 +++ src/services/AutoAssignmentConfigService.ts | 92 ++++ src/services/ParticipantService.ts | 54 +- src/services/TerminationWorkflowService.ts | 2 +- src/services/WorkflowService.ts | 43 +- trigger-relocation.js | 10 +- 41 files changed, 2345 insertions(+), 201 deletions(-) create mode 100644 check_db.js create mode 100644 scripts/assign_south_delhi.ts create mode 100644 scripts/check_recent_app.ts create mode 100644 scripts/diagnose_associations.ts create mode 100644 scripts/find_abhishek.ts create mode 100644 scripts/fix_south_delhi.ts create mode 100644 scripts/remove_abhishek_app.ts create mode 100644 scripts/seed-auto-assignment-configs.ts create mode 100644 scripts/seed-interview-configs.ts create mode 100644 src/common/middleware/checkRevocation.ts create mode 100644 src/database/models/verification/InterviewConfig.ts create mode 100644 src/database/models/verification/InterviewConfigItem.ts create mode 100644 src/database/models/verification/InterviewConfigItemOption.ts create mode 100644 src/modules/master/interviewConfig.controller.ts create mode 100644 src/scripts/seed-auto-assignment-configs.ts create mode 100644 src/services/AutoAssignmentConfigService.ts diff --git a/.gitignore b/.gitignore index af451cf..96feb50 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ uploads/ # GCP Service Account Key config/gcp-key.json +src/database/models/index.ts diff --git a/check_db.js b/check_db.js new file mode 100644 index 0000000..71130c6 --- /dev/null +++ b/check_db.js @@ -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(); diff --git a/scripts/assign_south_delhi.ts b/scripts/assign_south_delhi.ts new file mode 100644 index 0000000..a8b9ed4 --- /dev/null +++ b/scripts/assign_south_delhi.ts @@ -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(); diff --git a/scripts/check_recent_app.ts b/scripts/check_recent_app.ts new file mode 100644 index 0000000..a284706 --- /dev/null +++ b/scripts/check_recent_app.ts @@ -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(); diff --git a/scripts/diagnose_associations.ts b/scripts/diagnose_associations.ts new file mode 100644 index 0000000..38b294a --- /dev/null +++ b/scripts/diagnose_associations.ts @@ -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(); diff --git a/scripts/find_abhishek.ts b/scripts/find_abhishek.ts new file mode 100644 index 0000000..8415c8a --- /dev/null +++ b/scripts/find_abhishek.ts @@ -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(); diff --git a/scripts/fix_south_delhi.ts b/scripts/fix_south_delhi.ts new file mode 100644 index 0000000..ba8d4a6 --- /dev/null +++ b/scripts/fix_south_delhi.ts @@ -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(); diff --git a/scripts/remove_abhishek_app.ts b/scripts/remove_abhishek_app.ts new file mode 100644 index 0000000..befa1ba --- /dev/null +++ b/scripts/remove_abhishek_app.ts @@ -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(); diff --git a/scripts/seed-approval-policies.ts b/scripts/seed-approval-policies.ts index d1a3b98..b80aeaf 100644 --- a/scripts/seed-approval-policies.ts +++ b/scripts/seed-approval-policies.ts @@ -43,7 +43,7 @@ const policies = [ stageCode: 'FDD_VERIFICATION', minApprovals: 1, approvalMode: 'ROLE_MANDATORY', - requiredRoles: ['DD Admin', 'Super Admin'], + requiredRoles: ['DD Admin', 'Super Admin', 'DD Lead', 'DD Head'], isActive: true }, { diff --git a/scripts/seed-auto-assignment-configs.ts b/scripts/seed-auto-assignment-configs.ts new file mode 100644 index 0000000..6523ba1 --- /dev/null +++ b/scripts/seed-auto-assignment-configs.ts @@ -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(); diff --git a/scripts/seed-interview-configs.ts b/scripts/seed-interview-configs.ts new file mode 100644 index 0000000..bb6c61e --- /dev/null +++ b/scripts/seed-interview-configs.ts @@ -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); +}); diff --git a/scripts/seed-system-configs.ts b/scripts/seed-system-configs.ts index c8d2f4d..96e794a 100644 --- a/scripts/seed-system-configs.ts +++ b/scripts/seed-system-configs.ts @@ -22,6 +22,42 @@ const seedSystemConfigs = async () => { value: { amount: 1500000, currency: 'INR' }, category: 'SECURITY_DEPOSIT', description: 'Default First Fill amount for new dealer onboarding' + }, + { + key: 'AUTO_ASSIGN_ONBOARDING', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for Onboarding module' + }, + { + key: 'AUTO_ASSIGN_RELOCATION', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for Relocation module' + }, + { + key: 'AUTO_ASSIGN_TERMINATION', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for Termination module' + }, + { + key: 'AUTO_ASSIGN_CONSTITUTIONAL', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for Constitutional module' + }, + { + key: 'AUTO_ASSIGN_RESIGNATION', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for Resignation module' + }, + { + key: 'AUTO_ASSIGN_FNF', + value: { enabled: true }, + category: 'ASSIGNMENT', + description: 'Enable/Disable auto-assignment of participants for F&F module' } ]; diff --git a/src/common/middleware/checkRevocation.ts b/src/common/middleware/checkRevocation.ts new file mode 100644 index 0000000..1b866d9 --- /dev/null +++ b/src/common/middleware/checkRevocation.ts @@ -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 + } +}; diff --git a/src/common/utils/nomenclature.ts b/src/common/utils/nomenclature.ts index 0ab0e57..7f2ec67 100644 --- a/src/common/utils/nomenclature.ts +++ b/src/common/utils/nomenclature.ts @@ -1,5 +1,5 @@ import db from '../../database/models/index.js'; -import { v4 as uuidv4 } from 'uuid'; +import { Op } from 'sequelize'; /** * Centralized utility for ID generation and nomenclature across all modules. @@ -7,46 +7,89 @@ import { v4 as uuidv4 } from 'uuid'; */ export class NomenclatureService { /** - * Generates a Resignation ID (e.g., RES-2026-1234) + * Generic helper for generating sequential IDs with Month/Year prefix */ - static generateResignationId() { - return `RES-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; + private static async generateSequentialId(modelName: string, fieldName: string, modulePrefix: string) { + const now = new Date(); + const year = now.getFullYear(); + const monthNames = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const month = monthNames[now.getMonth()]; + const prefix = `${modulePrefix}-${year}-${month}-`; + + try { + const Model = (db as any)[modelName]; + if (!Model) { + throw new Error(`Model ${modelName} not found in database indexed models.`); + } + + // Find the last ID with this prefix to increment + const lastRecord = await Model.findOne({ + where: { + [fieldName]: { + [Op.like]: `${prefix}%` + } + }, + order: [['createdAt', 'DESC']], + attributes: [fieldName] + }); + + let nextNumber = 1; + if (lastRecord && lastRecord[fieldName]) { + const parts = lastRecord[fieldName].split('-'); + const lastNumStr = parts[parts.length - 1]; + const lastNum = parseInt(lastNumStr, 10); + if (!isNaN(lastNum)) { + nextNumber = lastNum + 1; + } + } + + return `${prefix}${nextNumber.toString().padStart(5, '0')}`; + } catch (error) { + console.error(`Error generating sequential ID for ${modulePrefix}:`, error); + // Fallback to random if DB fetch fails + return `${prefix}${Math.floor(10000 + Math.random() * 90000)}`; + } } /** - * Generates a Termination Request ID (e.g., TRM-2026-1234) + * Generates an Onboarding Application ID (e.g., DD-2026-FEB-00001) */ - static generateTerminationId() { - return `TRM-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; + static async generateApplicationId() { + return this.generateSequentialId('Application', 'applicationId', 'DD'); } /** - * Generates a Constitutional Change ID (e.g., CC-2026-1234) + * Generates a Resignation ID (e.g., RES-2026-FEB-00001) */ - static generateConstitutionalChangeId() { - return `CC-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; + static async generateResignationId() { + return this.generateSequentialId('Resignation', 'resignationId', 'RES'); } /** - * Generates an Onboarding Application ID (e.g., APP-2026-5678) + * Generates a Termination Request ID (e.g., TER-2026-FEB-00001) */ - static generateApplicationId() { - return `APP-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; + static async generateTerminationId() { + return this.generateSequentialId('TerminationRequest', 'requestId', 'TER'); } /** - * Generates a Settlement/FnF ID (e.g., FNF-2025-001) + * Generates a Constitutional Change ID (e.g., CC-2026-FEB-00001) */ - static generateFnFId() { - const year = new Date().getFullYear(); - const rand = Math.floor(1 + Math.random() * 999); - return `FNF-${year}-${rand.toString().padStart(3, '0')}`; + static async generateConstitutionalChangeId() { + return this.generateSequentialId('ConstitutionalChange', 'requestId', 'CC'); } /** - * Generates a Relocation Request ID (e.g., REL-2026-1234) + * Generates a Relocation Request ID (e.g., REL-2026-FEB-00001) */ - static generateRelocationId() { - return `REL-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`; + static async generateRelocationId() { + return this.generateSequentialId('RelocationRequest', 'requestId', 'REL'); + } + + /** + * Generates a Settlement/FnF ID (e.g., FNF-2026-FEB-00001) + */ + static async generateFnFId() { + return this.generateSequentialId('FnF', 'settlementId', 'FNF'); } } diff --git a/src/database/models/core/District.ts b/src/database/models/core/District.ts index 4a17ffa..907c17d 100644 --- a/src/database/models/core/District.ts +++ b/src/database/models/core/District.ts @@ -14,7 +14,7 @@ export interface DistrictAttributes { zmId?: string | null; zmCode?: string | null; city?: string | null; - isActive?: boolean; + isOpportunity?: boolean; description?: string | null; } @@ -91,7 +91,7 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true }, - isActive: { + isOpportunity: { type: DataTypes.BOOLEAN, defaultValue: true }, diff --git a/src/database/models/core/Location.ts b/src/database/models/core/Location.ts index 94fc8a5..a034e13 100644 --- a/src/database/models/core/Location.ts +++ b/src/database/models/core/Location.ts @@ -5,7 +5,7 @@ export interface LocationAttributes { name: string; districtId: string | null; city?: string | null; - isActive?: boolean; + isOpportunity?: boolean; openFrom?: Date | null; openTo?: Date | null; description?: string | null; @@ -36,7 +36,7 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true }, - isActive: { + isOpportunity: { type: DataTypes.BOOLEAN, defaultValue: true }, diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 4a40d17..9034f65 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -44,6 +44,9 @@ import createQuestionnaireQuestion from './verification/QuestionnaireQuestion.js import createQuestionnaireResponse from './verification/QuestionnaireResponse.js'; import createQuestionnaireScore from './verification/QuestionnaireScore.js'; import createKTMatrixScore from './verification/KTMatrixScore.js'; +import createInterviewConfig from './verification/InterviewConfig.js'; +import createInterviewConfigItem from './verification/InterviewConfigItem.js'; +import createInterviewConfigItemOption from './verification/InterviewConfigItemOption.js'; import createAiSummary from './verification/AiSummary.js'; import createFddAssignment from './verification/FddAssignment.js'; import createFddReport from './verification/FddReport.js'; @@ -182,6 +185,9 @@ db.InterviewParticipant = createInterviewParticipant(sequelize); db.InterviewEvaluation = createInterviewEvaluation(sequelize); db.KTMatrixScore = createKTMatrixScore(sequelize); db.InterviewFeedback = createInterviewFeedback(sequelize); +db.InterviewConfig = createInterviewConfig(sequelize); +db.InterviewConfigItem = createInterviewConfigItem(sequelize); +db.InterviewConfigItemOption = createInterviewConfigItemOption(sequelize); db.AiSummary = createAiSummary(sequelize); // Batch 4: Dealer Entity, Documents & Work Notes diff --git a/src/database/models/verification/InterviewConfig.ts b/src/database/models/verification/InterviewConfig.ts new file mode 100644 index 0000000..ea83edb --- /dev/null +++ b/src/database/models/verification/InterviewConfig.ts @@ -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 { } + +export default (sequelize: Sequelize) => { + const InterviewConfig = sequelize.define('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; +}; diff --git a/src/database/models/verification/InterviewConfigItem.ts b/src/database/models/verification/InterviewConfigItem.ts new file mode 100644 index 0000000..feb5f83 --- /dev/null +++ b/src/database/models/verification/InterviewConfigItem.ts @@ -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 { } + +export default (sequelize: Sequelize) => { + const InterviewConfigItem = sequelize.define('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; +}; diff --git a/src/database/models/verification/InterviewConfigItemOption.ts b/src/database/models/verification/InterviewConfigItemOption.ts new file mode 100644 index 0000000..73dfbf2 --- /dev/null +++ b/src/database/models/verification/InterviewConfigItemOption.ts @@ -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 { } + +export default (sequelize: Sequelize) => { + const InterviewConfigItemOption = sequelize.define('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; +}; diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 3e81338..5197d6a 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -308,7 +308,16 @@ export const getAllUsers = async (req: Request, res: Response) => { return userJson; }); - res.json({ success: true, data: result, total: count }); + res.json({ + success: true, + data: result, + meta: { + total: count, + totalPages: Math.ceil(count / Number(limit)), + currentPage: Number(page), + limit: Number(limit) + } + }); } catch (error) { console.error('Get users error:', error); res.status(500).json({ success: false, message: 'Error fetching users' }); diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index 2b0d0e4..5560d7c 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -5,7 +5,7 @@ const { OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument } = db; import { AuthRequest } from '../../types/express.types.js'; -import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { AUDIT_ACTIONS, REQUEST_TYPES, ROLES } from '../../common/config/constants.js'; import { resolveEntityUuidByType, requestTypeQueryVariants, @@ -482,22 +482,44 @@ export const addParticipant = async (req: AuthRequest, res: Response) => { export const removeParticipant = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; - const participant = await RequestParticipant.findByPk(id); - await RequestParticipant.destroy({ where: { id } }); + const { reason } = req.body || {}; // Safe destructuring - // Audit log for participant removed - if (participant) { - await AuditLog.create({ - userId: req.user?.id, - action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, - entityType: (participant as any).requestType || 'application', - entityId: (participant as any).requestId, - newData: { removedUserId: (participant as any).userId } - }); + const participant = await RequestParticipant.findByPk(id); + if (!participant) { + return res.status(404).json({ success: false, message: 'Participant not found' }); } - res.json({ success: true, message: 'Participant removed' }); + // ROLE SECURITY: DD Lead, DD Head, NBH, DD Admin, Super Admin + const authorizedRoles: string[] = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN]; + if (!authorizedRoles.includes(req.user?.roleCode || '')) { + return res.status(403).json({ success: false, message: 'Unauthorized: Only designated roles can revoke participants.' }); + } + + // SOFT DELETE: Update metadata instead of destroying record + const metadata = { + ...(participant.metadata || {}), + revokedAt: new Date(), + revokedBy: req.user?.id, + revocationReason: reason || 'Manual revocation' + }; + + await participant.update({ metadata }); + + // Audit log for participant revoked + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.PARTICIPANT_REMOVED, // Using existing constant + entityType: (participant as any).requestType || 'application', + entityId: (participant as any).requestId, + newData: { + revokedUserId: (participant as any).userId, + revocationReason: reason || 'Manual revocation' + } + }); + + res.json({ success: true, message: 'Participant access revoked successfully' }); } catch (error) { - res.status(500).json({ success: false, message: 'Error removing participant' }); + console.error('Revoke participant error:', error); + res.status(500).json({ success: false, message: 'Error revoking participant' }); } }; diff --git a/src/modules/collaboration/collaboration.routes.ts b/src/modules/collaboration/collaboration.routes.ts index 45903d6..7c10d25 100644 --- a/src/modules/collaboration/collaboration.routes.ts +++ b/src/modules/collaboration/collaboration.routes.ts @@ -2,14 +2,15 @@ import express from 'express'; const router = express.Router(); import * as collaborationController from './collaboration.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; +import { checkRevocation } from '../../common/middleware/checkRevocation.js'; import { uploadSingle, handleUploadError } from '../../common/middleware/upload.js'; router.use(authenticate as any); // Worknotes router.get('/worknotes', collaborationController.getWorknotes); -router.post('/worknotes', collaborationController.addWorknote); -router.post('/upload', uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment); +router.post('/worknotes', checkRevocation, collaborationController.addWorknote); +router.post('/upload', checkRevocation, uploadSingle, handleUploadError, collaborationController.uploadWorknoteAttachment); // Participants router.post('/participants', collaborationController.addParticipant); diff --git a/src/modules/master/interviewConfig.controller.ts b/src/modules/master/interviewConfig.controller.ts new file mode 100644 index 0000000..bbf208e --- /dev/null +++ b/src/modules/master/interviewConfig.controller.ts @@ -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' }); + } +}; diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index fc473c9..59a65ff 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -81,12 +81,15 @@ export const getAreas = async (req: Request, res: Response) => { const stateId = req.query.stateId as string; const zoneId = req.query.zoneId as string; const regionId = req.query.regionId as string; - const isActive = req.query.isActive as string; + const isOpportunity = req.query.isOpportunity as string || req.query.isActive as string; - if (stateId) where['$district.stateId$'] = stateId; - if (zoneId) where['$district.zoneId$'] = zoneId; - if (regionId) where['$district.regionId$'] = regionId; - if (isActive !== undefined) where.isActive = isActive === 'true'; + if (stateId && stateId !== 'all') where['$district.stateId$'] = stateId; + if (zoneId && zoneId !== 'all') where['$district.zoneId$'] = zoneId; + if (regionId && regionId !== 'all') where['$district.regionId$'] = regionId; + + if (isOpportunity === 'true' || isOpportunity === 'false') { + where.isOpportunity = isOpportunity === 'true'; + } const { count, rows: areas } = await db.Location.findAndCountAll({ where, @@ -138,21 +141,18 @@ export const getAreas = async (req: Request, res: Response) => { // --- Districts (Territory Entities) --- export const getDistricts = async (req: Request, res: Response) => { try { - let search = req.query.search as string; - let limit = (req.query.limit || 10) as any; - const stateId = req.query.stateId as string; - const zoneId = req.query.zoneId as string; - const regionId = req.query.regionId as string; - + const { search, page = 1, limit = 10, stateId, zoneId, regionId, isActive, isOpportunity } = req.query as any; const isAll = limit === 'all' || limit === -1 || limit === '-1'; + const offset = isAll ? null : (Number(page) - 1) * Number(limit); const where: any = {}; - if (search) { - where.name = { [Op.iLike]: `%${search}%` }; - } + const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive; + + if (search) where.name = { [Op.iLike]: `%${search}%` }; if (stateId) where.stateId = stateId; if (zoneId) where.zoneId = zoneId; if (regionId) where.regionId = regionId; + if (finalIsOpportunity !== undefined) where.isOpportunity = finalIsOpportunity === 'true'; const { count, rows: districts } = await db.District.findAndCountAll({ where, @@ -184,10 +184,10 @@ export const getDistricts = async (req: Request, res: Response) => { } }; - export const createDistrict = async (req: Request, res: Response) => { try { - const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body; + const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body; + const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : (isActive !== undefined ? isActive : true); // Preferred path: create location against an existing district if (districtId) { @@ -201,7 +201,7 @@ export const createDistrict = async (req: Request, res: Response) => { name: areaName, districtId: district.id, city: city || areaName, - isActive: isActive !== undefined ? isActive : true, + isOpportunity: finalIsOpportunity, openFrom: openFrom || null, openTo: openTo || null, description: description || null @@ -238,14 +238,14 @@ export const createDistrict = async (req: Request, res: Response) => { name, code, stateId, - isActive: isActive !== undefined ? isActive : true + isOpportunity: finalIsOpportunity }); const area = await db.Location.create({ name, districtId: district.id, city: city || name, - isActive: true, + isOpportunity: true, openFrom: openFrom || null, openTo: openTo || null, description: description || null @@ -810,7 +810,8 @@ export const deleteLocation = async (req: Request, res: Response) => { export const updateLocation = async (req: Request, res: Response) => { try { const { id } = req.params; // This is the Area ID - const { name, code, stateName, city, openFrom, openTo, status, isActive, description, districtId } = req.body; + const { name, code, stateName, city, openFrom, openTo, status, isActive, isOpportunity, description, districtId } = req.body; + const finalIsOpportunity = isOpportunity !== undefined ? isOpportunity : isActive; const area = await db.Location.findByPk(id, { include: [{ model: db.District, as: 'district' }] @@ -842,7 +843,7 @@ export const updateLocation = async (req: Request, res: Response) => { name: name || district.name, code: code || district.code, stateId: stateId || district.stateId, - isActive: isActive !== undefined ? isActive : district.isActive + isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : district.isOpportunity }); } @@ -851,9 +852,9 @@ export const updateLocation = async (req: Request, res: Response) => { name: name || area.name, districtId: district?.id || area.districtId, city: city || area.city, - isActive: isActive !== undefined ? isActive : area.isActive, - openFrom: openFrom !== undefined ? (openFrom || null) : area.openFrom, - openTo: openTo !== undefined ? (openTo || null) : area.openTo, + isOpportunity: finalIsOpportunity !== undefined ? finalIsOpportunity : area.isOpportunity, + openFrom: openFrom || area.openFrom, + openTo: openTo || area.openTo, description: description || area.description }); @@ -885,35 +886,32 @@ export const updateLocation = async (req: Request, res: Response) => { // --- Managers --- export const getASMs = async (req: Request, res: Response) => { try { - const asms = await db.User.findAll({ + const asms = await User.findAll({ where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] }, isActive: true }, include: [ { - model: db.UserRole, - as: 'userRoles', + association: 'userRoles', where: { isActive: true }, required: false, - include: [{ model: db.Role, as: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }] + include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }] }, { - model: db.District, - as: 'managedAsmDistricts', + association: 'managedAsmDistricts', include: [ - { model: db.State, as: 'state', attributes: ['id', 'name'] }, - { model: db.Region, as: 'region', attributes: ['id', 'name'] }, - { model: db.Zone, as: 'zone', attributes: ['id', 'name'] } + { association: 'state', attributes: ['id', 'name'] }, + { association: 'region', attributes: ['id', 'name'] }, + { association: 'zone', attributes: ['id', 'name'] } ] }, { - model: db.District, - as: 'managedDdAmDistricts', + association: 'managedAreaDistricts', include: [ - { model: db.State, as: 'state', attributes: ['id', 'name'] }, - { model: db.Region, as: 'region', attributes: ['id', 'name'] }, - { model: db.Zone, as: 'zone', attributes: ['id', 'name'] } + { association: 'state', attributes: ['id', 'name'] }, + { association: 'region', attributes: ['id', 'name'] }, + { association: 'zone', attributes: ['id', 'name'] } ] } ], @@ -922,7 +920,7 @@ export const getASMs = async (req: Request, res: Response) => { const result = (asms || []).map((u: any) => { const asmDistricts = u.managedAsmDistricts || []; - const ddAmDistricts = u.managedDdAmDistricts || []; + const ddAmDistricts = u.managedAreaDistricts || []; const districts = [...asmDistricts, ...ddAmDistricts]; const roleAssignment = (u.userRoles || []).find((r: any) => diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 531d1f1..36a9f44 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -32,6 +32,16 @@ import { initializeDefaultSlas } from './master.controller.js'; +import { + getInterviewConfigByType, + getInterviewConfigs, + getInterviewConfigById, + createInterviewConfig, + updateInterviewConfig, + deleteInterviewConfig, + initializeDefaultInterviewConfigs +} from './interviewConfig.controller.js'; + const router = Router(); @@ -77,4 +87,13 @@ router.get('/sla-configs', getSlaConfigs); router.post('/sla-configs', saveSlaConfig); router.post('/sla-configs/initialize', initializeDefaultSlas); +// --- Interview Configurations (KT Matrix, Level 2, Level 3 Feedback) --- +router.get('/interview-configs', getInterviewConfigs); +router.get('/interview-configs/active/:configType', getInterviewConfigByType); +router.get('/interview-configs/:id', getInterviewConfigById); +router.post('/interview-configs', createInterviewConfig); +router.put('/interview-configs/:id', updateInterviewConfig); +router.delete('/interview-configs/:id', deleteInterviewConfig); +router.post('/interview-configs/initialize', initializeDefaultInterviewConfigs); + export default router; diff --git a/src/modules/master/outlet.controller.ts b/src/modules/master/outlet.controller.ts index cd2e217..51e12cc 100644 --- a/src/modules/master/outlet.controller.ts +++ b/src/modules/master/outlet.controller.ts @@ -12,7 +12,11 @@ export const getOutlets = async (req: AuthRequest, res: Response) => { where.dealerId = req.user.id; } - const outlets = await Outlet.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: outlets } = await Outlet.findAndCountAll({ where, include: [ { @@ -29,12 +33,20 @@ export const getOutlets = async (req: AuthRequest, res: Response) => { } } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset }); res.json({ success: true, - outlets + outlets, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit + } }); } catch (error) { console.error('Get outlets error:', error); diff --git a/src/modules/master/syncHierarchy.service.ts b/src/modules/master/syncHierarchy.service.ts index 8698ff6..3c2177e 100644 --- a/src/modules/master/syncHierarchy.service.ts +++ b/src/modules/master/syncHierarchy.service.ts @@ -36,11 +36,11 @@ export const syncLocationManagers = async (districtId: string) => { // Find primary/last assigned manager for each type // ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion const asm = activeAssignments.find((a: any) => - ((a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER') && + ['ASM', 'AREA SALES MANAGER'].includes((a.role as any)?.roleCode) && a.districtId === districtId ); const ddAm = activeAssignments.find((a: any) => - ((a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER') && + ['DD-AM', 'DD AM', 'AREA MANAGER', ROLES.DD_AM].includes((a.role as any)?.roleCode) && a.districtId === districtId ); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index cef1866..5ccf5d5 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -12,6 +12,7 @@ import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; +import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js'; const { DocumentStageConfig } = db; @@ -60,7 +61,7 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { }); } - const applicationId = NomenclatureService.generateApplicationId(); + const applicationId = await NomenclatureService.generateApplicationId(); let districtId = null; // Normalize incoming ID sources for robustness const incomingLocationId = req.body.locationId || req.body.districtId; @@ -163,10 +164,28 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { if (isOpportunityAvailable) { sendOpportunityEmail(email, displayApplicantName, displayLocation, applicationId) - .catch(err => console.error('Error sending opportunity email', err)); + .then(() => { + db.AuditLog.create({ + userId: req.user?.id || null, + action: 'EMAIL_SENT', + entityType: 'application', + entityId: application.id, + newData: { email, type: 'OPPORTUNITY', location: displayLocation } + }).catch((err: any) => console.error('AuditLog error for email:', err)); + }) + .catch((err: any) => console.error('Error sending opportunity email', err)); } else { sendNonOpportunityEmail(email, displayApplicantName, displayLocation) - .catch(err => console.error('Error sending non-opportunity email', err)); + .then(() => { + db.AuditLog.create({ + userId: req.user?.id || null, + action: 'EMAIL_SENT', + entityType: 'application', + entityId: application.id, + newData: { email, type: 'NON_OPPORTUNITY', location: displayLocation } + }).catch((err: any) => console.error('AuditLog error for email:', err)); + }) + .catch((err: any) => console.error('Error sending non-opportunity email', err)); } await AuditLog.create({ @@ -191,32 +210,143 @@ export const getApplications = async (req: AuthRequest, res: Response) => { try { const whereClause: any = {}; - // Security Check: If prospective dealer, only show their own application - if (req.user?.roleCode === 'Prospective Dealer') { - // Filter by phone instead of email to show all applications from same user - whereClause.phone = (req.user as any).phone || req.user.email; - } + // Determine if user has national-level visibility + const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'Admin', 'Super Admin']; + const isNationalUser = nationalRoles.includes(req.user?.roleCode || ''); + const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer'; - // Security Check: If FDD user, only show applications where they are a participant - if (req.user?.roleCode === 'FDD') { + if (isProspectiveDealer) { + whereClause.phone = (req.user as any).phone || req.user?.email; + } else if (!isNationalUser) { + // Restriction: Only show applications where the user is a participant const participantApps = await db.RequestParticipant.findAll({ - where: { userId: req.user.id, requestType: 'application' }, + where: { userId: req.user?.id, requestType: 'application' }, attributes: ['requestId'] }); const appIds = participantApps.map((p: any) => p.requestId); whereClause.id = { [Op.in]: appIds }; } - const applications = await Application.findAll({ + // Apply Filters + const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query; + + if (fromDate || toDate) { + whereClause.createdAt = {}; + if (fromDate) { + const start = new Date(fromDate as string); + start.setHours(0, 0, 0, 0); + whereClause.createdAt[Op.gte] = start; + } + if (toDate) { + const end = new Date(toDate as string); + end.setHours(23, 59, 59, 999); + whereClause.createdAt[Op.lte] = end; + } + } + + if (search) { + whereClause[Op.or] = [ + { applicantName: { [Op.iLike]: `%${search}%` } }, + { applicationId: { [Op.iLike]: `%${search}%` } }, + { email: { [Op.iLike]: `%${search}%` } }, + { phone: { [Op.iLike]: `%${search}%` } } + ]; + } + + const applyStatusFilter = (val: any) => { + if (!val || val === 'all') return; + if (Array.isArray(val)) { + whereClause.overallStatus = { [Op.in]: val }; + } else if (typeof val === 'string' && val.includes(',')) { + whereClause.overallStatus = { [Op.in]: val.split(',') }; + } else { + whereClause.overallStatus = val; + } + }; + + // Pipeline Logic - Forced strict filtering by lifecycle stage + const isShortlistedStr = String(isShortlisted ?? '').toLowerCase(); + const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase(); + + if (isShortlistedStr === 'false') { + // Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity + whereClause.overallStatus = 'Submitted'; + whereClause.isShortlisted = { [Op.ne]: true }; + whereClause.ddLeadShortlisted = { [Op.ne]: true }; + whereClause.opportunityId = null; // Strictly lead-gen records only + } else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') { + // Opportunities (Prospects) MUST be shortlisted but NOT yet in workflow + whereClause.isShortlisted = true; + whereClause.ddLeadShortlisted = { [Op.ne]: true }; + if (status && status !== 'all') { + applyStatusFilter(status); + } else { + whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed'] }; + } + } else if (ddLeadShortlistedStr === 'true') { + // Workflow strictly shows shortlisted by DD Lead + whereClause.ddLeadShortlisted = true; + applyStatusFilter(status); + } else { + // 'All Requests' or other general views + applyStatusFilter(status); + } + + if (location && location !== 'all') { + whereClause.preferredLocation = location; + } + if (state && state !== 'all') { + whereClause.state = state; + } + if (assignedTo) { + whereClause.assignedTo = assignedTo; + } + + // Apply Pagination + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: applications } = await Application.findAndCountAll({ where: whereClause, include: [ { model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }, { model: SecurityDeposit, as: 'securityDeposits' } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true, + col: 'id' }); - res.json({ success: true, data: applications }); + // Get additional stats for the header + const stats = { + total: count, + uniqueLocations: await Application.count({ + where: whereClause, + distinct: true, + col: 'preferredLocation' + }), + withExperience: await Application.count({ + where: { + ...whereClause, + experienceYears: { [Op.gt]: 0 } + } + }) + }; + + res.json({ + success: true, + data: applications, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit, + stats + } + }); } catch (error) { console.error('Get applications error:', error); res.status(500).json({ success: false, message: 'Error fetching applications' }); @@ -573,13 +703,9 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { return res.status(400).json({ success: false, message: 'No applications selected' }); } - if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) { - return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' }); - } - - // Strategy: Assign the first user as primary assignee for the single FK field, - // but add ALL as participants to enforce dual-responsibility. - const primaryAssigneeId = assignedTo[0]; + // assignedTo is now optional as auto-assignment is handled via location + const assignedToArr = Array.isArray(assignedTo) ? assignedTo : []; + const primaryManualAssigneeId = assignedToArr.length > 0 ? assignedToArr[0] : null; // Update Applications sequentially via WorkflowService for consistency for (const appId of applicationIds) { @@ -587,11 +713,23 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: appId } }); + if (application) { + let resolvedAssigneeId = primaryManualAssigneeId; + + // If no manual assignee provided, auto-resolve from District mapping + if (!resolvedAssigneeId && application.districtId) { + const district = await db.District.findByPk(application.districtId); + if (district) { + // Prioritize DD-AM as per user request + resolvedAssigneeId = district.ddAmId || district.zmId || null; + } + } + await application.update({ ddLeadShortlisted: true, isShortlisted: true, - assignedTo: primaryAssigneeId, + assignedTo: resolvedAssigneeId, updatedAt: new Date(), }); @@ -608,22 +746,24 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { application.applicationId ).catch(err => console.error('Failed to send shortlist email:', err)); - // Add all assigned users as participants - for (const userId of assignedTo) { - await db.RequestParticipant.findOrCreate({ - where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' }, - defaults: { joinedMethod: 'auto' } - }); + // Add manual assignees as participants if provided + if (assignedToArr.length > 0) { + for (const userId of assignedToArr) { + await db.RequestParticipant.findOrCreate({ + where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' }, + defaults: { joinedMethod: 'auto' } + }); + } } - // AUTO-FILL Interview Evaluators + // TRIGGER AUTO-ASSIGNMENT for all stages based on location await assignStageEvaluators(application.id); } } res.json({ success: true, - message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.` + message: `Successfully shortlisted ${applicationIds.length} application(s). Assignments processed automatically based on location.` }); } catch (error) { console.error('Bulk shortlist error:', error); @@ -640,6 +780,10 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { */ const assignStageEvaluators = async (appIdOrId: string) => { try { + if (!await isAutoAssignmentEnabled('ONBOARDING')) { + console.log(`[debug] Auto-assignment disabled for ONBOARDING. Skipping for App: ${appIdOrId}`); + return; + } console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`); const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(appIdOrId); const application = await Application.findOne({ @@ -752,7 +896,6 @@ const assignStageEvaluators = async (appIdOrId: string) => { } for (const [userId, assignment] of Object.entries(userAssignments)) { - const isInterview = assignment.stages.some(s => typeof s === 'number'); const primaryStage = assignment.stages[0]; const primaryRole = assignment.roles[0]; @@ -769,29 +912,37 @@ const assignStageEvaluators = async (appIdOrId: string) => { interviewLevel: typeof primaryStage === 'number' ? primaryStage : null, stageCode: typeof primaryStage === 'string' ? primaryStage : null, role: primaryRole, - allAssignments: assignment.stages, // Store all assignments + allAssignments: assignment.stages, autoMapped: true } } }); if (!created) { - // Update metadata if it exists to include the new assignments - const meta = participant.metadata || {}; - const currentAssignments = meta.allAssignments || []; - const mergedAssignments = [...new Set([...currentAssignments, ...assignment.stages])]; - await participant.update({ + joinedMethod: 'auto', // Ensure it's marked as auto if it wasn't metadata: { - ...meta, - allAssignments: mergedAssignments, - // Maintain legacy fields for compatibility if they don't exist - interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null), - stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null) + ...(participant.metadata || {}), + interviewLevel: typeof primaryStage === 'number' ? primaryStage : null, + stageCode: typeof primaryStage === 'string' ? primaryStage : null, + role: primaryRole, + allAssignments: assignment.stages, + autoMapped: true } }); } } + + // --- Cleanup stale auto-assignments --- + const currentParticipantIds = Object.keys(userAssignments); + await db.RequestParticipant.destroy({ + where: { + requestId: application.id, + requestType: 'application', + joinedMethod: 'auto', + userId: { [db.Sequelize.Op.notIn]: currentParticipantIds } + } + }); } catch (error) { console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error); } @@ -1019,15 +1170,15 @@ export const getDocumentConfigMetadata = async (_req: AuthRequest, res: Response // Fetch Document Configurations based on Role and Stage export const getDocumentConfigs = async (req: AuthRequest, res: Response) => { - try { - const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query; + try { const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query; const roleCode = (roleFilter as string) || req.user?.role; const where: any = { module }; if (stageCode) { where.stageCode = { [Op.or]: [stageCode, 'General'] }; } - + + if (search) { where[Op.or] = [ { documentType: { [Op.iLike]: `%${search}%` } }, @@ -1190,8 +1341,225 @@ export const exportApplicationResponses = async (req: AuthRequest, res: Response }); res.json({ success: true, data: rows }); - } catch (error) { + } catch (error: any) { console.error('Export error:', error); res.status(500).json({ success: false, message: 'Error exporting data' }); } }; + +/** + * Handle the scenario when an applicant has applied for a location where + * the opportunity was unavailable (Non-Opportunity Application) but now + * an opportunity has opened up. + */ +export const convertToOpportunity = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const { opportunityId, remarks } = req.body; + + // 1. Resolve Application + const application = await Application.findByPk(id); + if (!application) { + return res.status(404).json({ success: false, message: 'Application not found' }); + } + + // 2. Guardians: Ensure it's not already an opportunity application + if (application.opportunityId) { + return res.status(400).json({ + success: false, + message: 'Application is already linked to an opportunity.' + }); + } + + let targetOpportunityId = opportunityId; + + // 3. Auto-discovery fallback: If no ID provided, look for active opportunity in same district + if (!targetOpportunityId && application.districtId) { + const activeOpp = await Opportunity.findOne({ + where: { + districtId: application.districtId, + status: 'active', + [Op.or]: [ + { openTo: null }, + { openTo: { [Op.gte]: new Date() } } + ] + } + }); + if (activeOpp) { + targetOpportunityId = activeOpp.id; + } + } + + if (!targetOpportunityId) { + return res.status(400).json({ + success: false, + message: 'No active opportunity found for this location. Please create an opportunity for this district first.' + }); + } + + // 4. Update Application Record + await application.update({ + opportunityId: targetOpportunityId, + isShortlisted: true, + updatedAt: new Date() + }); + + // 5. Trigger Workflow Transition (Moves to Questionnaire Pending) + // This handles status history, audit logs, and progress percentage sync (10%) + await WorkflowService.transitionApplication( + application, + APPLICATION_STATUS.QUESTIONNAIRE_PENDING, + req.user?.id || null, + { + reason: 'Application processing initiated.', + source: 'Lead Conversion', + skipNotification: true + } + ); + + // 6. Trigger Opportunity Email (Contains portal link + questionnaire invitation) + const displayApplicantName = toTitleCase(application.applicantName); + const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location'); + + sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId) + .then(() => { + db.AuditLog.create({ + userId: req.user?.id || null, + action: 'EMAIL_SENT', + entityType: 'application', + entityId: application.id, + newData: { + email: application.email, + type: 'OPPORTUNITY_CONVERSION', + status: 'SUCCESS' + } + }).catch((err: any) => console.error('[onboarding] Conversion email audit failed:', err)); + }) + .catch((err: any) => console.error('[onboarding] Failed to send conversion email:', err)); + + // 7. Log specialized Audit record + await AuditLog.create({ + userId: req.user?.id, + action: AUDIT_ACTIONS.UPDATED, + entityType: 'application', + entityId: application.id, + newData: { + action: 'CONVERT_TO_OPPORTUNITY', + opportunityId: targetOpportunityId, + remarks: remarks || 'N/A' + } + }); + + res.json({ + success: true, + message: 'Application successfully converted. Questionnaire link sent to applicant.', + data: application + }); + + } catch (error) { + console.error('Convert to opportunity error:', error); + res.status(500).json({ success: false, message: 'Error converting application' }); + } +}; + +/** + * Bulk conversion of lead-generation applications to active opportunities. + */ +export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response) => { + try { + const { ids, remarks } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ success: false, message: 'No application IDs provided' }); + } + + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (const id of ids) { + try { + const application = await Application.findByPk(id); + if (!application) { + results.failed++; + results.errors.push(`Application ${id} not found.`); + continue; + } + + if (application.opportunityId) { + results.failed++; + results.errors.push(`Application ${application.applicationId} is already linked to an opportunity.`); + continue; + } + + let targetOpportunityId = null; + + // Auto-discovery logic (matches the single conversion logic) + if (application.districtId) { + const activeOpp = await Opportunity.findOne({ + where: { + districtId: application.districtId, + status: 'active', + [Op.or]: [ + { openTo: null }, + { openTo: { [Op.gte]: new Date() } } + ] + } + }); + if (activeOpp) { + targetOpportunityId = activeOpp.id; + } + } + + if (!targetOpportunityId) { + results.failed++; + results.errors.push(`No active opportunity found for ${application.applicantName} (${application.preferredLocation}).`); + continue; + } + + // Update Application + await application.update({ + opportunityId: targetOpportunityId, + isShortlisted: true, + updatedAt: new Date() + }); + + // Transition Workflow + await WorkflowService.transitionApplication( + application, + APPLICATION_STATUS.QUESTIONNAIRE_PENDING, + req.user?.id || null, + { + reason: 'Application processing initiated.', + source: 'Lead Conversion', + skipNotification: true + } + ); + + // Send Email Notification + const displayApplicantName = toTitleCase(application.applicantName); + const displayLocation = toTitleCase(application.city || application.preferredLocation || 'Proposed Location'); + + sendOpportunityEmail(application.email, displayApplicantName, displayLocation, application.applicationId) + .catch((err: any) => console.error(`[bulk-convert] Mail failed for ${id}:`, err)); + + results.success++; + } catch (err: any) { + results.failed++; + results.errors.push(`Error converting ID ${id}: ${err.message}`); + } + } + + res.json({ + success: true, + message: `Batch processed ${ids.length} records. ${results.success} succeeded, ${results.failed} failed.`, + data: results + }); + + } catch (error) { + console.error('Bulk convert error:', error); + res.status(500).json({ success: false, message: 'Internal error during batch conversion' }); + } +}; diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index a45edfe..98cd6d2 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -6,7 +6,7 @@ import { assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes, retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata, createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication, - exportApplicationResponses + exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity } from './onboarding.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; @@ -33,6 +33,8 @@ router.put('/applications/:id', updateApplication); router.put('/applications/:id/status', updateApplicationStatus); router.post('/applications/:id/documents', uploadSingle, uploadDocuments); router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import +router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity); +router.post('/applications/:id/convert-to-opportunity', convertToOpportunity); // Architecture-related routes router.post('/applications/:id/assign-architecture', assignArchitectureTeam); diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index 91f13de..7dd239a 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -1,6 +1,6 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant } = db; +const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy, RequestParticipant, DealerCode } = db; import { NotificationService } from '../../services/NotificationService.js'; import { Op } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; @@ -164,7 +164,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null; } - const requestId = NomenclatureService.generateConstitutionalChangeId(); + const requestId = await NomenclatureService.generateConstitutionalChangeId(); const metadata = { newPartnersDetails, @@ -241,9 +241,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => { const where: any = {}; if (req.user.roleCode === 'Dealer') { where.dealerId = req.user.id; + } else { + const { status } = req.query; + if (status) { + if (status === 'pending') { + where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] }; + } else if (status === 'completed') { + where.status = { [Op.in]: ['Completed', 'Closed'] }; + } else if (status === 'rejected') { + where.status = { [Op.in]: ['Rejected', 'Revoked'] }; + } else { + where.status = status; + } + } } - const requests = await ConstitutionalChange.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: requests } = await ConstitutionalChange.findAndCountAll({ where, include: [ { model: Outlet, as: 'outlet' }, @@ -251,25 +268,31 @@ export const getRequests = async (req: AuthRequest, res: Response) => { model: User, as: 'dealer', attributes: ['fullName'], - include: [ - { - model: Dealer, - as: 'dealerProfile', - include: [ - { - model: Application, - as: 'application', - include: [{ model: District, as: 'district' }] - } - ] - } - ] + include: [{ model: Dealer, as: 'dealerProfile', include: [{ model: DealerCode, as: 'dealerCode' }] }] } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true }); - res.json({ success: true, requests }); + res.json({ + success: true, + requests, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit, + stats: { + total: count, + pending: await ConstitutionalChange.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }), + completed: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }), + rejected: await ConstitutionalChange.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } }) + } + } + }); } catch (error) { console.error('Get constitutional changes error:', error); res.status(500).json({ success: false, message: 'Error fetching requests' }); @@ -377,6 +400,11 @@ const CONSTITUTIONAL_STAGE_POLICY_CODES: Record = { [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW' }; +const hasRoleInConfig = (rolesArray: string[] | undefined, role: string) => { + if (!rolesArray || !role) return false; + return rolesArray.includes(role); +}; + const normalizeRoleKey = (rawRole: string) => { const role = String(rawRole || '').trim().toUpperCase(); if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM'; @@ -516,8 +544,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => { zmRbm }; - const approvedRequiredRoles = requiredRoles.filter((role) => Boolean(zmRbm[role]?.approvedByUserId)); - const waitingFor = requiredRoles.filter((role) => !zmRbm[role]?.approvedByUserId); + const approvedRequiredRoles = requiredRoles.filter((role: string) => Boolean(zmRbm[role]?.approvedByUserId)); + const waitingFor = requiredRoles.filter((role: string) => !zmRbm[role]?.approvedByUserId); const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length); if (!approvalThresholdMet) { diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index fe00051..bb4e90b 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -330,7 +330,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { }); } - const requestId = NomenclatureService.generateRelocationId(); + const requestId = await NomenclatureService.generateRelocationId(); const request = await RelocationRequest.create({ requestId, @@ -400,9 +400,26 @@ export const getRequests = async (req: AuthRequest, res: Response) => { const where: any = {}; if (req.user.roleCode === 'Dealer') { where.dealerId = req.user.id; + } else { + const { status } = req.query; + if (status) { + if (status === 'pending') { + where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] }; + } else if (status === 'completed') { + where.status = { [Op.in]: ['Completed', 'Closed'] }; + } else if (status === 'rejected') { + where.status = { [Op.in]: ['Rejected', 'Revoked'] }; + } else { + where.status = status; + } + } } - const requests = await RelocationRequest.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: requests } = await RelocationRequest.findAndCountAll({ where, include: [ { @@ -427,7 +444,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => { attributes: ['fullName'] } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true }); // Filter requests based on user's role and location assignments @@ -475,7 +495,22 @@ export const getRequests = async (req: AuthRequest, res: Response) => { }; }); - res.json({ success: true, requests: enrichedRequests }); + res.json({ + success: true, + requests: enrichedRequests, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit, + stats: { + total: count, + pending: await RelocationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] } } }), + completed: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }), + rejected: await RelocationRequest.count({ where: { ...where, status: { [Op.in]: ['Rejected', 'Revoked'] } } }) + } + } + }); } catch (error) { console.error('Get relocation requests error:', error); res.status(500).json({ success: false, message: 'Error fetching requests' }); diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 855c7b0..89b22a2 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -57,7 +57,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' }; }); - const resignationId = NomenclatureService.generateResignationId(); + const resignationId = await NomenclatureService.generateResignationId(); const resignation = await db.Resignation.create({ resignationId, outletId, @@ -115,9 +115,32 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex if (req.user.roleCode === ROLES.DEALER) { where.dealerId = req.user.id; + } else { + // For administrative users, filter by status or assignment if requested + const { status, onlyMine } = req.query; + + if (status) { + if (String(status).includes(',')) { + where.status = { [Op.in]: String(status).split(',') }; + } else if (status === 'open') { + where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] }; + } else { + where.status = status; + } + } + + if (onlyMine === 'true') { + // This would involve a subquery on RequestParticipants or assignedTo field + // Assuming currentStage context or RequestParticipants + where.currentStage = { [Op.like]: `%${req.user.roleCode}%` }; + } } - const resignations = await db.Resignation.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: resignations } = await db.Resignation.findAndCountAll({ where, include: [ { model: db.Outlet, as: 'outlet' }, @@ -130,9 +153,26 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex ] } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true + }); + res.json({ + success: true, + resignations, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit, + stats: { + total: count, + open: await db.Resignation.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] } } }), + completed: await db.Resignation.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } }) + } + } }); - res.json({ success: true, resignations }); } catch (error) { logger.error('Error fetching resignations:', error); next(error); @@ -384,7 +424,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: // No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances. const fnf = await db.FnF.create({ - settlementId: NomenclatureService.generateFnFId(), + settlementId: await NomenclatureService.generateFnFId(), resignationId: resignation.id, outletId: resignation.outletId, dealerId: dealerProfileId, // Correctly using the Dealer model ID diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index 7ff9971..50f35b5 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -9,7 +9,7 @@ import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; -import { NomenclatureService } from '../../services/NomenclatureService.js'; +import { NomenclatureService } from '../../common/utils/nomenclature.js'; const LINE_ITEM_DESCRIPTION_PREFIX = { DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', @@ -118,15 +118,30 @@ export const uploadFnFDocument = async (req: AuthRequest, res: Response) => { export const getOnboardingPayments = async (req: AuthRequest, res: Response) => { try { - const payments = await FinancePayment.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: payments } = await FinancePayment.findAndCountAll({ include: [{ model: Application, as: 'application', attributes: ['applicantName', 'applicationId'] }], - order: [['createdAt', 'ASC']] + order: [['createdAt', 'ASC']], + limit, + offset + }); + res.json({ + success: true, + payments, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit + } }); - res.json({ success: true, payments }); } catch (error) { console.error('Get onboarding payments error:', error); res.status(500).json({ success: false, message: 'Error fetching payments' }); @@ -254,7 +269,11 @@ export const updateFnF = async (req: AuthRequest, res: Response) => { export const getFnFSettlements = async (req: Request, res: Response) => { try { - const settlements = await FnF.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: settlements } = await FnF.findAndCountAll({ include: [ { model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] }, { model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] }, @@ -263,9 +282,21 @@ export const getFnFSettlements = async (req: Request, res: Response) => { { model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true + }); + res.json({ + success: true, + settlements, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit + } }); - res.json({ success: true, settlements }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching settlements' }); } diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index 9ae23c1..424a835 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -1,4 +1,5 @@ import { Response, NextFunction } from 'express'; +import { Op } from 'sequelize'; import db from '../../database/models/index.js'; import logger from '../../common/utils/logger.js'; import { @@ -32,7 +33,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N if (!req.user) throw new Error('Unauthorized'); const { dealerId, category, reason, proposedLwd, comments } = req.body; - const requestId = NomenclatureService.generateTerminationId(); + const requestId = await NomenclatureService.generateTerminationId(); const termination = await db.TerminationRequest.create({ requestId, dealerId, @@ -86,9 +87,24 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex if (req.user.roleCode === ROLES.DEALER) { const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } }); if (dealer) where.dealerId = dealer.id; + } else { + const { status } = req.query; + if (status) { + if (status === 'open') { + where.status = { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] }; + } else if (status === 'completed') { + where.status = { [Op.in]: ['Terminated', 'Completed', 'Closed'] }; + } else { + where.status = status; + } + } } - const terminations = await db.TerminationRequest.findAll({ + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + const { count, rows: terminations } = await db.TerminationRequest.findAndCountAll({ where, include: [ { @@ -97,9 +113,26 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex include: [{ model: db.DealerCode, as: 'dealerCode' }] } ], - order: [['createdAt', 'DESC']] + order: [['createdAt', 'DESC']], + limit, + offset, + distinct: true + }); + res.json({ + success: true, + terminations, + meta: { + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + limit, + stats: { + total: count, + open: await db.TerminationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] } } }), + completed: await db.TerminationRequest.count({ where: { ...where, status: { [Op.in]: ['Terminated', 'Completed', 'Closed'] } } }) + } + } }); - res.json({ success: true, terminations }); } catch (error) { logger.error('Error fetching terminations:', error); next(error); diff --git a/src/scripts/seed-auto-assignment-configs.ts b/src/scripts/seed-auto-assignment-configs.ts new file mode 100644 index 0000000..df79911 --- /dev/null +++ b/src/scripts/seed-auto-assignment-configs.ts @@ -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(); diff --git a/src/services/AutoAssignmentConfigService.ts b/src/services/AutoAssignmentConfigService.ts new file mode 100644 index 0000000..62f12f5 --- /dev/null +++ b/src/services/AutoAssignmentConfigService.ts @@ -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 = { + 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 { + 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> { + const result: Record = {}; + + 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; +} + +/** + * Get module name from requestType string (used in ParticipantService) + */ +export function getModuleFromRequestType(requestType: string): AutoAssignmentModule | null { + const mapping: Record = { + 'application': 'ONBOARDING', + 'relocation': 'RELOCATION', + 'termination': 'TERMINATION', + 'resignation': 'RESIGNATION', + 'constitutional': 'CONSTITUTIONAL', + 'fnf': 'FNF' + }; + return mapping[requestType.toLowerCase()] || null; +} diff --git a/src/services/ParticipantService.ts b/src/services/ParticipantService.ts index 711ac36..383e21d 100644 --- a/src/services/ParticipantService.ts +++ b/src/services/ParticipantService.ts @@ -1,6 +1,7 @@ import db from '../database/models/index.js'; import { ROLES, REQUEST_TYPES } from '../common/config/constants.js'; import { Op } from 'sequelize'; +import { isAutoAssignmentEnabled } from './AutoAssignmentConfigService.js'; const { RequestParticipant, @@ -22,20 +23,33 @@ export class ParticipantService { */ private static async addParticipant(requestId: string, requestType: string, userId: string, participantType: string = 'contributor', metadata: any = {}) { try { - await RequestParticipant.findOrCreate({ + const existing = await RequestParticipant.findOne({ where: { requestId, requestType, userId - }, - defaults: { - participantType, - joinedMethod: 'auto', - metadata: { - ...metadata, - autoMapped: true, - assignedAt: new Date() - } + } + }); + + if (existing) { + // Requirement: Auto-assignment logic skips participants with revokedAt to prevent re-adding + if (existing.metadata?.revokedAt) { + console.log(`[ParticipantService] Skipping revoked participant ${userId} for ${requestType} ${requestId}`); + return; + } + return; // Already exists + } + + await RequestParticipant.create({ + requestId, + requestType, + userId, + participantType, + joinedMethod: 'auto', + metadata: { + ...metadata, + autoMapped: true, + assignedAt: new Date() } }); } catch (error) { @@ -80,6 +94,10 @@ export class ParticipantService { */ static async assignTerminationParticipants(requestId: string) { try { + if (!await isAutoAssignmentEnabled('TERMINATION')) { + console.log(`[ParticipantService] Auto-assignment disabled for TERMINATION. Skipping for ${requestId}`); + return; + } const termination = await db.TerminationRequest.findByPk(requestId); if (!termination) { console.error(`[ParticipantService] Termination Request not found: ${requestId}`); @@ -154,6 +172,10 @@ export class ParticipantService { */ static async assignConstitutionalParticipants(requestId: string) { try { + if (!await isAutoAssignmentEnabled('CONSTITUTIONAL')) { + console.log(`[ParticipantService] Auto-assignment disabled for CONSTITUTIONAL. Skipping for ${requestId}`); + return; + } const request = await ConstitutionalChange.findByPk(requestId); if (!request) return; @@ -215,6 +237,10 @@ export class ParticipantService { */ static async assignResignationParticipants(requestId: string) { try { + if (!await isAutoAssignmentEnabled('RESIGNATION')) { + console.log(`[ParticipantService] Auto-assignment disabled for RESIGNATION. Skipping for ${requestId}`); + return; + } const resignation = await db.Resignation.findByPk(requestId); if (!resignation) { console.error(`[ParticipantService] Resignation not found: ${requestId}`); @@ -286,6 +312,10 @@ export class ParticipantService { */ static async assignRelocationParticipants(requestId: string) { try { + if (!await isAutoAssignmentEnabled('RELOCATION')) { + console.log(`[ParticipantService] Auto-assignment disabled for RELOCATION. Skipping for ${requestId}`); + return; + } const relocation = await db.RelocationRequest.findByPk(requestId, { include: [{ model: Outlet, @@ -367,6 +397,10 @@ export class ParticipantService { */ static async assignFnFParticipants(fnfId: string) { try { + if (!await isAutoAssignmentEnabled('FNF')) { + console.log(`[ParticipantService] Auto-assignment disabled for FNF. Skipping for ${fnfId}`); + return; + } const fnf = await db.FnF.findByPk(fnfId); if (!fnf) return; diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index 443acce..9933613 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -180,7 +180,7 @@ export class TerminationWorkflowService { if (!fnf) { fnf = await db.FnF.create({ - settlementId: NomenclatureService.generateFnFId(), + settlementId: await NomenclatureService.generateFnFId(), terminationRequestId: termination.id, dealerId: termination.dealerId, outletId: primaryOutlet?.id || null, diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index 4ef6d88..386b3f0 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -121,7 +121,7 @@ export class WorkflowService { } // 5. Notifications — non-fatal - if (application.email) { + if (application.email && !metadata.skipNotification) { try { const user = await User.findOne({ where: { email: application.email }, @@ -200,10 +200,12 @@ export class WorkflowService { * FIXED: Counts unique users instead of unique roles to allow same-role approvals */ static async evaluateStagePolicy(applicationId: string, stageCode: string) { - const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } }); - if (!policy) return { policyMet: true }; // No policy means no restriction + const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } }); + if (!policy) return { policyMet: true }; // No active policy means no restriction const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; + const mode = policy.approvalMode || 'MIN_N'; + const minNeeded = policy.minApprovals || 1; // Fetch all approved actions for this stage const actions = await db.StageApprovalAction.findAll({ @@ -213,21 +215,40 @@ export class WorkflowService { const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId)); const approvedRoles = new Set(actions.map((a: any) => a.actorRole)); - const isSuperAdminApproval = Array.from(approvedRoles).includes('Super Admin'); + // 1. Initial Gate: Super Admin bypass + if (approvedRoles.has('Super Admin')) { + return { policyMet: true, policy, overriddenBy: 'Super Admin' }; + } + + let roleConditionMet = false; - const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval) - ? true - : requiredRoles.every((role: string) => approvedRoles.has(role)); + switch (mode) { + case 'ALL': + case 'ROLE_MANDATORY': + // Every role in the required list MUST be present in the approved roles + roleConditionMet = requiredRoles.length === 0 || + requiredRoles.every(role => approvedRoles.has(role)); + break; + + case 'MIN_N': + default: + // If there are required roles, at least one approval must come from THAT list + // If the list is empty, any approval counts + roleConditionMet = requiredRoles.length === 0 || + requiredRoles.some(role => approvedRoles.has(role)); + break; + } - const meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1); + const meetsMinCount = uniqueApprovers.size >= minNeeded; return { - policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals, + policyMet: roleConditionMet && meetsMinCount, policy, uniqueApprovers: Array.from(uniqueApprovers), approvedRoles: Array.from(approvedRoles), - hasAllRequiredRoleApprovals, - meetsMinApprovals + roleConditionMet, + meetsMinCount, + mode }; } } diff --git a/trigger-relocation.js b/trigger-relocation.js index c831b26..f48f3d2 100644 --- a/trigger-relocation.js +++ b/trigger-relocation.js @@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500); const EMAILS = { DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com", - DEALER: args.dealerEmail, + DEALER: args.dealerEmail || "ramesh_1777038131833@gmail.com", ASM: args.asmEmail || "abhishek@royalenfield.com", RBM: args.rbmEmail || "manish@royalenfield.com", DD_ZM: args.ddZmEmail || "piyush@royalenfield.com", @@ -49,10 +49,10 @@ async function apiRequest(endpoint, method = "GET", body = null, token = null) { return data; } -async function login(email) { +async function login(email, password = PASSWORD) { if (!login.cache) login.cache = {}; if (login.cache[email]) return login.cache[email]; - const data = await apiRequest("/auth/login", "POST", { email, password: PASSWORD }); + const data = await apiRequest("/auth/login", "POST", { email, password }); login.cache[email] = data.token; return data.token; } @@ -89,7 +89,7 @@ async function approveCurrentStage(requestId, stageName) { } throw new Error( `Approval failed for stage: ${stageName}. Attempts -> ${attempts.join(" | ")}` - + (lastError ? ` | Last error: ${lastError.message}` : "") + + (lastError ? ` | Last error: ${lastError.message}` : "") ); } @@ -107,7 +107,7 @@ async function run() { throw new Error("Missing --dealerEmail. This script requires an existing dealer user email."); } const adminToken = await login(EMAILS.DD_ADMIN); - const dealerToken = await login(EMAILS.DEALER); + const dealerToken = await login(EMAILS.DEALER, "Dealer@123"); let requestId = args.requestId; if (!requestId) {