major chnges made in all modules lin worknote nline history audit log enhncement progress bar improvement aross differnt modules

This commit is contained in:
laxmanhalaki 2026-04-15 10:59:54 +05:30
parent 7e1e43bef3
commit 86f2323641
28 changed files with 1756 additions and 471 deletions

View File

@ -117,6 +117,62 @@ export const APPLICATION_STATUS = {
RETURNED_TO_FDD: 'Returned to FDD' RETURNED_TO_FDD: 'Returned to FDD'
} as const; } as const;
/**
* Maps `Application.overallStatus` `Application.currentStage` (Postgres enum = APPLICATION_STAGES only).
* Progress milestones like "Shortlist" / "LOI Issue" live in ApplicationProgress, not on this column.
*/
export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
string,
(typeof APPLICATION_STAGES)[keyof typeof APPLICATION_STAGES]
> = {
[APPLICATION_STATUS.PENDING]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.SUBMITTED]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.QUESTIONNAIRE_PENDING]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.SHORTLISTED]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.IN_REVIEW]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.APPROVED]: APPLICATION_STAGES.APPROVED,
[APPLICATION_STATUS.REJECTED]: APPLICATION_STAGES.REJECTED,
[APPLICATION_STATUS.LEVEL_1_PENDING]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.LEVEL_1_APPROVED]: APPLICATION_STAGES.LEVEL_1_APPROVED,
[APPLICATION_STATUS.LEVEL_2_PENDING]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.LEVEL_2_APPROVED]: APPLICATION_STAGES.LEVEL_2_APPROVED,
[APPLICATION_STATUS.LEVEL_2_RECOMMENDED]: APPLICATION_STAGES.LEVEL_2_RECOMMENDED,
[APPLICATION_STATUS.LEVEL_3_PENDING]: APPLICATION_STAGES.DD,
[APPLICATION_STATUS.LEVEL_3_APPROVED]: APPLICATION_STAGES.LEVEL_3_APPROVED,
[APPLICATION_STATUS.FDD_VERIFICATION]: APPLICATION_STAGES.FDD,
[APPLICATION_STATUS.SECURITY_DETAILS]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.PAYMENT_PENDING]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.LOI_IN_PROGRESS]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.LOI_ISSUED]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.DEALER_CODE_GENERATION]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED]: APPLICATION_STAGES.ARCHITECTURE_WORK,
[APPLICATION_STATUS.ARCHITECTURE_DOCUMENT_UPLOAD]: APPLICATION_STAGES.ARCHITECTURE_WORK,
[APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION]: APPLICATION_STAGES.ARCHITECTURE_WORK,
[APPLICATION_STATUS.STATUTORY_GST]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_PAN]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_NODAL]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_CHECK]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_PARTNERSHIP]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_FIRM_REG]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_VIRTUAL_CODE]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_DOMAIN]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_MSD]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.STATUTORY_LOI_ACK]: APPLICATION_STAGES.LOI,
[APPLICATION_STATUS.EOR_IN_PROGRESS]: APPLICATION_STAGES.EOR,
[APPLICATION_STATUS.LOA_PENDING]: APPLICATION_STAGES.LOA,
[APPLICATION_STATUS.ARCHITECTURE_WORK]: APPLICATION_STAGES.ARCHITECTURE_WORK,
[APPLICATION_STATUS.STATUTORY_WORK]: APPLICATION_STAGES.STATUTORY_WORK,
[APPLICATION_STATUS.LOA_ISSUED]: APPLICATION_STAGES.LOA,
[APPLICATION_STATUS.LOA_REJECTED]: APPLICATION_STAGES.LOA,
[APPLICATION_STATUS.EOR_COMPLETE]: APPLICATION_STAGES.EOR,
[APPLICATION_STATUS.INAUGURATION]: APPLICATION_STAGES.APPROVED,
[APPLICATION_STATUS.ONBOARDED]: APPLICATION_STAGES.APPROVED,
[APPLICATION_STATUS.DISQUALIFIED]: APPLICATION_STAGES.REJECTED,
[APPLICATION_STATUS.LOI_REJECTED]: APPLICATION_STAGES.REJECTED,
[APPLICATION_STATUS.RETURNED_TO_FDD]: APPLICATION_STAGES.FDD,
};
// Termination Stages // Termination Stages
export const TERMINATION_STAGES = { export const TERMINATION_STAGES = {
SUBMITTED: 'Submitted', SUBMITTED: 'Submitted',
@ -176,6 +232,14 @@ export const CONSTITUTIONAL_CHANGE_TYPES = {
DIRECTOR_CHANGE: 'Director Change' DIRECTOR_CHANGE: 'Director Change'
} as const; } as const;
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
export const CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS = [
{ value: CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP, label: 'Proprietorship' },
{ value: CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP, label: 'Partnership' },
{ value: CONSTITUTIONAL_CHANGE_TYPES.LLP, label: 'LLP (Limited Liability Partnership)' },
{ value: CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED, label: 'Private Limited' }
] as const;
// Constitutional Change Stages (Aligned with SRS v2.0) // Constitutional Change Stages (Aligned with SRS v2.0)
export const CONSTITUTIONAL_STAGES = { export const CONSTITUTIONAL_STAGES = {
SUBMITTED: 'Submitted', SUBMITTED: 'Submitted',
@ -187,7 +251,9 @@ export const CONSTITUTIONAL_STAGES = {
NBH_APPROVAL: 'NBH Approval', NBH_APPROVAL: 'NBH Approval',
LEGAL_REVIEW: 'Legal Review', LEGAL_REVIEW: 'Legal Review',
COMPLETED: 'Completed', COMPLETED: 'Completed',
REJECTED: 'Rejected' REJECTED: 'Rejected',
/** SRS §12.2.3 — administrative cancellation (distinct from rejection of proposal). */
REVOKED: 'Revoked'
} as const; } as const;
// Relocation Types // Relocation Types
@ -302,6 +368,7 @@ export const AUDIT_ACTIONS = {
// Documents & Collaboration // Documents & Collaboration
DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED', DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED',
DOCUMENT_VERIFIED: 'DOCUMENT_VERIFIED', DOCUMENT_VERIFIED: 'DOCUMENT_VERIFIED',
DOCUMENT_REJECTED: 'DOCUMENT_REJECTED',
WORKNOTE_ADDED: 'WORKNOTE_ADDED', WORKNOTE_ADDED: 'WORKNOTE_ADDED',
ATTACHMENT_UPLOADED: 'ATTACHMENT_UPLOADED', ATTACHMENT_UPLOADED: 'ATTACHMENT_UPLOADED',
PARTICIPANT_ADDED: 'PARTICIPANT_ADDED', PARTICIPANT_ADDED: 'PARTICIPANT_ADDED',
@ -354,6 +421,10 @@ export const AUDIT_ACTIONS = {
RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED', RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED',
RESIGNATION_APPROVED: 'RESIGNATION_APPROVED', RESIGNATION_APPROVED: 'RESIGNATION_APPROVED',
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED', RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
RELOCATION_REVOKED: 'RELOCATION_REVOKED',
CONSTITUTIONAL_SENT_BACK: 'CONSTITUTIONAL_SENT_BACK',
CONSTITUTIONAL_REVOKED: 'CONSTITUTIONAL_REVOKED',
EMAIL_SENT: 'EMAIL_SENT', EMAIL_SENT: 'EMAIL_SENT',
REMINDER_SENT: 'REMINDER_SENT' REMINDER_SENT: 'REMINDER_SENT'
} as const; } as const;

View File

@ -0,0 +1,54 @@
import { CONSTITUTIONAL_CHANGE_TYPES } from '../config/constants.js';
const ALL_CHANGE_TYPES = Object.values(CONSTITUTIONAL_CHANGE_TYPES) as string[];
export function isRegisteredConstitutionalChangeType(value: string): boolean {
return ALL_CHANGE_TYPES.includes(value);
}
/**
* Map UI / legacy profile labels to a value that exists on `constitutional_changes.changeType` ENUM.
*/
export function normalizeToConstitutionalChangeType(raw: string | null | undefined): string | null {
const s = String(raw || '').trim();
if (!s) return null;
if (isRegisteredConstitutionalChangeType(s)) return s;
const compact = s
.toLowerCase()
.replace(/\./g, '')
.replace(/\s+/g, ' ')
.trim();
if (
(compact.includes('private') && (compact.includes('ltd') || compact.includes('limited'))) ||
compact === 'pvt ltd' ||
compact === 'pvtltd'
) {
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;
}

View File

@ -89,15 +89,15 @@ export const updateApplicationProgress = async (applicationId: string, stageName
}; };
/** /**
* Syncs all progress stages based on current overall status * Maps application `overallStatus` to the pipeline stage label used in ApplicationProgress
* and (via WorkflowService) `Application.currentStage` + audit `newData.stage`.
* Keeps audit trail aligned with the post-LOI milestones (dealer code LOA EOR inauguration).
*/ */
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => { export const PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record<string, string> = {
// Map overallStatus to stage names Submitted: 'Submitted',
const statusToStageMap: Record<string, string> = {
'Submitted': 'Submitted',
'Questionnaire Pending': 'Questionnaire', 'Questionnaire Pending': 'Questionnaire',
'Questionnaire Completed': 'Questionnaire', 'Questionnaire Completed': 'Questionnaire',
'Shortlisted': 'Shortlist', Shortlisted: 'Shortlist',
'Level 1 Interview Pending': '1st Level Interview', 'Level 1 Interview Pending': '1st Level Interview',
'Level 1 Approved': '1st Level Interview', 'Level 1 Approved': '1st Level Interview',
'Level 2 Interview Pending': '2nd Level Interview', 'Level 2 Interview Pending': '2nd Level Interview',
@ -129,14 +129,25 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
'Statutory Work': 'Statutory Work', 'Statutory Work': 'Statutory Work',
'LOA Pending': 'LOA', 'LOA Pending': 'LOA',
'LOA Issued': 'LOA', 'LOA Issued': 'LOA',
'LOA Rejected': 'LOA',
'LOI Rejected': 'LOI Issue',
'EOR In Progress': 'EOR Complete', 'EOR In Progress': 'EOR Complete',
'EOR Complete': 'EOR Complete', 'EOR Complete': 'EOR Complete',
'Inauguration': 'Inauguration', Inauguration: 'Inauguration',
'Approved': 'Inauguration', Approved: 'Inauguration',
'Onboarded': 'Onboarded' Onboarded: 'Onboarded',
}; Rejected: 'Rejected',
Disqualified: 'Disqualified',
'Returned to FDD': 'FDD',
Pending: 'Submitted',
'In Review': 'Shortlist',
};
const currentStageName = statusToStageMap[overallStatus]; /**
* Syncs all progress stages based on current overall status
*/
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => {
const currentStageName = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[overallStatus];
if (currentStageName) { if (currentStageName) {
const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName); const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
if (currentStage) { if (currentStage) {

View File

@ -0,0 +1,31 @@
import db from '../../database/models/index.js';
/** Must match `REQUEST_TYPES` / collaboration / `RequestParticipant` (use `constitutional`, not `constitutional-change`). */
export type WorkflowActivityRequestType =
| 'relocation'
| 'constitutional'
| 'resignation'
| 'termination';
/**
* Persists a workflow / decision line for Work Notes (UI: activity strip when noteType is internal | workflow).
* No-op if noteText is empty after trim.
*/
export async function writeWorkflowActivityWorknote(opts: {
requestId: string;
requestType: WorkflowActivityRequestType;
userId: string;
noteText: string;
noteType: 'internal' | 'workflow';
}): Promise<void> {
const text = String(opts.noteText || '').trim();
if (!text) return;
await db.Worknote.create({
requestId: opts.requestId,
requestType: opts.requestType,
userId: opts.userId,
noteText: text,
noteType: opts.noteType,
status: 'active'
});
}

View File

@ -229,6 +229,11 @@ const processStageDecision = async (params: {
targetStatus = APPLICATION_STATUS.LOA_ISSUED; targetStatus = APPLICATION_STATUS.LOA_ISSUED;
targetStage = 'LOA'; targetStage = 'LOA';
targetProgress = 95; targetProgress = 95;
} else if (stageCode === 'LOI_APPROVAL') {
// Always land on Security Details for admin + finance checks before LOI Issued (ignore client nextStatus).
targetStatus = APPLICATION_STATUS.SECURITY_DETAILS;
targetStage = APPLICATION_STAGES.LOI;
targetProgress = typeof nextProgress === 'number' ? nextProgress : 78;
} }
if (targetStatus) { if (targetStatus) {

View File

@ -23,6 +23,7 @@ const ACTION_DESCRIPTIONS: Record<string, string> = {
QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant', QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant',
DOCUMENT_UPLOADED: 'Document uploaded', DOCUMENT_UPLOADED: 'Document uploaded',
DOCUMENT_VERIFIED: 'Document verified', DOCUMENT_VERIFIED: 'Document verified',
DOCUMENT_REJECTED: 'Document rejected',
WORKNOTE_ADDED: 'Work note added', WORKNOTE_ADDED: 'Work note added',
ATTACHMENT_UPLOADED: 'Attachment uploaded', ATTACHMENT_UPLOADED: 'Attachment uploaded',
PARTICIPANT_ADDED: 'Participant added', PARTICIPANT_ADDED: 'Participant added',
@ -62,29 +63,120 @@ const ACTION_DESCRIPTIONS: Record<string, string> = {
RESIGNATION_SUBMITTED: 'Resignation submitted', RESIGNATION_SUBMITTED: 'Resignation submitted',
RESIGNATION_APPROVED: 'Resignation approved', RESIGNATION_APPROVED: 'Resignation approved',
RESIGNATION_REJECTED: 'Resignation rejected', RESIGNATION_REJECTED: 'Resignation rejected',
RELOCATION_SENT_BACK: 'Relocation sent back',
RELOCATION_REVOKED: 'Relocation revoked',
CONSTITUTIONAL_SENT_BACK: 'Constitutional change sent back',
CONSTITUTIONAL_REVOKED: 'Constitutional change revoked',
EMAIL_SENT: 'Email notification sent', EMAIL_SENT: 'Email notification sent',
REMINDER_SENT: 'Reminder sent', REMINDER_SENT: 'Reminder sent',
FDD_FLAGGED_NON_RESPONSIVE: 'APPLICANT FLAGGED: Non-responsive to audit queries' FDD_FLAGGED_NON_RESPONSIVE: 'APPLICANT FLAGGED: Non-responsive to audit queries'
}; };
const isIdleParallelStatus = (v: unknown) => {
const s = String(v ?? '')
.trim()
.toUpperCase();
return !s || s === 'PENDING' || s === 'NULL' || s === 'NOT_STARTED';
};
/** Readable copy for onboarding status transitions (avoids repeating unchanged parallel tracks). */
function buildFriendlyApplicationUpdatedDescription(logData: any, payload: any): string {
const oldD = logData.oldData || {};
const oldCtx = (oldD.context || {}) as Record<string, unknown>;
const newCtx = (payload.context || {}) as Record<string, unknown>;
const pipeline = payload.pipelineStage as string | undefined;
const status = payload.status as string | undefined;
const oldStatus = oldD.status as string | undefined;
const reason = payload.reason != null ? String(payload.reason).trim() : '';
const parts: string[] = [];
if (pipeline) {
parts.push(`Onboarding progressed to ${pipeline}`);
if (oldStatus && status && oldStatus !== status) {
parts.push(`Overall status: ${oldStatus}${status}`);
}
} else if (status && oldStatus && oldStatus !== status) {
parts.push(`Application status: ${oldStatus}${status}`);
} else if (status) {
parts.push(`Application status: ${status}`);
} else {
parts.push('Application updated');
}
const statChanged = oldCtx.statutoryStatus !== newCtx.statutoryStatus;
const archChanged = oldCtx.architectureStatus !== newCtx.architectureStatus;
if (statChanged) {
parts.push(
`Statutory: ${oldCtx.statutoryStatus ?? '—'}${newCtx.statutoryStatus ?? '—'}`
);
} else if (!isIdleParallelStatus(newCtx.statutoryStatus) && String(newCtx.statutoryStatus)) {
parts.push(`Statutory: ${newCtx.statutoryStatus}`);
}
if (archChanged) {
parts.push(
`Architecture: ${oldCtx.architectureStatus ?? '—'}${newCtx.architectureStatus ?? '—'}`
);
} else if (!isIdleParallelStatus(newCtx.architectureStatus) && String(newCtx.architectureStatus)) {
parts.push(`Architecture: ${newCtx.architectureStatus}`);
}
const src = payload.transitionSource as string | undefined;
if (src && !/WorkflowService\.transitionApplication/i.test(src) && !/^WorkflowService$/i.test(src.trim())) {
parts.push(`Note: ${src}`);
}
if (reason && !/^Transitioned to\b/i.test(reason) && reason.length < 200) {
parts.push(reason);
}
return parts.join(' · ');
}
const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => { const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => {
const payload = logData.details || logData.newData || {}; const payload = logData.details || logData.newData || {};
const actorName = logData.user?.fullName || logData.userName || 'System'; const actorName = logData.user?.fullName || logData.userName || 'System';
const action = logData.action || 'UPDATED'; const action = logData.action || 'UPDATED';
const et = String(entityType || '').toLowerCase();
let description = ACTION_DESCRIPTIONS[action] || let description = ACTION_DESCRIPTIONS[action] ||
String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ');
if (et === 'application' && action === 'UPDATED') {
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
} else {
if (payload?.stage) description += ` - Stage: ${payload.stage}`; if (payload?.stage) description += ` - Stage: ${payload.stage}`;
else if (payload?.department) description += ` - ${payload.department}`; else if (payload?.department) description += ` - ${payload.department}`;
else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`; else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`;
if (payload?.transitionSource && et !== 'application') {
description += ` · Source: ${payload.transitionSource}`;
}
if (payload?.pipelineStage && et === 'application' && action !== 'UPDATED') {
description += ` · Pipeline: ${payload.pipelineStage}`;
}
}
if (payload?.documentType && action === 'DOCUMENT_UPLOADED') {
description += ` · ${payload.documentType}`;
}
if (payload?.paymentType && action === 'PAYMENT_UPDATED') {
description += ` · ${payload.paymentType}`;
}
return { return {
id: logData.id, id: logData.id,
action, action,
description, description,
entityType, entityType,
entityId, entityId,
stage:
payload?.pipelineStage ||
payload?.stage ||
payload?.targetStage ||
(payload?.context as any)?.currentStage ||
null,
actor: { actor: {
name: actorName, name: actorName,
email: logData.user?.email || logData.userEmail || null email: logData.user?.email || logData.userEmail || null
@ -92,8 +184,9 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s
userName: actorName, userName: actorName,
userEmail: logData.user?.email || logData.userEmail || null, userEmail: logData.user?.email || logData.userEmail || null,
remarks: logData.remarks || payload?.remarks || '', remarks: logData.remarks || payload?.remarks || '',
newData: payload, newData: logData.newData ?? payload,
details: payload, details: payload,
oldData: logData.oldData ?? null,
timestamp: logData.createdAt || logData.timestamp timestamp: logData.createdAt || logData.timestamp
}; };
}; };
@ -182,8 +275,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => {
count = result.count; count = result.count;
logs = result.rows; logs = result.rows;
} else if (type === 'relocation') { } else if (type === 'relocation') {
const relocation = await db.RelocationRequest.findOne({
where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] },
attributes: ['id']
});
const resolvedRelocationId = relocation?.id || (entityId as string);
const result = await db.RelocationAudit.findAndCountAll({ const result = await db.RelocationAudit.findAndCountAll({
where: { relocationRequestId: entityId as string }, where: { relocationRequestId: resolvedRelocationId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
limit: limitNum, offset limit: limitNum, offset
@ -297,9 +395,14 @@ export const getAuditSummary = async (req: AuthRequest, res: Response) => {
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });
} else if (type === 'relocation' || type === 'relocation_request') { } else if (type === 'relocation' || type === 'relocation_request') {
totalLogs = await db.RelocationAudit.count({ where: { relocationRequestId: entityId as string } }); const relocation = await db.RelocationRequest.findOne({
where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] },
attributes: ['id']
});
const resolvedRelocationId = relocation?.id || (entityId as string);
totalLogs = await db.RelocationAudit.count({ where: { relocationRequestId: resolvedRelocationId } });
latestLog = await db.RelocationAudit.findOne({ latestLog = await db.RelocationAudit.findOne({
where: { relocationRequestId: entityId as string }, where: { relocationRequestId: resolvedRelocationId },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });

View File

@ -1,11 +1,12 @@
import { Response } from 'express'; import { Response } from 'express';
import { Op } from 'sequelize';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { const {
Worknote, User, WorkNoteTag, WorkNoteAttachment, DocumentVersion, RequestParticipant, Application, AuditLog, Worknote, User, WorkNoteTag, WorkNoteAttachment, DocumentVersion, RequestParticipant, Application, AuditLog,
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
} = db; } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
import * as EmailService from '../../common/utils/email.service.js'; import * as EmailService from '../../common/utils/email.service.js';
import { getIO } from '../../common/utils/socket.js'; import { getIO } from '../../common/utils/socket.js';
import * as NotificationService from '../../common/utils/notification.service.js'; import * as NotificationService from '../../common/utils/notification.service.js';
@ -16,7 +17,8 @@ const getDocumentModel = (requestType: string) => {
switch (requestType?.toLowerCase()) { switch (requestType?.toLowerCase()) {
case 'relocation': return RelocationDocument; case 'relocation': return RelocationDocument;
case 'resignation': return ResignationDocument; case 'resignation': return ResignationDocument;
case 'constitutional': return ConstitutionalDocument; case 'constitutional':
case 'constitutional-change': return ConstitutionalDocument;
case 'termination': return TerminationDocument; case 'termination': return TerminationDocument;
case 'onboarding': case 'onboarding':
case 'application': return OnboardingDocument; case 'application': return OnboardingDocument;
@ -64,24 +66,59 @@ const stitchWorknoteAttachments = async (worknotes: any[]) => {
return Promise.all(notePromises); return Promise.all(notePromises);
}; };
/** Resolve REQ-… vs UUID and align constitutional aliases with `REQUEST_TYPES.CONSTITUTIONAL`. */
async function resolveWorknoteRequestKeys(rawId: string, rawType: string) {
const id = String(rawId || '');
let t = String(rawType || 'application').toLowerCase();
if (t === 'constitutional_change') t = 'constitutional-change';
let resolvedId = id;
if (id && (t === 'constitutional' || t === 'constitutional-change')) {
const row = await db.ConstitutionalChange.findOne({
where: { [Op.or]: [{ id }, { requestId: id }] },
attributes: ['id']
});
if (row) resolvedId = (row as any).id;
t = REQUEST_TYPES.CONSTITUTIONAL;
} else if (id && t === 'relocation') {
const row = await db.RelocationRequest.findOne({
where: { [Op.or]: [{ id }, { requestId: id }] },
attributes: ['id']
});
if (row) resolvedId = (row as any).id;
}
return { resolvedId, normalizedType: t };
}
function worknoteListWhere(resolvedId: string, normalizedType: string) {
if (normalizedType === REQUEST_TYPES.CONSTITUTIONAL) {
return {
requestId: resolvedId,
requestType: { [Op.in]: [REQUEST_TYPES.CONSTITUTIONAL, 'constitutional-change'] }
};
}
return { requestId: resolvedId, requestType: normalizedType };
}
// --- Worknotes --- // --- Worknotes ---
export const addWorknote = async (req: AuthRequest, res: Response) => { export const addWorknote = async (req: AuthRequest, res: Response) => {
try { try {
const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body; const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body;
logger.info(`Adding worknote for ${requestType} ${requestId}. Body:`, { noteText, tags, attachmentDocIds }); const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
logger.info(`Adding worknote for ${normalizedType} ${resolvedId}. Body:`, { noteText, tags, attachmentDocIds });
// Debug: Log participants // Debug: Log participants
const participants = await db.RequestParticipant.findAll({ const participants = await db.RequestParticipant.findAll({
where: { requestId, requestType }, where: { requestId: resolvedId, requestType: normalizedType },
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }] include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }]
}); });
const simplifiedParticipants = participants.map((p: any) => ({ id: p.user?.id, name: p.user?.fullName })); const simplifiedParticipants = participants.map((p: any) => ({ id: p.user?.id, name: p.user?.fullName }));
logger.info(`Participants for ${requestId}:`, simplifiedParticipants); logger.info(`Participants for ${resolvedId}:`, simplifiedParticipants);
const worknote = await Worknote.create({ const worknote = await Worknote.create({
requestId, requestId: resolvedId,
requestType, // application, opportunity, etc. requestType: normalizedType,
userId: req.user?.id, userId: req.user?.id,
noteText, noteText,
noteType: noteType || 'General', noteType: noteType || 'General',
@ -99,17 +136,17 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
await WorkNoteAttachment.create({ await WorkNoteAttachment.create({
noteId: worknote.id, noteId: worknote.id,
documentId: docId, documentId: docId,
documentType: requestType || 'onboarding' documentType: normalizedType || 'onboarding'
}); });
} }
} }
// Add author as participant // Add author as participant
if (req.user?.id && requestId && requestType) { if (req.user?.id && resolvedId && normalizedType) {
await db.RequestParticipant.findOrCreate({ await db.RequestParticipant.findOrCreate({
where: { where: {
requestId, requestId: resolvedId,
requestType, requestType: normalizedType,
userId: req.user.id userId: req.user.id
}, },
defaults: { defaults: {
@ -133,7 +170,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
// --- Real-time & Notifications --- // --- Real-time & Notifications ---
try { try {
const io = getIO(); const io = getIO();
io.to(requestId).emit('new_worknote', stitchedNote); io.to(resolvedId).emit('new_worknote', stitchedNote);
// Handle Mentions/Notifications // Handle Mentions/Notifications
const notifiedUserIds = new Set<string>(); const notifiedUserIds = new Set<string>();
@ -170,7 +207,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
title: 'New Mention', title: 'New Mention',
message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`, message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`,
type: 'info', type: 'info',
link: `/applications/${requestId}?tab=worknotes` link: `/applications/${resolvedId}?tab=worknotes`
}); });
} catch (notifyErr) { } catch (notifyErr) {
logger.warn(`Failed to send notification to ${userId}:`, notifyErr); logger.warn(`Failed to send notification to ${userId}:`, notifyErr);
@ -189,7 +226,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.WORKNOTE_ADDED, action: AUDIT_ACTIONS.WORKNOTE_ADDED,
entityType: 'application', entityType: 'application',
entityId: requestId, entityId: resolvedId,
newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) } newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) }
}); });
@ -203,9 +240,11 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
export const getWorknotes = async (req: AuthRequest, res: Response) => { export const getWorknotes = async (req: AuthRequest, res: Response) => {
try { try {
const { requestId, requestType } = req.query as any; const { requestId, requestType } = req.query as any;
const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
const where = worknoteListWhere(resolvedId, normalizedType);
const worknotes = await Worknote.findAll({ const worknotes = await Worknote.findAll({
where: { requestId, requestType }, where,
include: [ include: [
{ model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] },
{ model: WorkNoteTag, as: 'tags' }, { model: WorkNoteTag, as: 'tags' },
@ -225,12 +264,13 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
try { try {
const file = req.file; const file = req.file;
const { requestId, requestType } = req.body; const { requestId, requestType } = req.body;
const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
if (!file) { if (!file) {
return res.status(400).json({ success: false, message: 'No file uploaded' }); return res.status(400).json({ success: false, message: 'No file uploaded' });
} }
const DocModel = getDocumentModel(requestType); const DocModel = getDocumentModel(normalizedType);
let createData: any = { let createData: any = {
documentType: 'Worknote Attachment', documentType: 'Worknote Attachment',
@ -242,19 +282,19 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
status: 'active' status: 'active'
}; };
// Assign correct FK based on model // Assign correct FK based on model (always UUID for self-service modules)
if (DocModel === RelocationDocument) createData.relocationId = requestId; if (DocModel === RelocationDocument) createData.relocationId = resolvedId;
else if (DocModel === ResignationDocument) createData.resignationId = requestId; else if (DocModel === ResignationDocument) createData.resignationId = resolvedId;
else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = requestId; else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = resolvedId;
else if (DocModel === TerminationDocument) createData.terminationRequestId = requestId; else if (DocModel === TerminationDocument) createData.terminationRequestId = resolvedId;
else createData.applicationId = requestId; else createData.applicationId = resolvedId;
const document = await DocModel.create(createData); const document = await DocModel.create(createData);
// Create initial version // Create initial version
await DocumentVersion.create({ await DocumentVersion.create({
documentId: document.id, documentId: document.id,
documentType: requestType || 'onboarding', documentType: normalizedType || 'onboarding',
versionNumber: 1, versionNumber: 1,
filePath: file.path, filePath: file.path,
uploadedBy: req.user?.id, uploadedBy: req.user?.id,
@ -262,12 +302,12 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
}); });
// Audit log for attachment upload // Audit log for attachment upload
if (requestId) { if (resolvedId) {
await AuditLog.create({ await AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED, action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED,
entityType: requestType || 'application', entityType: normalizedType || 'application',
entityId: requestId, entityId: resolvedId,
newData: { fileName: file.originalname, mimeType: file.mimetype } newData: { fileName: file.originalname, mimeType: file.mimetype }
}); });
} }

View File

@ -12,11 +12,17 @@ import { WorkflowService } from '../../services/WorkflowService.js';
export const getDealers = async (req: Request, res: Response) => { export const getDealers = async (req: Request, res: Response) => {
try { try {
const where: Record<string, unknown> = {};
if (String((req.query as any)?.onboarded || '') === 'true') {
where.onboardedAt = { [Op.ne]: null };
}
const dealers = await Dealer.findAll({ const dealers = await Dealer.findAll({
where,
include: [ include: [
{ model: DealerCode, as: 'dealerCode' }, { model: DealerCode, as: 'dealerCode' },
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] }, { model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
{ model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive'] } { model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive', 'roleCode'] }
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });

View File

@ -4,6 +4,101 @@ import db from '../../database/models/index.js';
const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db; const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
/** Default EOR rows for relocation (SRS 12.2.8) — must stay aligned with relocation required-doc labels. */
export const RELOCATION_EOR_DEFAULT_ITEMS = [
{ itemType: 'Property', description: 'Property documents for new location' },
{ itemType: 'Property', description: 'Lease / Rental agreement' },
{ itemType: 'Property', description: 'Layout / Floor plan of new location' },
{ itemType: 'Infrastructure', description: 'Photos of new location' },
{ itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
{ itemType: 'Statutory', description: 'NOC from current landlord' },
{ itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
{ itemType: 'Utility', description: 'Electricity & Water supply documents' }
] as const;
/**
* Maps `RelocationDocument.documentType` (same strings as relocation required-doc UI) to EOR checklist row text.
*/
const RELOCATION_UPLOAD_TYPE_TO_EOR_DESCRIPTION: Record<string, string> = {
'Property documents for new location': 'Property documents for new location',
'Lease/Rental agreement for new location': 'Lease / Rental agreement',
'NOC from current landlord': 'NOC from current landlord',
'Municipal approvals': 'Municipal approvals (Fire safety / Pollution clearance)',
'Fire safety certificate': 'Municipal approvals (Fire safety / Pollution clearance)',
'Pollution clearance': 'Municipal approvals (Fire safety / Pollution clearance)',
'Layout/Floor plan of new location': 'Layout / Floor plan of new location',
'Photos of new location': 'Photos of new location',
'Locality map': 'Locality map / Building plan approval',
'Building plan approval': 'Locality map / Building plan approval',
'Electricity connection documents': 'Electricity & Water supply documents',
'Water supply documents': 'Electricity & Water supply documents'
};
async function mapRelocationDocumentsToEorItems(checklistId: string, relocationId: string) {
const docs = await RelocationDocument.findAll({
where: {
relocationId,
status: { [Op.notIn]: ['Rejected'] }
}
});
for (const doc of docs) {
const dt = String((doc as any).documentType || '').trim();
const eorDesc = RELOCATION_UPLOAD_TYPE_TO_EOR_DESCRIPTION[dt];
if (!eorDesc) continue;
const verified = String((doc as any).status || '').toLowerCase() === 'verified';
let item = await EorChecklistItem.findOne({
where: {
checklistId,
description: { [Op.iLike]: eorDesc },
proofDocumentId: { [Op.is]: null }
}
});
if (!item) {
const linked = await EorChecklistItem.findOne({
where: {
checklistId,
description: { [Op.iLike]: eorDesc },
proofDocumentId: (doc as any).id
}
});
if (linked && verified) {
await linked.update({ isCompliant: true });
}
continue;
}
await item.update({
proofDocumentId: (doc as any).id,
isCompliant: verified ? true : item.isCompliant
});
}
}
/**
* Relocation flow historically called `EorChecklist.findOrCreate` only, so checklists existed with **zero items**
* and no link to `relocation_documents`. Heal by seeding default rows and attaching proofs by document type.
*/
export async function ensureRelocationEorChecklistSeeded(relocationId: string): Promise<void> {
const checklist = await EorChecklist.findOne({ where: { relocationId } });
if (!checklist) return;
const itemCount = await EorChecklistItem.count({ where: { checklistId: checklist.id } });
if (itemCount === 0) {
const compliantDefault = String(checklist.status || '').toLowerCase() === 'completed';
await EorChecklistItem.bulkCreate(
RELOCATION_EOR_DEFAULT_ITEMS.map((item) => ({
itemType: item.itemType,
description: item.description,
checklistId: checklist.id,
isCompliant: compliantDefault
}))
);
}
await mapRelocationDocumentsToEorItems(checklist.id, relocationId);
}
export const getChecklist = async (req: Request, res: Response) => { export const getChecklist = async (req: Request, res: Response) => {
try { try {
const { applicationId, relocationId } = req.params; const { applicationId, relocationId } = req.params;
@ -23,8 +118,25 @@ export const getChecklist = async (req: Request, res: Response) => {
} }
} }
// Resolve relocation route param (UUID or REL-2026-xxxx) to UUID for DB lookup
let resolvedRelocationId: string | undefined;
if (relocationId) {
const relStr = relocationId as string;
const isRelUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(relStr);
if (isRelUUID) {
resolvedRelocationId = relStr;
} else {
const rel = await db.RelocationRequest.findOne({ where: { requestId: relStr } });
if (!rel) {
res.status(404).json({ success: false, message: 'Relocation request not found' });
return;
}
resolvedRelocationId = rel.id;
}
}
let checklist = await EorChecklist.findOne({ let checklist = await EorChecklist.findOne({
where: relocationId ? { relocationId } : { applicationId: resolvedAppId }, where: resolvedRelocationId ? { relocationId: resolvedRelocationId } : { applicationId: resolvedAppId },
include: [{ model: EorChecklistItem, as: 'items' }] include: [{ model: EorChecklistItem, as: 'items' }]
}); });
@ -33,28 +145,40 @@ export const getChecklist = async (req: Request, res: Response) => {
return; return;
} }
if (resolvedRelocationId) {
await ensureRelocationEorChecklistSeeded(resolvedRelocationId);
checklist = await EorChecklist.findOne({
where: { relocationId: resolvedRelocationId },
include: [{ model: EorChecklistItem, as: 'items' }]
});
if (!checklist) {
res.status(404).json({ success: false, message: 'Checklist not found' });
return;
}
}
const items = checklist.items || []; const items = checklist.items || [];
const proofDocIds = items.map((i: any) => i.proofDocumentId).filter(Boolean); const proofDocIds = items.map((i: any) => i.proofDocumentId).filter(Boolean);
let payload: any = checklist.toJSON ? checklist.toJSON() : checklist;
if (proofDocIds.length > 0) { if (proofDocIds.length > 0) {
// Find documents from the relevant table
let docs = []; let docs = [];
if (relocationId) { if (resolvedRelocationId) {
docs = await RelocationDocument.findAll({ where: { id: proofDocIds } }); docs = await RelocationDocument.findAll({ where: { id: proofDocIds } });
} else { } else {
docs = await OnboardingDocument.findAll({ where: { id: proofDocIds } }); docs = await OnboardingDocument.findAll({ where: { id: proofDocIds } });
} }
// Map docs to items
const docsMap = new Map(docs.map((d: any) => [d.id, d])); const docsMap = new Map(docs.map((d: any) => [d.id, d]));
checklist = checklist.toJSON(); payload = { ...payload };
checklist.items = checklist.items.map((item: any) => ({ payload.items = (payload.items || []).map((item: any) => ({
...item, ...item,
proofDocument: docsMap.get(item.proofDocumentId) || null proofDocument: docsMap.get(item.proofDocumentId) || null
})); }));
} }
res.json({ success: true, data: checklist }); res.json({ success: true, data: payload });
} catch (error) { } catch (error) {
console.error('Get EOR checklist error:', error); console.error('Get EOR checklist error:', error);
res.status(500).json({ success: false, message: 'Error fetching EOR checklist' }); res.status(500).json({ success: false, message: 'Error fetching EOR checklist' });
@ -95,20 +219,10 @@ export const createChecklist = async (req: AuthRequest, res: Response) => {
if (created) { if (created) {
// Define Default Mandatory Items per SRS/Frontend // Define Default Mandatory Items per SRS/Frontend
let defaultItems = []; let defaultItems: { itemType: string; description: string }[] = [];
if (relocationId) { if (relocationId) {
// Strictly per SRS Section 12.2.8 for Relocation defaultItems = [...RELOCATION_EOR_DEFAULT_ITEMS];
defaultItems = [
{ itemType: 'Property', description: 'Property documents for new location' },
{ itemType: 'Property', description: 'Lease / Rental agreement' },
{ itemType: 'Property', description: 'Layout / Floor plan of new location' },
{ itemType: 'Infrastructure', description: 'Photos of new location' },
{ itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
{ itemType: 'Statutory', description: 'NOC from current landlord' },
{ itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
{ itemType: 'Utility', description: 'Electricity & Water supply documents' }
];
} else { } else {
// Onboarding Default // Onboarding Default
defaultItems = [ defaultItems = [
@ -157,6 +271,8 @@ export const createChecklist = async (req: AuthRequest, res: Response) => {
); );
} }
} }
} else if (relocationId) {
await mapRelocationDocumentsToEorItems(checklist.id, relocationId);
} }
} }

View File

@ -1,10 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { FddAssignment, FddReport, AuditLog, Application } = db; const { FddAssignment, FddReport, Application } = db;
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowService } from '../../services/WorkflowService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
export const getAssignment = async (req: Request, res: Response) => { export const getAssignment = async (req: Request, res: Response) => {
try { try {
@ -67,11 +68,24 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
progressPercentage: 70 progressPercentage: 70
}); });
await AuditLog.create({ await safeAuditLogCreate({
userId: req.user?.id, userId: req.user?.id,
action: AUDIT_ACTIONS.FDD_ASSIGNED, action: AUDIT_ACTIONS.FDD_ASSIGNED,
entityType: 'fdd_assignment', entityType: 'fdd_assignment',
entityId: assignment.id entityId: assignment.id,
newData: { assignedToAgency, applicationId: application.id },
});
await safeAuditLogCreate({
userId: req.user?.id,
action: AUDIT_ACTIONS.FDD_ASSIGNED,
entityType: 'application',
entityId: application.id,
newData: {
assignmentId: assignment.id,
assignedToAgency,
note: 'FDD agency assigned; application moved to FDD verification.',
context: pickApplicationAuditContext(application),
},
}); });
res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment }); res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment });
@ -169,17 +183,26 @@ export const flagNonResponsive = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ success: false, message: 'Application not found' }); return res.status(404).json({ success: false, message: 'Application not found' });
} }
const previousStatutory = application.statutoryStatus;
// 1. Update Application status at model level // 1. Update Application status at model level
await application.update({ statutoryStatus: 'Flagged' }); await application.update({ statutoryStatus: 'Flagged' });
// 2. Add high-level Audit Log entry (Using literal 'UPDATED' to absolutely avoid ENUM errors) console.log(`[FDDController] Flagging application ${application.id} as non-responsive (FDD)`);
console.log(`[FDDController] Flagging application ${application.id} with action: UPDATED`); await safeAuditLogCreate({
await AuditLog.create({
userId: req.user?.id, userId: req.user?.id,
action: 'UPDATED', action: AUDIT_ACTIONS.FDD_FLAGGED_NON_RESPONSIVE,
entityType: 'application', entityType: 'application',
entityId: application.id, entityId: application.id,
newData: { statutoryStatus: 'Flagged', remarks: remarks || 'Applicant is non-responsive to FDD queries.' } oldData: {
statutoryStatus: previousStatutory,
overallStatus: application.overallStatus,
currentStage: application.currentStage,
},
newData: {
statutoryStatus: 'Flagged',
remarks: remarks || 'Applicant is non-responsive to FDD queries.',
context: pickApplicationAuditContext(application),
},
}); });
res.json({ success: true, message: 'Application flagged successfully' }); res.json({ success: true, message: 'Application flagged successfully' });

View File

@ -341,28 +341,47 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
// --- AUTOMATION: After verification transitions --- // --- AUTOMATION: After verification transitions ---
// 1. If SECURITY_DEPOSIT Payment Verified -> Move to LOI Issue Stage // 1. SECURITY_DEPOSIT verified: the deposit row is always updated above. Only touch application workflow when
// the app is already in the LOI payment/security corridor — never jump from earlier stages (e.g. interviews).
if ((depositType === 'SECURITY_DEPOSIT' || !depositType) && status === 'Verified') { if ((depositType === 'SECURITY_DEPOSIT' || !depositType) && status === 'Verified') {
console.log(`[DEBUG] Security Deposit verified. Moving to LOI Issued stage...`); const os = application.overallStatus;
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, req.user?.id || null, { const inInitialSdCorridor =
reason: 'Security Deposit verified. Proceeding to LOI Issuance.', os === APPLICATION_STATUS.PAYMENT_PENDING || os === APPLICATION_STATUS.SECURITY_DETAILS;
if (inInitialSdCorridor) {
console.log(
`[DEBUG] SECURITY_DEPOSIT verified (overallStatus=${os}). Aligning to Security Details for admin before LOI Issued.`,
);
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user?.id || null, {
reason: 'Security deposit verified by Finance. Awaiting admin approval to proceed to LOI issuance.',
stage: APPLICATION_STAGES.LOI, stage: APPLICATION_STAGES.LOI,
progressPercentage: 80 progressPercentage: 78
}); });
} else {
console.log(
`[DEBUG] SECURITY_DEPOSIT verified but overallStatus=${os}; no status transition (payment recorded only).`,
);
}
} }
// 2. If FIRST_FILL Payment Verified -> Move to LOA Pending stage // 2. FIRST_FILL verified: always persist the row above. Only touch workflow when already at LOA Pending
// (same idea as SECURITY_DEPOSIT — never jump from earlier stages into LOA).
if (depositType === 'FIRST_FILL' && status === 'Verified') { if (depositType === 'FIRST_FILL' && status === 'Verified') {
// Ensure LoaRequest exists for the next step const os = application.overallStatus;
if (os === APPLICATION_STATUS.LOA_PENDING) {
await db.LoaRequest.findOrCreate({ await db.LoaRequest.findOrCreate({
where: { applicationId: application.id }, where: { applicationId: application.id },
defaults: { status: 'pending', requestedBy: req.user?.id } defaults: { status: 'pending', requestedBy: req.user?.id }
}); });
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, {
reason: 'First Fill Verified. Initiating LOA Approval stage.', reason: 'First Fill verified by Finance while at LOA Pending. LOA approval may proceed.',
progressPercentage: 90 progressPercentage: 90
}); });
} else {
console.log(
`[DEBUG] FIRST_FILL verified but overallStatus=${os}; no status transition (payment recorded only).`,
);
}
} }
res.json({ success: true, message: 'Security Deposit updated', data: updatedDeposit }); res.json({ success: true, message: 'Security Deposit updated', data: updatedDeposit });

View File

@ -11,6 +11,7 @@ import { syncLocationManagers } from '../master/syncHierarchy.service.js';
import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowService } from '../../services/WorkflowService.js';
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js'; import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
const { DocumentStageConfig } = db; const { DocumentStageConfig } = db;
@ -454,6 +455,24 @@ export const uploadDocuments = async (req: any, res: Response) => {
console.log(`[debug] EOR items updated: ${updatedCount} for type: ${documentType}`); console.log(`[debug] EOR items updated: ${updatedCount} for type: ${documentType}`);
} }
const prospectiveUpload = req.user?.roleCode === 'Prospective Dealer' || !req.user?.id;
await safeAuditLogCreate({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
entityType: 'application',
entityId: application.id,
newData: {
documentId: newDoc.id,
documentType,
fileName: file.originalname,
docStage: stage || null,
prospectiveUpload,
uploaderRole: req.user?.roleCode || 'Prospective (session)',
eorChecklistLinked: eorDescriptions.includes(documentType),
context: pickApplicationAuditContext(application),
},
});
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: 'Document uploaded successfully', message: 'Document uploaded successfully',

View File

@ -1,34 +1,177 @@
import { Response } from 'express'; import { Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { ConstitutionalChange, Outlet, User, Worknote, Dealer, Application, District } = db; const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District } = db;
import { Op, Transaction } from 'sequelize'; import { Op } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js'; import {
CONSTITUTIONAL_STAGES,
AUDIT_ACTIONS,
ROLES,
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS
} from '../../common/config/constants.js';
import { ConstitutionalWorkflowService } from '../../services/ConstitutionalWorkflowService.js'; import { ConstitutionalWorkflowService } from '../../services/ConstitutionalWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js'; import { ParticipantService } from '../../services/ParticipantService.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
import {
isRegisteredConstitutionalChangeType,
normalizeToConstitutionalChangeType
} from '../../common/utils/constitutionalNormalize.js';
const STRUCTURE_TARGET_VALUES = new Set<string>(
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string)
);
export const getMeta = async (_req: AuthRequest, res: Response) => {
try {
res.json({
success: true,
structureTargets: CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => ({ value: o.value, label: o.label }))
});
} catch (error) {
console.error('Constitutional meta error:', error);
res.status(500).json({ success: false, message: 'Error loading options' });
}
};
export const submitRequest = async (req: AuthRequest, res: Response) => { export const submitRequest = 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' });
const { outletId, changeType, reason, currentConstitution, newPartnersDetails, shareholdingPattern } = req.body; const {
outletId,
changeType,
reason,
description,
currentConstitution,
newPartnersDetails,
shareholdingPattern,
forDealerUserId
} = req.body;
const remarksText = String(reason ?? description ?? '').trim();
if (!remarksText) {
return res.status(400).json({ success: false, message: 'Reason / description is required' });
}
const resolvedChangeType = normalizeToConstitutionalChangeType(String(changeType || '').trim());
if (!resolvedChangeType || !isRegisteredConstitutionalChangeType(resolvedChangeType)) {
return res.status(400).json({
success: false,
message: 'Invalid constitutional change type. Use a value returned from GET /constitutional-change/meta.'
});
}
const isDealerRole = req.user.roleCode === ROLES.DEALER;
const internalTargetUserId = forDealerUserId ? String(forDealerUserId).trim() : '';
if (isDealerRole && internalTargetUserId && internalTargetUserId !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Dealers cannot create a constitutional request for another account.'
});
}
if (!isDealerRole && !internalTargetUserId) {
return res.status(400).json({
success: false,
message: 'Select the dealer this request is for (forDealerUserId).'
});
}
let dealerUserId = req.user.id;
let subjectDealerProfile: any = null;
if (!isDealerRole) {
const targetUser = await User.findByPk(internalTargetUserId, {
include: [{ model: Dealer, as: 'dealerProfile' }]
});
if (!targetUser || targetUser.roleCode !== ROLES.DEALER) {
return res.status(400).json({ success: false, message: 'Invalid dealer user selected.' });
}
if (!(targetUser as any).dealerProfile) {
return res.status(400).json({
success: false,
message: 'Selected user has no dealer profile.'
});
}
dealerUserId = targetUser.id;
subjectDealerProfile = (targetUser as any).dealerProfile;
} else {
const selfUser = await User.findByPk(req.user.id, {
include: [{ model: Dealer, as: 'dealerProfile' }]
});
subjectDealerProfile = (selfUser as any)?.dealerProfile || null;
if (!subjectDealerProfile) {
return res.status(400).json({ success: false, message: 'Dealer profile not found for this account.' });
}
}
const profileConstitutionRaw = subjectDealerProfile?.constitutionType;
const resolvedCurrentFromBody = normalizeToConstitutionalChangeType(
currentConstitution != null ? String(currentConstitution) : ''
);
const resolvedCurrentFromProfile = normalizeToConstitutionalChangeType(
profileConstitutionRaw != null ? String(profileConstitutionRaw) : ''
);
let resolvedCurrent = resolvedCurrentFromBody || resolvedCurrentFromProfile;
if (!resolvedCurrent) {
if (String(profileConstitutionRaw || '').trim()) {
return res.status(400).json({
success: false,
message: `Dealer profile constitution "${profileConstitutionRaw}" is not recognized. Update constitution type in dealer master data.`
});
}
resolvedCurrent = normalizeToConstitutionalChangeType('Proprietorship')!;
}
if (
STRUCTURE_TARGET_VALUES.has(resolvedChangeType) &&
STRUCTURE_TARGET_VALUES.has(resolvedCurrent) &&
resolvedChangeType === resolvedCurrent
) {
return res.status(400).json({
success: false,
message: 'Proposed constitution must differ from the current constitution.'
});
}
let resolvedOutletId: string | null = outletId ? String(outletId) : null;
if (resolvedOutletId) {
const outlet = await Outlet.findByPk(resolvedOutletId);
if (!outlet || String(outlet.dealerId) !== String(dealerUserId)) {
return res.status(400).json({
success: false,
message: 'Selected outlet does not belong to the chosen dealer user.'
});
}
} else {
const firstOutlet = await Outlet.findOne({
where: { dealerId: dealerUserId },
order: [['createdAt', 'ASC']]
});
resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null;
}
const requestId = NomenclatureService.generateConstitutionalChangeId(); const requestId = NomenclatureService.generateConstitutionalChangeId();
// Store extra details in metadata
const metadata = { const metadata = {
newPartnersDetails, newPartnersDetails,
shareholdingPattern, shareholdingPattern,
currentConstitution currentConstitution: resolvedCurrent,
submittedByUserId: req.user.id,
submittedByRole: req.user.roleCode,
createdOnBehalfOfDealer: !isDealerRole
}; };
const request = await ConstitutionalChange.create({ const request = await ConstitutionalChange.create({
requestId, requestId,
outletId: outletId || null, // Optional for dealer-level changes outletId: resolvedOutletId,
dealerId: req.user.id, dealerId: dealerUserId,
changeType, changeType: resolvedChangeType,
description: reason, description: remarksText,
currentConstitution: currentConstitution || null, currentConstitution: resolvedCurrent,
currentStage: CONSTITUTIONAL_STAGES.SUBMITTED, currentStage: CONSTITUTIONAL_STAGES.SUBMITTED,
status: 'Submitted', status: 'Submitted',
progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED), progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED),
@ -38,8 +181,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
stage: 'Submitted', stage: 'Submitted',
timestamp: new Date(), timestamp: new Date(),
user: req.user.fullName, user: req.user.fullName,
action: 'Request submitted', action: isDealerRole ? 'Request submitted' : 'Request submitted (on behalf of dealer)',
remarks: reason remarks: remarksText
}] }]
}); });
@ -50,6 +193,14 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
entityId: request.id entityId: request.id
}); });
await ConstitutionalAudit.create({
userId: req.user.id,
constitutionalChangeId: request.id,
action: AUDIT_ACTIONS.CREATED,
remarks: remarksText || 'Constitutional change request submitted',
details: { stage: CONSTITUTIONAL_STAGES.SUBMITTED, requestId: request.requestId }
});
// Add as chat participants (Async) // Add as chat participants (Async)
ParticipantService.assignConstitutionalParticipants(request.id) ParticipantService.assignConstitutionalParticipants(request.id)
.catch(err => console.error('Error assigning participants to constitutional change:', err)); .catch(err => console.error('Error assigning participants to constitutional change:', err));
@ -125,6 +276,21 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
{ {
model: db.Dealer, model: db.Dealer,
as: 'dealerProfile', as: 'dealerProfile',
attributes: [
'id',
'legalName',
'businessName',
'constitutionType',
'registeredAddress',
'onboardedAt',
'loiDate',
'loaDate',
'gstNumber',
'panNumber',
'dealerCodeId',
'applicationId',
'status'
],
include: [ include: [
{ model: db.DealerCode, as: 'dealerCode' }, { model: db.DealerCode, as: 'dealerCode' },
{ {
@ -132,8 +298,18 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
as: 'application', as: 'application',
include: [ include: [
{ model: db.District, as: 'district' }, { model: db.District, as: 'district' },
{ model: db.LoiRequest, as: 'loiRequests', where: { status: 'approved' }, required: false }, {
{ model: db.LoaRequest, as: 'loaRequests', where: { status: 'approved' }, required: false } model: db.LoiRequest,
as: 'loiRequests',
where: { [Op.or]: [{ status: 'approved' }, { status: 'Approved' }] },
required: false
},
{
model: db.LoaRequest,
as: 'loaRequests',
where: { [Op.or]: [{ status: 'approved' }, { status: 'Approved' }] },
required: false
}
] ]
} }
] ]
@ -158,31 +334,7 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
} }
}; };
export const takeAction = async (req: AuthRequest, res: Response) => { const STAGE_FLOW_FORWARD: Record<string, string> = {
try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { id } = req.params;
const idStr = String(id);
const { action, comments } = req.body; // Approve, Reject, Send Back
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
const request = await ConstitutionalChange.findOne({
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
});
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
if (action === 'Reject') {
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
action: 'Rejected',
status: 'Rejected',
remarks: comments,
userFullName: req.user.fullName
});
} else {
// Multi-level approval flow as per SRS 12.2.4
const stageFlow: Record<string, string> = {
[CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW, [CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, [CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW, [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
@ -191,19 +343,160 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL, [CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL,
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW, [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW,
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED
}; };
const nextStage = stageFlow[request.currentStage]; /** SRS §12.2.3 — return to previous review stage */
if (!nextStage) return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' }); const STAGE_FLOW_BACK: Record<string, string | undefined> = {
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.SUBMITTED,
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW,
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.HEAD_REVIEW,
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL
};
await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, { const actionSuccessMessage = (raw: string): string => {
action: action === 'Approve' ? `Approved to ${nextStage}` : action, const a = String(raw || '').trim().toLowerCase();
remarks: comments, if (a === 'reject') return 'Request rejected successfully';
userFullName: req.user.fullName if (a === 'revoke') return 'Request revoked successfully';
if (a.includes('send') && a.includes('back')) return 'Request sent back successfully';
if (a === 'approve') return 'Request approved successfully';
return 'Action completed successfully';
};
export const takeAction = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { id } = req.params;
const idStr = String(id);
const rawAction = String(req.body.action || '').trim();
const actionNorm = rawAction.toLowerCase().replace(/\s+/g, ' ');
const comments = req.body.comments;
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
const request = await ConstitutionalChange.findOne({
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
}); });
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
const sourceStage = request.currentStage;
const remarksTrim = String(comments || '').trim();
const isReject = actionNorm === 'reject';
const isRevoke = actionNorm === 'revoke';
const isSendBack = actionNorm.includes('send') && actionNorm.includes('back');
const isApprove = actionNorm === 'approve' || actionNorm === 'approved';
if (isReject) {
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
action: 'Rejected',
status: 'Rejected',
remarks: comments,
userFullName: req.user.fullName,
auditAction: AUDIT_ACTIONS.REJECTED
});
try {
const reviewText = remarksTrim || `[Rejected] at ${sourceStage}`;
await writeWorkflowActivityWorknote({
requestId: request.id,
requestType: 'constitutional',
userId: req.user.id,
noteText: reviewText,
noteType: 'internal'
});
} catch (wnErr) {
console.error('[constitutional] workflow worknote:', wnErr);
}
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
} }
res.json({ success: true, message: `Request ${action.toLowerCase()}ed successfully` }); if (isRevoke) {
if (!remarksTrim) {
return res.status(400).json({ success: false, message: 'Remarks are required to revoke (SRS §12.2.3 Work Notes).' });
}
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REVOKED, req.user.id, {
action: 'Revoked',
status: 'Revoked',
remarks: comments,
userFullName: req.user.fullName,
auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_REVOKED
});
try {
await writeWorkflowActivityWorknote({
requestId: request.id,
requestType: 'constitutional',
userId: req.user.id,
noteText: `[Revoked] ${remarksTrim}`,
noteType: 'workflow'
});
} catch (wnErr) {
console.error('[constitutional] workflow worknote:', wnErr);
}
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
}
if (isSendBack) {
if (!remarksTrim) {
return res.status(400).json({ success: false, message: 'Remarks are required to send back (SRS §12.2.3 Work Notes).' });
}
const prevStage = STAGE_FLOW_BACK[request.currentStage];
if (!prevStage) {
return res.status(400).json({ success: false, message: 'Cannot send back from this stage' });
}
await ConstitutionalWorkflowService.transitionRequest(request, prevStage, req.user.id, {
action: `Sent back to ${prevStage}`,
remarks: comments,
userFullName: req.user.fullName,
auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_SENT_BACK
});
try {
await writeWorkflowActivityWorknote({
requestId: request.id,
requestType: 'constitutional',
userId: req.user.id,
noteText: `[Send Back] ${remarksTrim}`,
noteType: 'workflow'
});
} catch (wnErr) {
console.error('[constitutional] workflow worknote:', wnErr);
}
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
}
if (!isApprove) {
return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' });
}
const nextStage = STAGE_FLOW_FORWARD[request.currentStage];
if (!nextStage) {
return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
}
await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, {
action: `Approved to ${nextStage}`,
remarks: comments,
userFullName: req.user.fullName,
auditAction: AUDIT_ACTIONS.APPROVED
});
try {
const reviewText = remarksTrim;
const noteText = reviewText || `[Approved] ${sourceStage}${nextStage}`;
await writeWorkflowActivityWorknote({
requestId: request.id,
requestType: 'constitutional',
userId: req.user.id,
noteText,
noteType: 'internal'
});
} catch (wnErr) {
console.error('[constitutional] workflow worknote:', wnErr);
}
res.json({ success: true, message: actionSuccessMessage(rawAction) });
} catch (error) { } catch (error) {
console.error('Take action error:', error); console.error('Take action error:', error);
res.status(500).json({ success: false, message: 'Error processing action' }); res.status(500).json({ success: false, message: 'Error processing action' });
@ -227,6 +520,8 @@ export const getChecklist = async (req: AuthRequest, res: Response) => {
export const uploadDocuments = async (req: AuthRequest, res: Response) => { export const uploadDocuments = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { id } = req.params; const { id } = req.params;
const idStr = String(id); const idStr = String(id);
const { documents } = req.body; const { documents } = req.body;
@ -245,6 +540,15 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
updatedAt: new Date() updatedAt: new Date()
}); });
const docCount = Array.isArray(documents) ? documents.length : 0;
await ConstitutionalAudit.create({
userId: req.user.id,
constitutionalChangeId: request.id,
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
remarks: `Document checklist updated (${docCount} entr${docCount === 1 ? 'y' : 'ies'})`,
details: { documentCount: docCount, stage: request.currentStage }
});
res.json({ success: true, message: 'Documents uploaded successfully' }); res.json({ success: true, message: 'Documents uploaded successfully' });
} catch (error) { } catch (error) {
console.error('Upload documents error:', error); console.error('Upload documents error:', error);

View File

@ -4,6 +4,7 @@ import * as constitutionalController from './constitutional.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
// Constitutional change routes (Base at /) // Constitutional change routes (Base at /)
router.get('/meta', authenticate as any, constitutionalController.getMeta);
router.post('/', authenticate as any, constitutionalController.submitRequest); router.post('/', authenticate as any, constitutionalController.submitRequest);
router.get('/checklist', authenticate as any, constitutionalController.getChecklist); router.get('/checklist', authenticate as any, constitutionalController.getChecklist);
router.get('/', authenticate as any, constitutionalController.getRequests); router.get('/', authenticate as any, constitutionalController.getRequests);

View File

@ -3,7 +3,7 @@ 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 } = db; const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument } = db;
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS } from '../../common/config/constants.js';
import { Op, Transaction } from 'sequelize'; import { Op, Transaction } from 'sequelize';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
@ -122,6 +122,58 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
} }
}; };
const REQUIRED_RELOCATION_DOCUMENTS = [
'Property documents for new location',
'Lease/Rental agreement for new location',
'NOC from current landlord',
'Municipal approvals',
'Fire safety certificate',
'Pollution clearance',
'Layout/Floor plan of new location',
'Photos of new location',
'Locality map',
'Building plan approval',
'Electricity connection documents',
'Water supply documents'
];
const isDocumentMappedToRequirement = (doc: any, requirement: string) => {
if (!doc || !requirement) return false;
const docType = String(doc.type || '').trim().toLowerCase();
const docName = String(doc.name || '').trim().toLowerCase();
const req = requirement.toLowerCase();
if (docType === req) return true;
// Keep matching behavior aligned with current frontend checklist logic
const reqFirstToken = req.split(' ')[0];
return Boolean(reqFirstToken) && (docType.includes(reqFirstToken) || docName.includes(reqFirstToken));
};
const getRelocationDocumentReadiness = (documents: any[]) => {
const docs = Array.isArray(documents) ? documents : [];
const missingUploads: string[] = [];
const pendingVerification: string[] = [];
for (const requirement of REQUIRED_RELOCATION_DOCUMENTS) {
const matchedDocs = docs.filter((d: any) => isDocumentMappedToRequirement(d, requirement));
if (!matchedDocs.length) {
missingUploads.push(requirement);
continue;
}
const hasVerified = matchedDocs.some((d: any) => String(d.status || '').toLowerCase() === 'verified');
if (!hasVerified) {
pendingVerification.push(requirement);
}
}
return {
totalRequired: REQUIRED_RELOCATION_DOCUMENTS.length,
uploadedCount: REQUIRED_RELOCATION_DOCUMENTS.length - missingUploads.length,
verifiedCount: REQUIRED_RELOCATION_DOCUMENTS.length - pendingVerification.length - missingUploads.length,
missingUploads,
pendingVerification
};
};
export const submitRequest = async (req: AuthRequest, res: Response) => { export const submitRequest = 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' });
@ -143,6 +195,54 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
const finalState = proposedState || newState; const finalState = proposedState || newState;
const finalRelocationType = relocationType || 'Intercity'; const finalRelocationType = relocationType || 'Intercity';
if (!outletId) {
return res.status(400).json({ success: false, message: 'Outlet is required' });
}
const outlet = await Outlet.findByPk(outletId, { attributes: ['id', 'dealerId', 'status', 'name', 'code'] });
if (!outlet) {
return res.status(404).json({ success: false, message: 'Outlet not found' });
}
// SRS §12.2.8 — only active, eligible outlets
if (outlet.status !== OUTLET_STATUS.ACTIVE) {
return res.status(403).json({
success: false,
message: `Relocation can only be requested for active outlets. Current outlet status: ${outlet.status}`
});
}
const roleCode = req.user.roleCode as string;
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
return res.status(403).json({
success: false,
message: 'You can only submit a relocation request for outlets assigned to your dealership account.'
});
}
if (roleCode !== ROLES.DEALER && roleCode !== ROLES.SUPER_ADMIN) {
return res.status(403).json({
success: false,
message: 'Only a dealer may initiate a relocation request (or Super Admin for support).'
});
}
// SRS §12.2.7 — prevent parallel / duplicate open relocations for the same outlet
const openExisting = await RelocationRequest.findOne({
where: {
outletId,
status: { [Op.notIn]: ['Completed', 'Rejected'] }
},
attributes: ['id', 'requestId', 'status', 'currentStage']
});
if (openExisting) {
return res.status(409).json({
success: false,
message:
'An active relocation request already exists for this outlet. Complete or reject it before submitting a new one.',
existingRequestId: openExisting.requestId
});
}
const requestId = NomenclatureService.generateRelocationId(); const requestId = NomenclatureService.generateRelocationId();
const request = await RelocationRequest.create({ const request = await RelocationRequest.create({
@ -171,6 +271,18 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
// Auto-assign evaluators based on outlet location hierarchy // Auto-assign evaluators based on outlet location hierarchy
await assignRelocationEvaluators(request.id, outletId); await assignRelocationEvaluators(request.id, outletId);
await db.RelocationAudit.create({
userId: req.user.id,
relocationRequestId: request.id,
action: AUDIT_ACTIONS.CREATED,
remarks: 'Relocation request submitted',
details: {
requestId: request.requestId,
stage: RELOCATION_STAGES.ASM_REVIEW,
status: request.status
}
});
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: 'Relocation request submitted successfully', message: 'Relocation request submitted successfully',
@ -395,10 +507,13 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { id } = req.params; const { id } = req.params;
const { action, comments } = req.body; const { action, comments, remarks } = req.body;
const reviewComments = (comments ?? remarks ?? '') as string;
// Normalize action to uppercase for service consistency (APPROVE/REJECT) const normalizedAction = String(action || '')
const normalizedAction = action?.toUpperCase() || ''; .trim()
.toUpperCase()
.replace(/\s+/g, '_');
// Check if id is a UUID or a requestId string // Check if id is a UUID or a requestId string
const idStr = String(id); const idStr = String(id);
@ -412,6 +527,32 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
return res.status(404).json({ success: false, message: 'Request not found' }); return res.status(404).json({ success: false, message: 'Request not found' });
} }
if (request.status === 'Completed' || request.currentStage === RELOCATION_STAGES.COMPLETED) {
return res.status(400).json({ success: false, message: 'This relocation request is already completed.' });
}
if (request.status === 'Rejected' || request.currentStage === RELOCATION_STAGES.REJECTED) {
return res.status(400).json({ success: false, message: 'This relocation request is already rejected.' });
}
if (request.status === 'Revoked') {
return res.status(400).json({ success: false, message: 'This relocation request has been revoked.' });
}
const supportedActions = ['APPROVE', 'REJECT', 'SEND_BACK', 'REVOKE', 'HOLD'];
if (!supportedActions.includes(normalizedAction)) {
return res.status(400).json({ success: false, message: `Unsupported action: ${action}` });
}
if (normalizedAction === 'HOLD') {
return res.status(501).json({ success: false, message: 'Hold is not implemented for relocation yet.' });
}
// SRS §12.2.8 — Send Back / Revoke communicated through Work Notes with mandatory remarks
if ((normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE') && !String(reviewComments).trim()) {
return res.status(400).json({
success: false,
message: 'Remarks are required for Send Back and Revoke.'
});
}
// 1. Authorization Check via Workflow Service // 1. Authorization Check via Workflow Service
const canAction = await RelocationWorkflowService.canUserAction(request, req.user); const canAction = await RelocationWorkflowService.canUserAction(request, req.user);
if (!canAction) { if (!canAction) {
@ -421,7 +562,6 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
}); });
} }
// Update status and current_stage based on action
let newStatus = request.status; let newStatus = request.status;
let newCurrentStage = request.currentStage; let newCurrentStage = request.currentStage;
@ -438,35 +578,78 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
}; };
const reverseStageFlow: Record<string, string> = {
'DD Admin Review': RELOCATION_STAGES.ASM_REVIEW,
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.ASM_REVIEW,
[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.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
};
if (normalizedAction === 'APPROVE') { if (normalizedAction === 'APPROVE') {
newCurrentStage = stageFlow[request.currentStage] || request.currentStage; newCurrentStage = stageFlow[request.currentStage] || request.currentStage;
newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`; newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`;
} else if (normalizedAction === 'REJECT') { } else if (normalizedAction === 'REJECT') {
newStatus = 'Rejected'; newStatus = 'Rejected';
newCurrentStage = RELOCATION_STAGES.REJECTED; newCurrentStage = RELOCATION_STAGES.REJECTED;
} else if (normalizedAction === 'SEND_BACK') {
const prevStage = reverseStageFlow[request.currentStage as string];
if (!prevStage) {
return res.status(400).json({
success: false,
message: 'Send Back is not available at this stage (already at the first review step).'
});
}
newCurrentStage = prevStage;
newStatus = `Pending ${prevStage}`;
} else if (normalizedAction === 'REVOKE') {
newStatus = 'Revoked';
newCurrentStage = RELOCATION_STAGES.REJECTED;
}
// SRS §12.2.8 — enforce mandatory document submission + verification before late-stage approvals
if (
normalizedAction === 'APPROVE' &&
(
request.currentStage === RELOCATION_STAGES.NBH_APPROVAL ||
request.currentStage === RELOCATION_STAGES.LEGAL_CLEARANCE
)
) {
const readiness = getRelocationDocumentReadiness(request.documents || []);
if (readiness.missingUploads.length || readiness.pendingVerification.length) {
return res.status(400).json({
success: false,
message: 'Mandatory relocation documents are incomplete or pending verification.',
readiness
});
}
} }
// 2. Perform transition via Workflow Service (handles request update, timeline, audit logs)
const progressSteps = 9; const progressSteps = 9;
const currentStepIndex = Object.keys(stageFlow).indexOf(request.currentStage); const stageKeys = Object.keys(stageFlow);
const newProgress = normalizedAction === 'APPROVE' const currentStepIndex = stageKeys.indexOf(request.currentStage as string);
? Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100) const backStepIndex = stageKeys.indexOf(newCurrentStage as string);
: request.progressPercentage; let newProgress = request.progressPercentage;
if (normalizedAction === 'APPROVE') {
newProgress = Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100);
} else if (normalizedAction === 'SEND_BACK' && backStepIndex >= 0) {
newProgress = Math.max(0, Math.min(100, Math.round(((backStepIndex + 1) / progressSteps) * 100)));
}
await RelocationWorkflowService.transitionRelocation(request, newStatus, req.user?.id || null, { await RelocationWorkflowService.transitionRelocation(request, newStatus, req.user?.id || null, {
reason: comments || 'No remarks provided', reason: reviewComments || 'No remarks provided',
stage: newCurrentStage, stage: newCurrentStage,
action: normalizedAction, action: normalizedAction,
progressPercentage: newProgress progressPercentage: newProgress
}); });
// 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR // 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') { if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') {
try { try {
// Internal call to EOR controller logic (or we could use a service)
// For now, simpler to just trigger the DB creation here or ensure controller handles it
const { createChecklist } = await import('../eor/eor.controller.js');
// We mock the req/res for internal call or just use the DB directly
await db.EorChecklist.findOrCreate({ await db.EorChecklist.findOrCreate({
where: { relocationId: request.id }, where: { relocationId: request.id },
defaults: { defaults: {
@ -475,24 +658,48 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
applicationId: null applicationId: null
} }
}); });
console.log(`[RelocationController] EOR Checklist initiated for ${request.requestId}`); const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
await ensureRelocationEorChecklistSeeded(request.id);
console.log(`[RelocationController] EOR Checklist initiated/synced for ${request.requestId}`);
} catch (e) { } catch (e) {
console.error('Failed to auto-initiate EOR checklist:', e); console.error('Failed to auto-initiate EOR checklist:', e);
} }
} }
// 3. Create a worknote entry for the comment // 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
if (comments) { const shouldWriteWorknote =
Boolean(String(reviewComments).trim()) &&
(normalizedAction === 'SEND_BACK' ||
normalizedAction === 'REVOKE' ||
normalizedAction === 'APPROVE' ||
normalizedAction === 'REJECT');
if (shouldWriteWorknote) {
const prefix =
normalizedAction === 'SEND_BACK'
? '[Send Back] '
: normalizedAction === 'REVOKE'
? '[Revoke] '
: '';
await Worknote.create({ await Worknote.create({
requestId: request.id, requestId: request.id,
requestType: 'relocation' as any, requestType: 'relocation' as any,
userId: req.user.id, userId: req.user.id,
content: comments, noteText: `${prefix}${reviewComments}`,
isInternal: true noteType: normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE' ? 'workflow' : 'internal',
status: 'active'
}); });
} }
res.json({ success: true, message: `Request ${action.toLowerCase()} successfully` }); const actionLabel =
normalizedAction === 'SEND_BACK'
? 'sent back'
: normalizedAction === 'REVOKE'
? 'revoked'
: (normalizedAction || 'ACTION').toLowerCase();
res.json({
success: true,
message: `Request ${actionLabel} successfully`
});
} catch (error) { } catch (error) {
console.error('Take action error:', error); console.error('Take action error:', error);
res.status(500).json({ success: false, message: 'Error processing action' }); res.status(500).json({ success: false, message: 'Error processing action' });
@ -557,6 +764,26 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
updatedAt: new Date() updatedAt: new Date()
}); });
await db.RelocationAudit.create({
userId: req.user?.id || null,
relocationRequestId: request.id,
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
remarks: `Uploaded: ${documentType}`,
details: {
stage: request.currentStage,
documentType,
fileName: file.originalname,
documentId: newDoc.id
}
});
try {
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
await ensureRelocationEorChecklistSeeded(request.id);
} catch (e) {
console.error('[RelocationController] EOR checklist sync after upload:', e);
}
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: 'Document uploaded successfully', message: 'Document uploaded successfully',
@ -568,60 +795,69 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
} }
}; };
export const verifyDocument = async (req: AuthRequest, res: Response) => { const applyRelocationDocumentDecision = async (
req: AuthRequest,
res: Response,
targetStatus: 'Verified' | 'Rejected'
) => {
try { try {
const { id, documentId } = req.params; const { id, documentId } = req.params;
const { remarks } = req.body || {};
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const idStr = String(id); const idStr = String(id);
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr); const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
// Search by UUID or requestId for the request
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr } where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
}); });
if (!request) { if (!request) {
return res.status(404).json({ success: false, message: 'Relocation request not found' }); return res.status(404).json({ success: false, message: 'Relocation request not found' });
} }
// Authorization: Non-dealers only, and ideally matching the current stage
const isInternal = req.user.roleCode !== 'Dealer'; const isInternal = req.user.roleCode !== 'Dealer';
if (!isInternal) { if (!isInternal) {
return res.status(403).json({ success: false, message: 'Forbidden: Dealers cannot verify documents' }); return res.status(403).json({ success: false, message: 'Forbidden: Dealers cannot verify or reject documents' });
}
if (targetStatus === 'Rejected' && !String(remarks || '').trim()) {
return res.status(400).json({ success: false, message: 'Remarks are required when rejecting a document' });
} }
// Find and update the Document record
const docRecord = await RelocationDocument.findByPk(documentId); const docRecord = await RelocationDocument.findByPk(documentId);
if (docRecord) { if (docRecord) {
await docRecord.update({ status: 'Verified' }); await docRecord.update({ status: targetStatus });
} }
// Update the document entry in the request's JSON JSON array
const currentDocuments = request.documents || []; const currentDocuments = request.documents || [];
const documentIndex = currentDocuments.findIndex((d: any) => d.id === documentId); const documentIndex = currentDocuments.findIndex((d: any) => d.id === documentId);
if (documentIndex === -1) {
return res.status(404).json({ success: false, message: 'Document not found in request tracker' });
}
if (documentIndex !== -1) { currentDocuments[documentIndex].status = targetStatus;
currentDocuments[documentIndex].status = 'Verified';
currentDocuments[documentIndex].verifiedBy = req.user.fullName; currentDocuments[documentIndex].verifiedBy = req.user.fullName;
currentDocuments[documentIndex].verifiedOn = new Date(); currentDocuments[documentIndex].verifiedOn = new Date();
if (targetStatus === 'Rejected') {
currentDocuments[documentIndex].rejectionRemarks = remarks || 'Rejected by reviewer';
}
// Add simple timeline log for document verification in same update const actionText = targetStatus === 'Verified' ? 'Document Verified' : 'Document Rejected';
const updatedTimeline = [...(request.timeline || []), { const updatedTimeline = [...(request.timeline || []), {
stage: request.currentStage, stage: request.currentStage,
timestamp: new Date(), timestamp: new Date(),
user: req.user.fullName, user: req.user.fullName,
action: 'Document Verified', action: actionText,
remarks: `Verified document: ${currentDocuments[documentIndex].name}` remarks:
targetStatus === 'Verified'
? `Verified document: ${currentDocuments[documentIndex].name}`
: `Rejected document: ${currentDocuments[documentIndex].name}. ${remarks || ''}`.trim()
}]; }];
// Calculate progress percentage const totalRequired = REQUIRED_RELOCATION_DOCUMENTS.length;
const totalRequired = 12; // Standard relocation requirement
const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length; const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length;
const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100); const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100);
// Update request status to 'In Progress' if it was 'Pending'
let newStatus = request.status; let newStatus = request.status;
if (request.status === 'Pending') { if (request.status === 'Pending') {
newStatus = 'In Progress'; newStatus = 'In Progress';
@ -635,27 +871,52 @@ export const verifyDocument = async (req: AuthRequest, res: Response) => {
updatedAt: new Date() updatedAt: new Date()
}); });
// Force Sequelize to detect JSON changes
request.changed('documents', true); request.changed('documents', true);
request.changed('timeline', true); request.changed('timeline', true);
await request.save(); await request.save();
await db.RelocationAudit.create({
userId: req.user?.id || null,
relocationRequestId: request.id,
action: targetStatus === 'Verified' ? AUDIT_ACTIONS.DOCUMENT_VERIFIED : AUDIT_ACTIONS.DOCUMENT_REJECTED,
remarks:
targetStatus === 'Verified'
? `Verified: ${currentDocuments[documentIndex].name}`
: `Rejected: ${currentDocuments[documentIndex].name}. ${remarks || ''}`.trim(),
details: {
stage: request.currentStage,
documentId,
documentName: currentDocuments[documentIndex].name,
status: targetStatus
}
});
try {
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
await ensureRelocationEorChecklistSeeded(request.id);
} catch (e) {
console.error('[RelocationController] EOR checklist sync after document decision:', e);
}
return res.json({ return res.json({
success: true, success: true,
message: 'Document verified successfully', message: targetStatus === 'Verified' ? 'Document verified successfully' : 'Document rejected successfully',
document: currentDocuments[documentIndex], document: currentDocuments[documentIndex],
progressPercentage, progressPercentage,
status: newStatus status: newStatus
}); });
}
res.status(404).json({ success: false, message: 'Document not found in request tracker' });
} catch (error) { } catch (error) {
console.error('Verify document error:', error); console.error('Relocation document decision error:', error);
res.status(500).json({ success: false, message: 'Error verifying document' }); return res.status(500).json({ success: false, message: 'Error processing document decision' });
} }
}; };
export const verifyDocument = async (req: AuthRequest, res: Response) =>
applyRelocationDocumentDecision(req, res, 'Verified');
export const rejectDocument = async (req: AuthRequest, res: Response) =>
applyRelocationDocumentDecision(req, res, 'Rejected');
// Helper function to calculate distance between two coordinates // Helper function to calculate distance between two coordinates
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of Earth in km const R = 6371; // Radius of Earth in km

