diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 9b848a8..427b080 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -117,6 +117,62 @@ export const APPLICATION_STATUS = { RETURNED_TO_FDD: 'Returned to FDD' } 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 export const TERMINATION_STAGES = { SUBMITTED: 'Submitted', @@ -176,6 +232,14 @@ export const CONSTITUTIONAL_CHANGE_TYPES = { DIRECTOR_CHANGE: 'Director Change' } 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) export const CONSTITUTIONAL_STAGES = { SUBMITTED: 'Submitted', @@ -187,7 +251,9 @@ export const CONSTITUTIONAL_STAGES = { NBH_APPROVAL: 'NBH Approval', LEGAL_REVIEW: 'Legal Review', COMPLETED: 'Completed', - REJECTED: 'Rejected' + REJECTED: 'Rejected', + /** SRS §12.2.3 — administrative cancellation (distinct from rejection of proposal). */ + REVOKED: 'Revoked' } as const; // Relocation Types @@ -302,6 +368,7 @@ export const AUDIT_ACTIONS = { // Documents & Collaboration DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED', DOCUMENT_VERIFIED: 'DOCUMENT_VERIFIED', + DOCUMENT_REJECTED: 'DOCUMENT_REJECTED', WORKNOTE_ADDED: 'WORKNOTE_ADDED', ATTACHMENT_UPLOADED: 'ATTACHMENT_UPLOADED', PARTICIPANT_ADDED: 'PARTICIPANT_ADDED', @@ -354,6 +421,10 @@ export const AUDIT_ACTIONS = { RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED', RESIGNATION_APPROVED: 'RESIGNATION_APPROVED', 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', REMINDER_SENT: 'REMINDER_SENT' } as const; diff --git a/src/common/utils/constitutionalNormalize.ts b/src/common/utils/constitutionalNormalize.ts new file mode 100644 index 0000000..a26b50b --- /dev/null +++ b/src/common/utils/constitutionalNormalize.ts @@ -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; +} diff --git a/src/common/utils/progress.ts b/src/common/utils/progress.ts index 0b6c862..1bdb304 100644 --- a/src/common/utils/progress.ts +++ b/src/common/utils/progress.ts @@ -88,55 +88,66 @@ export const updateApplicationProgress = async (applicationId: string, stageName } }; +/** + * 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 PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record = { + Submitted: 'Submitted', + 'Questionnaire Pending': 'Questionnaire', + 'Questionnaire Completed': 'Questionnaire', + Shortlisted: 'Shortlist', + 'Level 1 Interview Pending': '1st Level Interview', + 'Level 1 Approved': '1st Level Interview', + 'Level 2 Interview Pending': '2nd Level Interview', + 'Level 2 Approved': '2nd Level Interview', + 'Level 2 Recommended': '2nd Level Interview', + 'Level 3 Interview Pending': '3rd Level Interview', + 'Level 3 Approved': '3rd Level Interview', + 'FDD Verification': 'FDD', + 'LOI In Progress': 'LOI Approval', + 'Security Details': 'Security Details', + 'Payment Pending': 'Security Details', + 'LOI Issued': 'LOI Issue', + 'Statutory LOI Ack': 'LOI Issue', + 'Dealer Code Generation': 'Dealer Code Generation', + 'Architecture Team Assigned': 'Architecture Work', + 'Architecture Document Upload': 'Architecture Work', + 'Architecture Team Completion': 'Architecture Work', + 'Architecture Work': 'Architecture Work', + 'Statutory GST': 'Statutory Work', + 'Statutory PAN': 'Statutory Work', + 'Statutory Nodal': 'Statutory Work', + 'Statutory Check': 'Statutory Work', + 'Statutory Partnership': 'Statutory Work', + 'Statutory Firm Reg': 'Statutory Work', + 'Statutory Rental': 'Statutory Work', + 'Statutory Virtual Code': 'Statutory Work', + 'Statutory Domain': 'Statutory Work', + 'Statutory MSD': 'Statutory Work', + 'Statutory Work': 'Statutory Work', + 'LOA Pending': 'LOA', + 'LOA Issued': 'LOA', + 'LOA Rejected': 'LOA', + 'LOI Rejected': 'LOI Issue', + 'EOR In Progress': 'EOR Complete', + 'EOR Complete': 'EOR Complete', + Inauguration: 'Inauguration', + Approved: 'Inauguration', + Onboarded: 'Onboarded', + Rejected: 'Rejected', + Disqualified: 'Disqualified', + 'Returned to FDD': 'FDD', + Pending: 'Submitted', + 'In Review': 'Shortlist', +}; + /** * Syncs all progress stages based on current overall status */ export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => { - // Map overallStatus to stage names - const statusToStageMap: Record = { - 'Submitted': 'Submitted', - 'Questionnaire Pending': 'Questionnaire', - 'Questionnaire Completed': 'Questionnaire', - 'Shortlisted': 'Shortlist', - 'Level 1 Interview Pending': '1st Level Interview', - 'Level 1 Approved': '1st Level Interview', - 'Level 2 Interview Pending': '2nd Level Interview', - 'Level 2 Approved': '2nd Level Interview', - 'Level 2 Recommended': '2nd Level Interview', - 'Level 3 Interview Pending': '3rd Level Interview', - 'Level 3 Approved': '3rd Level Interview', - 'FDD Verification': 'FDD', - 'LOI In Progress': 'LOI Approval', - 'Security Details': 'Security Details', - 'Payment Pending': 'Security Details', - 'LOI Issued': 'LOI Issue', - 'Statutory LOI Ack': 'LOI Issue', - 'Dealer Code Generation': 'Dealer Code Generation', - 'Architecture Team Assigned': 'Architecture Work', - 'Architecture Document Upload': 'Architecture Work', - 'Architecture Team Completion': 'Architecture Work', - 'Architecture Work': 'Architecture Work', - 'Statutory GST': 'Statutory Work', - 'Statutory PAN': 'Statutory Work', - 'Statutory Nodal': 'Statutory Work', - 'Statutory Check': 'Statutory Work', - 'Statutory Partnership': 'Statutory Work', - 'Statutory Firm Reg': 'Statutory Work', - 'Statutory Rental': 'Statutory Work', - 'Statutory Virtual Code': 'Statutory Work', - 'Statutory Domain': 'Statutory Work', - 'Statutory MSD': 'Statutory Work', - 'Statutory Work': 'Statutory Work', - 'LOA Pending': 'LOA', - 'LOA Issued': 'LOA', - 'EOR In Progress': 'EOR Complete', - 'EOR Complete': 'EOR Complete', - 'Inauguration': 'Inauguration', - 'Approved': 'Inauguration', - 'Onboarded': 'Onboarded' - }; - - const currentStageName = statusToStageMap[overallStatus]; + const currentStageName = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[overallStatus]; if (currentStageName) { const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName); if (currentStage) { diff --git a/src/common/utils/workflowWorknote.ts b/src/common/utils/workflowWorknote.ts new file mode 100644 index 0000000..eeef75b --- /dev/null +++ b/src/common/utils/workflowWorknote.ts @@ -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 { + 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' + }); +} diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 678a4ad..2f6f983 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -229,6 +229,11 @@ const processStageDecision = async (params: { targetStatus = APPLICATION_STATUS.LOA_ISSUED; targetStage = 'LOA'; 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) { diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index f2cbb32..4ab17fb 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -23,6 +23,7 @@ const ACTION_DESCRIPTIONS: Record = { QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant', DOCUMENT_UPLOADED: 'Document uploaded', DOCUMENT_VERIFIED: 'Document verified', + DOCUMENT_REJECTED: 'Document rejected', WORKNOTE_ADDED: 'Work note added', ATTACHMENT_UPLOADED: 'Attachment uploaded', PARTICIPANT_ADDED: 'Participant added', @@ -62,22 +63,107 @@ const ACTION_DESCRIPTIONS: Record = { RESIGNATION_SUBMITTED: 'Resignation submitted', RESIGNATION_APPROVED: 'Resignation approved', 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', REMINDER_SENT: 'Reminder sent', 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; + const newCtx = (payload.context || {}) as Record; + 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 payload = logData.details || logData.newData || {}; const actorName = logData.user?.fullName || logData.userName || 'System'; const action = logData.action || 'UPDATED'; + const et = String(entityType || '').toLowerCase(); let description = ACTION_DESCRIPTIONS[action] || String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' '); - if (payload?.stage) description += ` - Stage: ${payload.stage}`; - else if (payload?.department) description += ` - ${payload.department}`; - else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`; + if (et === 'application' && action === 'UPDATED') { + description = buildFriendlyApplicationUpdatedDescription(logData, payload); + } else { + if (payload?.stage) description += ` - Stage: ${payload.stage}`; + else if (payload?.department) description += ` - ${payload.department}`; + 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 { id: logData.id, @@ -85,6 +171,12 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s description, entityType, entityId, + stage: + payload?.pipelineStage || + payload?.stage || + payload?.targetStage || + (payload?.context as any)?.currentStage || + null, actor: { name: actorName, email: logData.user?.email || logData.userEmail || null @@ -92,8 +184,9 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s userName: actorName, userEmail: logData.user?.email || logData.userEmail || null, remarks: logData.remarks || payload?.remarks || '', - newData: payload, + newData: logData.newData ?? payload, details: payload, + oldData: logData.oldData ?? null, timestamp: logData.createdAt || logData.timestamp }; }; @@ -182,8 +275,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => { count = result.count; logs = result.rows; } 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({ - where: { relocationRequestId: entityId as string }, + where: { relocationRequestId: resolvedRelocationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }], order: [['createdAt', 'DESC']], limit: limitNum, offset @@ -297,9 +395,14 @@ export const getAuditSummary = async (req: AuthRequest, res: Response) => { order: [['createdAt', 'DESC']] }); } 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({ - where: { relocationRequestId: entityId as string }, + where: { relocationRequestId: resolvedRelocationId }, include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }], order: [['createdAt', 'DESC']] }); diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index d2125ba..463756e 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -1,11 +1,12 @@ import { Response } from 'express'; +import { Op } from 'sequelize'; import db from '../../database/models/index.js'; const { Worknote, User, WorkNoteTag, WorkNoteAttachment, DocumentVersion, RequestParticipant, Application, AuditLog, OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument } = db; 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 { getIO } from '../../common/utils/socket.js'; import * as NotificationService from '../../common/utils/notification.service.js'; @@ -16,7 +17,8 @@ const getDocumentModel = (requestType: string) => { switch (requestType?.toLowerCase()) { case 'relocation': return RelocationDocument; case 'resignation': return ResignationDocument; - case 'constitutional': return ConstitutionalDocument; + case 'constitutional': + case 'constitutional-change': return ConstitutionalDocument; case 'termination': return TerminationDocument; case 'onboarding': case 'application': return OnboardingDocument; @@ -64,24 +66,59 @@ const stitchWorknoteAttachments = async (worknotes: any[]) => { 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 --- export const addWorknote = async (req: AuthRequest, res: Response) => { try { 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 const participants = await db.RequestParticipant.findAll({ - where: { requestId, requestType }, + where: { requestId: resolvedId, requestType: normalizedType }, include: [{ model: db.User, as: 'user', attributes: ['id', '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({ - requestId, - requestType, // application, opportunity, etc. + requestId: resolvedId, + requestType: normalizedType, userId: req.user?.id, noteText, noteType: noteType || 'General', @@ -99,17 +136,17 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { await WorkNoteAttachment.create({ noteId: worknote.id, documentId: docId, - documentType: requestType || 'onboarding' + documentType: normalizedType || 'onboarding' }); } } // Add author as participant - if (req.user?.id && requestId && requestType) { + if (req.user?.id && resolvedId && normalizedType) { await db.RequestParticipant.findOrCreate({ where: { - requestId, - requestType, + requestId: resolvedId, + requestType: normalizedType, userId: req.user.id }, defaults: { @@ -133,7 +170,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { // --- Real-time & Notifications --- try { const io = getIO(); - io.to(requestId).emit('new_worknote', stitchedNote); + io.to(resolvedId).emit('new_worknote', stitchedNote); // Handle Mentions/Notifications const notifiedUserIds = new Set(); @@ -170,7 +207,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { title: 'New Mention', message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`, type: 'info', - link: `/applications/${requestId}?tab=worknotes` + link: `/applications/${resolvedId}?tab=worknotes` }); } catch (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, action: AUDIT_ACTIONS.WORKNOTE_ADDED, entityType: 'application', - entityId: requestId, + entityId: resolvedId, 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) => { try { const { requestId, requestType } = req.query as any; + const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType); + const where = worknoteListWhere(resolvedId, normalizedType); const worknotes = await Worknote.findAll({ - where: { requestId, requestType }, + where, include: [ { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, @@ -225,12 +264,13 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => { try { const file = req.file; const { requestId, requestType } = req.body; + const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType); if (!file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } - const DocModel = getDocumentModel(requestType); + const DocModel = getDocumentModel(normalizedType); let createData: any = { documentType: 'Worknote Attachment', @@ -242,19 +282,19 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => { status: 'active' }; - // Assign correct FK based on model - if (DocModel === RelocationDocument) createData.relocationId = requestId; - else if (DocModel === ResignationDocument) createData.resignationId = requestId; - else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = requestId; - else if (DocModel === TerminationDocument) createData.terminationRequestId = requestId; - else createData.applicationId = requestId; + // Assign correct FK based on model (always UUID for self-service modules) + if (DocModel === RelocationDocument) createData.relocationId = resolvedId; + else if (DocModel === ResignationDocument) createData.resignationId = resolvedId; + else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = resolvedId; + else if (DocModel === TerminationDocument) createData.terminationRequestId = resolvedId; + else createData.applicationId = resolvedId; const document = await DocModel.create(createData); // Create initial version await DocumentVersion.create({ documentId: document.id, - documentType: requestType || 'onboarding', + documentType: normalizedType || 'onboarding', versionNumber: 1, filePath: file.path, uploadedBy: req.user?.id, @@ -262,12 +302,12 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => { }); // Audit log for attachment upload - if (requestId) { + if (resolvedId) { await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED, - entityType: requestType || 'application', - entityId: requestId, + entityType: normalizedType || 'application', + entityId: resolvedId, newData: { fileName: file.originalname, mimeType: file.mimetype } }); } diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 4574f84..1b7bacb 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -12,11 +12,17 @@ import { WorkflowService } from '../../services/WorkflowService.js'; export const getDealers = async (req: Request, res: Response) => { try { + const where: Record = {}; + if (String((req.query as any)?.onboarded || '') === 'true') { + where.onboardedAt = { [Op.ne]: null }; + } + const dealers = await Dealer.findAll({ + where, include: [ { model: DealerCode, as: 'dealerCode' }, { 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']] }); diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index b8689d1..3bee83f 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -4,6 +4,101 @@ import db from '../../database/models/index.js'; const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db; 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 = { + '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 { + 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) => { try { 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({ - where: relocationId ? { relocationId } : { applicationId: resolvedAppId }, + where: resolvedRelocationId ? { relocationId: resolvedRelocationId } : { applicationId: resolvedAppId }, include: [{ model: EorChecklistItem, as: 'items' }] }); @@ -33,28 +145,40 @@ export const getChecklist = async (req: Request, res: Response) => { 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 proofDocIds = items.map((i: any) => i.proofDocumentId).filter(Boolean); - + + let payload: any = checklist.toJSON ? checklist.toJSON() : checklist; + if (proofDocIds.length > 0) { - // Find documents from the relevant table let docs = []; - if (relocationId) { + if (resolvedRelocationId) { docs = await RelocationDocument.findAll({ where: { id: proofDocIds } }); } else { docs = await OnboardingDocument.findAll({ where: { id: proofDocIds } }); } - - // Map docs to items + const docsMap = new Map(docs.map((d: any) => [d.id, d])); - checklist = checklist.toJSON(); - checklist.items = checklist.items.map((item: any) => ({ + payload = { ...payload }; + payload.items = (payload.items || []).map((item: any) => ({ ...item, proofDocument: docsMap.get(item.proofDocumentId) || null })); } - res.json({ success: true, data: checklist }); + res.json({ success: true, data: payload }); } catch (error) { console.error('Get EOR checklist error:', error); 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) { // Define Default Mandatory Items per SRS/Frontend - let defaultItems = []; - + let defaultItems: { itemType: string; description: string }[] = []; + if (relocationId) { - // Strictly per SRS Section 12.2.8 for Relocation - 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' } - ]; + defaultItems = [...RELOCATION_EOR_DEFAULT_ITEMS]; } else { // Onboarding Default defaultItems = [ @@ -157,6 +271,8 @@ export const createChecklist = async (req: AuthRequest, res: Response) => { ); } } + } else if (relocationId) { + await mapRelocationDocumentsToEorItems(checklist.id, relocationId); } } diff --git a/src/modules/fdd/fdd.controller.ts b/src/modules/fdd/fdd.controller.ts index 2592aa7..0c84fba 100644 --- a/src/modules/fdd/fdd.controller.ts +++ b/src/modules/fdd/fdd.controller.ts @@ -1,10 +1,11 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; 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 { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js'; import { WorkflowService } from '../../services/WorkflowService.js'; +import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; export const getAssignment = async (req: Request, res: Response) => { try { @@ -67,11 +68,24 @@ export const assignAgency = async (req: AuthRequest, res: Response) => { progressPercentage: 70 }); - await AuditLog.create({ + await safeAuditLogCreate({ userId: req.user?.id, action: AUDIT_ACTIONS.FDD_ASSIGNED, 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 }); @@ -169,17 +183,26 @@ export const flagNonResponsive = async (req: AuthRequest, res: Response) => { return res.status(404).json({ success: false, message: 'Application not found' }); } + const previousStatutory = application.statutoryStatus; // 1. Update Application status at model level 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} with action: UPDATED`); - await AuditLog.create({ + console.log(`[FDDController] Flagging application ${application.id} as non-responsive (FDD)`); + await safeAuditLogCreate({ userId: req.user?.id, - action: 'UPDATED', + action: AUDIT_ACTIONS.FDD_FLAGGED_NON_RESPONSIVE, entityType: 'application', 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' }); diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 86e079d..7c3ccfb 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -340,29 +340,48 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => }); // --- 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') { - console.log(`[DEBUG] Security Deposit verified. Moving to LOI Issued stage...`); - await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, req.user?.id || null, { - reason: 'Security Deposit verified. Proceeding to LOI Issuance.', - stage: APPLICATION_STAGES.LOI, - progressPercentage: 80 - }); + const os = application.overallStatus; + const inInitialSdCorridor = + 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, + 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') { - // Ensure LoaRequest exists for the next step - await db.LoaRequest.findOrCreate({ - where: { applicationId: application.id }, - defaults: { status: 'pending', requestedBy: req.user?.id } - }); + const os = application.overallStatus; + if (os === APPLICATION_STATUS.LOA_PENDING) { + await db.LoaRequest.findOrCreate({ + where: { applicationId: application.id }, + defaults: { status: 'pending', requestedBy: req.user?.id } + }); - await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, { - reason: 'First Fill Verified. Initiating LOA Approval stage.', - progressPercentage: 90 - }); + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, { + reason: 'First Fill verified by Finance while at LOA Pending. LOA approval may proceed.', + 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 }); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 4086455..b961256 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -11,6 +11,7 @@ import { syncLocationManagers } from '../master/syncHierarchy.service.js'; import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; +import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; 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}`); } + 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({ success: true, message: 'Document uploaded successfully', diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index b225fe1..b5af21f 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -1,34 +1,177 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { ConstitutionalChange, Outlet, User, Worknote, Dealer, Application, District } = db; -import { Op, Transaction } from 'sequelize'; +const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District } = db; +import { Op } from 'sequelize'; 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 { NomenclatureService } from '../../common/utils/nomenclature.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( + 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) => { try { 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(); - // Store extra details in metadata const metadata = { newPartnersDetails, shareholdingPattern, - currentConstitution + currentConstitution: resolvedCurrent, + submittedByUserId: req.user.id, + submittedByRole: req.user.roleCode, + createdOnBehalfOfDealer: !isDealerRole }; const request = await ConstitutionalChange.create({ requestId, - outletId: outletId || null, // Optional for dealer-level changes - dealerId: req.user.id, - changeType, - description: reason, - currentConstitution: currentConstitution || null, + outletId: resolvedOutletId, + dealerId: dealerUserId, + changeType: resolvedChangeType, + description: remarksText, + currentConstitution: resolvedCurrent, currentStage: CONSTITUTIONAL_STAGES.SUBMITTED, status: 'Submitted', progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED), @@ -38,8 +181,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { stage: 'Submitted', timestamp: new Date(), user: req.user.fullName, - action: 'Request submitted', - remarks: reason + action: isDealerRole ? 'Request submitted' : 'Request submitted (on behalf of dealer)', + remarks: remarksText }] }); @@ -50,6 +193,14 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { 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) ParticipantService.assignConstitutionalParticipants(request.id) .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, as: 'dealerProfile', + attributes: [ + 'id', + 'legalName', + 'businessName', + 'constitutionType', + 'registeredAddress', + 'onboardedAt', + 'loiDate', + 'loaDate', + 'gstNumber', + 'panNumber', + 'dealerCodeId', + 'applicationId', + 'status' + ], include: [ { model: db.DealerCode, as: 'dealerCode' }, { @@ -132,8 +298,18 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { as: 'application', include: [ { 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,13 +334,46 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { } }; +const STAGE_FLOW_FORWARD: Record = { + [CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW, + [CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, + [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW, + [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW, + [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.HEAD_REVIEW, + [CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL, + [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW, + [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED +}; + +/** SRS §12.2.3 — return to previous review stage */ +const STAGE_FLOW_BACK: Record = { + [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 +}; + +const actionSuccessMessage = (raw: string): string => { + const a = String(raw || '').trim().toLowerCase(); + if (a === 'reject') return 'Request rejected successfully'; + 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 { action, comments } = req.body; // Approve, Reject, Send Back + 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({ @@ -173,37 +382,121 @@ export const takeAction = async (req: AuthRequest, res: Response) => { if (!request) return res.status(404).json({ success: false, message: 'Request not found' }); - if (action === 'Reject') { + 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 - }); - } else { - // Multi-level approval flow as per SRS 12.2.4 - const stageFlow: Record = { - [CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW, - [CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, - [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW, - [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW, - [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.HEAD_REVIEW, - [CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL, - [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW, - [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED - }; - - const nextStage = stageFlow[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: action === 'Approve' ? `Approved to ${nextStage}` : action, - remarks: comments, - userFullName: req.user.fullName + 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) { console.error('Take action error:', error); 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) => { try { + if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); + const { id } = req.params; const idStr = String(id); const { documents } = req.body; @@ -245,6 +540,15 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => { 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' }); } catch (error) { console.error('Upload documents error:', error); diff --git a/src/modules/self-service/constitutional.routes.ts b/src/modules/self-service/constitutional.routes.ts index 15aa123..4a98964 100644 --- a/src/modules/self-service/constitutional.routes.ts +++ b/src/modules/self-service/constitutional.routes.ts @@ -4,6 +4,7 @@ import * as constitutionalController from './constitutional.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; // Constitutional change routes (Base at /) +router.get('/meta', authenticate as any, constitutionalController.getMeta); router.post('/', authenticate as any, constitutionalController.submitRequest); router.get('/checklist', authenticate as any, constitutionalController.getChecklist); router.get('/', authenticate as any, constitutionalController.getRequests); diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index cc734dc..089cc18 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -3,7 +3,7 @@ import db from '../../database/models/index.js'; import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument } = 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 { v4 as uuidv4 } from 'uuid'; 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) => { try { 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 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 request = await RelocationRequest.create({ @@ -171,6 +271,18 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { // Auto-assign evaluators based on outlet location hierarchy 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({ success: true, 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' }); 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 = action?.toUpperCase() || ''; + const normalizedAction = String(action || '') + .trim() + .toUpperCase() + .replace(/\s+/g, '_'); // Check if id is a UUID or a requestId string 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' }); } + 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 const canAction = await RelocationWorkflowService.canUserAction(request, req.user); 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 newCurrentStage = request.currentStage; @@ -438,61 +578,128 @@ export const takeAction = async (req: AuthRequest, res: Response) => { [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED }; + const reverseStageFlow: Record = { + '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') { newCurrentStage = stageFlow[request.currentStage] || request.currentStage; newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`; } else if (normalizedAction === 'REJECT') { newStatus = '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 currentStepIndex = Object.keys(stageFlow).indexOf(request.currentStage); - const newProgress = normalizedAction === 'APPROVE' - ? Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100) - : request.progressPercentage; + const stageKeys = Object.keys(stageFlow); + const currentStepIndex = stageKeys.indexOf(request.currentStage as string); + const backStepIndex = stageKeys.indexOf(newCurrentStage as string); + 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, { - reason: comments || 'No remarks provided', + reason: reviewComments || 'No remarks provided', stage: newCurrentStage, action: normalizedAction, 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') { 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({ where: { relocationId: request.id }, - defaults: { + defaults: { status: 'In Progress', relocationId: request.id, 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) { console.error('Failed to auto-initiate EOR checklist:', e); } } - // 3. Create a worknote entry for the comment - if (comments) { + // 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided + 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({ requestId: request.id, requestType: 'relocation' as any, userId: req.user.id, - content: comments, - isInternal: true + noteText: `${prefix}${reviewComments}`, + 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) { console.error('Take action error:', error); 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() }); + 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({ success: true, message: 'Document uploaded successfully', @@ -568,94 +795,128 @@ 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 { const { id, documentId } = req.params; + const { remarks } = req.body || {}; if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); 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); - // Search by UUID or requestId for the request const request = await RelocationRequest.findOne({ where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr } }); - if (!request) { 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'; 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); 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 documentIndex = currentDocuments.findIndex((d: any) => d.id === documentId); - - if (documentIndex !== -1) { - currentDocuments[documentIndex].status = 'Verified'; - currentDocuments[documentIndex].verifiedBy = req.user.fullName; - currentDocuments[documentIndex].verifiedOn = new Date(); - - // Add simple timeline log for document verification in same update - const updatedTimeline = [...(request.timeline || []), { - stage: request.currentStage, - timestamp: new Date(), - user: req.user.fullName, - action: 'Document Verified', - remarks: `Verified document: ${currentDocuments[documentIndex].name}` - }]; - - // Calculate progress percentage - const totalRequired = 12; // Standard relocation requirement - const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length; - const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100); - - // Update request status to 'In Progress' if it was 'Pending' - let newStatus = request.status; - if (request.status === 'Pending') { - newStatus = 'In Progress'; - } - - await request.update({ - documents: currentDocuments, - timeline: updatedTimeline, - progressPercentage, - status: newStatus, - updatedAt: new Date() - }); - - // Force Sequelize to detect JSON changes - request.changed('documents', true); - request.changed('timeline', true); - await request.save(); - - return res.json({ - success: true, - message: 'Document verified successfully', - document: currentDocuments[documentIndex], - progressPercentage, - status: newStatus - }); + if (documentIndex === -1) { + return res.status(404).json({ success: false, message: 'Document not found in request tracker' }); } - res.status(404).json({ success: false, message: 'Document not found in request tracker' }); + currentDocuments[documentIndex].status = targetStatus; + currentDocuments[documentIndex].verifiedBy = req.user.fullName; + currentDocuments[documentIndex].verifiedOn = new Date(); + if (targetStatus === 'Rejected') { + currentDocuments[documentIndex].rejectionRemarks = remarks || 'Rejected by reviewer'; + } + + const actionText = targetStatus === 'Verified' ? 'Document Verified' : 'Document Rejected'; + const updatedTimeline = [...(request.timeline || []), { + stage: request.currentStage, + timestamp: new Date(), + user: req.user.fullName, + action: actionText, + remarks: + targetStatus === 'Verified' + ? `Verified document: ${currentDocuments[documentIndex].name}` + : `Rejected document: ${currentDocuments[documentIndex].name}. ${remarks || ''}`.trim() + }]; + + const totalRequired = REQUIRED_RELOCATION_DOCUMENTS.length; + const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length; + const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100); + + let newStatus = request.status; + if (request.status === 'Pending') { + newStatus = 'In Progress'; + } + + await request.update({ + documents: currentDocuments, + timeline: updatedTimeline, + progressPercentage, + status: newStatus, + updatedAt: new Date() + }); + + request.changed('documents', true); + request.changed('timeline', true); + 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({ + success: true, + message: targetStatus === 'Verified' ? 'Document verified successfully' : 'Document rejected successfully', + document: currentDocuments[documentIndex], + progressPercentage, + status: newStatus + }); } catch (error) { - console.error('Verify document error:', error); - res.status(500).json({ success: false, message: 'Error verifying document' }); + console.error('Relocation document decision error:', error); + 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 function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; // Radius of Earth in km diff --git a/src/modules/self-service/relocation.routes.ts b/src/modules/self-service/relocation.routes.ts index 133b7d3..23f1ff1 100644 --- a/src/modules/self-service/relocation.routes.ts +++ b/src/modules/self-service/relocation.routes.ts @@ -12,5 +12,6 @@ router.get('/:id', authenticate as any, relocationController.getRequestById); router.post('/:id/action', authenticate as any, relocationController.takeAction); 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/reject', authenticate as any, relocationController.rejectDocument); export default router; \ No newline at end of file diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 26d51e2..4b04192 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -17,6 +17,7 @@ import { ResignationWorkflowService } from '../../services/ResignationWorkflowSe import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; +import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; // 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' }); } + const sourceStage = resignation.currentStage; + // Transition via Workflow Service await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, { remarks, @@ -374,6 +377,20 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: } 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 }); } catch (error) { 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 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 }); } catch (error) { 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 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' }); } catch (error) { if (transaction) await transaction.rollback(); @@ -512,6 +558,21 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: }); 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}` }); } catch (error) { if (transaction) await transaction.rollback(); diff --git a/src/modules/self-service/self-service.routes.ts b/src/modules/self-service/self-service.routes.ts index e02e192..b9c3cb5 100644 --- a/src/modules/self-service/self-service.routes.ts +++ b/src/modules/self-service/self-service.routes.ts @@ -18,5 +18,7 @@ router.get('/relocation', authenticate as any, relocationController.getRequests) router.get('/relocation/:id', authenticate as any, relocationController.getRequestById); 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/:documentId/verify', authenticate as any, relocationController.verifyDocument); +router.post('/relocation/:id/documents/:documentId/reject', authenticate as any, relocationController.rejectDocument); export default router; diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index 5e29de3..d2e43b2 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -6,6 +6,7 @@ import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINA import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; +import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; export const getDepartments = async (req: Request, res: Response) => { try { @@ -40,6 +41,13 @@ export const updatePayment = async (req: AuthRequest, res: Response) => { const payment = await FinancePayment.findByPk(id); 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'; 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 }); } catch (error) { console.error('Update payment error:', error); diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index cf1da55..6109f17 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -16,6 +16,7 @@ import { TerminationWorkflowService } from '../../services/TerminationWorkflowSe import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; +import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; // Create termination request 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' }); } + const fromStage = termination.currentStage; + let approvedToStage: string | null = null; + if (action === 'reject') { await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { 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' }); } + approvedToStage = nextStage; + await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, { remarks, status: getTerminationStatusForStage(nextStage) @@ -303,6 +309,32 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n } 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 }); } catch (error) { if (transaction) await transaction.rollback(); diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index fc65758..3305531 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -6,8 +6,12 @@ export class ConstitutionalWorkflowService { * Transitions a constitutional change request to a new stage */ static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) { - const { action, status, remarks, userFullName } = options; + const { action, status, remarks, userFullName, auditAction: explicitAuditAction } = options; const sourceStage = request.currentStage; + const actionLower = String(action || '').toLowerCase(); + const resolvedAuditAction = + explicitAuditAction ?? + (actionLower.includes('reject') ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED); const updatedTimeline = [ ...(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 = { currentStage: targetStage, - status: status || (targetStage === CONSTITUTIONAL_STAGES.COMPLETED ? 'Completed' : targetStage), + status: resolvedStatus, progressPercentage: this.calculateProgress(targetStage), timeline: updatedTimeline, updatedAt: new Date() @@ -35,7 +49,7 @@ export class ConstitutionalWorkflowService { await db.ConstitutionalAudit.create({ userId, constitutionalChangeId: request.id, - action: action === 'Reject' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED, + action: resolvedAuditAction, remarks: remarks || '', details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } }); @@ -57,7 +71,8 @@ export class ConstitutionalWorkflowService { [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85, [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95, [CONSTITUTIONAL_STAGES.COMPLETED]: 100, - [CONSTITUTIONAL_STAGES.REJECTED]: 0 + [CONSTITUTIONAL_STAGES.REJECTED]: 0, + [CONSTITUTIONAL_STAGES.REVOKED]: 0 }; return progress[stage] || 0; } diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index 18f0504..c57dce7 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -8,7 +8,7 @@ export class RelocationWorkflowService { */ static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { const previousStatus = request.status; - const { reason, stage, progressPercentage, action } = metadata; + const { reason, stage, progressPercentage, action, auditAction } = metadata; const updateData: any = { status: targetStatus, @@ -45,10 +45,21 @@ export class RelocationWorkflowService { await request.update({ timeline: updatedTimeline }); // 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({ userId: userId, relocationRequestId: request.id, - action: action === 'REJECT' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.APPROVED, + action: resolvedAuditAction, remarks: reason || '', details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus } }); diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index 594e3dc..f25612b 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -1,5 +1,5 @@ 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 { getResignationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; @@ -54,16 +54,6 @@ export class ResignationWorkflowService { 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}`); // 5. Send Notifications diff --git a/src/services/WorkflowIntegrityService.ts b/src/services/WorkflowIntegrityService.ts index cd85b30..1298034 100644 --- a/src/services/WorkflowIntegrityService.ts +++ b/src/services/WorkflowIntegrityService.ts @@ -114,17 +114,17 @@ export class WorkflowIntegrityService { }); 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 const request = await db.LoiRequest.findOne({ where: { applicationId: application.id } }); if (request && request.status !== 'Approved') { await request.update({ status: 'Approved', approvedAt: new Date() }); } - await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, null, { - reason: 'Auto-transitioned by Integrity Service: Policy and Payment criteria met.', - progressPercentage: 80 + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, null, { + reason: 'Integrity sync: LOI policy and deposit verified — use Security Details admin approval to reach LOI Issued.', + progressPercentage: 78 }); } } diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index fa00b34..b5c00b2 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -1,11 +1,13 @@ import db from '../database/models/index.js'; -const { - Application, ApplicationStatusHistory, AuditLog, - User, Dealer -} = db; -import { syncApplicationProgress } from '../common/utils/progress.js'; -import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js'; +const { Application, ApplicationStatusHistory, User, Dealer } = db; +import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../common/utils/progress.js'; +import { + AUDIT_ACTIONS, + APPLICATION_STAGES, + OVERALL_STATUS_TO_DB_CURRENT_STAGE, +} from '../common/config/constants.js'; import { NotificationService } from './NotificationService.js'; +import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js'; export class WorkflowService { /** @@ -14,7 +16,9 @@ export class WorkflowService { */ static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { 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) if (targetStatus === previousStatus && !forceLog) { @@ -22,14 +26,28 @@ export class WorkflowService { return application; } + const allowedStages = new Set(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 = { overallStatus: targetStatus, updatedAt: new Date() }; - // Update stage if provided and valid - if (stage && Object.values(APPLICATION_STAGES).includes(stage)) { - updateData.currentStage = stage; + if (stageForDbColumn && allowedStages.has(stageForDbColumn)) { + updateData.currentStage = stageForDbColumn; } // Update progress percentage if explicitly provided @@ -37,6 +55,11 @@ export class WorkflowService { updateData.progressPercentage = progressPercentage; } + const nextProgress = + progressPercentage !== undefined ? Number(progressPercentage) : previousProgress; + + const contextBefore = pickApplicationAuditContext(application); + // 1. Update Application Record await application.update(updateData); @@ -49,59 +72,72 @@ export class WorkflowService { changeReason: reason || `Transitioned to ${targetStatus}` }); - // 3. Create High-Fidelity Audit Log - await AuditLog.create({ + const contextAfter = pickApplicationAuditContext(application); + + // 3. Audit log — non-fatal: must not roll back or block the transition + await safeAuditLogCreate({ userId: userId, action: AUDIT_ACTIONS.UPDATED, entityType: 'application', entityId: application.id, - oldData: { - status: previousStatus, - stage: application.currentStage, - progress: application.progressPercentage + oldData: { + status: previousStatus, + stage: previousStage, + progress: previousProgress, + context: contextBefore, }, - newData: { - status: targetStatus, - stage: stage || application.currentStage, - progress: progressPercentage ?? application.progressPercentage, - reason: reason || `Transitioned to ${targetStatus}` + newData: { + status: targetStatus, + stage: stageForDbColumn ?? previousStage, + ...(pipelineStageLabel && + 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) - await syncApplicationProgress(application.id, targetStatus); + // 4. Progress sync — non-fatal (DB state is already committed) + try { + 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) { - const user = await User.findOne({ - where: { email: application.email }, - attributes: ['id'] - }); - const targetUserId = user ? user.id : null; + try { + const user = await User.findOne({ + where: { email: application.email }, + attributes: ['id'], + }); + const targetUserId = user ? user.id : null; - let templateCode = 'ONBOARDING_STATUS_UPDATE'; - if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED'; - if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; - if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; + let templateCode = 'ONBOARDING_STATUS_UPDATE'; + if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED'; + if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; + if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; - await NotificationService.notify(targetUserId, application.email, { - title: `Onboarding Update: ${targetStatus}`, - message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, - channels: ['email', 'whatsapp', 'system'], - templateCode: templateCode, - placeholders: { - status: targetStatus, - applicantName: application.applicantName, - applicationId: application.applicationId, - reason: reason || 'N/A', - salesCode: application.dealerCode?.salesCode || 'N/A', - serviceCode: application.dealerCode?.serviceCode || 'N/A' - } - }); + await NotificationService.notify(targetUserId, application.email, { + title: `Onboarding Update: ${targetStatus}`, + message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, + channels: ['email', 'whatsapp', 'system'], + templateCode: templateCode, + placeholders: { + status: targetStatus, + applicantName: application.applicantName, + applicationId: application.applicationId, + reason: reason || 'N/A', + salesCode: application.dealerCode?.salesCode || '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}`); diff --git a/src/services/applicationAuditLog.service.ts b/src/services/applicationAuditLog.service.ts new file mode 100644 index 0000000..b06a0a9 --- /dev/null +++ b/src/services/applicationAuditLog.service.ts @@ -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 { + 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 | null; + newData?: Record | 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 { + try { + await AuditLog.create(payload as any); + } catch (err) { + console.error('[safeAuditLogCreate] Non-fatal audit failure:', (err as Error)?.message || err); + } +} diff --git a/trigger-relocation.js b/trigger-relocation.js index 8ae9a79..f5b0922 100644 --- a/trigger-relocation.js +++ b/trigger-relocation.js @@ -13,7 +13,7 @@ const EMAILS = { DEALER: args.dealerEmail || "dealer@royalenfield.com", ASM: args.asmEmail || "asm.sdelhi@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", DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com", DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com", @@ -24,7 +24,7 @@ const EMAILS = { const ROLE_BY_STAGE = { "ASM Review": ["ASM"], "RBM Review": ["RBM"], - "DD ZM Review": ["DD_ZM", "RBM"], + "DD ZM Review": ["DD_ZM"], "ZBH Review": ["ZBH"], "DD Lead Review": ["DD_LEAD"], "DD Head Approval": ["DD_HEAD"], @@ -68,9 +68,13 @@ async function approveCurrentStage(requestId, stageName) { } let lastError = null; + const attempts = []; for (const roleKey of candidateRoles) { const email = EMAILS[roleKey]; - if (!email) continue; + if (!email) { + attempts.push(`${roleKey}:missing-email`); + continue; + } try { const token = await login(email); 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" }; } catch (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) { diff --git a/trigger-workflow.js b/trigger-workflow.js index f25f83d..190fdfb 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -123,7 +123,7 @@ async function prospectLogin(phone) { async function mockUploadDocument(appId, token, docType) { const formData = new FormData(); - const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-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' }); formData.append('file', blob, 'screenshot.png'); formData.append('documentType', docType); @@ -389,172 +389,172 @@ async function triggerWorkflow() { await delay(1000); // 7.5 LOI APPROVAL - log(7.5, 'LOI Generation & Approval...'); - const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken); - const loiRequestId = loiRes.data.id; + // log(7.5, 'LOI Generation & Approval...'); + // const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken); + // const loiRequestId = loiRes.data.id; - // Head Approval - await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { - action: 'Approved', - remarks: 'Head Authorization for LOI' - }, headToken); + // // Head Approval + // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { + // action: 'Approved', + // remarks: 'Head Authorization for LOI' + // }, headToken); - // NBH Approval - await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { - action: 'Approved', - remarks: 'NBH Authorization for LOI' - }, nbhToken); + // // NBH Approval + // await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', { + // action: 'Approved', + // remarks: 'NBH Authorization for LOI' + // }, nbhToken); - log(7.5, 'LOI Milestone Complete.'); - await delay(); + // log(7.5, 'LOI Milestone Complete.'); + // await delay(); - // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW) - log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...'); - const financeToken = await login(EMAILS.FINANCE); - await apiRequest('/loa/security-deposit', 'POST', { - applicationId: applicationUUID, - amount: 500000, - paymentReference: 'PAY-888999', - depositType: 'SECURITY_DEPOSIT', - status: 'Verified' - }, financeToken); - log(8, 'Security Deposit Verified.'); - await delay(); + // // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW) + // log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...'); + // const financeToken = await login(EMAILS.FINANCE); + // await apiRequest('/loa/security-deposit', 'POST', { + // applicationId: applicationUUID, + // amount: 500000, + // paymentReference: 'PAY-888999', + // depositType: 'SECURITY_DEPOSIT', + // status: 'Verified' + // }, financeToken); + // log(8, 'Security Deposit Verified.'); + // await delay(); - // 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK) - let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); - log(9, `Current status before code generation: ${statusBeforeCodeGen}`); - log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...'); - await ensureMandatoryCodeGenFields(applicationUUID, adminToken); - await delay(300); + // // 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK) + // let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); + // log(9, `Current status before code generation: ${statusBeforeCodeGen}`); + // log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...'); + // await ensureMandatoryCodeGenFields(applicationUUID, adminToken); + // await delay(300); - if (statusBeforeCodeGen === 'Security Details') { - log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...'); - await apiRequest('/loa/security-deposit', 'POST', { - applicationId: applicationUUID, - amount: 500000, - paymentReference: `PAY-RETRY-${Date.now()}`, - depositType: 'SECURITY_DEPOSIT', - status: 'Verified' - }, financeToken); - await delay(); - statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); - log(9, `Status after re-verify: ${statusBeforeCodeGen}`); - } + // if (statusBeforeCodeGen === 'Security Details') { + // log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...'); + // await apiRequest('/loa/security-deposit', 'POST', { + // applicationId: applicationUUID, + // amount: 500000, + // paymentReference: `PAY-RETRY-${Date.now()}`, + // depositType: 'SECURITY_DEPOSIT', + // status: 'Verified' + // }, financeToken); + // await delay(); + // statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken); + // log(9, `Status after re-verify: ${statusBeforeCodeGen}`); + // } - log(9, 'Admin Generating SAP Dealer Codes...'); - await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken); - log(9, 'Dealer Codes Generated.'); - await delay(); + // log(9, 'Admin Generating SAP Dealer Codes...'); + // await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken); + // log(9, 'Dealer Codes Generated.'); + // await delay(); - // 10. FIRST FILL (POST CODE-GENERATION) - log(10, 'Finance Verifying FIRST FILL (₹15L)...'); - await apiRequest('/loa/security-deposit', 'POST', { - applicationId: applicationUUID, - amount: 1500000, - paymentReference: 'PAY-FIN-999', - depositType: 'FIRST_FILL', - status: 'Verified' - }, financeToken); - log(10, 'Final Security Deposit Verified.'); - await delay(); + // // 10. FIRST FILL (POST CODE-GENERATION) + // log(10, 'Finance Verifying FIRST FILL (₹15L)...'); + // await apiRequest('/loa/security-deposit', 'POST', { + // applicationId: applicationUUID, + // amount: 1500000, + // paymentReference: 'PAY-FIN-999', + // depositType: 'FIRST_FILL', + // status: 'Verified' + // }, financeToken); + // log(10, 'Final Security Deposit Verified.'); + // await delay(); - // 11. ADMIN UPDATING STATUTORY & BANK DETAILS - log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...'); - await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', { - accountHolderName: 'Ramesh Automobiles Private Limited', - panNumber: 'ABCDE1234F', - gstNumber: '07ABCDE1234F1Z5', - bankName: 'HDFC Bank', - accountNumber: '50100223344556', - ifscCode: 'HDFC0001234' - }, adminToken); - log(11, 'Statutory & Bank details updated.'); - await delay(); + // // 11. ADMIN UPDATING STATUTORY & BANK DETAILS + // log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...'); + // await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', { + // accountHolderName: 'Ramesh Automobiles Private Limited', + // panNumber: 'ABCDE1234F', + // gstNumber: '07ABCDE1234F1Z5', + // bankName: 'HDFC Bank', + // accountNumber: '50100223344556', + // ifscCode: 'HDFC0001234' + // }, adminToken); + // log(11, 'Statutory & Bank details updated.'); + // await delay(); - // 12. FINAL LOA APPROVAL - log(12, 'NBH & Head Approving Final LOA...'); - const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken); - const finalLoaRequestId = loaRes.data.id; + // // 12. FINAL LOA APPROVAL + // log(12, 'NBH & Head Approving Final LOA...'); + // const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken); + // const finalLoaRequestId = loaRes.data.id; - await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { - action: 'Approved', - remarks: 'Head Authorization (Level 1)' - }, headToken); + // await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { + // action: 'Approved', + // remarks: 'Head Authorization (Level 1)' + // }, headToken); - await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { - action: 'Approved', - remarks: 'NBH Approval (Level 2)' - }, nbhToken); - log(12, 'LOA Fully Approved.'); - await delay(); + // await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', { + // action: 'Approved', + // remarks: 'NBH Approval (Level 2)' + // }, nbhToken); + // log(12, 'LOA Fully Approved.'); + // await delay(); - // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION - log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...'); - const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken); - const checklistId = eorInit.data.id; - log(13, `EOR Checklist Created (ID: ${checklistId})`); + // // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION + // log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...'); + // const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken); + // const checklistId = eorInit.data.id; + // log(13, `EOR Checklist Created (ID: ${checklistId})`); - log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...'); - const eorItems = [ - { itemType: 'Sales', description: 'Sales Standards' }, - { itemType: 'Service', description: 'Service & Spares' }, - { itemType: 'IT', description: 'DMS infra' }, - { itemType: 'Training', description: 'Manpower Training' }, - { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' }, - { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' }, - { itemType: 'Finance', description: 'Inventory Funding' }, - { itemType: 'IT', description: 'Virtual code availability' }, - { itemType: 'Finance', description: 'Vendor payments' }, - { itemType: 'Marketing', description: 'Details for website submission' }, - { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' }, - { itemType: 'IT', description: 'Auto ordering' } - ]; + // log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...'); + // const eorItems = [ + // { itemType: 'Sales', description: 'Sales Standards' }, + // { itemType: 'Service', description: 'Service & Spares' }, + // { itemType: 'IT', description: 'DMS infra' }, + // { itemType: 'Training', description: 'Manpower Training' }, + // { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' }, + // { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' }, + // { itemType: 'Finance', description: 'Inventory Funding' }, + // { itemType: 'IT', description: 'Virtual code availability' }, + // { itemType: 'Finance', description: 'Vendor payments' }, + // { itemType: 'Marketing', description: 'Details for website submission' }, + // { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' }, + // { itemType: 'IT', description: 'Auto ordering' } + // ]; - for (const item of eorItems) { - process.stdout.write(`.`); // Visual progress - await apiRequest(`/eor/item/${checklistId}`, 'POST', { - ...item, - isCompliant: true, - remarks: 'Verified by Auditor - Compliant' - }, adminToken); - } - console.log('\n[STEP 13.1] All EOR items marked as compliant.'); + // for (const item of eorItems) { + // process.stdout.write(`.`); // Visual progress + // await apiRequest(`/eor/item/${checklistId}`, 'POST', { + // ...item, + // isCompliant: true, + // remarks: 'Verified by Auditor - Compliant' + // }, adminToken); + // } + // console.log('\n[STEP 13.1] All EOR items marked as compliant.'); - log(13.2, 'Auditor Submitting Final EOR Audit...'); - await apiRequest(`/eor/audit/${checklistId}`, 'POST', { - status: 'Completed', - overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.' - }, adminToken); + // log(13.2, 'Auditor Submitting Final EOR Audit...'); + // await apiRequest(`/eor/audit/${checklistId}`, 'POST', { + // status: 'Completed', + // overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.' + // }, adminToken); - // Status check - const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken); - log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); - await delay(); + // // Status check + // const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken); + // log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`); + // await delay(); - // 14. FINAL ONBOARDING - log(14, 'Admin Finalizing Dealer Onboarding...'); - await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken); - await delay(); + // // 14. FINAL ONBOARDING + // log(14, 'Admin Finalizing Dealer Onboarding...'); + // await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken); + // await delay(); - // 15. VERIFICATION - log(15, 'Verifying Dealer Record Creation...'); - const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken); - if (!dealerRes.success || !dealerRes.data) { - throw new Error('Verification Failed: Dealer record not found after onboarding.'); - } - log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`); + // // 15. VERIFICATION + // log(15, 'Verifying Dealer Record Creation...'); + // const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken); + // if (!dealerRes.success || !dealerRes.data) { + // throw new Error('Verification Failed: Dealer record not found after onboarding.'); + // } + // log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`); - log(15.1, 'Verifying User Account Role Update...'); - const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); - const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL); - if (!dealerUser || dealerUser.roleCode !== 'Dealer') { - 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, 'Verifying User Account Role Update...'); + // const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken); + // const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL); + // if (!dealerUser || dealerUser.roleCode !== 'Dealer') { + // 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.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---'); - log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`); + // log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---'); + // log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`); } /**