import db from '../database/models/index.js'; import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, DOCUMENT_TYPES, REQUEST_TYPES } from '../common/config/constants.js'; import { NotificationService } from './NotificationService.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; import { getFrontendBaseUrl } from '../common/utils/frontendUrl.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)); } private static aliasesByRequirement(): Record { return { [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'] }; } private static docMatchesRequirement(doc: any, required: string): boolean { const aliases = this.aliasesByRequirement(); const tokens = aliases[required] || [this.normalizeDocLabel(required)]; const labels = [ doc?.documentType, doc?.type, doc?.name, doc?.title, doc?.label, doc?.fileName ] .filter((v: any) => typeof v === 'string' && v.trim().length > 0) .map((v: string) => this.normalizeDocLabel(v)); return tokens.some((token) => labels.some((label) => label.includes(token) || token.includes(label))); } /** Readiness aligned with relocation: uploaded vs verified vs rejected. */ static getDocumentReadiness(targetConstitution: string, documents: any[]) { const checklist = this.getDocumentChecklist(targetConstitution); const docs = Array.isArray(documents) ? documents : []; const missingUploads: string[] = []; const pendingVerification: string[] = []; for (const required of checklist) { const matched = docs.filter((d) => this.docMatchesRequirement(d, required)); if (!matched.length) { missingUploads.push(required); continue; } const hasVerified = matched.some((d) => String(d.status || '').toLowerCase() === 'verified'); if (!hasVerified) { pendingVerification.push(required); } } return { totalRequired: checklist.length, missingUploads, pendingVerification }; } static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] { const readiness = this.getDocumentReadiness(targetConstitution, documents); return [...readiness.missingUploads, ...readiness.pendingVerification]; } /** * Transitions a constitutional change request to a new stage */ static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) { const { action, status, remarks, userFullName, metadata = {} } = options; const sourceStage = request.currentStage; const actionLower = String(action || '').toLowerCase(); // Audit Action resolution // 3. Create Audit Log using standardized mapper const { actionType } = metadata; const resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.CONSTITUTIONAL); const updatedTimeline = [ ...(request.timeline || []), { stage: sourceStage, // Correctly Associate remark with the stage where action happened targetStage: targetStage, timestamp: new Date(), user: userFullName || 'System', action: action || `Moved to ${targetStage}`, remarks: remarks || '' } ]; const resolvedStatus = status || (targetStage === CONSTITUTIONAL_STAGES.COMPLETED ? 'Completed' : targetStage === CONSTITUTIONAL_STAGES.REJECTED ? 'Rejected' : targetStage === CONSTITUTIONAL_STAGES.REVOKED ? 'Revoked' : targetStage); const updateData: any = { currentStage: targetStage, status: resolvedStatus, progressPercentage: this.calculateProgress(targetStage), timeline: updatedTimeline, updatedAt: new Date() }; // GAP CLOSURE: Reset joint approval metadata if sent back to or before ZM/RBM review stage // Controller uses "Sent back to …" (past tense). `sent` does not include substring `send`, so the old check never fired and joint-approval metadata was not cleared on send-back from ZBH. const isSendBack = /\b(send|sent)\s*back\b/i.test(String(action || '')) || (actionLower.includes('send') && actionLower.includes('back')); const resetTargetStages = [CONSTITUTIONAL_STAGES.ASM_REVIEW, CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]; if (isSendBack && resetTargetStages.includes(targetStage as any)) { const metadata = { ...(request.metadata || {}) }; if (metadata.jointApprovals?.zmRbm) { console.log(`[ConstitutionalWorkflowService] Resetting joint approval metadata for ${request.requestId} on send-back to ${targetStage}`); delete metadata.jointApprovals.zmRbm; updateData.metadata = metadata; // Explicitly inform Sequelize that metadata has changed request.changed('metadata', true); } } await request.update(updateData); await syncSlaOnStageTransition({ entityType: 'constitutional', entityId: request.id, fromStage: sourceStage, toStage: targetStage }); if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) { await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId); } // Audit Log await db.ConstitutionalAudit.create({ userId, constitutionalChangeId: request.id, action: formatOffboardingAction(resolvedAuditAction), remarks: remarks || '', details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) } }); // 5. Create Worknote for standardized communication trail (always when we have a user — empty modal comments still need a row) if (userId) { try { const body = String(remarks ?? '').trim() || 'No remarks entered.'; await writeWorkflowActivityWorknote({ requestId: request.id, requestType: 'constitutional', userId: userId, noteText: `${formatOffboardingAction(action || '')}: ${body}`, noteType: isSendBack ? 'workflow' : 'internal' }); } catch (wnErr) { console.error('[ConstitutionalWorkflowService] failed to write worknote:', wnErr); } } const dealerUser = await db.User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] }); if (dealerUser?.email) { const portalBase = getFrontendBaseUrl(); const remarkText = String(remarks ?? '').trim() || 'N/A'; const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); await notifyStakeholdersOnTransition( request.id, REQUEST_TYPES.CONSTITUTIONAL, targetStage, { code: request.requestId, dealerName: dealerUser.fullName || 'Dealer', dealerId: dealerUser.id, actionUserFullName: userFullName || 'System', action: action || `Moved to ${targetStage}`, remarks: remarkText, link: `${portalBase}/constitutional-change/${request.id}`, changeType: request.changeType } ); } return request; } /** * SRS §12.2 — upon final approval, dealer master constitution must reflect the approved change. * Logs to constitutional audit trail and Work Notes (workflow) — important compliance record. */ static async syncDealerProfileConstitution(request: any, actingUserId: string | null): Promise { const nextConstitution = mapConstitutionalChangeTypeToDealerProfile(request.changeType); if (!nextConstitution) return; try { const user = await db.User.findByPk(request.dealerId, { include: [{ model: db.Dealer, as: 'dealerProfile' }] }); const dealer = (user as any)?.dealerProfile; if (!dealer) { console.warn('[ConstitutionalWorkflowService] Completed CC request but no dealer profile for user', request.dealerId); return; } const previous = String(dealer.constitutionType || '').trim(); if (previous === nextConstitution) { return; } await dealer.update({ constitutionType: nextConstitution }); const auditRemarks = `Dealer master constitution updated from "${previous}" to "${nextConstitution}" via request ${request.requestId}.`; const auditDetails = { dealerProfileId: dealer.id, dealerUserId: request.dealerId, previousConstitution: previous, newConstitution: nextConstitution, requestId: request.requestId, changeType: request.changeType, stage: CONSTITUTIONAL_STAGES.COMPLETED }; try { await db.ConstitutionalAudit.create({ userId: actingUserId || null, constitutionalChangeId: request.id, action: formatOffboardingAction(AUDIT_ACTIONS.UPDATED), remarks: auditRemarks, details: auditDetails }); } catch (auditErr) { console.error('[ConstitutionalWorkflowService] Dealer sync audit log failed:', auditErr); } const worknoteUserId = actingUserId || String(request.dealerId); if (worknoteUserId) { try { await writeWorkflowActivityWorknote({ requestId: request.id, requestType: 'constitutional', userId: worknoteUserId, noteText: `Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`, noteType: 'workflow' }); } catch (wnErr) { console.error('[ConstitutionalWorkflowService] dealer sync worknote failed:', wnErr); } } } catch (err) { console.error('[ConstitutionalWorkflowService] Failed to update dealer constitutionType:', err); } } /** * Calculates progress percentage based on stage */ static calculateProgress(stage: string): number { const progress: Record = { [CONSTITUTIONAL_STAGES.ASM_REVIEW]: 15, [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 30, [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: 45, [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: 60, [CONSTITUTIONAL_STAGES.HEAD_REVIEW]: 75, [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85, [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95, [CONSTITUTIONAL_STAGES.COMPLETED]: 100, [CONSTITUTIONAL_STAGES.REJECTED]: 100, [CONSTITUTIONAL_STAGES.REVOKED]: 100 }; return progress[stage] || 0; } /** * Returns mandatory document checklist based on target constitution * Per SRS Section 12.2.4 */ static getDocumentChecklist(targetConstitution: string): string[] { const commonDocs = [ DOCUMENT_TYPES.GST_CERTIFICATE, DOCUMENT_TYPES.PAN_CARD, DOCUMENT_TYPES.AADHAAR, DOCUMENT_TYPES.CANCELLED_CHECK, DOCUMENT_TYPES.OTHER // Declaration/Authorization Letter ]; if (targetConstitution.includes('Partnership')) { return [ ...commonDocs, DOCUMENT_TYPES.PARTNERSHIP_DEED, 'Business Purchase Agreement (BPA)', DOCUMENT_TYPES.FIRM_REGISTRATION ]; } if (targetConstitution.includes('LLP')) { return [ ...commonDocs, DOCUMENT_TYPES.INCORPORATION_CERTIFICATE, 'Business Purchase Agreement (BPA)', DOCUMENT_TYPES.LLP_AGREEMENT ]; } if (targetConstitution.includes('Private Limited')) { return [ ...commonDocs, DOCUMENT_TYPES.MOA, DOCUMENT_TYPES.AOA, DOCUMENT_TYPES.INCORPORATION_CERTIFICATE, 'Business Purchase Agreement (BPA)' ]; } // Default/Proprietorship return commonDocs; } }