diff --git a/debug_opp_bijapur.js b/debug_opp_bijapur.js new file mode 100644 index 0000000..3228ce9 --- /dev/null +++ b/debug_opp_bijapur.js @@ -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(); diff --git a/docs/dealer_onboard_backend_schema.mermaid b/docs/dealer_onboard_backend_schema.mermaid index 5c14cc6..2eb9ef4 100644 --- a/docs/dealer_onboard_backend_schema.mermaid +++ b/docs/dealer_onboard_backend_schema.mermaid @@ -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" + diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 1bd600d..5ef078f 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -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', diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts new file mode 100644 index 0000000..0a16adf --- /dev/null +++ b/src/common/utils/email.service.ts @@ -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) => { + 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" ', + 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" ', + to, + subject: 'Update on your Royal Enfield Dealership Application', + html + }); + + console.log(`[Email Sent] Non-Opportunity: ${nodemailer.getTestMessageUrl(info)}`); +}; diff --git a/src/emailtemplates/non_opportunity.html b/src/emailtemplates/non_opportunity.html new file mode 100644 index 0000000..5420a15 --- /dev/null +++ b/src/emailtemplates/non_opportunity.html @@ -0,0 +1,59 @@ + + + + + + + + +
+
+

Royal Enfield Dealership Application

+
+
+

Dear {{applicantName}},

+

Thank you for showing interest in becoming a Royal Enfield dealer.

+

We have reviewed our current network plan for {{location}}, and currently, there are no + open opportunities available in this area.

+

We have saved your details in our database and will contact you should an opportunity arise in the + future.

+

We appreciate your enthusiasm for the brand.

+
+ +
+ + + \ No newline at end of file diff --git a/src/emailtemplates/opportunity.html b/src/emailtemplates/opportunity.html new file mode 100644 index 0000000..51ecfb2 --- /dev/null +++ b/src/emailtemplates/opportunity.html @@ -0,0 +1,36 @@ + + + + + + +
+
+

Royal Enfield Dealership Opportunity

+
+
+

Dear {{applicantName}},

+

Thank you for your interest in partnering with Royal Enfield.

+

We are pleased to inform you that there is an open opportunity for a dealership in your requested location ({{location}}).

+

To proceed with your application, please complete the detailed Dealership Assessment Questionnaire by clicking the link below:

+ + Start Questionnaire + +

Or copy this link to your browser:

+

{{link}}

+ +

Note: No login credentials are required at this stage. Your Application ID is {{applicationId}}.

+
+ +
+ + diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index ba95394..c586ca3 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -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, diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index fc11324..a5c644b 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -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; diff --git a/src/modules/onboarding/questionnaire.controller.ts b/src/modules/onboarding/questionnaire.controller.ts index 55aa811..d8cbbbe 100644 --- a/src/modules/onboarding/questionnaire.controller.ts +++ b/src/modules/onboarding/questionnaire.controller.ts @@ -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' }); + } +}; diff --git a/src/modules/onboarding/questionnaire.routes.ts b/src/modules/onboarding/questionnaire.routes.ts index 4b46de6..3a4575d 100644 --- a/src/modules/onboarding/questionnaire.routes.ts +++ b/src/modules/onboarding/questionnaire.routes.ts @@ -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); diff --git a/update_enums_raw.js b/update_enums_raw.js new file mode 100644 index 0000000..33525de --- /dev/null +++ b/update_enums_raw.js @@ -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();