View File

@ -12,5 +12,6 @@ router.get('/:id', authenticate as any, relocationController.getRequestById);
router.post('/:id/action', authenticate as any, relocationController.takeAction); router.post('/:id/action', authenticate as any, relocationController.takeAction);
router.post('/:id/documents', authenticate as any, uploadSingle, relocationController.uploadDocuments); router.post('/:id/documents', authenticate as any, uploadSingle, relocationController.uploadDocuments);
router.post('/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument); router.post('/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument);
router.post('/:id/documents/:documentId/reject', authenticate as any, relocationController.rejectDocument);
export default router; export default router;

View File

@ -17,6 +17,7 @@ import { ResignationWorkflowService } from '../../services/ResignationWorkflowSe
import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js'; import { ParticipantService } from '../../services/ParticipantService.js';
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
// Removed generateResignationId and moved to NomenclatureService // Removed generateResignationId and moved to NomenclatureService
@ -316,6 +317,8 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' }); return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
} }
const sourceStage = resignation.currentStage;
// Transition via Workflow Service // Transition via Workflow Service
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, { await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
remarks, remarks,
@ -374,6 +377,20 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
} }
await transaction.commit(); await transaction.commit();
try {
const noteText = String(remarks || '').trim() || `[Approved] ${sourceStage}${nextStage}`;
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText,
noteType: 'internal'
});
} catch (wnErr) {
logger.error('[resignation] workflow worknote (approve):', wnErr);
}
res.json({ success: true, message: 'Resignation approved successfully', nextStage, resignation }); res.json({ success: true, message: 'Resignation approved successfully', nextStage, resignation });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
@ -413,6 +430,19 @@ export const rejectResignation = async (req: AuthRequest, res: Response, next: N
await (resignation as any).outlet.update({ status: 'Active' }, { transaction }); await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
await transaction.commit(); await transaction.commit();
try {
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText: String(reason || '').trim(),
noteType: 'internal'
});
} catch (wnErr) {
logger.error('[resignation] workflow worknote (reject):', wnErr);
}
res.json({ success: true, message: 'Resignation rejected', resignation }); res.json({ success: true, message: 'Resignation rejected', resignation });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
@ -464,6 +494,22 @@ export const withdrawResignation = async (req: AuthRequest, res: Response, next:
await (resignation as any).outlet.update({ status: 'Active' }, { transaction }); await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
await transaction.commit(); await transaction.commit();
try {
const noteText = String(reason || '').trim()
? `[Withdrawn] ${String(reason).trim()}`
: '[Withdrawn]';
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText,
noteType: 'workflow'
});
} catch (wnErr) {
logger.error('[resignation] workflow worknote (withdraw):', wnErr);
}
res.json({ success: true, message: 'Resignation withdrawn successfully' }); res.json({ success: true, message: 'Resignation withdrawn successfully' });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
@ -512,6 +558,21 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next:
}); });
await transaction.commit(); await transaction.commit();
try {
const r = String(remarks || '').trim();
const noteText = r ? `[Send Back] ${r}` : `[Send Back] Returned to ${prevStage}`;
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText,
noteType: 'workflow'
});
} catch (wnErr) {
logger.error('[resignation] workflow worknote (send back):', wnErr);
}
res.json({ success: true, message: `Resignation sent back to ${prevStage}` }); res.json({ success: true, message: `Resignation sent back to ${prevStage}` });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();

