Dealer_Onboarding_Backend/src/services/ConstitutionalWorkflowService.ts

358 lines
15 KiB
TypeScript

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<string, string[]> {
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<void> {
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<string, number> = {
[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;
}
}