diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 5fe8ed0..cbba5ca 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -84,10 +84,13 @@ export const APPLICATION_STATUS = { STATUTORY_LOI_ACK: 'Statutory LOI Ack', EOR_IN_PROGRESS: 'EOR In Progress', LOA_PENDING: 'LOA Pending', + LOA_ISSUED: 'LOA Issued', + LOA_REJECTED: 'LOA Rejected', EOR_COMPLETE: 'EOR Complete', INAUGURATION: 'Inauguration', ONBOARDED: 'Onboarded', - DISQUALIFIED: 'Disqualified' + DISQUALIFIED: 'Disqualified', + LOI_REJECTED: 'LOI Rejected' } as const; // Termination Stages diff --git a/src/common/utils/progress.ts b/src/common/utils/progress.ts index 50688fa..cc1ec1c 100644 --- a/src/common/utils/progress.ts +++ b/src/common/utils/progress.ts @@ -49,10 +49,11 @@ export const updateApplicationProgress = async (applicationId: string, stageName await progress.update(updates); } - // Mark previous stages as completed if this one is completed - if (status === 'completed') { + // Whenever a stage is marked 'active' or 'completed', + // all previous stages MUST be completed. + if (status === 'active' || status === 'completed') { await ApplicationProgress.update( - { status: 'completed', completionPercentage: 100 }, + { status: 'completed', completionPercentage: 100, stageCompletedAt: new Date() }, { where: { applicationId, @@ -76,6 +77,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat // Map overallStatus to stage names const statusToStageMap: Record = { 'Submitted': 'Submitted', + 'Questionnaire Pending': 'Submitted', 'Questionnaire Completed': 'Questionnaire', 'Shortlisted': 'Shortlist', 'Level 1 Interview Pending': '1st Level Interview', @@ -87,6 +89,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat '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', @@ -96,7 +99,8 @@ export const syncApplicationProgress = async (applicationId: string, overallStat 'Architecture Team Completion': 'Dealer Code Generation', 'Statutory GST': 'Dealer Code Generation', 'LOA Pending': 'LOA', - 'EOR In Progress': 'LOA', + 'LOA Issued': 'LOA', + 'EOR In Progress': 'EOR Complete', 'EOR Complete': 'EOR Complete', 'Inauguration': 'Inauguration', 'Approved': 'Inauguration', @@ -108,11 +112,12 @@ export const syncApplicationProgress = async (applicationId: string, overallStat const stage = ONBOARDING_STAGES.find(s => s.name === currentStageName); if (stage) { // Determine status for this stage - // If the status IS exactly the target "Complete" status for a stage, mark it as completed const isCompleted = [ - 'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 3 Approved', - 'LOI Issued', 'EOR Complete', 'Approved', 'Onboarded' + 'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved', + 'Level 2 Approved', 'Level 3 Approved', 'LOI Issued', 'EOR Complete', + 'Approved', 'Onboarded' ].includes(overallStatus); + await updateApplicationProgress(applicationId, currentStageName, isCompleted ? 'completed' : 'active', isCompleted ? 100 : 50); } diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 99747ee..672b444 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -7,7 +7,9 @@ const { import { AuthRequest } from '../../types/express.types.js'; import { Op } from 'sequelize'; import * as EmailService from '../../common/utils/email.service.js'; -import { APPLICATION_STAGES } from '../../common/config/constants.js'; +import { APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; +import { WorkflowService } from '../../services/WorkflowService.js'; +import { syncApplicationProgress } from '../../common/utils/progress.js'; const getLocationAncestors = async (locationId: string): Promise => { const district: any = await District.findByPk(locationId); @@ -60,22 +62,14 @@ const processStageDecision = async (params: { const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; - // Check if user is an assigned participant for this specifically (Interviews use metadata mapping) + // Check if user is an assigned participant const userAssignments = await db.RequestParticipant.findAll({ - where: { - requestId: applicationId, - requestType: 'application', - userId: userId - } + where: { requestId: applicationId, requestType: 'application', userId } }); - // Strategy: If it's an interview, check interviewLevel. If it's a stage, check stageCode. const isAssigned = userAssignments.some((p: any) => { if (!p.metadata) return false; - if (interviewId && p.metadata.interviewLevel) { - // Check if this participant is for THIS interview (rough check via level) - return true; - } + if (interviewId && p.metadata.interviewLevel) return true; if (p.metadata.stageCode === stageCode) return true; if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true; return false; @@ -83,113 +77,80 @@ const processStageDecision = async (params: { const assignedRole = userAssignments.find((p: any) => p.metadata?.role)?.metadata?.role; - console.log(`[decision] User: ${userId}, Role: ${roleCode}, Stage: ${stageCode}, isAssigned: ${isAssigned}`); - - // Forbidden if not Super Admin AND not in required roles AND not an assigned participant for this stage if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) { return { forbidden: true, policy, requiredRoles, currentRole: roleCode }; } - // Record Action - await db.StageApprovalAction.upsert({ - id: undefined, // Let it generate or find by unique index - applicationId, - interviewId: interviewId || null, - stageCode, - actorUserId: userId, - actorRole: assignedRole || roleCode, - decision, - remarks: remarks || null - }); + // Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres + if (!interviewId) { + const existing = await db.StageApprovalAction.findOne({ + where: { applicationId, stageCode, actorUserId: userId, interviewId: null } + }); + if (existing) { + await existing.update({ decision, remarks: remarks || null, actorRole: assignedRole || roleCode }); + } else { + await db.StageApprovalAction.create({ + applicationId, + stageCode, + actorUserId: userId, + actorRole: assignedRole || roleCode, + decision, + remarks: remarks || null + }); + } + } else { + await db.StageApprovalAction.upsert({ + applicationId, + interviewId, + stageCode, + actorUserId: userId, + actorRole: assignedRole || roleCode, + decision, + remarks: remarks || null + }); + } - // Update the evaluation decision and recommendation for dashboard consistency if (interviewId) { await InterviewEvaluation.update( - { - decision: decision, - recommendation: decision // Sync for combined dashboard view - }, + { decision, recommendation: decision }, { where: { interviewId, evaluatorId: userId } } ); } - // Evaluate Policy - const actions = await db.StageApprovalAction.findAll({ - where: { applicationId, stageCode } - }); - - const approvedActions = actions.filter((a: any) => a.decision === 'Approved'); - const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole)); + // Evaluate Policy via Centralized Service (FIXED unique user count) + const evaluation = await WorkflowService.evaluateStagePolicy(applicationId, stageCode); - const isSuperAdminApproval = Array.from(uniqueApprovalsByRole).includes('Super Admin'); - const hasRejection = actions.some((a: any) => a.decision === 'Rejected'); - - const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval) - ? true - : requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role)); - - const meetsMinApprovals = isSuperAdminApproval || uniqueApprovalsByRole.size >= (policy.minApprovals || 1); - - console.log(`[decision] Policy Meet: ${hasAllRequiredRoleApprovals && meetsMinApprovals} (Rejection: ${hasRejection})`); - + const hasRejection = decision === 'Rejected'; // Immediate rejection if ANY required actor rejects (business rule) let statusUpdated = false; + if (hasRejection) { - await db.Application.update({ - overallStatus: 'Rejected', - currentStage: 'Rejected' - }, { where: { id: applicationId } }); - - await db.ApplicationStatusHistory.create({ - applicationId, - previousStatus: 'In Progress', - newStatus: 'Rejected', - changedBy: userId, - reason: `Rejected during ${stageCode} stage` - }); - statusUpdated = true; - } else if (hasAllRequiredRoleApprovals && meetsMinApprovals) { - if (nextStatus) { - const validStages = Object.values(APPLICATION_STAGES); - const updateData: any = { - overallStatus: nextStatus, - progressPercentage: nextProgress || undefined, - updatedAt: new Date() - }; - - if (nextStatus && validStages.includes(nextStatus as any)) { - updateData.currentStage = nextStatus; - } - - await db.Application.update(updateData, { where: { id: applicationId } }); - - await db.ApplicationStatusHistory.create({ - applicationId, - previousStatus: 'In Progress', - newStatus: nextStatus, - changedBy: userId, - reason: `Policy met for ${stageCode}` + const application = await db.Application.findByPk(applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, { + reason: `Rejected during ${stageCode} stage: ${remarks}`, + stage: APPLICATION_STAGES.REJECTED + }); + statusUpdated = true; + } + } else if (evaluation.policyMet && nextStatus) { + const application = await db.Application.findByPk(applicationId); + if (application) { + await WorkflowService.transitionApplication(application, nextStatus, userId, { + reason: `Policy met for ${stageCode}`, + progressPercentage: nextProgress }); - - // Sync Progress tracking - const { syncApplicationProgress } = await import('../../common/utils/progress.js'); - await syncApplicationProgress(applicationId, nextStatus); - statusUpdated = true; } } - const message = hasRejection ? 'Rejected' - : statusUpdated ? 'Policy satisfied. Stage complete.' - : `Approval recorded. Waiting for ${requiredRoles.filter(r => !uniqueApprovalsByRole.has(r)).join(', ') || 'other approvers'}.`; - return { success: true, - message, + message: hasRejection ? 'Rejected' : statusUpdated ? 'Policy satisfied. Stage complete.' : 'Approval recorded.', policy, - requiredRoles, - uniqueApprovalsByRole, - hasAllRequiredRoleApprovals, - meetsMinApprovals, + requiredRoles: evaluation.policy.requiredRoles, + uniqueApprovalsByRole: evaluation.approvedRoles, + hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals, + meetsMinApprovals: evaluation.meetsMinApprovals, statusUpdated }; }; @@ -304,25 +265,12 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons status: 'Completed' }); - // Update Application - await application.update({ - score: totalWeightedScore, - overallStatus: 'Questionnaire Completed', - progressPercentage: 20 - }); - - // Log Status History - await db.ApplicationStatusHistory.create({ - applicationId: application.id, - previousStatus: 'Questionnaire Pending', - newStatus: 'Questionnaire Completed', - changedBy: req.user?.id || null, - reason: 'Questionnaire submitted by applicant' - }); - - // Sync Progress tracking - const { syncApplicationProgress } = await import('../../common/utils/progress.js'); - await syncApplicationProgress(application.id, 'Questionnaire Completed'); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED, req.user?.id || null, { + reason: 'Questionnaire submitted by applicant', + progressPercentage: 20 + }); + } res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore }); } catch (error) { @@ -380,7 +328,12 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { const newStatus = statusMap[levelNum] || 'Interview Scheduled'; - await db.Application.update({ overallStatus: newStatus }, { where: { id: applicationId } }); + const application = await db.Application.findByPk(applicationId); + if (application) { + await WorkflowService.transitionApplication(application, newStatus, req.user?.id || null, { + reason: `Interview Level ${levelNum} Scheduled` + }); + } // MOCK INTEGRATIONS // 1. Google Calendar Mock @@ -400,7 +353,6 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { ); } - const application = await db.Application.findByPk(applicationId); let participantIds: string[] = Array.isArray(participants) ? participants : []; // Auto-fill participants from pre-assigned RequestParticipants if not provided @@ -964,11 +916,13 @@ export const submitStageDecision = async (req: AuthRequest, res: Response) => { if (result.noPolicy) { // Fallback: If no policy, just update application status directly (legacy behavior) if (nextStatus) { - await db.Application.update({ - overallStatus: nextStatus, - currentStage: nextStatus, - progressPercentage: nextProgress || undefined - }, { where: { id: applicationId } }); + const application = await db.Application.findByPk(applicationId); + if (application) { + await WorkflowService.transitionApplication(application, nextStatus, req.user?.id || null, { + reason: 'Fallback Transition (No Policy)', + progressPercentage: nextProgress + }); + } } return res.json({ success: true, message: 'Status updated (No policy found)' }); } diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index d94a4c4..5e473bf 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -6,8 +6,9 @@ const { Resignation, RelocationRequest, ConstitutionalChange } = db; import { AuthRequest } from '../../types/express.types.js'; -import { AUDIT_ACTIONS } from '../../common/config/constants.js'; +import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js'; import { Op } from 'sequelize'; +import { WorkflowService } from '../../services/WorkflowService.js'; export const getDealers = async (req: Request, res: Response) => { try { @@ -32,6 +33,20 @@ export const createDealer = async (req: AuthRequest, res: Response) => { const application = await Application.findByPk(applicationId); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + // SRS Validation: Only allow onboarding at the 'Inauguration' stage + if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION) { + return res.status(400).json({ + success: false, + message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' stage before final onboarding. Current status: ${application.overallStatus}` + }); + } + + // Optional: Check EOR Progress (if progressPercentage is tracked accurately) + if (application.progressPercentage < 95 && application.overallStatus !== APPLICATION_STATUS.INAUGURATION) { + // We can be conservative here or strictly check 100% + // For now, enforcing the Status is the most critical SRS requirement. + } + // Find existing dealer or auto-detect dealer code let targetDealerCodeId = dealerCodeId; if (!targetDealerCodeId) { @@ -165,24 +180,12 @@ export const createDealer = async (req: AuthRequest, res: Response) => { } // Final Step: Update Application Status to Onboarded - await application.update({ - overallStatus: 'Onboarded', - progressPercentage: 100, - updatedAt: new Date() - }); - - // Update progress tracking - const { updateApplicationProgress } = await import('../../common/utils/progress.js'); - await updateApplicationProgress(application.id, 'Onboarded', 'completed', 100); - - // Add history entry - await db.ApplicationStatusHistory.create({ - applicationId: application.id, - previousStatus: application.overallStatus, - newStatus: 'Onboarded', - changedBy: req.user?.id, - reason: 'Dealer Onboarding Finalized' - }); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ONBOARDED, req.user?.id || null, { + reason: 'Dealer Onboarding Finalized', + progressPercentage: 100 + }); + } res.status(201).json({ success: true, diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 6571770..8de0395 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -2,7 +2,8 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; import { AuthRequest } from '../../types/express.types.js'; -import { AUDIT_ACTIONS } from '../../common/config/constants.js'; +import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js'; +import { WorkflowService } from '../../services/WorkflowService.js'; const LOA_STAGE_CODE = 'LOA_APPROVAL'; @@ -63,8 +64,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => { } }); - await application.update({ - overallStatus: 'LOA Pending', + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, { + reason: 'LOA Request initiated with DD Head approval', progressPercentage: 92 }); @@ -133,11 +134,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); - await db.Application.update({ - overallStatus: 'LOA Rejected', - currentStage: 'Rejected', - progressPercentage: 92 - }, { where: { id: request.applicationId } }); + const application = await db.Application.findByPk(request.applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_REJECTED, req.user.id, { + reason: 'LOA Request rejected during approval', + stage: 'Rejected', + progressPercentage: 92 + }); + } return res.json({ success: true, message: 'LOA Request rejected' }); } @@ -152,10 +156,13 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { filePath: `/uploads/loa/${mockFile}` }); - await db.Application.update({ - overallStatus: 'Authorized for Operations', - progressPercentage: 97 - }, { where: { id: request.applicationId } }); + const application = await db.Application.findByPk(request.applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_ISSUED, req.user.id, { + reason: 'LOA fully approved and issued', + progressPercentage: 97 + }); + } res.json({ success: true, message: 'LOA fully approved and issued' }); } else { res.json({ diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index c337086..d9674dc 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -2,7 +2,8 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db; import { AuthRequest } from '../../types/express.types.js'; -import { AUDIT_ACTIONS } from '../../common/config/constants.js'; +import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js'; +import { WorkflowService } from '../../services/WorkflowService.js'; const LOI_STAGE_CODE = 'LOI_APPROVAL'; @@ -54,10 +55,13 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => { status: 'Acknowledged' }); - await db.Application.update({ - overallStatus: 'Dealer Code Generation', - progressPercentage: 90 - }, { where: { id: request.applicationId } }); + const application = await db.Application.findByPk(request.applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, { + reason: 'LOI Acknowledged by applicant', + progressPercentage: 90 + }); + } res.json({ success: true, message: 'LOI Acknowledged by applicant' }); } catch (error) { @@ -90,8 +94,8 @@ export const createRequest = async (req: AuthRequest, res: Response) => { } }); - await application.update({ - overallStatus: 'LOI In Progress', + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_IN_PROGRESS, req.user?.id || null, { + reason: 'LOI Request initiated with Finance approval', progressPercentage: 75 }); @@ -182,11 +186,14 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { // 2. Handle Logic based on Action if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); - await db.Application.update({ - overallStatus: 'LOI Rejected', - currentStage: 'Rejected', - progressPercentage: 75 - }, { where: { id: request.applicationId } }); + const application = await db.Application.findByPk(request.applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_REJECTED, req.user.id, { + reason: 'LOI Request rejected during approval', + stage: 'Rejected', + progressPercentage: 75 + }); + } return res.json({ success: true, message: 'LOI Request rejected' }); } @@ -203,10 +210,13 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { filePath: `/uploads/loi/${mockFile}` }); - await db.Application.update({ - overallStatus: 'Security Details', - progressPercentage: 80 - }, { where: { id: request.applicationId } }); + const application = await db.Application.findByPk(request.applicationId); + if (application) { + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, { + reason: 'LOI Request fully approved and document generated', + progressPercentage: 80 + }); + } res.json({ success: true, message: 'LOI Request fully approved and document generated' }); } else { diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 1923a1b..f8d7086 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -8,6 +8,7 @@ import { AuthRequest } from '../../types/express.types.js'; import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js'; import { syncLocationManagers } from '../master/syncHierarchy.service.js'; +import { WorkflowService } from '../../services/WorkflowService.js'; // Helper to find district by name and state name combination const findDistrictByName = async (districtName: string, stateName?: string) => { @@ -93,13 +94,10 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { timeline: [] }); - // Log Status History - await ApplicationStatusHistory.create({ - applicationId: application.id, - previousStatus: null, - newStatus: application.overallStatus, - changedBy: req.user?.id || null, - reason: 'Initial Submission' + // Use WorkflowService for initial status and progress sync + await WorkflowService.transitionApplication(application, application.overallStatus, req.user?.id || null, { + reason: 'Initial Submission', + stage: application.currentStage }); // Send Email (Async) @@ -207,37 +205,11 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) = const application = await Application.findByPk(id); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); - const previousStatus = application.overallStatus; - - await application.update({ - overallStatus: status, - currentStage: stage || application.currentStage, - updatedAt: new Date() - }); - - // Log Status History - await ApplicationStatusHistory.create({ - applicationId: application.id, - previousStatus, - newStatus: status, - changedBy: req.user?.id, - reason - }); - - await AuditLog.create({ - userId: req.user?.id, - action: AUDIT_ACTIONS.UPDATED, - entityType: 'application', - entityId: application.id, - newData: { status, stage } - }); - - // Sync Progress tracking based on new status - try { - const { syncApplicationProgress } = await import('../../common/utils/progress.js'); - await syncApplicationProgress(application.id, status); - } catch (progErr) { - console.error('Progress sync error:', progErr); + if (application) { + await WorkflowService.transitionApplication(application, status, req.user?.id || null, { + reason: reason || 'Manual Status Update', + stage: stage + }); } res.json({ success: true, message: 'Application status updated successfully' }); @@ -402,60 +374,35 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { // but add ALL as participants to enforce dual-responsibility. const primaryAssigneeId = assignedTo[0]; - // Update Applications - await Application.update({ - ddLeadShortlisted: true, - isShortlisted: true, - overallStatus: 'Shortlisted', - progressPercentage: 30, - assignedTo: primaryAssigneeId, - updatedAt: new Date(), - }, { - where: { - id: { [Op.in]: applicationIds } - } - }); - - // Add all assigned users as participants for each application + // Update Applications sequentially via WorkflowService for consistency for (const appId of applicationIds) { - for (const userId of assignedTo) { - await db.RequestParticipant.findOrCreate({ - where: { - requestId: appId, - requestType: 'application', - userId, - participantType: 'assignee' - }, - defaults: { - joinedMethod: 'auto' - } + const application = await Application.findByPk(appId); + if (application) { + await application.update({ + ddLeadShortlisted: true, + isShortlisted: true, + assignedTo: primaryAssigneeId, + updatedAt: new Date(), }); + + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SHORTLISTED, req.user?.id || null, { + reason: remarks || 'Bulk Shortlist', + progressPercentage: 30 + }); + + // Add all assigned users as participants + for (const userId of assignedTo) { + await db.RequestParticipant.findOrCreate({ + where: { requestId: appId, requestType: 'application', userId, participantType: 'assignee' }, + defaults: { joinedMethod: 'auto' } + }); + } + + // AUTO-FILL Interview Evaluators + await assignStageEvaluators(appId); } - - // AUTO-FILL Interview Evaluators for all 3 levels - await assignStageEvaluators(appId); } - // Create Status History Entries - const historyEntries = applicationIds.map(appId => ({ - applicationId: appId, - previousStatus: 'Questionnaire Completed', - newStatus: 'Shortlisted', - changedBy: req.user?.id, - reason: remarks || 'Bulk Shortlist' - })); - await ApplicationStatusHistory.bulkCreate(historyEntries); - - // Audit Log - const auditEntries = applicationIds.map(appId => ({ - userId: req.user?.id, - action: AUDIT_ACTIONS.SHORTLISTED, - entityType: 'application', - entityId: appId, - newData: { isShortlisted: true, assignedTo } - })); - await AuditLog.bulkCreate(auditEntries); - res.json({ success: true, message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.` @@ -674,10 +621,13 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) => architectureAssignedTo: targetUserId, architectureStatus: 'IN_PROGRESS', architectureAssignedDate: new Date(), - overallStatus: 'Architecture Team Assigned', updatedAt: new Date() }); + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED, req.user?.id || null, { + reason: remarks || 'Architecture team assigned' + }); + // Add as participant await db.RequestParticipant.findOrCreate({ where: { @@ -689,14 +639,6 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) => defaults: { joinedMethod: 'auto' } }); - await AuditLog.create({ - userId: req.user?.id, - action: AUDIT_ACTIONS.UPDATED, - entityType: 'application', - entityId: application.id, - newData: { architectureAssignedTo: targetUserId, remarks } - }); - res.json({ success: true, message: 'Architecture team assigned successfully' }); } catch (error) { console.error('Assign architecture team error:', error); @@ -718,21 +660,18 @@ export const updateArchitectureStatus = async (req: AuthRequest, res: Response) }; // Sync overall status if architecture is completed - if (status === 'COMPLETED') { - updateData.overallStatus = 'Architecture Team Completion'; - updateData.architectureCompletionDate = new Date(); - } else if (status === 'IN_PROGRESS') { - updateData.overallStatus = 'Architecture Team Assigned'; - } + const targetOverallStatus = status === 'COMPLETED' + ? APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION + : APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED; - await application.update(updateData); + await application.update({ + architectureStatus: status, + architectureCompletionDate: status === 'COMPLETED' ? new Date() : application.architectureCompletionDate, + updatedAt: new Date() + }); - await AuditLog.create({ - userId: req.user?.id, - action: AUDIT_ACTIONS.UPDATED, - entityType: 'application', - entityId: application.id, - newData: { architectureStatus: status, remarks } + await WorkflowService.transitionApplication(application, targetOverallStatus, req.user?.id || null, { + reason: remarks || `Architecture status updated to ${status}` }); res.json({ success: true, message: 'Architecture status updated successfully' }); @@ -767,33 +706,11 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => { generatedBy: req.user?.id }); - const previousStatus = application.overallStatus; - - // Update application status to reflect codes are generated - // We STAY in Dealer Code Generation until architecture is assigned - await application.update({ - overallStatus: 'Dealer Code Generation', + await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, { + reason: 'SAP Dealer Codes Generated', progressPercentage: 80 }); - // Log Status History - await db.ApplicationStatusHistory.create({ - applicationId: application.id, - previousStatus, - newStatus: 'Dealer Code Generation', - changedBy: req.user?.id, - reason: 'SAP Dealer Codes Generated' - }); - - // Audit Log - await db.AuditLog.create({ - userId: req.user?.id, - action: AUDIT_ACTIONS.DEALER_CODE_GENERATED, - entityType: 'application', - entityId: application.id, - newData: { dealerCode: sapData.salesCode } - }); - res.json({ success: true, message: 'SAP Dealer Codes generated successfully (Mock)', diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts new file mode 100644 index 0000000..771cc1b --- /dev/null +++ b/src/services/WorkflowService.ts @@ -0,0 +1,94 @@ +import db from '../database/models/index.js'; +const { Application, ApplicationStatusHistory, AuditLog } = db; +import { syncApplicationProgress } from '../common/utils/progress.js'; +import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js'; + +export class WorkflowService { + /** + * Standardized method to transition an application's status + * Handles: DB Update, Status History, Audit Log, and Progress Synchronization + */ + static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { + const previousStatus = application.overallStatus; + const { reason, stage, progressPercentage } = metadata; + + 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; + } + + // Update progress percentage if explicitly provided + if (progressPercentage !== undefined) { + updateData.progressPercentage = progressPercentage; + } + + // 1. Update Application Record + await application.update(updateData); + + // 2. Log Status History + await ApplicationStatusHistory.create({ + applicationId: application.id, + previousStatus, + newStatus: targetStatus, + changedBy: userId, + changeReason: reason || `Transitioned to ${targetStatus}` + }); + + // 3. Create Audit Log + await AuditLog.create({ + userId: userId, + action: AUDIT_ACTIONS.UPDATED, + entityType: 'application', + entityId: application.id, + newData: { status: targetStatus, stage: stage || application.currentStage } + }); + + // 4. Synchronize Progress Tracker (The true source of truth for the frontend UI) + await syncApplicationProgress(application.id, targetStatus); + + console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`); + + return application; + } + + /** + * Centralized policy evaluation for multi-role stage approvals + * FIXED: Counts unique users instead of unique roles to allow same-role approvals + */ + static async evaluateStagePolicy(applicationId: string, stageCode: string) { + const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } }); + if (!policy) return { policyMet: true }; // No policy means no restriction + + const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; + + // Fetch all approved actions for this stage + const actions = await db.StageApprovalAction.findAll({ + where: { applicationId, stageCode, decision: 'Approved' } + }); + + const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId)); + const approvedRoles = new Set(actions.map((a: any) => a.actorRole)); + + const isSuperAdminApproval = Array.from(approvedRoles).includes('Super Admin'); + + const hasAllRequiredRoleApprovals = (requiredRoles.length === 0 || isSuperAdminApproval) + ? true + : requiredRoles.every((role: string) => approvedRoles.has(role)); + + const meetsMinApprovals = isSuperAdminApproval || uniqueApprovers.size >= (policy.minApprovals || 1); + + return { + policyMet: hasAllRequiredRoleApprovals && meetsMinApprovals, + policy, + uniqueApprovers: Array.from(uniqueApprovers), + approvedRoles: Array.from(approvedRoles), + hasAllRequiredRoleApprovals, + meetsMinApprovals + }; + } +}