import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone, SecurityDeposit, FddAssignment, FddReport, OnboardingDocument, Worknote, StageApprovalAction, DealerCode, Dealer, RequestParticipant, QuestionnaireResponse, QuestionnaireQuestion, QuestionnaireOption, User } = db; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; import { sendOpportunityEmail, sendNonOpportunityEmail, sendShortlistedEmail } from '../../common/utils/email.service.js'; 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'; const { DocumentStageConfig } = db; // Helper to find district by name and state name combination const findDistrictByName = async (districtName: string, stateName?: string) => { if (!districtName) return null; return await District.findOne({ where: { name: { [Op.iLike]: districtName.trim() } }, include: stateName ? [{ model: State, as: 'state', where: { name: { [Op.iLike]: stateName.trim() } } }] : [] }); }; export const submitApplication = async (req: AuthRequest, res: Response) => { try { const { opportunityId, applicantName, email, phone, businessType, locationType, preferredLocation, city, state, experienceYears, investmentCapacity, age, education, companyName, source, existingDealer, ownRoyalEnfield, royalEnfieldModel, description, address, pincode, constitutionType } = req.body; // Check for duplicate application for SAME location const existingApp = await Application.findOne({ where: { email, city: city || null, preferredLocation: preferredLocation || null, overallStatus: { [Op.ne]: 'Rejected' } // Don't block if previous was rejected } }); if (existingApp) { return res.status(400).json({ success: false, message: 'An active application for this location already exists with this email address.' }); } const applicationId = NomenclatureService.generateApplicationId(); let districtId = null; // Primary Mapping: Resolve district by Name (State + District combination) // This is robust for external sources where ID mapping is difficult. if (req.body.district) { const districtRecord: any = await findDistrictByName(req.body.district, req.body.state); if (districtRecord) { districtId = districtRecord.id; } } // Secondary Fallback: If ID is explicitly provided (Legacy/Internal use) if (!districtId && req.body.districtId) { const selectedDistrict = await District.findByPk(req.body.districtId); if (selectedDistrict) { districtId = selectedDistrict.id; } } let activeOpportunityId = null; if (districtId) { const opportunity = await Opportunity.findOne({ where: { districtId, status: 'active', [Op.or]: [ { openTo: null }, { openTo: { [Op.gte]: new Date() } } ] } }); if (opportunity) { activeOpportunityId = opportunity.id; } } const isOpportunityAvailable = !!activeOpportunityId; const application = await Application.create({ opportunityId: activeOpportunityId, applicationId, applicantName, email, phone, businessType, preferredLocation, city, state, experienceYears, investmentCapacity, age, education, companyName, source, existingDealer, ownRoyalEnfield, royalEnfieldModel, description, address, pincode, locationType, constitutionType: constitutionType || 'Proprietorship', currentStage: APPLICATION_STAGES.DD, overallStatus: isOpportunityAvailable ? APPLICATION_STATUS.QUESTIONNAIRE_PENDING : APPLICATION_STATUS.SUBMITTED, progressPercentage: isOpportunityAvailable ? 10 : 0, districtId, score: 0, documents: [], timeline: [] }); // 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) if (isOpportunityAvailable) { sendOpportunityEmail(email, applicantName, city || preferredLocation, applicationId) .catch(err => console.error('Error sending opportunity email', err)); } else { sendNonOpportunityEmail(email, applicantName, city || preferredLocation) .catch(err => console.error('Error sending non-opportunity email', err)); } await AuditLog.create({ userId: req.user?.id, action: AUDIT_ACTIONS.CREATED, entityType: 'application', entityId: application.id }); res.status(201).json({ success: true, message: 'Application submitted successfully', data: application }); } catch (error) { console.error('Submit application error:', error); res.status(500).json({ success: false, message: 'Error submitting application' }); } }; export const getApplications = async (req: AuthRequest, res: Response) => { try { const whereClause: any = {}; // Security Check: If prospective dealer, only show their own application if (req.user?.roleCode === 'Prospective Dealer') { // Filter by phone instead of email to show all applications from same user whereClause.phone = (req.user as any).phone || req.user.email; } // Security Check: If FDD user, only show applications where they are a participant if (req.user?.roleCode === 'FDD') { const participantApps = await db.RequestParticipant.findAll({ where: { userId: req.user.id, requestType: 'application' }, attributes: ['requestId'] }); const appIds = participantApps.map((p: any) => p.requestId); whereClause.id = { [Op.in]: appIds }; } const applications = await Application.findAll({ where: whereClause, include: [ { model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }, { model: SecurityDeposit, as: 'securityDeposits' } ], order: [['createdAt', 'DESC']] }); res.json({ success: true, data: applications }); } catch (error) { console.error('Get applications error:', error); res.status(500).json({ success: false, message: 'Error fetching applications' }); } }; export const getApplicationById = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; const where: any = {}; 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(targetId); if (isUUID) { where[Op.or] = [{ id: targetId }, { applicationId: targetId }]; } else { where.applicationId = targetId; } // PROACTIVE INTEGRITY CHECK: Ensure application isn't stalled before returning await WorkflowIntegrityService.synchronizeApplicationState(targetId); const application = await Application.findOne({ where, include: [ { model: ApplicationStatusHistory, as: 'statusHistory', separate: true, order: [['createdAt', 'DESC']] }, { model: ApplicationProgress, as: 'progressTracking', separate: true, order: [['stageOrder', 'ASC']] }, { model: SecurityDeposit, as: 'securityDeposits', include: [{ model: User, as: 'verifier', attributes: ['fullName'] }] }, { model: QuestionnaireResponse, as: 'questionnaireResponses', separate: true, include: [ { model: QuestionnaireQuestion, as: 'question', include: [{ model: QuestionnaireOption, as: 'questionOptions' }] } ] }, { model: RequestParticipant, as: 'participants', separate: true, include: [{ model: User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] }, { model: OnboardingDocument, as: 'uploadedDocuments', separate: true, include: [{ model: User, as: 'uploader', attributes: ['fullName', 'roleCode'] }], order: [['createdAt', 'DESC']] }, { model: StageApprovalAction, as: 'stageApprovals', separate: true }, { model: DealerCode, as: 'dealerCode' }, { model: Dealer, as: 'dealer' }, { model: FddAssignment, as: 'fddAssignments', include: [ { model: FddReport, as: 'reports', include: [{ model: OnboardingDocument, as: 'reportDocument' }] } ] }, { model: Worknote, as: 'worknotes', separate: true, include: [{ model: User, as: 'author', attributes: ['id', 'fullName', 'email', 'roleCode'] }], order: [['createdAt', 'DESC']] } ] }); if (!application) { return res.status(404).json({ success: false, message: 'Application not found' }); } // Security Check for FDD: If user is FDD, only return restricted data if (req.user?.roleCode === 'FDD') { const isParticipant = await db.RequestParticipant.findOne({ where: { requestId: application.id, userId: req.user.id, requestType: 'application' } }); if (!isParticipant) { return res.status(403).json({ success: false, message: 'Access denied. You are not assigned to this application.' }); } // Strip sensitive internal data for FDD const restrictedData = application.toJSON(); delete (restrictedData as any).questionnaireResponses; delete (restrictedData as any).stageApprovals; delete (restrictedData as any).score; // FDD should only see relevant documents for security // FDD should only see relevant documents for security // OR documents they uploaded themselves const fddRelevantDocs = [ 'GST Certificate', 'PAN Card', 'Bank Statement', 'Cancelled Check', 'Partnership Deed', 'LLP Agreement', 'Certificate of Incorporation', 'MOA', 'AOA', 'Property Documents', 'Rental Agreement', 'Firm Registration', 'CIBIL Report', 'FDD Final Audit Report', 'FDD Audit Report', 'Income Tax Returns (ITR)', 'Business Valuation Report' ]; if (restrictedData.uploadedDocuments) { restrictedData.uploadedDocuments = (restrictedData.uploadedDocuments as any[]).filter( (doc: any) => fddRelevantDocs.includes(doc.documentType) || (req.user && doc.uploadedBy === req.user.id) ); } return res.json({ success: true, data: restrictedData }); } // Security Check: Ensure prospective dealer controls data ownership and document privacy if (req.user?.roleCode === 'Prospective Dealer') { const userEmail = req.user.email; const userPhone = (req.user as any).phone; // Helper to normalize phone for comparison (last 10 digits) const normalize = (p: string) => p ? String(p).replace(/[^0-9]/g, '').slice(-10) : ''; const normalizedAppPhone = normalize(application.phone); const normalizedUserPhone = normalize(userPhone); const hasAccess = (application.email && userEmail && application.email.toLowerCase() === userEmail.toLowerCase()) || (normalizedAppPhone && normalizedUserPhone && normalizedAppPhone === normalizedUserPhone); if (!hasAccess) { return res.status(403).json({ success: false, message: 'Forbidden: You do not have permission to view this application' }); } // FILTER: Prospect should ONLY see documents they uploaded const restrictedData = application.toJSON(); if (restrictedData.uploadedDocuments) { restrictedData.uploadedDocuments = (restrictedData.uploadedDocuments as any[]).filter( (doc: any) => doc.uploadedBy === req.user?.id || (doc.uploadedBy === null && doc.applicationId === application.id) ); } return res.json({ success: true, data: restrictedData }); } res.json({ success: true, data: application }); } catch (error) { console.error('Get application error:', error); res.status(500).json({ success: false, message: 'Error fetching application' }); } }; export const updateApplicationStatus = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; const { status, stage, reason } = req.body; // Resolve application by ID (UUID) or Registeration Number (applicationId) 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); 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' }); } catch (error) { console.error('Update application status error:', error); res.status(500).json({ success: false, message: 'Error updating application status' }); } }; export const uploadDocuments = async (req: any, res: Response) => { try { const { id } = req.params; const { documentType, stage } = req.body; const file = req.file; if (!file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } if (!documentType) { return res.status(400).json({ success: false, message: 'Document type is required' }); } const targetId = id as string; 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) { return res.status(404).json({ success: false, message: 'Application not found' }); } // Create Document Record const newDoc = await db.OnboardingDocument.create({ applicationId: application.id, documentType, stage: stage || null, fileName: file.originalname, filePath: file.path, fileSize: file.size, mimeType: file.mimetype, uploadedBy: req.user?.roleCode === 'Prospective Dealer' ? null : req.user?.id, status: 'active' }); // Update architecture document date if relevant if (['Architecture Blueprint', 'Site Plan'].includes(documentType)) { await application.update({ architectureDocumentDate: new Date() }); } // Handle EOR Checklist Automatic Linking & Compliance 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' } ]; const eorDescriptions = eorItems.map(i => i.description); if (eorDescriptions.includes(documentType)) { // Find or Create Checklist const [checklist, created] = await db.EorChecklist.findOrCreate({ where: { applicationId: application.id }, defaults: { status: 'In Progress' } }); // If newly created or no items exist, seed them const existingItemCount = await db.EorChecklistItem.count({ where: { checklistId: checklist.id } }); if (created || existingItemCount === 0) { const itemsData = eorItems.map(item => ({ ...item, checklistId: checklist.id, isCompliant: false })); await db.EorChecklistItem.bulkCreate(itemsData); } console.log(`[debug] EOR Checklist found/created: ${checklist.id} for Application: ${application.id}`); const [updatedCount] = await db.EorChecklistItem.update( { proofDocumentId: newDoc.id, isCompliant: false }, { where: { checklistId: checklist.id, description: { [Op.iLike]: documentType.trim() } } } ); console.log(`[debug] EOR items updated: ${updatedCount} for type: ${documentType}`); } res.status(201).json({ success: true, message: 'Document uploaded successfully', data: newDoc }); } catch (error) { console.error('Upload document error:', error); res.status(500).json({ success: false, message: 'Error uploading document' }); } }; export const getApplicationDocuments = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; 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(targetId); // Resolve ID to primary key if it's an appId string const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) { return res.status(404).json({ success: false, message: 'Application not found' }); } const whereClause: any = { applicationId: application.id, status: 'active' }; // ENFORCE PRIVACY: Prospect should ONLY see documents they uploaded if (req.user?.roleCode === 'Prospective Dealer') { whereClause[Op.or] = [ { uploadedBy: req.user?.id || null }, { uploadedBy: null } ]; } const documents = await db.OnboardingDocument.findAll({ where: whereClause, include: [ { model: db.User, as: 'uploader', attributes: ['fullName'] } ], order: [['createdAt', 'DESC']] }); res.json({ success: true, data: documents }); } catch (error) { console.error('Get documents error:', error); res.status(500).json({ success: false, message: 'Error fetching documents' }); } }; export const bulkShortlist = async (req: AuthRequest, res: Response) => { try { const { applicationIds, assignedTo, remarks } = req.body; if (!applicationIds || !Array.isArray(applicationIds) || applicationIds.length === 0) { return res.status(400).json({ success: false, message: 'No applications selected' }); } if (!assignedTo || !Array.isArray(assignedTo) || assignedTo.length === 0) { return res.status(400).json({ success: false, message: 'At least one assignee (DD-ZM/RBM) is required' }); } // Strategy: Assign the first user as primary assignee for the single FK field, // but add ALL as participants to enforce dual-responsibility. const primaryAssigneeId = assignedTo[0]; // Update Applications sequentially via WorkflowService for consistency for (const appId of applicationIds) { 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(appId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: appId }, { applicationId: appId }] } : { applicationId: 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 }); // Send Shortlist Email sendShortlistedEmail( application.email, application.applicantName, application.preferredLocation || application.city, application.applicationId ).catch(err => console.error('Failed to send shortlist email:', err)); // Add all assigned users as participants for (const userId of assignedTo) { await db.RequestParticipant.findOrCreate({ where: { requestId: application.id, requestType: 'application', userId, participantType: 'assignee' }, defaults: { joinedMethod: 'auto' } }); } // AUTO-FILL Interview Evaluators await assignStageEvaluators(application.id); } } res.json({ success: true, message: `Successfully shortlisted ${applicationIds.length} application(s) and assigned to ${assignedTo.length} users.` }); } catch (error) { console.error('Bulk shortlist error:', error); res.status(500).json({ success: false, message: 'Error processing shortlist' }); } }; /** /** * Helper to assign default evaluators for all 3 interview levels based on location */ /** * Helper to assign default evaluators for all 3 interview levels, LOI, and LOA based on location */ const assignStageEvaluators = async (appIdOrId: string) => { try { console.log(`[debug] Starting stage evaluator assignment for App: ${appIdOrId}`); 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(appIdOrId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: appIdOrId }, { applicationId: appIdOrId }] } : { applicationId: appIdOrId }, include: [ { model: District, as: 'district', include: [ { model: Region, as: 'region' }, { model: Zone, as: 'zone' } ] } ] }); if (!application) { console.log(`[debug] Application ${appIdOrId} not found`); return; } if (!application.district) { console.log(`[debug] Application ${appIdOrId} has NO district linked. Skipping auto-assign.`); return; } const district = application.district; const region = district.region; const zone = district.zone; console.log(`[debug] Mapping for District: ${district.name}, Region: ${region?.name}, Zone: ${zone?.name}`); const evaluatorMappings: any = { 1: [], // Level 1 Interview: DD-ZM + RBM 2: [], // Level 2 Interview: DD Lead + ZBH 3: [], // Level 3 Interview: NBH + DD Head 'LOI_APPROVAL': [], // LOI: Finance, DD Head, NBH 'LOA_APPROVAL': [] // LOA: DD Head, NBH }; // --- INTERVIEWS --- // Level 1: DD-ZM (District manager) + RBM (Region manager) if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' }); if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' }); // Level 2: ZBH (Zone manager) + DD Lead (Filtered by Zone) if (zone && zone.zbhId) evaluatorMappings[2].push({ id: zone.zbhId, role: 'ZBH' }); if (zone) { const ddLead = await db.User.findOne({ where: { roleCode: 'DD Lead', status: 'active' }, include: [{ model: db.UserRole, as: 'userRoles', where: { zoneId: zone.id, isActive: true } }] }); if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' }); } // Level 3: NBH + DD Head (National Level Roles) const level3Roles = ['NBH', 'DD Head']; for (const roleCode of level3Roles) { const user = await db.User.findOne({ where: { roleCode, status: 'active' } }); if (user) evaluatorMappings[3].push({ id: user.id, role: roleCode }); } // --- LOI & LOA --- // National roles for LOI / LOA const nationalRoles = ['NBH', 'DD Head', 'Finance']; const nationalUsers: Record = {}; for (const r of nationalRoles) { const user = await db.User.findOne({ where: { roleCode: r, status: 'active' } }); if (user) nationalUsers[r] = user.id; } // LOI: DD Head, NBH (Finance removed per user requirement) if (nationalUsers['DD Head']) evaluatorMappings['LOI_APPROVAL'].push({ id: nationalUsers['DD Head'], role: 'DD Head' }); if (nationalUsers['NBH']) evaluatorMappings['LOI_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' }); // LOA: DD Head, NBH if (nationalUsers['DD Head']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['DD Head'], role: 'DD Head' }); if (nationalUsers['NBH']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' }); // Persistence logic: Store in RequestParticipant with metadata // Persistence logic: Consolidate all stage/interview assignments into the same user record // to prevent duplication in the participants list. const userAssignments: Record = {}; const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL']; for (const stage of allStages) { const assignments = evaluatorMappings[stage]; for (const assign of assignments) { const { id: userId, role } = assign; if (!userAssignments[userId]) { userAssignments[userId] = { stages: [], roles: [] }; } userAssignments[userId].stages.push(stage); userAssignments[userId].roles.push(role); } } for (const [userId, assignment] of Object.entries(userAssignments)) { const isInterview = assignment.stages.some(s => typeof s === 'number'); const primaryStage = assignment.stages[0]; const primaryRole = assignment.roles[0]; const [participant, created] = await db.RequestParticipant.findOrCreate({ where: { requestId: application.id, requestType: 'application', userId: userId }, defaults: { participantType: 'contributor', joinedMethod: 'auto', metadata: { interviewLevel: typeof primaryStage === 'number' ? primaryStage : null, stageCode: typeof primaryStage === 'string' ? primaryStage : null, role: primaryRole, allAssignments: assignment.stages, // Store all assignments autoMapped: true } } }); if (!created) { // Update metadata if it exists to include the new assignments const meta = participant.metadata || {}; const currentAssignments = meta.allAssignments || []; const mergedAssignments = [...new Set([...currentAssignments, ...assignment.stages])]; await participant.update({ metadata: { ...meta, allAssignments: mergedAssignments, // Maintain legacy fields for compatibility if they don't exist interviewLevel: meta.interviewLevel || (typeof primaryStage === 'number' ? primaryStage : null), stageCode: meta.stageCode || (typeof primaryStage === 'string' ? primaryStage : null) } }); } } } catch (error) { console.error(`Error assigning stage evaluators for application ${appIdOrId}:`, error); } }; export const retriggerEvaluators = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); // Remove existing auto-mapped participants (Interviews, LOI, LOA) // Using a more robust Postgres-compatible JSON path check await db.RequestParticipant.destroy({ where: { requestId: application.id, requestType: 'application', joinedMethod: 'auto', [Op.and]: [ db.sequelize.literal(`"metadata"->>'autoMapped' = 'true'`) ] } }); // Sync district data before re-assignment to ensure fresh manager mapping if (application.districtId) { await syncLocationManagers(application.districtId); } await assignStageEvaluators(id as string); res.json({ success: true, message: 'All stage evaluators (Interviews, LOI, LOA) have been re-assigned successfully.' }); } catch (error) { console.error('Retrigger evaluators error:', error); res.status(500).json({ success: false, message: 'Error re-triggering evaluator assignment' }); } }; export const assignArchitectureTeam = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; const { userId, assignedTo, remarks } = req.body; const targetUserId = userId || assignedTo; if (!targetUserId) { return res.status(400).json({ success: false, message: 'Architecture team member (userId) is required' }); } 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); await application.update({ architectureAssignedTo: targetUserId, architectureStatus: 'IN_PROGRESS', architectureAssignedDate: new Date(), 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: { requestId: application.id, requestType: 'application', userId: targetUserId, participantType: 'architecture' }, defaults: { joinedMethod: 'auto' } }); res.json({ success: true, message: 'Architecture team assigned successfully' }); } catch (error) { console.error('Assign architecture team error:', error); res.status(500).json({ success: false, message: 'Error assigning architecture team' }); } }; export const updateArchitectureStatus = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; const { status, remarks } = req.body; 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); const updateData: any = { architectureStatus: status, updatedAt: new Date() }; // Sync overall status if architecture is completed const targetOverallStatus = status === 'COMPLETED' ? APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION : APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED; await application.update({ architectureStatus: status, architectureCompletionDate: status === 'COMPLETED' ? new Date() : application.architectureCompletionDate, updatedAt: new Date() }); 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' }); } catch (error) { console.error('Update architecture status error:', error); res.status(500).json({ success: false, message: 'Error updating architecture status' }); } }; import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; export const generateDealerCodes = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; // applicationId const targetId = id as string; 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); // Strict Workflow Validation: Dealer Code Generation requires LOI Issued status if (application.overallStatus !== APPLICATION_STATUS.LOI_ISSUED && application.overallStatus !== APPLICATION_STATUS.DEALER_CODE_GENERATION) { return res.status(400).json({ success: false, message: `Cannot generate dealer codes. The application must be in 'LOI Issued' status (Current: ${application.overallStatus}).` }); } // Validation: Check for mandatory fields before triggering Dealer Code Generation const mandatoryFields = [ { key: 'panNumber', label: 'PAN Number' }, { key: 'gstNumber', label: 'GST Number' }, { key: 'bankName', label: 'Bank Name' }, { key: 'accountNumber', label: 'Account Number' }, { key: 'ifscCode', label: 'IFSC Code' } ]; const missingFields = mandatoryFields .filter(f => !application[f.key as keyof typeof application]) .map(f => f.label); if (missingFields.length > 0) { return res.status(400).json({ success: false, message: `Cannot generate dealer codes. Missing mandatory fields: ${missingFields.join(', ')}. Please update application details first.` }); } // Trigger Mock SAP Integration const { data: sapData } = await ExternalMocksService.mockGenerateSapCodes(application.applicationId); // Save Dealer Codes await db.DealerCode.create({ dealerCode: sapData.salesCode, // Use sales code as primary dealer code applicationId: application.id, salesCode: sapData.salesCode, serviceCode: sapData.serviceCode, gmaCode: sapData.gmaCode, gearCode: sapData.gearCode, sapMasterId: sapData.sapMasterId, status: 'Active', generatedBy: req.user?.id }); // Create Final Security Deposit record (Blocker for LOA) await db.SecurityDeposit.findOrCreate({ where: { applicationId: application.id, depositType: 'FIRST_FILL' }, defaults: { amount: 1500000, // 15 Lakhs Final status: 'Pending' } }); await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, { reason: 'SAP Dealer Codes Generated', progressPercentage: 80 }); res.json({ success: true, message: 'SAP Dealer Codes generated successfully (Mock)', data: sapData }); } catch (error) { console.error('Generate dealer code error:', error); res.status(500).json({ success: false, message: 'Error generating dealer codes' }); } }; // Fetch Metadata for Document Management (Modules & Stages) export const getDocumentConfigMetadata = async (_req: AuthRequest, res: Response) => { try { const { MODULE_LIST, STAGES_MAP } = await import('../../common/config/constants.js'); res.json({ success: true, data: { modules: MODULE_LIST, stages: STAGES_MAP } }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching metadata' }); } }; // Fetch Document Configurations based on Role and Stage export const getDocumentConfigs = async (req: AuthRequest, res: Response) => { try { const { stageCode, search, page = 1, limit = 10, roleFilter, module = 'ONBOARDING' } = req.query; const roleCode = (roleFilter as string) || req.user?.role; const where: any = { module }; if (stageCode) { where.stageCode = { [Op.or]: [stageCode, 'General'] }; } if (search) { where[Op.or] = [ { documentType: { [Op.iLike]: `%${search}%` } }, { stageCode: { [Op.iLike]: `%${search}%` } } ]; } const offset = (Number(page) - 1) * Number(limit); const { rows: configs, count } = await DocumentStageConfig.findAndCountAll({ where, order: [['stageCode', 'DESC'], ['documentType', 'ASC']], limit: Number(limit), offset: Number(offset) }); // Manual role filtering because it's a JSON field // Note: For admin search, we might want to skip this let filteredConfigs = configs; if (roleCode && !req.query.isAdminView) { filteredConfigs = configs.filter((c: any) => { const allowedRoles = c.allowedRoles || []; return allowedRoles.length === 0 || allowedRoles.includes(roleCode); }); } return res.json({ success: true, data: filteredConfigs, pagination: { total: count, page: Number(page), limit: Number(limit), pages: Math.ceil(count / Number(limit)) } }); } catch (error) { console.error('Failed to fetch document configs:', error); return res.status(500).json({ success: false, message: 'Internal server error' }); } }; export const createDocumentConfig = async (req: AuthRequest, res: Response) => { try { const config = await DocumentStageConfig.create(req.body); return res.json({ success: true, data: config }); } catch (error) { console.error('Failed to create document config:', error); return res.status(500).json({ success: false, message: 'Internal server error' }); } }; export const updateDocumentConfig = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const config = await DocumentStageConfig.findByPk(id); if (!config) return res.status(404).json({ success: false, message: 'Config not found' }); await config.update(req.body); return res.json({ success: true, data: config }); } catch (error) { console.error('Failed to update document config:', error); return res.status(500).json({ success: false, message: 'Internal server error' }); } }; export const deleteDocumentConfig = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const config = await DocumentStageConfig.findByPk(id); if (!config) return res.status(404).json({ success: false, message: 'Config not found' }); await config.destroy(); return res.json({ success: true, message: 'Deleted successfully' }); } catch (error) { console.error('Failed to delete document config:', error); return res.status(500).json({ success: false, message: 'Internal server error' }); } }; export const updateApplication = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const targetId = id as string; 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(targetId); const application = await Application.findOne({ where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId } }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); await application.update(req.body); return res.json({ success: true, message: 'Application updated successfully', data: application }); } catch (error) { console.error('Failed to update application:', error); return res.status(500).json({ success: false, message: 'Internal server error' }); } };