diff --git a/scripts/fix_constitutional_enum.ts b/scripts/fix_constitutional_enum.ts deleted file mode 100644 index 26e73a5..0000000 --- a/scripts/fix_constitutional_enum.ts +++ /dev/null @@ -1,30 +0,0 @@ -import db from '../src/database/models/index.js'; - -async function fixEnum() { - const enumName = 'enum_constitutional_changes_changeType'; - const newValues = ['Proprietorship', 'Partnership', 'LLP', 'Private Limited']; - - console.log(`--- Patching DB ENUM: ${enumName} ---`); - - for (const val of newValues) { - try { - // Sequelize does not have a direct method for ADD VALUE to ENUM in all dialects, using raw query - // Using check to avoid "already exists" error - await db.sequelize.query(`ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${val}'`); - console.log(`✅ Added '${val}' to ${enumName}`); - } catch (err: any) { - if (err.message.includes('already exists')) { - console.log(`ℹ️ '${val}' already exists in ${enumName}`); - } else { - console.log(`❌ Failed to add '${val}':`, err.message); - } - } - } - - console.log('--- ENUM Patching Complete ---'); -} - -fixEnum().catch(err => { - console.error('Migration failed:', err); - process.exit(1); -}).then(() => process.exit(0)); diff --git a/scripts/migrate.ts b/scripts/migrate.ts index c0d87c7..763bfd3 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,8 +1,12 @@ /** * Database Migration Script - * Synchronizes all Sequelize models with the database + * Synchronizes all Sequelize models with the database (PostgreSQL). * This script will DROP all existing tables and recreate them. - * + * + * Schema for modules such as constitutional change (ENUM values, partial unique indexes, + * columns) is defined only on Sequelize models — no separate "table alteration" scripts are + * required after a fresh `migrate` + `seed:all` (see package.json `setup:fresh`). + * * Run: npx tsx scripts/migrate.ts */ diff --git a/scripts/migrate_constitutional.ts b/scripts/migrate_constitutional.ts deleted file mode 100644 index 4551a54..0000000 --- a/scripts/migrate_constitutional.ts +++ /dev/null @@ -1,37 +0,0 @@ -import db from '../src/database/models/index.js'; - -async function migrate() { - const queryInterface = db.sequelize.getQueryInterface(); - - // Using describeTable to check existence - const tableDefinition = await queryInterface.describeTable('constitutional_changes'); - - console.log('--- Migrating constitutional_changes table ---'); - - if (!tableDefinition.currentConstitution) { - console.log('Adding currentConstitution column...'); - await queryInterface.addColumn('constitutional_changes', 'currentConstitution', { - type: db.Sequelize.DataTypes.STRING, - allowNull: true - }); - } - - if (!tableDefinition.metadata) { - console.log('Adding metadata column...'); - await queryInterface.addColumn('constitutional_changes', 'metadata', { - type: db.Sequelize.DataTypes.JSON, - defaultValue: {} - }); - } - - // Update outletId to be nullable - console.log('Updating outletId to be nullable...'); - await queryInterface.changeColumn('constitutional_changes', 'outletId', { - type: db.Sequelize.DataTypes.UUID, - allowNull: true - }); - - console.log('✅ Migration complete!'); -} - -migrate(); diff --git a/src/__tests__/constitutional-alignment.test.ts b/src/__tests__/constitutional-alignment.test.ts new file mode 100644 index 0000000..7f519b8 --- /dev/null +++ b/src/__tests__/constitutional-alignment.test.ts @@ -0,0 +1,35 @@ +import { normalizeToConstitutionalChangeType, mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; +import { ConstitutionalWorkflowService } from '../services/ConstitutionalWorkflowService.js'; + +describe('Constitutional alignment', () => { + it('rejects legacy non-structure change types after scope tightening', () => { + expect(normalizeToConstitutionalChangeType('Director Change')).toBeNull(); + expect(normalizeToConstitutionalChangeType('Ownership Transfer')).toBeNull(); + expect(normalizeToConstitutionalChangeType('Company Formation')).toBeNull(); + }); + + it('maps supported structure change types to dealer profile', () => { + expect(mapConstitutionalChangeTypeToDealerProfile('Proprietorship')).toBe('Proprietorship'); + expect(mapConstitutionalChangeTypeToDealerProfile('Partnership')).toBe('Partnership'); + expect(mapConstitutionalChangeTypeToDealerProfile('LLP')).toBe('LLP'); + expect(mapConstitutionalChangeTypeToDealerProfile('Private Limited')).toBe('Private Limited'); + }); + + it('computes missing mandatory documents from uploaded checklist payload', () => { + const completeDocs = [ + { documentType: 'GST Certificate' }, + { documentType: 'PAN Card' }, + { documentType: 'Aadhaar' }, + { documentType: 'Certificate of Incorporation' }, + { documentType: 'Business Purchase Agreement (BPA)' }, + { documentType: 'LLP Agreement' }, + { documentType: 'Cancelled Check' }, + { documentType: 'Declaration / Authorization Letter' } + ]; + expect(ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', completeDocs)).toEqual([]); + + const missingOne = completeDocs.filter((d) => d.documentType !== 'Business Purchase Agreement (BPA)'); + const missing = ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', missingOne); + expect(missing).toContain('Business Purchase Agreement (BPA)'); + }); +}); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index d5024c1..6192b15 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -223,13 +223,8 @@ export const RESIGNATION_TYPES = { export const CONSTITUTIONAL_CHANGE_TYPES = { PROPRIETORSHIP: 'Proprietorship', PARTNERSHIP: 'Partnership', - LLP_CONVERSION: 'LLP Conversion', LLP: 'LLP', - PRIVATE_LIMITED: 'Private Limited', - COMPANY_FORMATION: 'Company Formation', - OWNERSHIP_TRANSFER: 'Ownership Transfer', - PARTNERSHIP_CHANGE: 'Partnership Change', - DIRECTOR_CHANGE: 'Director Change' + PRIVATE_LIMITED: 'Private Limited' } as const; /** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */ @@ -270,10 +265,8 @@ export const RELOCATION_STAGES = { DD_ZM_REVIEW: 'DD ZM Review', ZBH_REVIEW: 'ZBH Review', DD_LEAD_REVIEW: 'DD Lead Review', - DD_HEAD_APPROVAL: 'DD Head Approval', NBH_APPROVAL: 'NBH Approval', LEGAL_CLEARANCE: 'Legal Clearance', - NBH_CLEARANCE_EOR: 'NBH Clearance with EOR', COMPLETED: 'Completed', REJECTED: 'Rejected' } as const; @@ -570,7 +563,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU export const STAGES_MAP = { 'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'], 'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'], - 'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'], + 'RELOCATION': ['Initiated', 'ASM Review', 'RBM Review', 'DD ZM Review', 'ZBH Review', 'DD Lead Review', 'NBH Approval', 'Legal Clearance', 'Completed'], 'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'], 'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed'] } as const; diff --git a/src/common/utils/constitutionalNormalize.ts b/src/common/utils/constitutionalNormalize.ts index a7ac5ee..f6c2095 100644 --- a/src/common/utils/constitutionalNormalize.ts +++ b/src/common/utils/constitutionalNormalize.ts @@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin ) { return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED; } - if (compact.includes('llp') && compact.includes('conversion')) { - return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION; - } if (compact.includes('llp')) { return CONSTITUTIONAL_CHANGE_TYPES.LLP; } - if (compact.includes('partnership') && compact.includes('change')) { - return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE; - } if (compact.includes('partnership')) { return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP; } if (compact.includes('proprietorship') || compact === 'sole proprietorship') { return CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP; } - if (compact.includes('director')) { - return CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE; - } - if (compact.includes('ownership') && compact.includes('transfer')) { - return CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER; - } - if (compact.includes('company') && compact.includes('formation')) { - return CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION; - } const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase()); return exact || null; } @@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string): const t = String(changeType || '').trim(); if (!t) return null; - if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP; - const structureTargets = [ CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP, CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP, @@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string): ]; if (structureTargets.includes(t as (typeof structureTargets)[number])) return t; - const skipAutoUpdate = [ - CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE, - CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE, - CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER, - CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION - ]; - if (skipAutoUpdate.includes(t as (typeof skipAutoUpdate)[number])) return null; - return null; } diff --git a/src/common/utils/offboardingWorkflow.utils.ts b/src/common/utils/offboardingWorkflow.utils.ts index 6fda6be..169e64c 100644 --- a/src/common/utils/offboardingWorkflow.utils.ts +++ b/src/common/utils/offboardingWorkflow.utils.ts @@ -67,10 +67,9 @@ export const getPreviousStage = (requestType: string, currentStage: string): str [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW, [RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW, - [RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW, - [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL, + [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW, [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL, - [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE + [RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE }; return flow[currentStage] || null; } diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index 3e8f324..ec35138 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -11,7 +11,7 @@ import { ROLES } from '../config/constants.js'; -const { RequestParticipant, User, Outlet, District } = db; +const { RequestParticipant, User, Outlet, District, Dealer } = db; const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173'; @@ -150,7 +150,12 @@ export async function notifyRelocationSubmittedEmails( const outlet = await Outlet.findByPk(request.outletId, { include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }] }); - const asmId = (outlet as any)?.district?.asmId; + const dealerAccount = await User.findByPk(request.dealerId, { + attributes: ['id'], + include: [{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }] + }); + const outletLevelAsmId = (dealerAccount as any)?.dealerProfile?.asmId ?? null; + const asmId = outletLevelAsmId || (outlet as any)?.district?.asmId; if (!asmId) return; const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); @@ -202,7 +207,6 @@ export async function resolveNextActors(requestId: string, requestType: string, 'DD Lead Review': [ROLES.DD_LEAD], 'DD Head': [ROLES.DD_HEAD], 'DD Head Review': [ROLES.DD_HEAD], - 'DD Head Approval': [ROLES.DD_HEAD], 'NBH': [ROLES.NBH], 'NBH Approval': [ROLES.NBH], 'NBH Evaluation': [ROLES.NBH], @@ -238,7 +242,6 @@ export async function resolveNextActors(requestId: string, requestType: string, 'Architecture Document Upload': [ROLES.ARCHITECTURE], // --- Relocation/Constitutional Specific --- - 'NBH Clearance with EOR': [ROLES.NBH], 'Submitted': [ROLES.ASM], 'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM], @@ -315,6 +318,7 @@ export async function notifyStakeholdersOnTransition( action: string; remarks: string; link: string; + changeType?: string; } ): Promise { try { @@ -412,7 +416,7 @@ export async function notifyStakeholdersOnTransition( // Override for Constitutional Change Completion if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) { templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED'; - placeholders.proposedConstitution = metadata.remarks || 'Approved Structure'; // Remarks often contain the final structure or approval note + placeholders.proposedConstitution = metadata.changeType || 'Approved Structure'; } // Override for Relocation Completion diff --git a/src/database/models/offboarding/constitutional/ConstitutionalChange.ts b/src/database/models/offboarding/constitutional/ConstitutionalChange.ts index 3620b91..d13e291 100644 --- a/src/database/models/offboarding/constitutional/ConstitutionalChange.ts +++ b/src/database/models/offboarding/constitutional/ConstitutionalChange.ts @@ -1,4 +1,4 @@ -import { Model, DataTypes, Sequelize } from 'sequelize'; +import { Model, DataTypes, Sequelize, Op } from 'sequelize'; import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js'; export interface ConstitutionalChangeAttributes { @@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => { { fields: ['requestId'] }, { fields: ['outletId'] }, { fields: ['dealerId'] }, - { fields: ['currentStage'] } + { fields: ['currentStage'] }, + /** SRS §12.2 — at most one non-terminal request per dealer (PostgreSQL partial unique index). */ + { + name: 'uq_constitutional_open_per_dealer', + unique: true, + fields: ['dealerId'], + where: { + status: { + [Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked'] + } + } + } ] }); diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index 7dd239a..b02009a 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -26,6 +26,7 @@ import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow const STRUCTURE_TARGET_VALUES = new Set( CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string) ); +const CLOSED_CONSTITUTIONAL_STATUSES = ['Completed', 'Closed', 'Rejected', 'Revoked']; const resolveConstitutionalUuid = async (id: string) => { const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional'); @@ -166,6 +167,30 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { const requestId = await NomenclatureService.generateConstitutionalChangeId(); + const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase(); + const isDealerActive = !dealerProfileStatus || dealerProfileStatus === 'active'; + if (!isDealerActive || !subjectDealerProfile?.onboardedAt) { + return res.status(400).json({ + success: false, + message: 'Constitutional change can be initiated only for active onboarded dealers.' + }); + } + + const existingOpenRequest = await ConstitutionalChange.findOne({ + where: { + dealerId: dealerUserId, + status: { [Op.notIn]: CLOSED_CONSTITUTIONAL_STATUSES } + }, + attributes: ['id', 'requestId', 'status', 'currentStage'], + order: [['createdAt', 'DESC']] + }); + if (existingOpenRequest) { + return res.status(409).json({ + success: false, + message: `Open constitutional request ${existingOpenRequest.requestId} already exists at ${existingOpenRequest.currentStage}. Complete it before creating a new one.` + }); + } + const metadata = { newPartnersDetails, shareholdingPattern, @@ -215,19 +240,23 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { }); - try { - await ParticipantService.assignConstitutionalParticipants(request.id); - const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer'; - await notifyConstitutionalSubmittedEmails(request, displayName); - } catch (e) { - console.error('Error assigning participants or sending constitutional submit emails:', e); - } - res.status(201).json({ success: true, message: 'Constitutional change request submitted successfully', requestId: request.requestId }); + + // Run participant assignment + notifications asynchronously to keep API responsive. + // With Redis disabled, email sending is synchronous and can otherwise block for many seconds. + void (async () => { + try { + await ParticipantService.assignConstitutionalParticipants(request.id); + const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer'; + await notifyConstitutionalSubmittedEmails(request, displayName); + } catch (e) { + console.error('Error assigning participants or sending constitutional submit emails:', e); + } + })(); } catch (error) { console.error('Submit constitutional change error:', error); res.status(500).json({ success: false, message: 'Error submitting request' }); @@ -366,6 +395,9 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { }); if (!request) return res.status(404).json({ success: false, message: 'Request not found' }); + if (req.user?.roleCode === ROLES.DEALER && String(request.dealerId) !== String(req.user.id)) { + return res.status(403).json({ success: false, message: 'Forbidden: You can only access your own request.' }); + } res.json({ success: true, request }); } catch (error) { @@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => { export const takeAction = async (req: AuthRequest, res: Response) => { try { if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + if (req.user.roleCode === ROLES.DEALER) { + return res.status(403).json({ success: false, message: 'Dealers cannot perform review actions.' }); + } const { id } = req.params; const rawAction = String(req.body.action || '').trim(); @@ -446,6 +481,23 @@ export const takeAction = async (req: AuthRequest, res: Response) => { const isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK); const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE); + const stageRoleMap: Record = { + [CONSTITUTIONAL_STAGES.ASM_REVIEW]: [ROLES.ASM, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: [ROLES.DD_ZM, ROLES.RBM, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: [ROLES.ZBH, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: [ROLES.DD_LEAD, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.HEAD_REVIEW]: [ROLES.DD_HEAD, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: [ROLES.NBH, ROLES.SUPER_ADMIN], + [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: [ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN] + }; + const allowedRoles = stageRoleMap[sourceStage] || [ROLES.SUPER_ADMIN]; + if (!allowedRoles.includes(req.user.roleCode as any)) { + return res.status(403).json({ + success: false, + message: `Role ${req.user.roleCode} cannot act at stage ${sourceStage}. Allowed: ${allowedRoles.join(', ')}` + }); + } + if (isReject) { await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, { @@ -498,6 +550,17 @@ export const takeAction = async (req: AuthRequest, res: Response) => { return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' }); } + const missingDocs = ConstitutionalWorkflowService.getMissingMandatoryDocuments( + request.changeType, + request.documents || [] + ); + if (missingDocs.length > 0) { + return res.status(400).json({ + success: false, + message: `Mandatory documents missing for ${request.changeType}: ${missingDocs.join(', ')}. Upload all required documents before approval.` + }); + } + const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW; if (isZmRbmJointStage && isApprove) { diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index 62a92ef..0f24e02 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; -const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog } = db; +const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog, Dealer } = db; import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS, OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; @@ -167,29 +167,18 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) = evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); } - // Stage 6: DD Head (national) - const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } }); - if (ddHead) { - evaluators.push({ id: ddHead.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL }); - } - - // Stage 7: NBH Approval (national) + // Stage 6: NBH Approval (national) const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); if (nbh) { evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL }); } - // Stage 8: Legal Clearance (national) + // Stage 7: Legal Clearance (national) const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } }); if (legal) { evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE }); } - // Stage 9: NBH Clearance with EOR (national) - if (nbh) { - evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR }); - } - console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`); const evaluatorInfo = evaluators.map(e => ({ @@ -298,6 +287,21 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { message: `Relocation can only be requested for active outlets. Current outlet status: ${outlet.status}` }); } + const subjectDealerUser = await User.findByPk(outlet.dealerId, { + attributes: ['id', 'status', 'dealerId'] + }); + const subjectDealerProfile = subjectDealerUser?.dealerId + ? await db.Dealer.findByPk(subjectDealerUser.dealerId, { attributes: ['id', 'status', 'onboardedAt'] }) + : null; + const isDealerActive = String(subjectDealerUser?.status || '').toLowerCase() === 'active'; + const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase(); + const isDealerProfileEligible = dealerProfileStatus === '' || dealerProfileStatus === 'active'; + if (!isDealerActive || !subjectDealerProfile?.onboardedAt || !isDealerProfileEligible) { + return res.status(403).json({ + success: false, + message: 'Relocation can be initiated only for active onboarded dealers.' + }); + } const roleCode = req.user.roleCode as string; if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) { @@ -317,7 +321,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { const openExisting = await RelocationRequest.findOne({ where: { outletId, - status: { [Op.notIn]: ['Completed', 'Rejected'] } + status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] } }, attributes: ['id', 'requestId', 'status', 'currentStage'] }); @@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => { { model: User, as: 'dealer', - attributes: ['fullName'] + attributes: ['fullName'], + include: [ + { model: Dealer, as: 'dealerProfile', attributes: ['asmId'] } + ] } ], order: [['createdAt', 'DESC']], @@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => { 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 + // ASM is tied to the dealer/outlet (Dealer.asmId); district.asmId kept as fallback for legacy data. + const outletAsmId = (request as any).dealer?.dealerProfile?.asmId as string | undefined; + const isAssigned = + outletAsmId === userId || + district.asmId === userId || + region?.rbmId === userId || + district.zmId === userId || + zone?.zbhId === userId; return isAssigned; }); @@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { { model: User, as: 'dealer', - attributes: ['fullName', 'email'] + attributes: ['fullName', 'email'], + include: [ + { model: Dealer, as: 'dealerProfile', attributes: ['asmId'] } + ] }, { model: Worknote, @@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { const region = district.region; const zone = district.zone; + const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null; + const asmReviewerId = outletLevelAsmId || district.asmId; const evaluatorRoles: any[] = [ - { id: district.asmId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW }, + { id: asmReviewerId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW }, { id: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW }, { id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW }, { id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW } @@ -593,10 +607,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { }); if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW }); - // Get DD Head (national) - const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } }); - if (ddHead) evaluatorRoles.push({ id: ddHead.id, roleCode: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL }); - // Get NBH (national) - Approval Stage const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); if (nbh) { @@ -607,11 +617,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } }); if (legal) evaluatorRoles.push({ id: legal.id, roleCode: 'Legal Admin', stage: RELOCATION_STAGES.LEGAL_CLEARANCE }); - // Get NBH (national) - Final Clearance Stage - if (nbh) { - evaluatorRoles.push({ id: nbh.id, roleCode: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR }); - } - // Fetch user details for each evaluator for (const evaluator of evaluatorRoles) { if (evaluator.id) { @@ -718,11 +723,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => { [RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW, [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW, - [RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.DD_HEAD_APPROVAL, - [RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.NBH_APPROVAL, + [RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.NBH_APPROVAL, [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE, - [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR, - [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED + [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED }; const reverseStageFlow: Record = { @@ -731,10 +734,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => { [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW, [RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW, - [RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW, - [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL, + [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW, [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL, - [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE + [RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE }; /** Canonical order for progress % (must stay aligned with stageFlow chain, excluding terminal). */ @@ -744,10 +746,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => { RELOCATION_STAGES.DD_ZM_REVIEW, RELOCATION_STAGES.ZBH_REVIEW, RELOCATION_STAGES.DD_LEAD_REVIEW, - RELOCATION_STAGES.DD_HEAD_APPROVAL, RELOCATION_STAGES.NBH_APPROVAL, - RELOCATION_STAGES.LEGAL_CLEARANCE, - RELOCATION_STAGES.NBH_CLEARANCE_EOR + RELOCATION_STAGES.LEGAL_CLEARANCE ]; /** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */ @@ -833,25 +833,6 @@ export const takeAction = async (req: AuthRequest, res: Response) => { progressPercentage: newProgress }); - // 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR (header row + default checklist lines + doc map) - if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') { - try { - await db.EorChecklist.findOrCreate({ - where: { relocationId: request.id }, - defaults: { - status: 'In Progress', - relocationId: request.id, - applicationId: null - } - }); - const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js'); - await ensureRelocationEorChecklistSeeded(request.id); - console.log(`[RelocationController] EOR Checklist initiated/synced for ${request.requestId}`); - } catch (e) { - console.error('Failed to auto-initiate EOR checklist:', e); - } - } - // 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided const shouldWriteWorknote = Boolean(String(reviewComments).trim()) && diff --git a/src/scripts/check-interviews.ts b/src/scripts/check-interviews.ts index d3b2c1c..9c84ca3 100644 --- a/src/scripts/check-interviews.ts +++ b/src/scripts/check-interviews.ts @@ -10,7 +10,7 @@ const checkInterviews = async () => { }] }); - console.log(`--- Interviews ---`); + // console.log(`--- Interviews ---`); interviews.forEach((i: any) => { console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`); i.participants?.forEach((p: any) => { diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index 4a148ee..f53d074 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; export class ConstitutionalWorkflowService { + private static normalizeDocLabel(input: string): string { + return String(input || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + } + + private static extractUploadedDocumentLabels(documents: any[]): string[] { + if (!Array.isArray(documents)) return []; + return documents + .flatMap((doc: any) => [ + doc?.documentType, + doc?.type, + doc?.name, + doc?.title, + doc?.label, + doc?.fileName, + typeof doc === 'string' ? doc : null + ]) + .filter((v: any) => typeof v === 'string' && v.trim().length > 0) + .map((v: string) => this.normalizeDocLabel(v)); + } + + static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] { + const checklist = this.getDocumentChecklist(targetConstitution); + const uploaded = this.extractUploadedDocumentLabels(documents); + const hasToken = (aliases: string[]) => aliases.some((a) => uploaded.some((u) => u.includes(a))); + + const aliasesByRequirement: Record = { + [DOCUMENT_TYPES.GST_CERTIFICATE]: ['gst'], + [DOCUMENT_TYPES.PAN_CARD]: ['pan', 'firm pan'], + [DOCUMENT_TYPES.AADHAAR]: ['aadhaar', 'kyc'], + [DOCUMENT_TYPES.CANCELLED_CHECK]: ['cancelled cheque', 'canceled cheque', 'cancelled check', 'cancelled'], + [DOCUMENT_TYPES.OTHER]: ['declaration', 'authorization', 'authorisation'], + [DOCUMENT_TYPES.PARTNERSHIP_DEED]: ['partnership deed', 'partnership agreement'], + [DOCUMENT_TYPES.FIRM_REGISTRATION]: ['firm registration'], + [DOCUMENT_TYPES.LLP_AGREEMENT]: ['llp agreement'], + [DOCUMENT_TYPES.INCORPORATION_CERTIFICATE]: ['incorporation', 'coi'], + [DOCUMENT_TYPES.MOA]: ['moa'], + [DOCUMENT_TYPES.AOA]: ['aoa'], + 'Business Purchase Agreement (BPA)': ['business purchase agreement', 'bpa'] + }; + + return checklist.filter((required) => { + const tokens = aliasesByRequirement[required] || [this.normalizeDocLabel(required)]; + return !hasToken(tokens.map((t) => this.normalizeDocLabel(t))); + }); + } + /** * Transitions a constitutional change request to a new stage */ @@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService { actionUserFullName: userFullName || 'System', action: action || `Moved to ${targetStage}`, remarks: remarkText, - link: `${portalBase}/constitutional-change/${request.id}` + link: `${portalBase}/constitutional-change/${request.id}`, + changeType: request.changeType } ); } diff --git a/src/services/ParticipantService.ts b/src/services/ParticipantService.ts index ff1097e..0312da3 100644 --- a/src/services/ParticipantService.ts +++ b/src/services/ParticipantService.ts @@ -129,7 +129,6 @@ export class ParticipantService { // 2. National roles const nationalRoles = [ ROLES.DD_LEAD, - ROLES.DD_HEAD, ROLES.NBH, ROLES.CCO, ROLES.CEO, @@ -347,6 +346,9 @@ export class ParticipantService { const outlet = (relocation as any).outlet; if (outlet && outlet.district) { const district = outlet.district; + // Canonical ASM for relocation visibility/actions is district.asmId (see relocation.controller getRequests / evaluators). + // Dealer.asmId may differ or be unset while district.asmId is set. + if (district.asmId) participantIds.add(district.asmId); if (relocation.dealerId) { const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] }); if (dealerUser?.dealerId) { diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index 11d95c9..a0916e3 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -1,5 +1,5 @@ import db from '../database/models/index.js'; -const { RelocationRequest, AuditLog, User } = db; +const { RelocationRequest, AuditLog, User, RequestParticipant, Outlet } = db; import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; @@ -131,10 +131,8 @@ export class RelocationWorkflowService { [RELOCATION_STAGES.DD_ZM_REVIEW]: ROLES.DD_ZM, [RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH, [RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD, - [RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD, [RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH, - [RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN, - [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH + [RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN }; const requiredRole = stageMapping[request.currentStage]; @@ -143,10 +141,65 @@ export class RelocationWorkflowService { // Role-based check if (user.roleCode !== requiredRole) return false; - // Optional: Hierarchy check - // We could verify if the user is the SPECIFIC person assigned in participants - // but for now, any user with the correct role can act (consistent with simple RBAC) - - return true; + // Stage-specific participant assignment enforcement: actor must be mapped on this request. + const participant = await RequestParticipant.findOne({ + where: { + requestId: request.id, + requestType: REQUEST_TYPES.RELOCATION, + userId: user.id + }, + attributes: ['id'] + }); + if (participant) return true; + const anyParticipant = await RequestParticipant.findOne({ + where: { requestId: request.id, requestType: REQUEST_TYPES.RELOCATION }, + attributes: ['id'] + }); + // Backward compatibility for legacy requests created before participant auto-assignment. + if (!anyParticipant) return true; + + // Match relocation.controller getRequests: territory actors must act even if RequestParticipant + // omitted them (e.g. ASM was taken only from Dealer.asmId while list uses district.asmId). + return await RelocationWorkflowService.userMatchesRelocationOutletHierarchy(request.id, user); + } + + /** Same outlet hierarchy checks as internal-user filter on relocation list (ASM/RBM/DD-ZM/ZBH). */ + static async userMatchesRelocationOutletHierarchy(requestId: string, user: any): Promise { + const row = await RelocationRequest.findByPk(requestId, { + include: [ + { + model: User, + as: 'dealer', + attributes: ['id'], + include: [{ model: db.Dealer, as: 'dealerProfile', attributes: ['asmId'] }] + }, + { + model: Outlet, + as: 'outlet', + include: [ + { + model: db.District, + as: 'district', + include: [ + { model: db.Region, as: 'region' }, + { model: db.Zone, as: 'zone' } + ] + } + ] + } + ] + }); + if (!row) return false; + const outlet = (row as any).outlet; + const district = outlet?.district; + if (!district) return false; + const uid = user.id; + const rc = user.roleCode; + const outletLevelAsmId = (row as any).dealer?.dealerProfile?.asmId ?? null; + if (rc === ROLES.ASM && (outletLevelAsmId === uid || district.asmId === uid)) return true; + if (rc === ROLES.RBM && district.region?.rbmId === uid) return true; + if (rc === ROLES.DD_ZM && district.zmId === uid) return true; + if (rc === ROLES.ZBH && district.zone?.zbhId === uid) return true; + return false; } } diff --git a/trigger-workflow.js b/trigger-workflow.js index a60ff8e..11a529b 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -123,7 +123,7 @@ async function prospectLogin(phone) { async function mockUploadDocument(appId, token, docType) { const formData = new FormData(); - const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-24 19-16-05.png'); + const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG'); const blob = new Blob([fileBuffer], { type: 'image/png' }); formData.append('file', blob, 'screenshot.png'); formData.append('documentType', docType); diff --git a/verify_relocation_workflow.ts b/verify_relocation_workflow.ts index 0bed89b..6d61842 100644 --- a/verify_relocation_workflow.ts +++ b/verify_relocation_workflow.ts @@ -63,12 +63,7 @@ async function run() { console.log(`Created Test Request: ${request.requestId}`); // Now call the logic that calculates participants (similar to getRequestById) - // We'll just look at the DB for now to see if DD Head and NBH (dual) would be assigned. - - // Check DD Head - const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } }); - console.log(`DD Head found in DB: ${ddHead ? ddHead.fullName : 'NO'}`); - + // We'll just look at the DB for now to see if NBH exists for the approval stage. // Check NBH const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`); @@ -76,9 +71,7 @@ async function run() { // Verify Evaluator Assignment Logic (Re-running a piece of it) const evaluators = []; evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW }); - evaluators.push({ id: ddHead?.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL }); evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL }); - evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE }); console.log('Expected Evaluators for this request:'); evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));