View File

@ -18,5 +18,7 @@ router.get('/relocation', authenticate as any, relocationController.getRequests)
router.get('/relocation/:id', authenticate as any, relocationController.getRequestById); router.get('/relocation/:id', authenticate as any, relocationController.getRequestById);
router.post('/relocation/:id/action', authenticate as any, relocationController.takeAction); router.post('/relocation/:id/action', authenticate as any, relocationController.takeAction);
router.post('/relocation/:id/documents', authenticate as any, relocationController.uploadDocuments); router.post('/relocation/:id/documents', authenticate as any, relocationController.uploadDocuments);
router.post('/relocation/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument);
router.post('/relocation/:id/documents/:documentId/reject', authenticate as any, relocationController.rejectDocument);
export default router; export default router;

View File

@ -6,6 +6,7 @@ import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINA
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
export const getDepartments = async (req: Request, res: Response) => { export const getDepartments = async (req: Request, res: Response) => {
try { try {
@ -40,6 +41,13 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
const payment = await FinancePayment.findByPk(id); const payment = await FinancePayment.findByPk(id);
if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' }); if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' });
const previousPaymentSnapshot = {
paymentStatus: payment.paymentStatus,
paymentType: payment.paymentType,
amount: payment.amount,
transactionId: payment.transactionId,
};
const isVerifying = status === 'Paid' && payment.paymentStatus !== 'Paid'; const isVerifying = status === 'Paid' && payment.paymentStatus !== 'Paid';
await payment.update({ await payment.update({
@ -61,6 +69,24 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
] ]
}); });
const p = updatedPayment || payment;
await safeAuditLogCreate({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.PAYMENT_UPDATED,
entityType: 'application',
entityId: payment.applicationId,
oldData: { paymentId: payment.id, ...previousPaymentSnapshot },
newData: {
paymentId: payment.id,
paymentType: p.paymentType,
paymentStatus: p.paymentStatus,
financeVerified: !!isVerifying,
amount: p.amount,
transactionId: p.transactionId,
remarks: p.remarks,
},
});
res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment }); res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment });
} catch (error) { } catch (error) {
console.error('Update payment error:', error); console.error('Update payment error:', error);

View File

@ -16,6 +16,7 @@ import { TerminationWorkflowService } from '../../services/TerminationWorkflowSe
import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js'; import { ParticipantService } from '../../services/ParticipantService.js';
import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
// Create termination request // Create termination request
export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => { export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => {
@ -246,6 +247,9 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
return res.status(404).json({ success: false, message: 'Termination not found' }); return res.status(404).json({ success: false, message: 'Termination not found' });
} }
const fromStage = termination.currentStage;
let approvedToStage: string | null = null;
if (action === 'reject') { if (action === 'reject') {
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
action: 'Rejected', action: 'Rejected',
@ -276,6 +280,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' }); return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
} }
approvedToStage = nextStage;
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, { await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
remarks, remarks,
status: getTerminationStatusForStage(nextStage) status: getTerminationStatusForStage(nextStage)
@ -303,6 +309,32 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
} }
await transaction.commit(); await transaction.commit();
try {
if (action === 'reject') {
const noteText = String(remarks || '').trim() || `[Rejected] at ${fromStage}`;
await writeWorkflowActivityWorknote({
requestId: termination.id,
requestType: 'termination',
userId: req.user.id,
noteText,
noteType: 'internal'
});
} else if (approvedToStage) {
const noteText =
String(remarks || '').trim() || `[Approved] ${fromStage}${approvedToStage}`;
await writeWorkflowActivityWorknote({
requestId: termination.id,
requestType: 'termination',
userId: req.user.id,
noteText,
noteType: 'internal'
});
}
} catch (wnErr) {
logger.error('[termination] workflow worknote:', wnErr);
}
res.json({ success: true, message: 'Termination updated', termination }); res.json({ success: true, message: 'Termination updated', termination });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();

