email emplate added fo opportunity and non opportunity and new enums added for the application status

This commit is contained in:
laxmanhalaki 2026-01-29 20:25:59 +05:30
parent 5959a6a225
commit 859416a9a9
11 changed files with 539 additions and 42 deletions

49
debug_opp_bijapur.js Normal file
View File

@ -0,0 +1,49 @@
import pg from 'pg';
const { Client } = pg;
const client = new Client({
user: 'laxman',
host: 'localhost',
database: 'royal_enfield_onboarding',
password: 'Admin@123',
port: 5432,
});
async function debugOpportunity() {
try {
await client.connect();
const districtName = 'Bijapur';
console.log(`Checking for District: ${districtName}`);
// Note: ILIKE is Postgres specific, checking matches
const districtRes = await client.query(`SELECT * FROM districts WHERE "districtName" ILIKE $1`, [`%${districtName}%`]);
if (districtRes.rows.length === 0) {
console.log('No district found matching "Bijapur"');
} else {
console.log(`Found ${districtRes.rows.length} districts:`);
for (const d of districtRes.rows) {
console.log(`- ID: ${d.id}, Name: ${d.districtName}, StateID: ${d.stateId}`);
// Check opportunities
const oppRes = await client.query(`SELECT * FROM opportunities WHERE "districtId" = $1`, [d.id]);
if (oppRes.rows.length === 0) {
console.log(` -> No opportunities found for this district.`);
} else {
for (const o of oppRes.rows) {
console.log(` -> Opportunity Found: ID=${o.id}, Status=${o.status}, Type=${o.opportunityType}`);
}
}
}
}
} catch (err) {
console.error('Error:', err);
} finally {
await client.end();
process.exit();
}
}
debugOpportunity();

View File

