diff --git a/package.json b/package.json index 8dd06a5..a84bede 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "seed:permissions": "tsx scripts/seed-permissions.ts", "seed:approval-policies": "tsx scripts/seed-approval-policies.ts", "seed:email-templates": "tsx src/scripts/seed-master-emails.ts", - "seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates", + "seed:configs": "tsx scripts/seed-system-configs.ts", + "seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates && npm run seed:configs", "setup:fresh": "npm run migrate && npm run seed:real-geo && npm run seed:all && npm run sync:hierarchy", "seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy", "sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts", diff --git a/scripts/delete-test-relocation.ts b/scripts/delete-test-relocation.ts new file mode 100644 index 0000000..3965226 --- /dev/null +++ b/scripts/delete-test-relocation.ts @@ -0,0 +1,53 @@ +/** + * Script to delete a test relocation request by requestId + * Usage: npx tsx scripts/delete-test-relocation.ts REL-1775129490244-5B9C + */ +import db from '../src/database/models/index.js'; + +async function deleteRelocationRequest(requestId: string) { + try { + console.log(`Deleting relocation request: ${requestId}`); + + // Find the request + const request = await db.RelocationRequest.findOne({ + where: { requestId } + }); + + if (!request) { + console.log(`Request ${requestId} not found`); + process.exit(0); + } + + // Delete associated RequestParticipants + await db.RequestParticipant.destroy({ + where: { requestId: request.id, requestType: 'relocation' } + }); + console.log('Deleted associated participants'); + + // Delete associated Worknotes + await db.Worknote.destroy({ + where: { requestId: request.id, requestType: 'relocation' } + }); + console.log('Deleted associated worknotes'); + + // Delete the request + await request.destroy(); + console.log(`Deleted relocation request: ${requestId}`); + + console.log('✅ Done!'); + process.exit(0); + } catch (error) { + console.error('❌ Error:', error); + process.exit(1); + } finally { + await db.sequelize.close(); + } +} + +const requestId = process.argv[2]; +if (!requestId) { + console.log('Usage: npx tsx scripts/delete-test-relocation.ts '); + process.exit(1); +} + +deleteRelocationRequest(requestId); \ No newline at end of file diff --git a/scripts/migrate-relocation-schema.ts b/scripts/migrate-relocation-schema.ts new file mode 100644 index 0000000..be4a69e --- /dev/null +++ b/scripts/migrate-relocation-schema.ts @@ -0,0 +1,74 @@ +/** + * Migration Script: Add newDistrictId and newStateId to RelocationRequest + * Run: npx ts-node scripts/migrate-relocation-schema.ts + */ +import db from '../src/database/models/index.js'; + +async function migrate() { + const queryInterface = db.sequelize.getQueryInterface(); + + try { + console.log('Starting relocation schema migration...'); + + // Get table description to check existing columns + const tableInfo = await queryInterface.describeTable('relocation_requests'); + + // Add newDistrictId column if not exists + if (!tableInfo.newDistrictId) { + console.log('Adding newDistrictId column...'); + await queryInterface.addColumn('relocation_requests', 'newDistrictId', { + type: db.Sequelize.DataTypes.UUID, + allowNull: true, + references: { + model: 'districts', + key: 'id' + } + }); + console.log('✓ newDistrictId column added'); + } else { + console.log('- newDistrictId column already exists'); + } + + // Add newStateId column if not exists + if (!tableInfo.newStateId) { + console.log('Adding newStateId column...'); + await queryInterface.addColumn('relocation_requests', 'newStateId', { + type: db.Sequelize.DataTypes.UUID, + allowNull: true, + references: { + model: 'states', + key: 'id' + } + }); + console.log('✓ newStateId column added'); + } else { + console.log('- newStateId column already exists'); + } + + // Update enum to include 'Intercity' if not already present + console.log('Checking relocationType enum...'); + try { + await db.sequelize.query(` + ALTER TYPE "enum_relocation_requests_relocationType" + ADD VALUE IF NOT EXISTS 'Intercity'; + `); + console.log('✓ Intercity added to enum (if not already present)'); + } catch (enumError: any) { + // PostgreSQL doesn't support IF NOT EXISTS for enum values in some versions + if (enumError.code === '42710') { + console.log('- Intercity already exists in enum'); + } else { + console.log('Warning: Could not update enum:', enumError.message); + } + } + + console.log('\n✅ Migration completed successfully!'); + } catch (error) { + console.error('❌ Migration failed:', error); + process.exit(1); + } finally { + await db.sequelize.close(); + } +} + +migrate(); \ No newline at end of file diff --git a/scripts/seed-system-configs.ts b/scripts/seed-system-configs.ts new file mode 100644 index 0000000..afbecc1 --- /dev/null +++ b/scripts/seed-system-configs.ts @@ -0,0 +1,46 @@ +import db from '../src/database/models/index.js'; + +const seedSystemConfigs = async () => { + try { + console.log('Seeding system configurations...'); + + const configs = [ + { + key: 'INITIAL_SECURITY_DEPOSIT', + value: { amount: 500000, currency: 'INR' }, + category: 'SECURITY_DEPOSIT', + description: 'Default Initial Security Deposit amount for new dealer onboarding' + }, + { + key: 'FINAL_SECURITY_DEPOSIT', + value: { amount: 1500000, currency: 'INR' }, + category: 'SECURITY_DEPOSIT', + description: 'Default Final Security Deposit amount for new dealer onboarding' + } + ]; + + for (const config of configs) { + await db.SystemConfiguration.findOrCreate({ + where: { key: config.key }, + defaults: { + ...config, + isActive: true + } + }); + } + + console.log('System configurations seeded successfully.'); + } catch (error) { + console.error('Error seeding system configurations:', error); + } finally { + // Only close if this is the main module + // db.sequelize.close(); + } +}; + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + seedSystemConfigs().then(() => process.exit(0)); +} + +export default seedSystemConfigs; diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index cbba5ca..494a799 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -336,6 +336,8 @@ export const DOCUMENT_TYPES = { STATUTORY_AUDIT: 'Statutory Approval Certificate', BANK_GUARANTEE: 'Bank Guarantee Document', SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt', + SECURITY_DEPOSIT_INITIAL: 'Initial Security Deposit Receipt', + SECURITY_DEPOSIT_FINAL: 'Final Security Deposit Receipt', OTHER: 'Other' } as const; diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index 365dee6..f014542 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -266,6 +266,7 @@ export default (sequelize: Sequelize) => { Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' }); Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' }); Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' }); + Application.hasMany(models.SecurityDeposit, { foreignKey: 'applicationId', as: 'securityDeposits' }); }; return Application; diff --git a/src/database/models/RelocationRequest.ts b/src/database/models/RelocationRequest.ts index 4a60e73..e4b6bc2 100644 --- a/src/database/models/RelocationRequest.ts +++ b/src/database/models/RelocationRequest.ts @@ -10,6 +10,8 @@ export interface RelocationRequestAttributes { newAddress: string; newCity: string; newState: string; + newDistrictId: string | null; + newStateId: string | null; reason: string; currentStage: typeof RELOCATION_STAGES[keyof typeof RELOCATION_STAGES]; status: string; @@ -64,6 +66,22 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: false }, + newDistrictId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'districts', + key: 'id' + } + }, + newStateId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'states', + key: 'id' + } + }, reason: { type: DataTypes.TEXT, allowNull: false @@ -108,12 +126,22 @@ export default (sequelize: Sequelize) => { foreignKey: 'dealerId', as: 'dealer' }); + RelocationRequest.belongsTo(models.District, { + foreignKey: 'newDistrictId', + as: 'newDistrict' + }); + RelocationRequest.belongsTo(models.State, { + foreignKey: 'newStateId', + as: 'newStateRef' + }); RelocationRequest.hasMany(models.Worknote, { foreignKey: 'requestId', as: 'worknotes', scope: { requestType: 'relocation' }, constraints: false }); + // Note: Participants are computed dynamically based on outlet location hierarchy + // See getRequestById in relocation.controller.ts }; return RelocationRequest; diff --git a/src/database/models/SecurityDeposit.ts b/src/database/models/SecurityDeposit.ts index d45a373..eed2c28 100644 --- a/src/database/models/SecurityDeposit.ts +++ b/src/database/models/SecurityDeposit.ts @@ -7,6 +7,7 @@ export interface SecurityDepositAttributes { paymentReference: string | null; proofDocumentId: string | null; status: string; + depositType: 'INITIAL' | 'FINAL'; verifiedAt: Date | null; verifiedBy: string | null; } @@ -48,6 +49,11 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, defaultValue: 'pending' }, + depositType: { + type: DataTypes.ENUM('INITIAL', 'FINAL'), + allowNull: false, + defaultValue: 'INITIAL' + }, verifiedAt: { type: DataTypes.DATE, allowNull: true diff --git a/src/database/models/SystemConfiguration.ts b/src/database/models/SystemConfiguration.ts new file mode 100644 index 0000000..97da473 --- /dev/null +++ b/src/database/models/SystemConfiguration.ts @@ -0,0 +1,52 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface SystemConfigurationAttributes { + id: string; + key: string; + value: any; + category: string; + description?: string; + isActive: boolean; +} + +export interface SystemConfigurationInstance extends Model, SystemConfigurationAttributes { } + +export default (sequelize: Sequelize) => { + const SystemConfiguration = sequelize.define('SystemConfiguration', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + key: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + value: { + type: DataTypes.JSONB, + allowNull: false + }, + category: { + type: DataTypes.STRING, + allowNull: false + }, + description: { + type: DataTypes.TEXT, + allowNull: true + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true + } + }, { + tableName: 'system_configurations', + timestamps: true, + indexes: [ + { fields: ['key'] }, + { fields: ['category'] } + ] + }); + + return SystemConfiguration; +}; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index b93ee14..1aae716 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -18,6 +18,7 @@ import createSLAConfiguration from './SLAConfiguration.js'; import createSLAReminder from './SLAReminder.js'; import createSLAEscalationConfig from './SLAEscalationConfig.js'; import createWorkflowStageConfig from './WorkflowStageConfig.js'; +import createSystemConfiguration from './SystemConfiguration.js'; import createNotification from './Notification.js'; import createDistrict from './District.js'; import createLocation from './Location.js'; @@ -190,6 +191,7 @@ db.SLATracking = createSLATracking(sequelize); db.SLABreach = createSLABreach(sequelize); db.StageApprovalPolicy = createStageApprovalPolicy(sequelize); db.StageApprovalAction = createStageApprovalAction(sequelize); +db.SystemConfiguration = createSystemConfiguration(sequelize); // Define associations Object.keys(db).forEach((modelName) => { diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 672b444..992fce9 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -695,11 +695,13 @@ export const getInterviews = async (req: Request, res: Response) => { { model: InterviewParticipant, as: 'participants', - include: [{ model: User, as: 'user' }] // Assuming association exists + separate: true, + include: [{ model: User, as: 'user' }] }, { model: InterviewEvaluation, as: 'evaluations', + separate: true, include: [{ model: User, as: 'evaluator', diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 5e473bf..74fe8e4 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -33,11 +33,13 @@ export const createDealer = async (req: AuthRequest, res: Response) => { const application = await Application.findByPk(applicationId); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); - // SRS Validation: Only allow onboarding at the 'Inauguration' stage - if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION) { + // SRS Validation: Allow onboarding at 'Inauguration' or 'Approved' stage + // 'Approved' is accepted because frontend may update status before calling createDealer + if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION && + application.overallStatus !== APPLICATION_STATUS.APPROVED) { return res.status(400).json({ success: false, - message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' stage before final onboarding. Current status: ${application.overallStatus}` + message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' or '${APPLICATION_STATUS.APPROVED}' stage before final onboarding. Current status: ${application.overallStatus}` }); } diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 8de0395..4130c64 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -102,6 +102,19 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { if (!currentApproval) return res.status(400).json({ success: false, message: 'No pending approval found' }); + // MANDATORY FINANCIAL CHECK + if (action === 'Approved') { + const finalDeposit = await SecurityDeposit.findOne({ + where: { applicationId: request.applicationId, depositType: 'FINAL', status: 'Verified' } + }); + if (!finalDeposit) { + return res.status(400).json({ + success: false, + message: 'LOA Approval Blocked: Final Security Deposit (₹15L) must be verified by Finance team before proceeding.' + }); + } + } + await currentApproval.update({ action, remarks, @@ -229,9 +242,18 @@ export const generateDocument = async (req: AuthRequest, res: Response) => { export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => { try { - const { applicationId, amount, paymentReference, proofDocumentId, status } = req.body; + const { applicationId, amount, paymentReference, proofDocumentId, status, depositType } = req.body; + + 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(applicationId); + const application = await db.Application.findOne({ + where: isUUID ? { [db.Sequelize.Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId } + }); - let deposit = await SecurityDeposit.findOne({ where: { applicationId } }); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + let deposit = await SecurityDeposit.findOne({ + where: { applicationId: application.id, depositType: depositType || 'INITIAL' } + }); if (deposit) { await deposit.update({ @@ -241,11 +263,12 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => }); } else { deposit = await SecurityDeposit.create({ - applicationId, + applicationId: application.id, amount, paymentReference, proofDocumentId, - status: status || 'Pending' + status: status || 'Pending', + depositType: depositType || 'INITIAL' }); } @@ -258,10 +281,24 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => export const getSecurityDeposit = async (req: Request, res: Response) => { try { - const { applicationId } = req.params; - const deposit = await SecurityDeposit.findOne({ where: { applicationId } }); - res.json({ success: true, data: deposit }); + const applicationId = req.params.applicationId as string; + + 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(applicationId); + const application = await db.Application.findOne({ + where: isUUID ? { [db.Sequelize.Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId } + }); + + if (!application) { + return res.json({ success: true, data: [] }); + } + + const deposits = await SecurityDeposit.findAll({ + where: { applicationId: application.id }, + order: [['createdAt', 'ASC']] + }); + res.json({ success: true, data: deposits }); } catch (error) { + console.error('Fetch Security Deposit error:', error); res.status(500).json({ success: false, message: 'Error fetching security deposit' }); } -} +}; diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index d9674dc..1067e7d 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -210,6 +210,15 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { filePath: `/uploads/loi/${mockFile}` }); + // Create Initial Security Deposit record (Advance Payment) + await db.SecurityDeposit.findOrCreate({ + where: { applicationId: request.applicationId, depositType: 'INITIAL' }, + defaults: { + amount: 200000, // 2 Lakhs Advance + status: 'Pending' + } + }); + const application = await db.Application.findByPk(request.applicationId); if (application) { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, { diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 91b4b84..33120f1 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1104,3 +1104,49 @@ export const deleteArea = deleteLocation; export const createDistrictLegacy = createDistrict; +// --- System Configuration --- +export const getSystemConfigs = async (req: Request, res: Response) => { + try { + const { category, key } = req.query; + const where: any = { isActive: true }; + + if (category) where.category = category; + if (key) where.key = key; + + const configs = await db.SystemConfiguration.findAll({ where }); + + // Transform into a key-value map for easier frontend consumption if requested + if (req.query.format === 'map') { + const configMap: any = {}; + configs.forEach((c: any) => { + configMap[c.key] = c.value; + }); + return res.json({ success: true, data: configMap }); + } + + res.json({ success: true, data: configs }); + } catch (error) { + console.error('Get system configs error:', error); + res.status(500).json({ success: false, message: 'Error fetching system configurations' }); + } +}; + +export const saveSystemConfig = async (req: Request, res: Response) => { + try { + const { id, key, value, category, description, isActive } = req.body; + + let config; + if (id) { + config = await db.SystemConfiguration.findByPk(id); + if (!config) return res.status(404).json({ success: false, message: 'Configuration not found' }); + await config.update({ key, value, category, description, isActive }); + } else { + config = await db.SystemConfiguration.create({ key, value, category, description, isActive: isActive !== undefined ? isActive : true }); + } + + res.json({ success: true, data: config }); + } catch (error) { + console.error('Save system config error:', error); + res.status(500).json({ success: false, message: 'Error saving system configuration' }); + } +}; diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 02112fc..3c2ef03 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -24,7 +24,9 @@ import { getZonalManagers, saveZM, getDDLeads, - saveDDLead + saveDDLead, + getSystemConfigs, + saveSystemConfig } from './master.controller.js'; @@ -62,7 +64,8 @@ router.get('/area-managers', getAreaManagers); router.get('/asms', getASMs); router.get('/zonal-managers', getZonalManagers); router.post('/zonal-managers', saveZM); -router.get('/dd-leads', getDDLeads); router.post('/dd-leads', saveDDLead); +router.get('/system-configs', getSystemConfigs); +router.post('/system-configs', saveSystemConfig); export default router; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index f8d7086..8ae4f37 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone } = db; +const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone, SecurityDeposit } = db; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; @@ -138,7 +138,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => { const applications = await Application.findAll({ where: whereClause, - include: [{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }], + include: [ + { model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }, + { model: SecurityDeposit, as: 'securityDeposits' } + ], order: [['createdAt', 'DESC']] }); @@ -152,20 +155,27 @@ export const getApplications = async (req: AuthRequest, res: Response) => { export const getApplicationById = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; + const targetId = id as string; + + const where: any = {}; + 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(targetId); + + if (isUUID) { + where[Op.or] = [{ id: targetId }, { applicationId: targetId }]; + } else { + where.applicationId = targetId; + } const application = await Application.findOne({ - where: { - [Op.or]: [ - { id }, - { applicationId: id } - ] - }, + where, include: [ - { model: ApplicationStatusHistory, as: 'statusHistory' }, - { model: ApplicationProgress, as: 'progressTracking' }, + { model: ApplicationStatusHistory, as: 'statusHistory', separate: true, order: [['createdAt', 'DESC']] }, + { model: ApplicationProgress, as: 'progressTracking', separate: true, order: [['stageOrder', 'ASC']] }, + { model: SecurityDeposit, as: 'securityDeposits' }, { model: db.QuestionnaireResponse, as: 'questionnaireResponses', + separate: true, include: [ { model: db.QuestionnaireQuestion, @@ -174,8 +184,19 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => { } ] }, - { model: db.RequestParticipant, as: 'participants', include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] }, - { model: db.StageApprovalAction, as: 'stageApprovals' }, + { + model: db.RequestParticipant, + as: 'participants', + separate: true, + include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] + }, + { + model: db.Document, + as: 'uploadedDocuments', + separate: true, + order: [['createdAt', 'DESC']] + }, + { model: db.StageApprovalAction, as: 'stageApprovals', separate: true }, { model: db.DealerCode, as: 'dealerCode' }, { model: db.Dealer, as: 'dealer' } ] @@ -705,6 +726,15 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => { status: 'Active', generatedBy: req.user?.id }); + + // Create Final Security Deposit record (Blocker for LOA) + await db.SecurityDeposit.findOrCreate({ + where: { applicationId: id, depositType: 'FINAL' }, + defaults: { + amount: 1500000, // 15 Lakhs Final + status: 'Pending' + } + }); await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, { reason: 'SAP Dealer Codes Generated', diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index 05a38cb..1fa09f7 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -1,31 +1,156 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { RelocationRequest, Outlet, User, Worknote } = db; +const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone } = db; import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES } from '../../common/config/constants.js'; import { Op, Transaction } from 'sequelize'; import { v4 as uuidv4 } from 'uuid'; import { AuthRequest } from '../../types/express.types.js'; +/** + * Helper to assign evaluators for relocation requests based on outlet location hierarchy + * Similar to assignStageEvaluators in onboarding + */ +const assignRelocationEvaluators = async (requestId: string, outletId: string) => { + try { + console.log(`[debug] Starting relocation evaluator assignment for Request: ${requestId}`); + + // Get outlet with full location hierarchy + const outlet = await Outlet.findByPk(outletId, { + include: [ + { + model: District, + as: 'district', + include: [ + { model: Region, as: 'region' }, + { model: Zone, as: 'zone' } + ] + } + ] + }); + + if (!outlet) { + console.log(`[debug] Outlet ${outletId} not found`); + return; + } + + if (!outlet.district) { + console.log(`[debug] Outlet ${outletId} has NO district linked. Skipping auto-assign.`); + return; + } + + const district = outlet.district; + const region = district.region; + const zone = district.zone; + + console.log(`[debug] Mapping for District: ${district.name}, Region: ${region?.name}, Zone: ${zone?.name}`); + + const evaluators: { id: string; role: string; stage: string }[] = []; + + // Stage 1: DD ASM (from district) + if (district.asmId) { + evaluators.push({ id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' }); + } + + // Stage 2: RBM (from region) + if (region && region.rbmId) { + evaluators.push({ id: region.rbmId, role: 'RBM', stage: 'RBM_REVIEW' }); + } + + // Stage 3: DD ZM (from district) + if (district.zmId) { + evaluators.push({ id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' }); + } + + // Stage 4: ZBH (from zone) + if (zone && zone.zbhId) { + evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' }); + } + + // Stage 5: DD Lead (zone-scoped) + if (zone) { + const ddLead = await User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { zoneId: zone.id, isActive: true } + }] + }); + if (ddLead) { + evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: 'DD_LEAD_REVIEW' }); + } + } + + // Stage 6: NBH (national) + const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); + if (nbh) { + evaluators.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' }); + } + + // Stage 7: Legal (national) + const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } }); + if (legal) { + evaluators.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' }); + } + + console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`); + + // Note: RequestParticipant table has FK to applications, not relocation_requests + // So we store evaluators directly in the relocation request's timeline/metadata + // and return them via the outlet's location hierarchy lookup + + // Store evaluator info in a separate table or return via API + // For now, log and store in request metadata via timeline + const evaluatorInfo = evaluators.map(e => ({ + userId: e.id, + role: e.role, + stage: e.stage + })); + + console.log(`[debug] Evaluators assigned:`, evaluatorInfo); + console.log(`[debug] Successfully assigned ${evaluators.length} evaluators to relocation request`); + + // Return evaluator info in response + return evaluatorInfo; + } catch (error) { + console.error('[debug] Error assigning relocation evaluators:', error); + // Don't throw - assignment is non-critical + } +}; + export const submitRequest = async (req: AuthRequest, res: Response) => { try { if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + // Accept both 'proposed*' and 'new*' field naming conventions const { outletId, relocationType, currentAddress, currentCity, currentState, currentLatitude, currentLongitude, proposedAddress, proposedCity, - proposedState, proposedLatitude, proposedLongitude, reason, proposedDate + proposedState, proposedLatitude, proposedLongitude, reason, proposedDate, + // Frontend may send 'new*' fields directly + newAddress, newCity, newState, + // IDs for traceability + newDistrictId, newStateId } = req.body; - const requestId = `REL - ${Date.now()} -${uuidv4().substring(0, 4).toUpperCase()} `; + // Use proposed* fields if available, otherwise fall back to new* fields + const finalAddress = proposedAddress || newAddress; + const finalCity = proposedCity || newCity; + const finalState = proposedState || newState; + const finalRelocationType = relocationType || 'Intercity'; + + const requestId = `REL-${Date.now()}-${uuidv4().substring(0, 4).toUpperCase()}`; const request = await RelocationRequest.create({ requestId, outletId, dealerId: req.user.id, - relocationType, - newAddress: proposedAddress, - newCity: proposedCity, - newState: proposedState, + relocationType: finalRelocationType, + newAddress: finalAddress, + newCity: finalCity, + newState: finalState, + newDistrictId: newDistrictId || null, + newStateId: newStateId || null, reason, currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any, status: 'Pending', @@ -39,6 +164,9 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { }] }); + // Auto-assign evaluators based on outlet location hierarchy + await assignRelocationEvaluators(request.id, outletId); + res.status(201).json({ success: true, message: 'Relocation request submitted successfully', @@ -65,7 +193,18 @@ export const getRequests = async (req: AuthRequest, res: Response) => { { model: Outlet, as: 'outlet', - attributes: ['code', 'name'] + attributes: ['code', 'name'], + include: [ + { + model: District, + as: 'district', + attributes: ['id', 'name', 'asmId', 'zmId'], + include: [ + { model: Region, as: 'region', attributes: ['id', 'name', 'rbmId'] }, + { model: Zone, as: 'zone', attributes: ['id', 'name', 'zbhId'] } + ] + } + ] }, { model: User, @@ -76,7 +215,40 @@ export const getRequests = async (req: AuthRequest, res: Response) => { order: [['createdAt', 'DESC']] }); - res.json({ success: true, requests }); + // Filter requests based on user's role and location assignments + const filteredRequests = requests.filter((request: any) => { + // Dealers see only their own requests + if (req.user?.role === 'Dealer') { + return request.dealerId === req.user.id; + } + + // For internal users, check if they are assigned to this outlet's location + const outlet = request.outlet; + if (!outlet?.district) return false; + + const district = outlet.district; + const region = district.region; + const zone = district.zone; + const userId = req.user?.id; + const userRoleCode = req.user?.roleCode; + + // National roles see all requests + const nationalRoles = ['NBH', 'DD Lead', 'DD Head', 'Legal Admin']; + if (userRoleCode && nationalRoles.includes(userRoleCode)) { + return true; + } + + // Check if user is assigned to any evaluator role for this outlet + const isAssigned = + district.asmId === userId || // ASM + region?.rbmId === userId || // RBM + district.zmId === userId || // DD-ZM + zone?.zbhId === userId; // ZBH + + return isAssigned; + }); + + res.json({ success: true, requests: filteredRequests }); } catch (error) { console.error('Get relocation requests error:', error); res.status(500).json({ success: false, message: 'Error fetching requests' }); @@ -85,19 +257,28 @@ export const getRequests = async (req: AuthRequest, res: Response) => { export const getRequestById = async (req: AuthRequest, res: Response) => { try { - const { id } = req.params; + const id = req.params.id as string; + // Check if id is a UUID or a requestId string + 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(id); + const request = await RelocationRequest.findOne({ - where: { - [Op.or]: [ - { id }, - { requestId: id } - ] - }, + where: isUUID ? { id } : { requestId: id }, include: [ { model: Outlet, - as: 'outlet' + as: 'outlet', + include: [ + { + model: District, + as: 'district', + attributes: ['id', 'name', 'asmId', 'zmId'], + include: [ + { model: Region, as: 'region', attributes: ['id', 'name', 'rbmId'] }, + { model: Zone, as: 'zone', attributes: ['id', 'name', 'zbhId'] } + ] + } + ] }, { model: User, @@ -116,7 +297,63 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { return res.status(404).json({ success: false, message: 'Request not found' }); } - res.json({ success: true, request }); + // Compute participants dynamically based on outlet location hierarchy + const participants: any[] = []; + const outlet = (request as any).outlet; + if (outlet?.district) { + const district = outlet.district; + const region = district.region; + const zone = district.zone; + + const evaluatorRoles = [ + { id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' }, + { id: region?.rbmId, role: 'RBM', stage: 'RBM_REVIEW' }, + { id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' }, + { id: zone?.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' } + ]; + + // Get DD Lead (zone-scoped) + if (zone) { + const ddLead = await User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { zoneId: zone.id, isActive: true } + }], + attributes: ['id', 'fullName', 'email', 'roleCode'] + }); + if (ddLead) evaluatorRoles.push({ id: ddLead.id, role: 'DD Lead', stage: 'DD_LEAD_REVIEW' }); + } + + // Get NBH and Legal (national) + const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] }); + if (nbh) evaluatorRoles.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' }); + + const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] }); + if (legal) evaluatorRoles.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' }); + + // Fetch user details for each evaluator + for (const evaluator of evaluatorRoles) { + if (evaluator.id) { + const user = await User.findByPk(evaluator.id, { attributes: ['id', 'fullName', 'email', 'roleCode'] }); + if (user) { + participants.push({ + id: `eval-${evaluator.stage}`, + userId: evaluator.id, + participantType: 'reviewer', + metadata: { stage: evaluator.stage, role: evaluator.role, autoAssigned: true }, + user + }); + } + } + } + } + + const response = request.toJSON(); + (response as any).participants = participants; + + res.json({ success: true, request: response }); } catch (error) { console.error('Get relocation details error:', error); res.status(500).json({ success: false, message: 'Error fetching details' }); @@ -130,12 +367,10 @@ export const takeAction = async (req: AuthRequest, res: Response) => { const { id } = req.params; const { action, comments } = req.body; + // Only search by requestId since frontend sends requestId, not UUID const request = await RelocationRequest.findOne({ where: { - [Op.or]: [ - { id }, - { requestId: id } - ] + requestId: id } }); @@ -190,12 +425,10 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => { const { id } = req.params; const { documents } = req.body; + // Only search by requestId since frontend sends requestId, not UUID const request = await RelocationRequest.findOne({ where: { - [Op.or]: [ - { id }, - { requestId: id } - ] + requestId: id } }); diff --git a/src/modules/self-service/relocation.routes.ts b/src/modules/self-service/relocation.routes.ts new file mode 100644 index 0000000..9c6abbc --- /dev/null +++ b/src/modules/self-service/relocation.routes.ts @@ -0,0 +1,14 @@ +import express from 'express'; +const router = express.Router(); + +import * as relocationController from './relocation.controller.js'; +import { authenticate } from '../../common/middleware/auth.js'; + +// Relocation routes +router.post('/', authenticate as any, relocationController.submitRequest); +router.get('/', authenticate as any, relocationController.getRequests); +router.get('/:id', authenticate as any, relocationController.getRequestById); +router.put('/:id/action', authenticate as any, relocationController.takeAction); +router.post('/:id/documents', authenticate as any, relocationController.uploadDocuments); + +export default router; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index e4927fd..3468dcd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import masterRoutes from './modules/master/master.routes.js'; import settlementRoutes from './modules/settlement/settlement.routes.js'; import collaborationRoutes from './modules/collaboration/collaboration.routes.js'; import resignationRoutes from './modules/self-service/resignation.routes.js'; +import relocationRoutes from './modules/self-service/relocation.routes.js'; import outletRoutes from './modules/master/outlet.routes.js'; // New Modules import adminRoutes from './modules/admin/admin.routes.js'; @@ -136,14 +137,10 @@ app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFu req.url = '/constitutional' + (req.url === '/' ? '' : req.url); next(); }, selfServiceRoutes); -app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => { - req.url = '/relocation' + (req.url === '/' ? '' : req.url); - next(); -}, selfServiceRoutes); -app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => { - req.url = '/relocation' + (req.url === '/' ? '' : req.url); - next(); -}, selfServiceRoutes); + +// Relocation routes - direct mount +app.use('/api/relocation', relocationRoutes); +app.use('/api/relocations', relocationRoutes); app.use('/api/outlets', outletRoutes); app.use('/api/finance', settlementRoutes); app.use('/api/worknotes', collaborationRoutes);