View File

@ -6,8 +6,12 @@ export class ConstitutionalWorkflowService {
* Transitions a constitutional change request to a new stage * Transitions a constitutional change request to a new stage
*/ */
static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) { static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) {
const { action, status, remarks, userFullName } = options; const { action, status, remarks, userFullName, auditAction: explicitAuditAction } = options;
const sourceStage = request.currentStage; const sourceStage = request.currentStage;
const actionLower = String(action || '').toLowerCase();
const resolvedAuditAction =
explicitAuditAction ??
(actionLower.includes('reject') ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED);
const updatedTimeline = [ const updatedTimeline = [
...(request.timeline || []), ...(request.timeline || []),
@ -21,9 +25,19 @@ export class ConstitutionalWorkflowService {
} }
]; ];
const resolvedStatus =
status ||
(targetStage === CONSTITUTIONAL_STAGES.COMPLETED
? 'Completed'
: targetStage === CONSTITUTIONAL_STAGES.REJECTED
? 'Rejected'
: targetStage === CONSTITUTIONAL_STAGES.REVOKED
? 'Revoked'
: targetStage);
const updateData: any = { const updateData: any = {
currentStage: targetStage, currentStage: targetStage,
status: status || (targetStage === CONSTITUTIONAL_STAGES.COMPLETED ? 'Completed' : targetStage), status: resolvedStatus,
progressPercentage: this.calculateProgress(targetStage), progressPercentage: this.calculateProgress(targetStage),
timeline: updatedTimeline, timeline: updatedTimeline,
updatedAt: new Date() updatedAt: new Date()
@ -35,7 +49,7 @@ export class ConstitutionalWorkflowService {
await db.ConstitutionalAudit.create({ await db.ConstitutionalAudit.create({
userId, userId,
constitutionalChangeId: request.id, constitutionalChangeId: request.id,
action: action === 'Reject' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED, action: resolvedAuditAction,
remarks: remarks || '', remarks: remarks || '',
details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } details: { status: updateData.status, stage: sourceStage, targetStage: targetStage }
}); });
@ -57,7 +71,8 @@ export class ConstitutionalWorkflowService {
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85, [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85,
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95, [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95,
[CONSTITUTIONAL_STAGES.COMPLETED]: 100, [CONSTITUTIONAL_STAGES.COMPLETED]: 100,
[CONSTITUTIONAL_STAGES.REJECTED]: 0 [CONSTITUTIONAL_STAGES.REJECTED]: 0,
[CONSTITUTIONAL_STAGES.REVOKED]: 0
}; };
return progress[stage] || 0; return progress[stage] || 0;
} }

View File

@ -8,7 +8,7 @@ export class RelocationWorkflowService {
*/ */
static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
const previousStatus = request.status; const previousStatus = request.status;
const { reason, stage, progressPercentage, action } = metadata; const { reason, stage, progressPercentage, action, auditAction } = metadata;
const updateData: any = { const updateData: any = {
status: targetStatus, status: targetStatus,
@ -45,10 +45,21 @@ export class RelocationWorkflowService {
await request.update({ timeline: updatedTimeline }); await request.update({ timeline: updatedTimeline });
// 3. Create Audit Log // 3. Create Audit Log
let resolvedAuditAction: string = AUDIT_ACTIONS.APPROVED;
if (auditAction) {
resolvedAuditAction = auditAction;
} else if (action === 'REJECT') {
resolvedAuditAction = AUDIT_ACTIONS.REJECTED;
} else if (action === 'REVOKE') {
resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_REVOKED;
} else if (action === 'SEND_BACK') {
resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_SENT_BACK;
}
await db.RelocationAudit.create({ await db.RelocationAudit.create({
userId: userId, userId: userId,
relocationRequestId: request.id, relocationRequestId: request.id,
action: action === 'REJECT' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.APPROVED, action: resolvedAuditAction,
remarks: reason || '', remarks: reason || '',
details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus } details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus }
}); });

View File

@ -1,5 +1,5 @@
import db from '../database/models/index.js'; import db from '../database/models/index.js';
const { AuditLog, User, Worknote } = db; const { AuditLog, User } = db;
import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js'; import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js';
import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js'; import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js';
import { NotificationService } from './NotificationService.js'; import { NotificationService } from './NotificationService.js';
@ -54,16 +54,6 @@ export class ResignationWorkflowService {
details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } details: { status: updateData.status, stage: sourceStage, targetStage: targetStage }
}); });
// 4. Create Worknote if it's a "Sent Back" action for communication
if (action === 'Sent Back') {
await Worknote.create({
requestId: resignation.id,
requestType: 'resignation',
userId: userId,
note: `Resignation sent back to ${targetStage}. Comments: ${remarks}`
});
}
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`); console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
// 5. Send Notifications // 5. Send Notifications

View File

@ -114,7 +114,7 @@ export class WorkflowIntegrityService {
}); });
if (policyMet && deposit) { if (policyMet && deposit) {
console.log(`[WorkflowIntegrityService] Policy met and Payment Verified for LOI on ${application.applicationId}. Transitioning to LOI Issued...`); console.log(`[WorkflowIntegrityService] Policy met and payment verified for LOI on ${application.applicationId}. Aligning to Security Details for admin approval before LOI Issued.`);
// Ensure LoiRequest is also updated // Ensure LoiRequest is also updated
const request = await db.LoiRequest.findOne({ where: { applicationId: application.id } }); const request = await db.LoiRequest.findOne({ where: { applicationId: application.id } });
@ -122,9 +122,9 @@ export class WorkflowIntegrityService {
await request.update({ status: 'Approved', approvedAt: new Date() }); await request.update({ status: 'Approved', approvedAt: new Date() });
} }
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, null, { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, null, {
reason: 'Auto-transitioned by Integrity Service: Policy and Payment criteria met.', reason: 'Integrity sync: LOI policy and deposit verified — use Security Details admin approval to reach LOI Issued.',
progressPercentage: 80 progressPercentage: 78
}); });
} }
} }

View File

@ -1,11 +1,13 @@
import db from '../database/models/index.js'; import db from '../database/models/index.js';
const { const { Application, ApplicationStatusHistory, User, Dealer } = db;
Application, ApplicationStatusHistory, AuditLog, import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../common/utils/progress.js';
User, Dealer import {
} = db; AUDIT_ACTIONS,
import { syncApplicationProgress } from '../common/utils/progress.js'; APPLICATION_STAGES,
import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js'; OVERALL_STATUS_TO_DB_CURRENT_STAGE,
} from '../common/config/constants.js';
import { NotificationService } from './NotificationService.js'; import { NotificationService } from './NotificationService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
export class WorkflowService { export class WorkflowService {
/** /**
@ -14,7 +16,9 @@ export class WorkflowService {
*/ */
static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
const previousStatus = application.overallStatus; const previousStatus = application.overallStatus;
const { reason, stage, progressPercentage, forceLog } = metadata; const previousStage = application.currentStage;
const previousProgress = application.progressPercentage;
const { reason, stage, progressPercentage, forceLog, transitionSource } = metadata;
// Skip redundant history logging if status is identical (unless forced) // Skip redundant history logging if status is identical (unless forced)
if (targetStatus === previousStatus && !forceLog) { if (targetStatus === previousStatus && !forceLog) {
@ -22,14 +26,28 @@ export class WorkflowService {
return application; return application;
} }
const allowedStages = new Set<string>(Object.values(APPLICATION_STAGES) as string[]);
/** Role-based gates (NBH, DD Head, …) — only these may override when explicitly passed. */
const explicitStage =
stage && allowedStages.has(stage as string) ? (stage as string) : null;
/** Progress-tracker label (not necessarily a DB enum value). */
const pipelineStageLabel = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[targetStatus];
/** Value allowed by Postgres enum `applications.currentStage`. */
const dbStageFromOverallStatus = OVERALL_STATUS_TO_DB_CURRENT_STAGE[targetStatus];
const stageForDbColumn =
explicitStage ||
(pipelineStageLabel && allowedStages.has(pipelineStageLabel) ? pipelineStageLabel : null) ||
dbStageFromOverallStatus ||
null;
const updateData: any = { const updateData: any = {
overallStatus: targetStatus, overallStatus: targetStatus,
updatedAt: new Date() updatedAt: new Date()
}; };
// Update stage if provided and valid if (stageForDbColumn && allowedStages.has(stageForDbColumn)) {
if (stage && Object.values(APPLICATION_STAGES).includes(stage)) { updateData.currentStage = stageForDbColumn;
updateData.currentStage = stage;
} }
// Update progress percentage if explicitly provided // Update progress percentage if explicitly provided
@ -37,6 +55,11 @@ export class WorkflowService {
updateData.progressPercentage = progressPercentage; updateData.progressPercentage = progressPercentage;
} }
const nextProgress =
progressPercentage !== undefined ? Number(progressPercentage) : previousProgress;
const contextBefore = pickApplicationAuditContext(application);
// 1. Update Application Record // 1. Update Application Record
await application.update(updateData); await application.update(updateData);
@ -49,37 +72,47 @@ export class WorkflowService {
changeReason: reason || `Transitioned to ${targetStatus}` changeReason: reason || `Transitioned to ${targetStatus}`
}); });
// 3. Create High-Fidelity Audit Log const contextAfter = pickApplicationAuditContext(application);
await AuditLog.create({
// 3. Audit log — non-fatal: must not roll back or block the transition
await safeAuditLogCreate({
userId: userId, userId: userId,
action: AUDIT_ACTIONS.UPDATED, action: AUDIT_ACTIONS.UPDATED,
entityType: 'application', entityType: 'application',
entityId: application.id, entityId: application.id,
oldData: { oldData: {
status: previousStatus, status: previousStatus,
stage: application.currentStage, stage: previousStage,
progress: application.progressPercentage progress: previousProgress,
context: contextBefore,
}, },
newData: { newData: {
status: targetStatus, status: targetStatus,
stage: stage || application.currentStage, stage: stageForDbColumn ?? previousStage,
progress: progressPercentage ?? application.progressPercentage, ...(pipelineStageLabel &&
reason: reason || `Transitioned to ${targetStatus}` pipelineStageLabel !== (stageForDbColumn ?? previousStage)
? { pipelineStage: pipelineStageLabel }
: {}),
progress: nextProgress,
reason: reason || `Transitioned to ${targetStatus}`,
context: contextAfter,
transitionSource: transitionSource || metadata?.source || 'WorkflowService.transitionApplication',
}, },
metadata: {
...metadata,
timestamp: new Date()
}
}); });
// 4. Synchronize Progress Tracker (The true source of truth for the frontend UI) // 4. Progress sync — non-fatal (DB state is already committed)
try {
await syncApplicationProgress(application.id, targetStatus); await syncApplicationProgress(application.id, targetStatus);
} catch (syncErr) {
console.error('[WorkflowService] syncApplicationProgress failed (non-fatal):', syncErr);
}
// 5. Send Status Update Notification (Intelligent Template Selection) // 5. Notifications — non-fatal
if (application.email) { if (application.email) {
try {
const user = await User.findOne({ const user = await User.findOne({
where: { email: application.email }, where: { email: application.email },
attributes: ['id'] attributes: ['id'],
}); });
const targetUserId = user ? user.id : null; const targetUserId = user ? user.id : null;
@ -99,9 +132,12 @@ export class WorkflowService {
applicationId: application.applicationId, applicationId: application.applicationId,
reason: reason || 'N/A', reason: reason || 'N/A',
salesCode: application.dealerCode?.salesCode || 'N/A', salesCode: application.dealerCode?.salesCode || 'N/A',
serviceCode: application.dealerCode?.serviceCode || 'N/A' serviceCode: application.dealerCode?.serviceCode || 'N/A',
} },
}); });
} catch (notifyErr) {
console.error('[WorkflowService] Notification failed (non-fatal):', notifyErr);
}
} }
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`); console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);

