1065 lines
45 KiB
TypeScript
1065 lines
45 KiB
TypeScript
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<string, string> = {};
|
|
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<string, { stages: any[], roles: string[] }> = {};
|
|
|
|
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' });
|
|
}
|
|
};
|