Dealer_Onboarding_Backend/src/modules/onboarding/onboarding.controller.ts

428 lines
16 KiB
TypeScript

import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, Region, Zone, Area } = 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 } from '../../common/utils/email.service.js';
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
} = req.body;
// Check for duplicate email
const existingApp = await Application.findOne({ where: { email } });
if (existingApp) {
return res.status(400).json({ success: false, message: 'Application with this email already exists' });
}
const applicationId = `APP-${new Date().getFullYear()}-${uuidv4().substring(0, 6).toUpperCase()}`;
// Fetch hierarchy from Auto-detected Area
let zoneId, regionId, areaId;
let isOpportunityAvailable = false;
// Auto-detect Area from District
if (req.body.district) {
const districtName = req.body.district;
// 1. Find District ID by Name
const districtRecord = await District.findOne({
where: { districtName: { [Op.iLike]: districtName } }
});
if (districtRecord) {
// 2. Find Active Area for this District
const today = new Date();
const validArea = await Area.findOne({
where: {
districtId: districtRecord.id,
isActive: true,
[Op.and]: [
{
[Op.or]: [
{ activeFrom: { [Op.eq]: null } },
{ activeFrom: { [Op.lte]: today } }
]
},
{
[Op.or]: [
{ activeTo: { [Op.eq]: null } },
{ activeTo: { [Op.gte]: today } }
]
}
]
}
});
if (validArea) {
areaId = validArea.id;
zoneId = validArea.zoneId;
regionId = validArea.regionId;
isOpportunityAvailable = true;
}
}
}
// Determine Initial Status
let initialStatus = isOpportunityAvailable ? APPLICATION_STATUS.QUESTIONNAIRE_PENDING : APPLICATION_STATUS.SUBMITTED;
// Auto-assign Zone/Region from District if still null (even if no opportunity found)
if (!zoneId && req.body.district) {
const districtRecord = await District.findOne({
where: { districtName: { [Op.iLike]: req.body.district } },
include: [{ model: Region, as: 'region' }, { model: Zone, as: 'zone' }]
});
if (districtRecord) {
regionId = districtRecord.regionId;
zoneId = districtRecord.zoneId;
}
}
const application = await Application.create({
opportunityId: null, // De-coupled from Opportunity table as per user request
applicationId,
applicantName,
email,
phone,
businessType,
preferredLocation,
city,
state,
experienceYears,
investmentCapacity,
age, education, companyName, source, existingDealer, ownRoyalEnfield, royalEnfieldModel, description, address, pincode, locationType,
currentStage: APPLICATION_STAGES.DD,
overallStatus: initialStatus,
progressPercentage: isOpportunityAvailable ? 10 : 0,
zoneId,
regionId,
areaId // Link to Area
});
// Log Status History
await ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus: null,
newStatus: initialStatus,
changedBy: req.user?.id || null,
reason: 'Initial Submission'
});
// 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') {
whereClause.email = req.user.email;
}
const applications = await Application.findAll({
where: whereClause,
include: [{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }],
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 application = await Application.findOne({
where: {
[Op.or]: [
{ id },
{ applicationId: id }
]
},
include: [
{ model: ApplicationStatusHistory, as: 'statusHistory' },
{ model: ApplicationProgress, as: 'progressTracking' },
{
model: db.QuestionnaireResponse,
as: 'questionnaireResponses',
include: [
{
model: db.QuestionnaireQuestion,
as: 'question',
include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }]
}
]
},
{
model: db.RequestParticipant,
as: 'participants',
include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }]
}
]
});
if (!application) {
return res.status(404).json({ success: false, message: 'Application not found' });
}
// Security Check: Ensure prospective dealer controls data ownership
if (req.user?.roleCode === 'Prospective Dealer' && application.email !== req.user.email) {
return res.status(403).json({ success: false, message: 'Forbidden: You do not have permission to view this application' });
}
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 { status, stage, reason } = req.body;
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 }
});
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 application = await Application.findOne({
where: {
[Op.or]: [
{ id },
{ applicationId: id }
]
}
});
if (!application) {
return res.status(404).json({ success: false, message: 'Application not found' });
}
// Create Document Record
const newDoc = await db.Document.create({
applicationId: application.id,
requestId: application.id,
requestType: 'application',
documentType,
stage: stage || null,
fileName: file.originalname,
filePath: file.path, // Store relative path or full path as needed by your storage strategy
fileSize: file.size,
mimeType: file.mimetype,
// For prospective users (who are applications, not in Users table), set uploadedBy to null to avoid FK violation
uploadedBy: req.user?.roleCode === 'Prospective Dealer' ? null : req.user?.id,
status: 'active'
});
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;
// Resolve ID to primary key if it's an appId string
const application = await Application.findOne({
where: {
[Op.or]: [
{ id },
{ applicationId: id }
]
}
});
if (!application) {
return res.status(404).json({ success: false, message: 'Application not found' });
}
const documents = await db.Document.findAll({
where: {
requestId: application.id,
requestType: 'application',
status: 'active'
},
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' });
}
// assignedTo is expected to be an array of User IDs from frontend now
// But database only supports single UUID for assignedTo.
// Strategy: Assign the first user as primary assignee.
const primaryAssigneeId = Array.isArray(assignedTo) && assignedTo.length > 0 ? assignedTo[0] : null;
// Verify primaryAssigneeId is a valid UUID if strictly enforced by DB, but Sequelize might handle null.
// Update Applications
const updateData: any = {
ddLeadShortlisted: true,
overallStatus: 'Shortlisted',
updatedAt: new Date(),
};
if (primaryAssigneeId) {
updateData.assignedTo = primaryAssigneeId;
}
await Application.update(updateData, {
where: {
id: { [Op.in]: applicationIds }
}
});
// Add participants
if (Array.isArray(assignedTo) && assignedTo.length > 0) {
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'
}
});
}
}
}
// Create Status History Entries
const historyEntries = applicationIds.map(appId => ({
applicationId: appId,
previousStatus: 'Questionnaire Completed',
newStatus: 'Shortlisted',
changedBy: req.user?.id,
reason: remarks ? `${remarks} (Assignees: ${Array.isArray(assignedTo) ? assignedTo.join(', ') : assignedTo})` : 'Bulk Shortlist'
}));
await ApplicationStatusHistory.bulkCreate(historyEntries);
// Audit Log
const auditEntries = applicationIds.map(appId => ({
userId: req.user?.id,
action: AUDIT_ACTIONS.UPDATED,
entityType: 'application',
entityId: appId,
newData: { ddLeadShortlisted: true, assignedTo: primaryAssigneeId, remarks }
}));
await AuditLog.bulkCreate(auditEntries);
res.json({
success: true,
message: `Successfully shortlisted ${applicationIds.length} application(s)`
});
} catch (error) {
console.error('Bulk shortlist error:', error);
res.status(500).json({ success: false, message: 'Error processing shortlist' });
}
};