View File

@ -0,0 +1,39 @@
import db from '../database/models/index.js';
const { AuditLog } = db;
/** Fields that help explain pipeline / FDD / statutory state without large payloads */
export function pickApplicationAuditContext(app: any): Record<string, unknown> {
if (!app) return {};
return {
applicationHumanId: app.applicationId ?? null,
overallStatus: app.overallStatus ?? null,
currentStage: app.currentStage ?? null,
progressPercentage: app.progressPercentage ?? null,
statutoryStatus: app.statutoryStatus ?? null,
architectureStatus: app.architectureStatus ?? null,
isShortlisted: app.isShortlisted ?? null,
opportunityId: app.opportunityId ?? null,
};
}
export type SafeAuditPayload = {
userId?: string | null;
action: string;
entityType: string;
entityId: string;
oldData?: Record<string, unknown> | null;
newData?: Record<string, unknown> | null;
};
/**
* Writes an audit row and never throws avoids breaking sensitive workflows
* (status transitions, prospective uploads, finance verification) if audit storage fails.
*/
export async function safeAuditLogCreate(payload: SafeAuditPayload): Promise<void> {
try {
await AuditLog.create(payload as any);
} catch (err) {
console.error('[safeAuditLogCreate] Non-fatal audit failure:', (err as Error)?.message || err);
}
}

