contitutional and relocation changes done based on document alignment

This commit is contained in:
laxmanhalaki 2026-05-06 10:45:14 +05:30
parent 5ddbe525e6
commit 0ab90ee356
17 changed files with 300 additions and 204 deletions

View File

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

View File

@ -1,8 +1,12 @@
/** /**
* Database Migration Script * 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. * 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 * Run: npx tsx scripts/migrate.ts
*/ */

View File

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

View File

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

View File

@ -223,13 +223,8 @@ export const RESIGNATION_TYPES = {
export const CONSTITUTIONAL_CHANGE_TYPES = { export const CONSTITUTIONAL_CHANGE_TYPES = {
PROPRIETORSHIP: 'Proprietorship', PROPRIETORSHIP: 'Proprietorship',
PARTNERSHIP: 'Partnership', PARTNERSHIP: 'Partnership',
LLP_CONVERSION: 'LLP Conversion',
LLP: 'LLP', LLP: 'LLP',
PRIVATE_LIMITED: 'Private Limited', PRIVATE_LIMITED: 'Private Limited'
COMPANY_FORMATION: 'Company Formation',
OWNERSHIP_TRANSFER: 'Ownership Transfer',
PARTNERSHIP_CHANGE: 'Partnership Change',
DIRECTOR_CHANGE: 'Director Change'
} as const; } as const;
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */ /** 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', DD_ZM_REVIEW: 'DD ZM Review',
ZBH_REVIEW: 'ZBH Review', ZBH_REVIEW: 'ZBH Review',
DD_LEAD_REVIEW: 'DD Lead Review', DD_LEAD_REVIEW: 'DD Lead Review',
DD_HEAD_APPROVAL: 'DD Head Approval',
NBH_APPROVAL: 'NBH Approval', NBH_APPROVAL: 'NBH Approval',
LEGAL_CLEARANCE: 'Legal Clearance', LEGAL_CLEARANCE: 'Legal Clearance',
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
COMPLETED: 'Completed', COMPLETED: 'Completed',
REJECTED: 'Rejected' REJECTED: 'Rejected'
} as const; } as const;
@ -570,7 +563,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
export const STAGES_MAP = { 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'], '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'], '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'], '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'] 'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
} as const; } as const;

View File

@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
) { ) {
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED; return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
} }
if (compact.includes('llp') && compact.includes('conversion')) {
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
}
if (compact.includes('llp')) { if (compact.includes('llp')) {
return CONSTITUTIONAL_CHANGE_TYPES.LLP; return CONSTITUTIONAL_CHANGE_TYPES.LLP;
} }
if (compact.includes('partnership') && compact.includes('change')) {
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
}
if (compact.includes('partnership')) { if (compact.includes('partnership')) {
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP; return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
} }
if (compact.includes('proprietorship') || compact === 'sole proprietorship') { if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
return CONSTITUTIONAL_CHANGE_TYPES.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()); const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
return exact || null; return exact || null;
} }
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
const t = String(changeType || '').trim(); const t = String(changeType || '').trim();
if (!t) return null; if (!t) return null;
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
const structureTargets = [ const structureTargets = [
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP, CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP, CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
]; ];
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t; 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; return null;
} }

View File

@ -67,10 +67,9 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW, [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL, [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; return flow[currentStage] || null;
} }

View File

@ -11,7 +11,7 @@ import {
ROLES ROLES
} from '../config/constants.js'; } 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'; const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
@ -150,7 +150,12 @@ export async function notifyRelocationSubmittedEmails(
const outlet = await Outlet.findByPk(request.outletId, { const outlet = await Outlet.findByPk(request.outletId, {
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }] 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; if (!asmId) return;
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); 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 Lead Review': [ROLES.DD_LEAD],
'DD Head': [ROLES.DD_HEAD], 'DD Head': [ROLES.DD_HEAD],
'DD Head Review': [ROLES.DD_HEAD], 'DD Head Review': [ROLES.DD_HEAD],
'DD Head Approval': [ROLES.DD_HEAD],
'NBH': [ROLES.NBH], 'NBH': [ROLES.NBH],
'NBH Approval': [ROLES.NBH], 'NBH Approval': [ROLES.NBH],
'NBH Evaluation': [ROLES.NBH], 'NBH Evaluation': [ROLES.NBH],
@ -238,7 +242,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
'Architecture Document Upload': [ROLES.ARCHITECTURE], 'Architecture Document Upload': [ROLES.ARCHITECTURE],
// --- Relocation/Constitutional Specific --- // --- Relocation/Constitutional Specific ---
'NBH Clearance with EOR': [ROLES.NBH],
'Submitted': [ROLES.ASM], 'Submitted': [ROLES.ASM],
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM], 'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
@ -315,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
action: string; action: string;
remarks: string; remarks: string;
link: string; link: string;
changeType?: string;
} }
): Promise<void> { ): Promise<void> {
try { try {
@ -412,7 +416,7 @@ export async function notifyStakeholdersOnTransition(
// Override for Constitutional Change Completion // Override for Constitutional Change Completion
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) { if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED'; 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 // Override for Relocation Completion

View File

@ -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'; import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
export interface ConstitutionalChangeAttributes { export interface ConstitutionalChangeAttributes {
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
{ fields: ['requestId'] }, { fields: ['requestId'] },
{ fields: ['outletId'] }, { fields: ['outletId'] },
{ fields: ['dealerId'] }, { 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']
}
}
}
] ]
}); });

View File

@ -26,6 +26,7 @@ import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow
const STRUCTURE_TARGET_VALUES = new Set<string>( const STRUCTURE_TARGET_VALUES = new Set<string>(
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string) CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string)
); );
const CLOSED_CONSTITUTIONAL_STATUSES = ['Completed', 'Closed', 'Rejected', 'Revoked'];
const resolveConstitutionalUuid = async (id: string) => { const resolveConstitutionalUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional'); 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 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 = { const metadata = {
newPartnersDetails, newPartnersDetails,
shareholdingPattern, shareholdingPattern,
@ -215,6 +240,15 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
}); });
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 { try {
await ParticipantService.assignConstitutionalParticipants(request.id); await ParticipantService.assignConstitutionalParticipants(request.id);
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer'; const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
@ -222,12 +256,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
} catch (e) { } catch (e) {
console.error('Error assigning participants or sending constitutional submit emails:', 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
});
} catch (error) { } catch (error) {
console.error('Submit constitutional change error:', error); console.error('Submit constitutional change error:', error);
res.status(500).json({ success: false, message: 'Error submitting request' }); 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 (!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 }); res.json({ success: true, request });
} catch (error) { } catch (error) {
@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => {
export const takeAction = async (req: AuthRequest, res: Response) => { export const takeAction = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); 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 { id } = req.params;
const rawAction = String(req.body.action || '').trim(); 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 isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK);
const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE); const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE);
const stageRoleMap: Record<string, string[]> = {
[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) { if (isReject) {
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, { 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.' }); 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; const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW;
if (isZmRbmJointStage && isApprove) { if (isZmRbmJointStage && isApprove) {

View File

@ -2,7 +2,7 @@ import { Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js'; import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.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 { 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 { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.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 }); evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
} }
// Stage 6: DD Head (national) // Stage 6: NBH Approval (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)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) { if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL }); 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' } }); const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) { if (legal) {
evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE }); 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`); console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
const evaluatorInfo = evaluators.map(e => ({ 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}` 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; const roleCode = req.user.roleCode as string;
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) { 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({ const openExisting = await RelocationRequest.findOne({
where: { where: {
outletId, outletId,
status: { [Op.notIn]: ['Completed', 'Rejected'] } status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] }
}, },
attributes: ['id', 'requestId', 'status', 'currentStage'] attributes: ['id', 'requestId', 'status', 'currentStage']
}); });
@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
{ {
model: User, model: User,
as: 'dealer', as: 'dealer',
attributes: ['fullName'] attributes: ['fullName'],
include: [
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
]
} }
], ],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
return true; return true;
} }
// Check if user is assigned to any evaluator role for this outlet // 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 = const isAssigned =
district.asmId === userId || // ASM outletAsmId === userId ||
region?.rbmId === userId || // RBM district.asmId === userId ||
district.zmId === userId || // DD-ZM region?.rbmId === userId ||
zone?.zbhId === userId; // ZBH district.zmId === userId ||
zone?.zbhId === userId;
return isAssigned; return isAssigned;
}); });
@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
{ {
model: User, model: User,
as: 'dealer', as: 'dealer',
attributes: ['fullName', 'email'] attributes: ['fullName', 'email'],
include: [
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
]
}, },
{ {
model: Worknote, model: Worknote,
@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
const region = district.region; const region = district.region;
const zone = district.zone; const zone = district.zone;
const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null;
const asmReviewerId = outletLevelAsmId || district.asmId;
const evaluatorRoles: any[] = [ 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: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW }, { id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_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 }); 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 // Get NBH (national) - Approval Stage
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) { 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' } }); 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 }); 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 // Fetch user details for each evaluator
for (const evaluator of evaluatorRoles) { for (const evaluator of evaluatorRoles) {
if (evaluator.id) { 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.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW, [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
[RELOCATION_STAGES.DD_LEAD_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.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE, [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR, [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
}; };
const reverseStageFlow: Record<string, string> = { const reverseStageFlow: Record<string, string> = {
@ -731,10 +734,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW, [RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW, [RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL, [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). */ /** 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.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.NBH_APPROVAL, RELOCATION_STAGES.NBH_APPROVAL,
RELOCATION_STAGES.LEGAL_CLEARANCE, RELOCATION_STAGES.LEGAL_CLEARANCE
RELOCATION_STAGES.NBH_CLEARANCE_EOR
]; ];
/** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */ /** ~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 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 // 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
const shouldWriteWorknote = const shouldWriteWorknote =
Boolean(String(reviewComments).trim()) && Boolean(String(reviewComments).trim()) &&

View File

@ -10,7 +10,7 @@ const checkInterviews = async () => {
}] }]
}); });
console.log(`--- Interviews ---`); // console.log(`--- Interviews ---`);
interviews.forEach((i: any) => { interviews.forEach((i: any) => {
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`); console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
i.participants?.forEach((p: any) => { i.participants?.forEach((p: any) => {

View File

@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
export class ConstitutionalWorkflowService { 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<string, string[]> = {
[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 * Transitions a constitutional change request to a new stage
*/ */
@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService {
actionUserFullName: userFullName || 'System', actionUserFullName: userFullName || 'System',
action: action || `Moved to ${targetStage}`, action: action || `Moved to ${targetStage}`,
remarks: remarkText, remarks: remarkText,
link: `${portalBase}/constitutional-change/${request.id}` link: `${portalBase}/constitutional-change/${request.id}`,
changeType: request.changeType
} }
); );
} }