@ -8,6 +8,48 @@ erDiagram
%% ============================================
%% USER MANAGEMENT & AUTHENTICATION
%% ============================================
%% ============================================
%% FINANCE & PAYMENTS
%% ============================================
FINANCE_PAYMENTS {
uuid payment_id PK
uuid application_id FK
string payment_type
decimal amount
string payment_status
string transaction_id
date payment_date
uuid verified_by FK
timestamp verification_date
text remarks
timestamp created_at
timestamp updated_at
}
%% ============================================
%% EXIT FEEDBACK & SLA BREACHES
%% ============================================
EXIT_FEEDBACK {
uuid feedback_id PK
uuid resignation_id FK
uuid termination_request_id FK
string feedback_type
json ratings
text comments
timestamp submitted_at
uuid submitted_by FK
}
SLA_BREACHES {
uuid breach_id PK
uuid tracking_id FK
timestamp breached_at
string notified_to
string status
text action_taken
timestamp created_at
}
USERS {
uuid user_id PK
string employee_id UK
@ -614,6 +656,29 @@ erDiagram
timestamp updated_at
}
%% ============================================
%% OUTLET MANAGEMENT
%% ============================================
OUTLETS {
uuid outlet_id PK
string code UK
string name
string type
text address
string city
string state
string pincode
decimal latitude
decimal longitude
string status
date established_date
uuid dealer_id FK
string region
string zone
timestamp created_at
timestamp updated_at
}
%% ============================================
%% DEALER CODE GENERATION
%% ============================================
@ -666,14 +731,19 @@ erDiagram
DEALER_CONSTITUTION_CHANGES {
uuid constitution_change_id PK
string request_id UK
uuid outlet_id FK
uuid dealer_id FK
string current_constitution
string proposed_constitution
text reason
json new_partners_details
json shareholding_pattern
string change_type
text description
string current_stage
string status
integer progress_percentage
json documents
json timeline
timestamp submitted_at
timestamp created_at
timestamp updated_at
}
%% ============================================
@ -1206,4 +1276,16 @@ erDiagram
USERS ||--o{ FNF_LINE_ITEMS : "added"
USERS ||--o{ APPLICATIONS : "currently_assigned"
USERS ||--o{ OUTLETS : "has_outlets"
OUTLETS ||--o{ DEALER_CONSTITUTION_CHANGES : "requests_change"
APPLICATIONS ||--o{ FINANCE_PAYMENTS : "has_payments"
USERS ||--o{ FINANCE_PAYMENTS : "verifies_payments"
DEALER_RESIGNATIONS ||--o{ EXIT_FEEDBACK : "has_feedback"
TERMINATION_REQUESTS ||--o{ EXIT_FEEDBACK : "has_feedback"
USERS ||--o{ EXIT_FEEDBACK : "submitted_by"
SLA_TRACKING ||--o{ SLA_BREACHES : "has_breaches"

View File

@ -42,6 +42,10 @@ export const APPLICATION_STAGES = {
// Application Status
export const APPLICATION_STATUS = {
PENDING: 'Pending',
SUBMITTED: 'Submitted',
QUESTIONNAIRE_PENDING: 'Questionnaire Pending',
QUESTIONNAIRE_COMPLETED: 'Questionnaire Completed',
SHORTLISTED: 'Shortlisted',
IN_REVIEW: 'In Review',
APPROVED: 'Approved',
REJECTED: 'Rejected',

View File

@ -0,0 +1,74 @@
import nodemailer from 'nodemailer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Create test account (or use env vars in production)
let transporter: nodemailer.Transporter;
nodemailer.createTestAccount().then((account) => {
transporter = nodemailer.createTransport({
host: account.smtp.host,
port: account.smtp.port,
secure: account.smtp.secure,
auth: {
user: account.user,
pass: account.pass,
},
});
console.log('Email Transporter Ready (Ethereal Test Account)');
console.log(`Preview URL: https://ethereal.email/messages`);
}).catch(err => console.error('Failed to create test account:', err));
const readTemplate = (templateName: string, replacements: Record<string, string>) => {
const templatePath = path.join(__dirname, '../../emailtemplates', `${templateName}.html`);
let html = fs.readFileSync(templatePath, 'utf-8');
for (const key in replacements) {
html = html.replace(new RegExp(`{{${key}}}`, 'g'), replacements[key]);
}
return html;
};
export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
const link = `http://localhost:5173/questionnaire/${applicationId}`;
const html = readTemplate('opportunity', {
applicantName,
location,
applicationId,
link,
year: new Date().getFullYear().toString()
});
if (!transporter) return;
const info = await transporter.sendMail({
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
to,
subject: 'Action Required: Royal Enfield Dealership Opportunity',
html
});
console.log(`[Email Sent] Opportunity: ${nodemailer.getTestMessageUrl(info)}`);
};
export const sendNonOpportunityEmail = async (to: string, applicantName: string, location: string) => {
const html = readTemplate('non_opportunity', {
applicantName,
location,
year: new Date().getFullYear().toString()
});
if (!transporter) return;
const info = await transporter.sendMail({
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
to,
subject: 'Update on your Royal Enfield Dealership Application',
html
});
console.log(`[Email Sent] Non-Opportunity: ${nodemailer.getTestMessageUrl(info)}`);
};

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #000;
color: #fff;
padding: 15px;
text-align: center;
}
.content {
padding: 20px;
background-color: #f9f9f9;
}
.footer {
font-size: 12px;
color: #777;
margin-top: 30px;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Royal Enfield Dealership Application</h2>
</div>
<div class="content">
<p>Dear {{applicantName}},</p>
<p>Thank you for showing interest in becoming a Royal Enfield dealer.</p>
<p>We have reviewed our current network plan for <strong>{{location}}</strong>, and currently, there are no
open opportunities available in this area.</p>
<p>We have saved your details in our database and will contact you should an opportunity arise in the
future.</p>
<p>We appreciate your enthusiasm for the brand.</p>
</div>
<div class="footer">
<p>&copy; {{year}} Royal Enfield. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #000; color: #fff; padding: 15px; text-align: center; }
.content { padding: 20px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 10px 20px; background-color: #d32f2f; color: white; text-decoration: none; border-radius: 5px; margin-top: 20px; }
.footer { font-size: 12px; color: #777; margin-top: 30px; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Royal Enfield Dealership Opportunity</h2>
</div>
<div class="content">
<p>Dear {{applicantName}},</p>
<p>Thank you for your interest in partnering with Royal Enfield.</p>
<p>We are pleased to inform you that there is an open opportunity for a dealership in your requested location (<strong>{{location}}</strong>).</p>
<p>To proceed with your application, please complete the detailed Dealership Assessment Questionnaire by clicking the link below:</p>
<a href="{{link}}" class="button">Start Questionnaire</a>
<p style="margin-top: 20px;">Or copy this link to your browser:</p>
<p>{{link}}</p>
<p><strong>Note:</strong> No login credentials are required at this stage. Your Application ID is <strong>{{applicationId}}</strong>.</p>
</div>
<div class="footer">
<p>&copy; {{year}} Royal Enfield. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@ -1,11 +1,13 @@
import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, Region, Zone } = db;
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 {
@ -23,40 +25,70 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
const applicationId = `APP-${new Date().getFullYear()}-${uuidv4().substring(0, 6).toUpperCase()}`;
// Fetch hierarchy from Opportunity if available
// Fetch hierarchy from Opportunity if available, OR resolve from Location
// Fetch hierarchy from Auto-detected Area
let zoneId, regionId, areaId;
let isOpportunityAvailable = false;
if (opportunityId) {
const opportunity = await Opportunity.findByPk(opportunityId);
if (opportunity) {
zoneId = opportunity.zoneId;
regionId = opportunity.regionId;
}
} else if (req.body.district) {
// Resolve hierarchy from submitted District
// 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 } // Case-insensitive match
},
include: [
{ model: Region, as: 'region', attributes: ['id', 'zoneId'] },
{ model: Zone, as: 'zone', attributes: ['id'] }
]
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;
console.log(`[Auto-Match] Found Active Area ${validArea.areaName} for District: ${districtName}`);
}
}
}
// 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 || (districtRecord.region ? districtRecord.region.zoneId : null);
console.log(`Auto-assigned Application to Region: ${regionId}, Zone: ${zoneId} based on District: ${districtName}`);
} else {
console.log(`Could not find District: ${districtName} for auto-assignment.`);
zoneId = districtRecord.zoneId;
}
}
const application = await Application.create({
opportunityId,
opportunityId: null, // De-coupled from Opportunity table as per user request
applicationId,
applicantName,
email,
@ -69,29 +101,30 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
investmentCapacity,
age, education, companyName, source, existingDealer, ownRoyalEnfield, royalEnfieldModel, description, address, pincode, locationType,
currentStage: APPLICATION_STAGES.DD,
overallStatus: APPLICATION_STATUS.PENDING,
progressPercentage: 10,
overallStatus: initialStatus,
progressPercentage: isOpportunityAvailable ? 10 : 0,
zoneId,
regionId
regionId,
areaId // Link to Area
});
// Log Status History
await ApplicationStatusHistory.create({
applicationId: application.id,
previousStatus: null,
newStatus: APPLICATION_STATUS.PENDING,
changedBy: req.user?.id,
newStatus: initialStatus,
changedBy: req.user?.id || null,
reason: 'Initial Submission'
});
// Initialize Progress (Optional, or created as needed per stage)
await ApplicationProgress.create({
applicationId: application.id,
stageName: 'Application',
stageOrder: 1,
status: 'Completed',
completionPercentage: 100
});
// 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,

View File

@ -13,6 +13,15 @@ router.use(authenticate as any);
router.get('/applications', onboardingController.getApplications);
router.get('/applications/:id', onboardingController.getApplicationById);
router.put('/applications/:id/status', onboardingController.updateApplicationStatus);
router.put('/applications/:id/status', onboardingController.updateApplicationStatus);
// router.post('/applications/:id/documents', onboardingController.uploadDocuments); // Moving to DMS module
// Questionnaire Routes
router.get('/questionnaires', (req, res, next) => {
import('./questionnaire.controller.js').then(c => c.getAllQuestionnaires(req, res)).catch(next);
});
router.get('/questionnaires/:id', (req, res, next) => {
import('./questionnaire.controller.js').then(c => c.getQuestionnaireById(req, res)).catch(next);
});
export default router;

View File

@ -100,3 +100,98 @@ export const submitResponse = async (req: AuthRequest, res: Response) => {
res.status(500).json({ success: false, message: 'Error submitting responses' });
}
};
export const getAllQuestionnaires = async (req: Request, res: Response) => {
try {
const questionnaires = await Questionnaire.findAll({
order: [['createdAt', 'DESC']],
attributes: ['id', 'version', 'isActive', 'createdAt']
});
res.json({ success: true, data: questionnaires });
} catch (error) {
console.error('Get all questionnaires error:', error);
res.status(500).json({ success: false, message: 'Error fetching questionnaires' });
}
};
export const getQuestionnaireById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const questionnaire = await Questionnaire.findByPk(id, {
include: [{
model: QuestionnaireQuestion,
as: 'questions',
order: [['order', 'ASC']]
}]
});
if (!questionnaire) {
return res.status(404).json({ success: false, message: 'Questionnaire not found' });
}
res.json({ success: true, data: questionnaire });
} catch (error) {
console.error('Get questionnaire by id error:', error);
res.status(500).json({ success: false, message: 'Error fetching questionnaire' });
}
};
export const getPublicQuestionnaire = async (req: Request, res: Response) => {
try {
const { applicationId } = req.params;
// Verify valid application exists
const application = await Application.findOne({ where: { applicationId } });
if (!application) {
return res.status(404).json({ success: false, message: 'Invalid Application ID' });
}
// Fetch active questionnaire
const questionnaire = await Questionnaire.findOne({
where: { isActive: true },
include: [{
model: QuestionnaireQuestion,
as: 'questions',
order: [['order', 'ASC']]
}]
});
if (!questionnaire) {
return res.status(404).json({ success: false, message: 'No active questionnaire found' });
}
res.json({ success: true, data: { applicationName: application.applicantName, ...questionnaire.toJSON() } });
} catch (error) {
console.error('Get public questionnaire error:', error);
res.status(500).json({ success: false, message: 'Error fetching questionnaire' });
}
};
export const submitPublicResponse = async (req: Request, res: Response) => {
try {
const { applicationId, responses } = req.body;
const application = await Application.findOne({ where: { applicationId } });
if (!application) {
return res.status(404).json({ success: false, message: 'Invalid Application ID' });
}
const questionnaire = await Questionnaire.findOne({ where: { isActive: true } });
if (!questionnaire) return res.status(400).json({ success: false, message: 'No active questionnaire' });
const responseRecords = responses.map((r: any) => ({
applicationId: application.id, // Use UUID from database
questionnaireId: questionnaire.id,
questionId: r.questionId,
responseValue: r.value,
attachmentUrl: r.attachmentUrl || null
}));
await QuestionnaireResponse.bulkCreate(responseRecords);
res.json({ success: true, message: 'Responses submitted successfully' });
} catch (error) {
console.error('Submit public response error:', error);
res.status(500).json({ success: false, message: 'Error submitting responses' });
}
};

View File

@ -5,9 +5,17 @@ import { authenticate } from '../../common/middleware/auth.js';
import { checkRole } from '../../common/middleware/roleCheck.js';
import { ROLES } from '../../common/config/constants.js';
// Public routes (No Auth Required)
router.get('/public/:applicationId', questionnaireController.getPublicQuestionnaire);
router.post('/public/submit', questionnaireController.submitPublicResponse);
// Public routes (No Auth Required)
router.get('/public/:applicationId', questionnaireController.getPublicQuestionnaire);
router.post('/public/submit', questionnaireController.submitPublicResponse);
router.use(authenticate as any);
// Public/Dealer routes (Application context)
// Dealer routes (Application context)
router.get('/latest', questionnaireController.getLatestQuestionnaire);
router.post('/response', questionnaireController.submitResponse);

48
update_enums_raw.js Normal file
View File

@ -0,0 +1,48 @@
import pg from 'pg';
const { Client } = pg;
const client = new Client({
user: 'laxman',
host: 'localhost',
database: 'royal_enfield_onboarding',
password: 'Admin@123',
port: 5432,
});
async function updateEnums() {
try {
await client.connect();
console.log('Connected to database.');
const enums = [
'Submitted',
'Questionnaire Pending',
'Questionnaire Completed',
'Shortlisted',
'Level 1 Pending',
'Level 1 Approved',
'Level 2 Pending',
'Level 2 Approved',
'EOR In Progress'
];
for (const val of enums) {
try {
// Quotes around value are crucial for case sensitivity if the enum type was created with quotes,
// but usually enum values are string literals.
await client.query(`ALTER TYPE "enum_applications_overallStatus" ADD VALUE IF NOT EXISTS '${val}'`);
console.log(`Added enum value: ${val}`);
} catch (e) {
console.log(`Could not add ${val}:`, e.message);
}
}
} catch (err) {
console.error('Connection error:', err);
} finally {
await client.end();
process.exit();
}
}
updateEnums();