View File

@ -13,7 +13,7 @@ const EMAILS = {
DEALER: args.dealerEmail || "dealer@royalenfield.com", DEALER: args.dealerEmail || "dealer@royalenfield.com",
ASM: args.asmEmail || "asm.sdelhi@royalenfield.com", ASM: args.asmEmail || "asm.sdelhi@royalenfield.com",
RBM: args.rbmEmail || "rbm.ncr@royalenfield.com", RBM: args.rbmEmail || "rbm.ncr@royalenfield.com",
DD_ZM: args.ddZmEmail || "ddzm.ncr@royalenfield.com", DD_ZM: args.ddZmEmail || "zm.ncr@royalenfield.com",
ZBH: args.zbhEmail || "yashwin@gmail.com", ZBH: args.zbhEmail || "yashwin@gmail.com",
DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com", DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com",
DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com", DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com",
@ -24,7 +24,7 @@ const EMAILS = {
const ROLE_BY_STAGE = { const ROLE_BY_STAGE = {
"ASM Review": ["ASM"], "ASM Review": ["ASM"],
"RBM Review": ["RBM"], "RBM Review": ["RBM"],
"DD ZM Review": ["DD_ZM", "RBM"], "DD ZM Review": ["DD_ZM"],
"ZBH Review": ["ZBH"], "ZBH Review": ["ZBH"],
"DD Lead Review": ["DD_LEAD"], "DD Lead Review": ["DD_LEAD"],
"DD Head Approval": ["DD_HEAD"], "DD Head Approval": ["DD_HEAD"],
@ -68,9 +68,13 @@ async function approveCurrentStage(requestId, stageName) {
} }
let lastError = null; let lastError = null;
const attempts = [];
for (const roleKey of candidateRoles) { for (const roleKey of candidateRoles) {
const email = EMAILS[roleKey]; const email = EMAILS[roleKey];
if (!email) continue; if (!email) {
attempts.push(`${roleKey}:missing-email`);
continue;
}
try { try {
const token = await login(email); const token = await login(email);
const res = await apiRequest(`/self-service/relocation/${requestId}/action`, "POST", { const res = await apiRequest(`/self-service/relocation/${requestId}/action`, "POST", {
@ -80,9 +84,13 @@ async function approveCurrentStage(requestId, stageName) {
return { roleKey, email, message: res.message || "Approved" }; return { roleKey, email, message: res.message || "Approved" };
} catch (error) { } catch (error) {
lastError = error; lastError = error;
attempts.push(`${roleKey}:${error.message}`);
} }
} }
throw lastError || new Error(`Approval failed for stage: ${stageName}`); throw new Error(
`Approval failed for stage: ${stageName}. Attempts -> ${attempts.join(" | ")}`
+ (lastError ? ` | Last error: ${lastError.message}` : "")
);
} }
async function resolveDealerOutlet(dealerToken) { async function resolveDealerOutlet(dealerToken) {

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-26 10-08-00.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);
@ -389,172 +389,172 @@ async function triggerWorkflow() {
await delay(1000); await delay(1000);
// 7.5 LOI APPROVAL // 7.5 LOI APPROVAL
log(7.5, 'LOI Generation & Approval...'); // log(7.5, 'LOI Generation & Approval...');
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken); // const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
const loiRequestId = loiRes.data.id; // const loiRequestId = loiRes.data.id;
// Head Approval // // Head Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved', // action: 'Approved',
remarks: 'Head Authorization for LOI' // remarks: 'Head Authorization for LOI'
}, headToken); // }, headToken);
// NBH Approval // // NBH Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved', // action: 'Approved',
remarks: 'NBH Authorization for LOI' // remarks: 'NBH Authorization for LOI'
}, nbhToken); // }, nbhToken);
log(7.5, 'LOI Milestone Complete.'); // log(7.5, 'LOI Milestone Complete.');
await delay(); // await delay();
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW) // // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...'); // log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
const financeToken = await login(EMAILS.FINANCE); // const financeToken = await login(EMAILS.FINANCE);
await apiRequest('/loa/security-deposit', 'POST', { // await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID, // applicationId: applicationUUID,
amount: 500000, // amount: 500000,
paymentReference: 'PAY-888999', // paymentReference: 'PAY-888999',
depositType: 'SECURITY_DEPOSIT', // depositType: 'SECURITY_DEPOSIT',
status: 'Verified' // status: 'Verified'
}, financeToken); // }, financeToken);
log(8, 'Security Deposit Verified.'); // log(8, 'Security Deposit Verified.');
await delay(); // await delay();
// 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK) // // 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK)
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); // let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Current status before code generation: ${statusBeforeCodeGen}`); // log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...'); // log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
await ensureMandatoryCodeGenFields(applicationUUID, adminToken); // await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
await delay(300); // await delay(300);
if (statusBeforeCodeGen === 'Security Details') { // if (statusBeforeCodeGen === 'Security Details') {
log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...'); // log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
await apiRequest('/loa/security-deposit', 'POST', { // await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID, // applicationId: applicationUUID,
amount: 500000, // amount: 500000,
paymentReference: `PAY-RETRY-${Date.now()}`, // paymentReference: `PAY-RETRY-${Date.now()}`,
depositType: 'SECURITY_DEPOSIT', // depositType: 'SECURITY_DEPOSIT',
status: 'Verified' // status: 'Verified'
}, financeToken); // }, financeToken);
await delay(); // await delay();
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); // statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Status after re-verify: ${statusBeforeCodeGen}`); // log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
} // }
log(9, 'Admin Generating SAP Dealer Codes...'); // log(9, 'Admin Generating SAP Dealer Codes...');
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken); // await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
log(9, 'Dealer Codes Generated.'); // log(9, 'Dealer Codes Generated.');
await delay(); // await delay();
// 10. FIRST FILL (POST CODE-GENERATION) // // 10. FIRST FILL (POST CODE-GENERATION)
log(10, 'Finance Verifying FIRST FILL (₹15L)...'); // log(10, 'Finance Verifying FIRST FILL (₹15L)...');
await apiRequest('/loa/security-deposit', 'POST', { // await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID, // applicationId: applicationUUID,
amount: 1500000, // amount: 1500000,
paymentReference: 'PAY-FIN-999', // paymentReference: 'PAY-FIN-999',
depositType: 'FIRST_FILL', // depositType: 'FIRST_FILL',
status: 'Verified' // status: 'Verified'
}, financeToken); // }, financeToken);
log(10, 'Final Security Deposit Verified.'); // log(10, 'Final Security Deposit Verified.');
await delay(); // await delay();
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS // // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...'); // log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', { // await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
accountHolderName: 'Ramesh Automobiles Private Limited', // accountHolderName: 'Ramesh Automobiles Private Limited',
panNumber: 'ABCDE1234F', // panNumber: 'ABCDE1234F',
gstNumber: '07ABCDE1234F1Z5', // gstNumber: '07ABCDE1234F1Z5',
bankName: 'HDFC Bank', // bankName: 'HDFC Bank',
accountNumber: '50100223344556', // accountNumber: '50100223344556',
ifscCode: 'HDFC0001234' // ifscCode: 'HDFC0001234'
}, adminToken); // }, adminToken);
log(11, 'Statutory & Bank details updated.'); // log(11, 'Statutory & Bank details updated.');
await delay(); // await delay();
// 12. FINAL LOA APPROVAL // // 12. FINAL LOA APPROVAL
log(12, 'NBH & Head Approving Final LOA...'); // log(12, 'NBH & Head Approving Final LOA...');
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken); // const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
const finalLoaRequestId = loaRes.data.id; // const finalLoaRequestId = loaRes.data.id;
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { // await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved', // action: 'Approved',
remarks: 'Head Authorization (Level 1)' // remarks: 'Head Authorization (Level 1)'
}, headToken); // }, headToken);
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { // await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved', // action: 'Approved',
remarks: 'NBH Approval (Level 2)' // remarks: 'NBH Approval (Level 2)'
}, nbhToken); // }, nbhToken);
log(12, 'LOA Fully Approved.'); // log(12, 'LOA Fully Approved.');
await delay(); // await delay();
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION // // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...'); // log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken); // const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
const checklistId = eorInit.data.id; // const checklistId = eorInit.data.id;
log(13, `EOR Checklist Created (ID: ${checklistId})`); // log(13, `EOR Checklist Created (ID: ${checklistId})`);
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...'); // log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
const eorItems = [ // const eorItems = [
{ itemType: 'Sales', description: 'Sales Standards' }, // { itemType: 'Sales', description: 'Sales Standards' },
{ itemType: 'Service', description: 'Service & Spares' }, // { itemType: 'Service', description: 'Service & Spares' },
{ itemType: 'IT', description: 'DMS infra' }, // { itemType: 'IT', description: 'DMS infra' },
{ itemType: 'Training', description: 'Manpower Training' }, // { itemType: 'Training', description: 'Manpower Training' },
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' }, // { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' }, // { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
{ itemType: 'Finance', description: 'Inventory Funding' }, // { itemType: 'Finance', description: 'Inventory Funding' },
{ itemType: 'IT', description: 'Virtual code availability' }, // { itemType: 'IT', description: 'Virtual code availability' },
{ itemType: 'Finance', description: 'Vendor payments' }, // { itemType: 'Finance', description: 'Vendor payments' },
{ itemType: 'Marketing', description: 'Details for website submission' }, // { itemType: 'Marketing', description: 'Details for website submission' },
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' }, // { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
{ itemType: 'IT', description: 'Auto ordering' } // { itemType: 'IT', description: 'Auto ordering' }
]; // ];
for (const item of eorItems) { // for (const item of eorItems) {
process.stdout.write(`.`); // Visual progress // process.stdout.write(`.`); // Visual progress
await apiRequest(`/eor/item/${checklistId}`, 'POST', { // await apiRequest(`/eor/item/${checklistId}`, 'POST', {
...item, // ...item,
isCompliant: true, // isCompliant: true,
remarks: 'Verified by Auditor - Compliant' // remarks: 'Verified by Auditor - Compliant'
}, adminToken); // }, adminToken);
} // }
console.log('\n[STEP 13.1] All EOR items marked as compliant.'); // console.log('\n[STEP 13.1] All EOR items marked as compliant.');
log(13.2, 'Auditor Submitting Final EOR Audit...'); // log(13.2, 'Auditor Submitting Final EOR Audit...');
await apiRequest(`/eor/audit/${checklistId}`, 'POST', { // await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
status: 'Completed', // status: 'Completed',
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.' // overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
}, adminToken); // }, adminToken);
// Status check // // Status check
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken); // const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); // log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
await delay(); // await delay();
// 14. FINAL ONBOARDING // // 14. FINAL ONBOARDING
log(14, 'Admin Finalizing Dealer Onboarding...'); // log(14, 'Admin Finalizing Dealer Onboarding...');
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken); // await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
await delay(); // await delay();
// 15. VERIFICATION // // 15. VERIFICATION
log(15, 'Verifying Dealer Record Creation...'); // log(15, 'Verifying Dealer Record Creation...');
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken); // const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
if (!dealerRes.success || !dealerRes.data) { // if (!dealerRes.success || !dealerRes.data) {
throw new Error('Verification Failed: Dealer record not found after onboarding.'); // throw new Error('Verification Failed: Dealer record not found after onboarding.');
} // }
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`); // log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
log(15.1, 'Verifying User Account Role Update...'); // log(15.1, 'Verifying User Account Role Update...');
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); // const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL); // const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
if (!dealerUser || dealerUser.roleCode !== 'Dealer') { // if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`); // throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
} // }
log(15.1, `User role confirmed: ${dealerUser.roleCode}`); // log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---'); // log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`); // log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
} }
/** /**