View File

@ -129,7 +129,6 @@ export class ParticipantService {
// 2. National roles // 2. National roles
const nationalRoles = [ const nationalRoles = [
ROLES.DD_LEAD, ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH, ROLES.NBH,
ROLES.CCO, ROLES.CCO,
ROLES.CEO, ROLES.CEO,
@ -347,6 +346,9 @@ export class ParticipantService {
const outlet = (relocation as any).outlet; const outlet = (relocation as any).outlet;
if (outlet && outlet.district) { if (outlet && outlet.district) {
const district = 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) { if (relocation.dealerId) {
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] }); const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
if (dealerUser?.dealerId) { if (dealerUser?.dealerId) {

View File

@ -1,5 +1,5 @@
import db from '../database/models/index.js'; 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 { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.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.DD_ZM_REVIEW]: ROLES.DD_ZM,
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH, [RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD, [RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD,
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH, [RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN, [RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH
}; };
const requiredRole = stageMapping[request.currentStage]; const requiredRole = stageMapping[request.currentStage];
@ -143,10 +141,65 @@ export class RelocationWorkflowService {
// Role-based check // Role-based check
if (user.roleCode !== requiredRole) return false; if (user.roleCode !== requiredRole) return false;
// Optional: Hierarchy check // Stage-specific participant assignment enforcement: actor must be mapped on this request.
// We could verify if the user is the SPECIFIC person assigned in participants const participant = await RequestParticipant.findOne({
// but for now, any user with the correct role can act (consistent with simple RBAC) 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;
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<boolean> {
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;
} }
} }

View File

@ -123,7 +123,7 @@ async function prospectLogin(phone) {
async function mockUploadDocument(appId, token, docType) { async function mockUploadDocument(appId, token, docType) {
const formData = new FormData(); 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' }); const blob = new Blob([fileBuffer], { type: 'image/png' });
formData.append('file', blob, 'screenshot.png'); formData.append('file', blob, 'screenshot.png');
formData.append('documentType', docType); formData.append('documentType', docType);

View File

@ -63,12 +63,7 @@ async function run() {
console.log(`Created Test Request: ${request.requestId}`); console.log(`Created Test Request: ${request.requestId}`);
// Now call the logic that calculates participants (similar to getRequestById) // 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. // We'll just look at the DB for now to see if NBH exists for the approval stage.
// 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'}`);
// Check NBH // Check NBH
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`); 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) // Verify Evaluator Assignment Logic (Re-running a piece of it)
const evaluators = []; const evaluators = [];
evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW }); 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_APPROVAL });
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE });
console.log('Expected Evaluators for this request:'); console.log('Expected Evaluators for this request:');
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`)); evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));