973 lines
35 KiB
JavaScript
973 lines
35 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const moment = require('moment');
|
|
|
|
const DubaiRealEstateParser = require('../services/nlpService');
|
|
const SQLGenerator = require('../services/sqlGenerator');
|
|
const ContextAwareSQLGenerator = require('../services/contextAwareSQLGenerator');
|
|
const ChartFormatter = require('../services/chartFormatter');
|
|
const ContextManager = require('../services/contextManager');
|
|
const QueryTemplates = require('../services/queryTemplates');
|
|
const database = require('../models/database');
|
|
const { validateQuery } = require('../middleware/validation');
|
|
|
|
const nlpParser = new DubaiRealEstateParser();
|
|
const sqlGenerator = new SQLGenerator();
|
|
const contextAwareSQLGenerator = new ContextAwareSQLGenerator();
|
|
const chartFormatter = new ChartFormatter();
|
|
const contextManager = new ContextManager();
|
|
const templates = new QueryTemplates();
|
|
|
|
// Main query endpoint
|
|
router.post('/query', validateQuery, async (req, res) => {
|
|
try {
|
|
const { query, sessionId } = req.validatedData;
|
|
|
|
console.log(`🔍 Processing query: "${query}"`);
|
|
|
|
// Step 1: Parse the natural language query
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
console.log('📝 Parsed query:', parsedQuery);
|
|
|
|
// Step 2: Generate SQL query with context awareness
|
|
const sqlQuery = await contextAwareSQLGenerator.generateSQL(parsedQuery, sessionId);
|
|
console.log('🗄️ Generated SQL:', sqlQuery.sql);
|
|
|
|
// Step 3: Execute SQL query
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
console.log(`📊 Query returned ${sqlResult.length} rows`);
|
|
|
|
// Step 4: Format response for Chart.js
|
|
// Use merged context if available (for follow-up queries)
|
|
const contextForFormatter = sqlQuery.mergedContext || parsedQuery;
|
|
const response = chartFormatter.formatResponse(contextForFormatter, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Query processing error:', error);
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Failed to process query',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Specific query endpoints for the 10 questions
|
|
// Q1-3: Rental price trend with context awareness
|
|
router.post('/queries/rental-trend', async (req, res) => {
|
|
try {
|
|
const { area = 'business bay', sessionId, refinement } = req.body;
|
|
const query = `Give me the last 6 months rental price trend for ${area}`;
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
|
|
// Apply refinement if provided
|
|
if (refinement) {
|
|
parsedQuery.refinements = contextManager.extractRefinements(refinement);
|
|
}
|
|
|
|
const sqlQuery = await contextAwareSQLGenerator.generateSQL(parsedQuery, sessionId);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Q4: Brief about the Project
|
|
router.get('/queries/project-summary-detail', async (req, res) => {
|
|
try {
|
|
const params = { startDate: moment().subtract(6, 'months').format('YYYY-MM-DD') };
|
|
const sqlQuery = templates.getTemplate('project_transaction_summary', params);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
text: 'Transaction summary for projects',
|
|
visualizations: chartFormatter.createVisualizations(sqlResult, { intent: 'summary' }),
|
|
cards: chartFormatter.createCards(sqlResult, { intent: 'summary' })
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Q5: Fast moving projects
|
|
router.get('/queries/fast-moving-projects', async (req, res) => {
|
|
try {
|
|
const params = { startDate: moment().subtract(6, 'months').format('YYYY-MM-DD') };
|
|
const sqlQuery = templates.getTemplate('fast_moving_projects', params);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
text: 'Fast moving projects in last 6 months',
|
|
visualizations: chartFormatter.createVisualizations(sqlResult, { intent: 'compare' }),
|
|
cards: chartFormatter.createCards(sqlResult, { intent: 'compare' })
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Q6: Off-plan uptick areas
|
|
router.get('/queries/offplan-uptick', async (req, res) => {
|
|
try {
|
|
const params = { startDate: moment().subtract(6, 'months').format('YYYY-MM-DD') };
|
|
const sqlQuery = templates.getTemplate('offplan_uptick_areas', params);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
text: 'Areas seeing uptick in off-plan projects',
|
|
visualizations: chartFormatter.createVisualizations(sqlResult, { intent: 'compare' }),
|
|
cards: chartFormatter.createCards(sqlResult, { intent: 'compare' })
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Predefined query endpoints for common questions
|
|
router.get('/queries/rental-trend/:area?', async (req, res) => {
|
|
try {
|
|
const area = req.params.area || 'business bay';
|
|
const query = `Give me the last 6 months rental price trend for ${area}`;
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
const sqlQuery = sqlGenerator.generateSQL(parsedQuery);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/queries/top-areas', async (req, res) => {
|
|
try {
|
|
const query = 'Which area is having more rental transactions?';
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
const sqlQuery = sqlGenerator.generateSQL(parsedQuery);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/queries/project-summary', async (req, res) => {
|
|
try {
|
|
const query = 'Brief about the Project';
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
const sqlQuery = sqlGenerator.generateSQL(parsedQuery);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/queries/commercial-leasing', async (req, res) => {
|
|
try {
|
|
const query = 'Top 5 areas for Commercial leasing and why?';
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
const sqlQuery = sqlGenerator.generateSQL(parsedQuery);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/queries/residential-leasing', async (req, res) => {
|
|
try {
|
|
const query = 'Top 5 areas for Residential leasing and why?';
|
|
|
|
const parsedQuery = await nlpParser.parseQuery(query);
|
|
const sqlQuery = sqlGenerator.generateSQL(parsedQuery);
|
|
const sqlResult = await database.query(sqlQuery.sql, sqlQuery.params);
|
|
const response = chartFormatter.formatResponse(parsedQuery, sqlResult, sqlQuery);
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Database info endpoint
|
|
router.get('/database/info', async (req, res) => {
|
|
try {
|
|
const tables = ['transactions', 'rents', 'projects', 'buildings', 'lands', 'valuations', 'brokers'];
|
|
const info = {};
|
|
|
|
for (const table of tables) {
|
|
try {
|
|
const countResult = await database.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
info[table] = {
|
|
count: countResult[0].count,
|
|
status: 'available'
|
|
};
|
|
} catch (error) {
|
|
info[table] = {
|
|
count: 0,
|
|
status: 'error',
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
database: process.env.DB_NAME || 'dubai_dld',
|
|
tables: info,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
});
|
|
|
|
// Available queries endpoint
|
|
router.get('/queries/available', (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
predefined_queries: [
|
|
{
|
|
endpoint: '/api/queries/rental-trend/:area',
|
|
description: 'Get rental price trend for a specific area',
|
|
example: '/api/queries/rental-trend/business-bay'
|
|
},
|
|
{
|
|
endpoint: '/api/queries/top-areas',
|
|
description: 'Get top areas with most rental transactions'
|
|
},
|
|
{
|
|
endpoint: '/api/queries/project-summary',
|
|
description: 'Get project summary and statistics'
|
|
},
|
|
{
|
|
endpoint: '/api/queries/commercial-leasing',
|
|
description: 'Get top areas for commercial leasing'
|
|
},
|
|
{
|
|
endpoint: '/api/queries/residential-leasing',
|
|
description: 'Get top areas for residential leasing'
|
|
}
|
|
],
|
|
custom_queries: {
|
|
endpoint: '/api/query',
|
|
method: 'POST',
|
|
description: 'Process custom natural language queries',
|
|
examples: [
|
|
'Give me the last 6 months rental price trend for Business Bay',
|
|
'Which area is having more rental transactions?',
|
|
'Top 5 areas for Commercial leasing and why?',
|
|
'Avg price of 3BHK apartment by area in last 6 months',
|
|
'Brief about the Project'
|
|
]
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Recent transactions endpoint with filters
|
|
router.get('/transactions/recent', async (req, res) => {
|
|
try {
|
|
const {
|
|
area_name,
|
|
property_type,
|
|
size,
|
|
size_min,
|
|
size_max,
|
|
beds,
|
|
project,
|
|
// pagination params
|
|
page,
|
|
page_size,
|
|
limit
|
|
} = req.query;
|
|
|
|
// Validate limit only if provided; no upper cap enforced by API
|
|
let limitNum = undefined;
|
|
if (limit !== undefined && limit !== null && `${limit}`.trim() !== '') {
|
|
const parsed = parseInt(limit, 10);
|
|
if (isNaN(parsed) || parsed < 1) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Limit must be a positive integer when provided'
|
|
});
|
|
}
|
|
limitNum = parsed;
|
|
}
|
|
|
|
// Build base WHERE with optional filters
|
|
let baseWhere = ' WHERE 1=1';
|
|
const params = [];
|
|
|
|
// Apply filters (case-insensitive matching where applicable)
|
|
if (area_name && area_name.trim() !== '') {
|
|
baseWhere += ' AND LOWER(area_en) LIKE ?';
|
|
params.push(`%${area_name.toLowerCase().trim()}%`);
|
|
}
|
|
|
|
if (property_type && property_type.trim() !== '' && property_type !== 'all') {
|
|
baseWhere += ' AND (LOWER(prop_type_en) LIKE ? OR LOWER(prop_sb_type_en) LIKE ?)';
|
|
const propType = `%${property_type.toLowerCase().trim()}%`;
|
|
params.push(propType, propType);
|
|
}
|
|
|
|
// Size filtering: support size_min/size_max. Keep legacy 'size' as upper bound
|
|
const minNum = size_min !== undefined ? parseFloat(size_min) : undefined;
|
|
const maxNum = size_max !== undefined ? parseFloat(size_max) : (size !== undefined ? parseFloat(size) : undefined);
|
|
const hasMin = minNum !== undefined && !isNaN(minNum);
|
|
const hasMax = maxNum !== undefined && !isNaN(maxNum);
|
|
if (hasMin && hasMax) {
|
|
baseWhere += ' AND actual_area BETWEEN ? AND ?';
|
|
params.push(minNum, maxNum);
|
|
} else if (hasMin) {
|
|
baseWhere += ' AND actual_area >= ?';
|
|
params.push(minNum);
|
|
} else if (hasMax) {
|
|
baseWhere += ' AND actual_area <= ?';
|
|
params.push(maxNum);
|
|
}
|
|
|
|
if (beds && beds !== 'all' && beds !== '') {
|
|
const bedsStr = beds.toString().trim();
|
|
// Match beds - handle formats like "3 b/r", "3.0", "3", "studio"
|
|
if (bedsStr.toLowerCase() === 'studio') {
|
|
baseWhere += ' AND LOWER(rooms_en) = ?';
|
|
params.push('studio');
|
|
} else if (bedsStr.toLowerCase() === 'null' || bedsStr === '') {
|
|
baseWhere += ' AND (rooms_en IS NULL OR rooms_en = "")';
|
|
} else {
|
|
baseWhere += ' AND (rooms_en LIKE ? OR rooms_en = ? OR rooms_en = ?)';
|
|
params.push(`%${bedsStr}%`, bedsStr, `${bedsStr}.0`);
|
|
}
|
|
}
|
|
|
|
if (project && project.trim() !== '' && project !== 'all') {
|
|
baseWhere += ' AND (LOWER(project_en) LIKE ? OR LOWER(master_project_en) LIKE ?)';
|
|
const projectName = `%${project.toLowerCase().trim()}%`;
|
|
params.push(projectName, projectName);
|
|
}
|
|
|
|
// Pagination decision: page/page_size OR legacy limit triggers bounded fetch; otherwise full data
|
|
const rawPageSize = page_size ?? undefined;
|
|
const wantsPagination = (page !== undefined) || (rawPageSize !== undefined) || (limitNum !== undefined);
|
|
const defaultPage = 1;
|
|
const defaultPageSize = 30;
|
|
const pageNum = (() => { const n = parseInt(page ?? defaultPage, 10); return isNaN(n) || n < 1 ? defaultPage : n; })();
|
|
const pageSizeNum = (() => { const n = parseInt((rawPageSize ?? (limitNum !== undefined ? limitNum : defaultPageSize)), 10); return isNaN(n) || n < 1 ? defaultPageSize : n; })();
|
|
const offsetNum = (pageNum - 1) * pageSizeNum;
|
|
|
|
const orderBy = ' ORDER BY instance_date DESC, transaction_id DESC';
|
|
let dataSql;
|
|
let total = null;
|
|
if (!wantsPagination) {
|
|
dataSql = `
|
|
SELECT *
|
|
FROM transactions
|
|
${baseWhere}
|
|
${orderBy}
|
|
`;
|
|
} else if (limitNum !== undefined && page === undefined && page_size === undefined) {
|
|
// Legacy behavior: only limit provided (no paging), apply LIMIT without OFFSET
|
|
dataSql = `
|
|
SELECT *
|
|
FROM transactions
|
|
${baseWhere}
|
|
${orderBy}
|
|
LIMIT ${limitNum}
|
|
`;
|
|
} else {
|
|
// Full pagination with COUNT
|
|
const countSql = `
|
|
SELECT COUNT(*) AS total
|
|
FROM transactions
|
|
${baseWhere}
|
|
`;
|
|
const countRows = await database.query(countSql, params);
|
|
total = countRows && countRows[0] ? countRows[0].total : 0;
|
|
dataSql = `
|
|
SELECT *
|
|
FROM transactions
|
|
${baseWhere}
|
|
${orderBy}
|
|
LIMIT ${pageSizeNum} OFFSET ${offsetNum}
|
|
`;
|
|
}
|
|
|
|
console.log(`🔍 Fetching recent transactions with filters:`, {
|
|
area_name, property_type, size, size_min, size_max, beds, project, limit: limitNum, page, page_size
|
|
});
|
|
|
|
const transactions = await database.query(dataSql, params);
|
|
|
|
const totalPages = (total !== null) ? Math.max(1, Math.ceil(total / pageSizeNum)) : 1;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
transactions: transactions,
|
|
count: transactions.length,
|
|
total: total !== null ? total : transactions.length,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null),
|
|
total_pages: total !== null ? totalPages : 1,
|
|
has_next: total !== null ? pageNum < totalPages : false,
|
|
has_prev: total !== null ? pageNum > 1 : false,
|
|
filters: {
|
|
area_name: area_name || null,
|
|
property_type: property_type || null,
|
|
size: size || null,
|
|
beds: beds || null,
|
|
project: project || null,
|
|
limit: limitNum ?? null,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null)
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Transactions query error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Failed to retrieve transactions',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Recent rents endpoint with filters
|
|
router.get('/rents/recent', async (req, res) => {
|
|
try {
|
|
const {
|
|
area_name,
|
|
property_type,
|
|
size_min,
|
|
size_max,
|
|
rooms,
|
|
project,
|
|
page,
|
|
page_size,
|
|
limit
|
|
} = req.query;
|
|
|
|
// Validate limit
|
|
let limitNum = undefined;
|
|
if (limit !== undefined && limit !== null && `${limit}`.trim() !== '') {
|
|
const parsed = parseInt(limit, 10);
|
|
if (isNaN(parsed) || parsed < 1) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Limit must be a positive integer when provided'
|
|
});
|
|
}
|
|
limitNum = parsed;
|
|
}
|
|
|
|
// Build WHERE clause
|
|
let baseWhere = ' WHERE 1=1';
|
|
const params = [];
|
|
|
|
// Area filter (case-insensitive)
|
|
if (area_name && area_name.trim() !== '') {
|
|
baseWhere += ' AND LOWER(area_en) LIKE ?';
|
|
params.push(`%${area_name.toLowerCase().trim()}%`);
|
|
}
|
|
|
|
// Property type filter
|
|
if (property_type && property_type.trim() !== '' && property_type !== 'all') {
|
|
baseWhere += ' AND (LOWER(prop_type_en) LIKE ? OR LOWER(prop_sub_type_en) LIKE ?)';
|
|
const propType = `%${property_type.toLowerCase().trim()}%`;
|
|
params.push(propType, propType);
|
|
}
|
|
|
|
// Actual area filtering (min/max)
|
|
const minNum = size_min !== undefined ? parseFloat(size_min) : undefined;
|
|
const maxNum = size_max !== undefined ? parseFloat(size_max) : undefined;
|
|
const hasMin = minNum !== undefined && !isNaN(minNum);
|
|
const hasMax = maxNum !== undefined && !isNaN(maxNum);
|
|
if (hasMin && hasMax) {
|
|
baseWhere += ' AND actual_area BETWEEN ? AND ?';
|
|
params.push(minNum, maxNum);
|
|
} else if (hasMin) {
|
|
baseWhere += ' AND actual_area >= ?';
|
|
params.push(minNum);
|
|
} else if (hasMax) {
|
|
baseWhere += ' AND actual_area <= ?';
|
|
params.push(maxNum);
|
|
}
|
|
|
|
// Rooms filter (decimal values: 1.0, 2.0, etc.)
|
|
if (rooms && rooms !== 'all' && rooms !== '') {
|
|
const roomsNum = parseFloat(rooms);
|
|
if (!isNaN(roomsNum)) {
|
|
baseWhere += ' AND rooms = ?';
|
|
params.push(roomsNum);
|
|
}
|
|
}
|
|
|
|
// Project filter
|
|
if (project && project.trim() !== '' && project !== 'all') {
|
|
baseWhere += ' AND (LOWER(project_en) LIKE ? OR LOWER(master_project_en) LIKE ?)';
|
|
const projectName = `%${project.toLowerCase().trim()}%`;
|
|
params.push(projectName, projectName);
|
|
}
|
|
|
|
// Pagination logic
|
|
const wantsPagination = (page !== undefined) || (page_size !== undefined) || (limitNum !== undefined);
|
|
const defaultPage = 1;
|
|
const defaultPageSize = 30;
|
|
const pageNum = (() => { const n = parseInt(page ?? defaultPage, 10); return isNaN(n) || n < 1 ? defaultPage : n; })();
|
|
const pageSizeNum = (() => { const n = parseInt((page_size ?? (limitNum !== undefined ? limitNum : defaultPageSize)), 10); return isNaN(n) || n < 1 ? defaultPageSize : n; })();
|
|
const offsetNum = (pageNum - 1) * pageSizeNum;
|
|
|
|
const orderBy = ' ORDER BY registration_date DESC, rent_id DESC';
|
|
let dataSql;
|
|
let total = null;
|
|
|
|
if (!wantsPagination) {
|
|
dataSql = `SELECT * FROM rents ${baseWhere} ${orderBy}`;
|
|
} else if (limitNum !== undefined && page === undefined && page_size === undefined) {
|
|
dataSql = `SELECT * FROM rents ${baseWhere} ${orderBy} LIMIT ${limitNum}`;
|
|
} else {
|
|
const countSql = `SELECT COUNT(*) AS total FROM rents ${baseWhere}`;
|
|
const countRows = await database.query(countSql, params);
|
|
total = countRows && countRows[0] ? countRows[0].total : 0;
|
|
dataSql = `SELECT * FROM rents ${baseWhere} ${orderBy} LIMIT ${pageSizeNum} OFFSET ${offsetNum}`;
|
|
}
|
|
|
|
console.log(`🔍 Fetching recent rents with filters:`, {
|
|
area_name, property_type, size_min, size_max, rooms, project, limit: limitNum, page, page_size
|
|
});
|
|
|
|
const rents = await database.query(dataSql, params);
|
|
const totalPages = (total !== null) ? Math.max(1, Math.ceil(total / pageSizeNum)) : 1;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
rents: rents,
|
|
count: rents.length,
|
|
total: total !== null ? total : rents.length,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null),
|
|
total_pages: total !== null ? totalPages : 1,
|
|
has_next: total !== null ? pageNum < totalPages : false,
|
|
has_prev: total !== null ? pageNum > 1 : false,
|
|
filters: {
|
|
area_name: area_name || null,
|
|
property_type: property_type || null,
|
|
rooms: rooms || null,
|
|
project: project || null,
|
|
limit: limitNum ?? null,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null)
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('❌ Rents query error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Failed to retrieve rents',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Unified endpoint for both rent and sale queries
|
|
// Works exactly like /api/transactions/recent for type=sale
|
|
// Works exactly like /api/rents/recent for type=rent
|
|
router.get('/properties/recent', async (req, res) => {
|
|
try {
|
|
// Extract all possible parameters
|
|
const {
|
|
type, // Required: 'rent' or 'sale'
|
|
area_name,
|
|
property_type,
|
|
size, // Legacy parameter for sale (upper bound)
|
|
size_min,
|
|
size_max,
|
|
rooms, // For rent: decimal (2.0, 3.0). For sale: accepts string ("3", "studio", "3 b/r")
|
|
beds, // Alternative parameter for sale (backward compatibility)
|
|
project,
|
|
page,
|
|
page_size,
|
|
limit
|
|
} = req.query;
|
|
|
|
// ============================================
|
|
// VALIDATION: Type parameter (required)
|
|
// ============================================
|
|
if (!type || typeof type !== 'string') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Type parameter is required and must be either "rent" or "sale"'
|
|
});
|
|
}
|
|
|
|
const normalizedType = type.toLowerCase().trim();
|
|
if (normalizedType !== 'rent' && normalizedType !== 'sale') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Type parameter must be either "rent" or "sale"'
|
|
});
|
|
}
|
|
|
|
const isRent = normalizedType === 'rent';
|
|
|
|
// ============================================
|
|
// CONFIGURATION: Type-specific settings
|
|
// ============================================
|
|
const config = {
|
|
tableName: isRent ? 'rents' : 'transactions',
|
|
dateColumn: isRent ? 'registration_date' : 'instance_date',
|
|
idColumn: isRent ? 'rent_id' : 'transaction_id',
|
|
dataKey: isRent ? 'rents' : 'transactions',
|
|
propSubTypeColumn: isRent ? 'prop_sub_type_en' : 'prop_sb_type_en',
|
|
roomsColumn: isRent ? 'rooms' : 'rooms_en'
|
|
};
|
|
|
|
// ============================================
|
|
// VALIDATION: Limit parameter
|
|
// ============================================
|
|
let limitNum = undefined;
|
|
if (limit !== undefined && limit !== null && `${limit}`.trim() !== '') {
|
|
const parsed = parseInt(limit, 10);
|
|
if (isNaN(parsed) || parsed < 1) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Limit must be a positive integer when provided'
|
|
});
|
|
}
|
|
limitNum = parsed;
|
|
}
|
|
|
|
// ============================================
|
|
// BUILD WHERE CLAUSE: Base condition
|
|
// ============================================
|
|
let baseWhere = ' WHERE 1=1';
|
|
const params = [];
|
|
|
|
// ============================================
|
|
// FILTER: Area name (case-insensitive, partial match)
|
|
// ============================================
|
|
if (area_name !== undefined && area_name !== null && typeof area_name === 'string' && area_name.trim() !== '') {
|
|
baseWhere += ' AND LOWER(area_en) LIKE ?';
|
|
params.push(`%${area_name.toLowerCase().trim()}%`);
|
|
}
|
|
|
|
// ============================================
|
|
// FILTER: Property type (case-insensitive, partial match)
|
|
// ============================================
|
|
if (property_type !== undefined &&
|
|
property_type !== null &&
|
|
typeof property_type === 'string' &&
|
|
property_type.trim() !== '' &&
|
|
property_type.toLowerCase().trim() !== 'all') {
|
|
baseWhere += ` AND (LOWER(prop_type_en) LIKE ? OR LOWER(${config.propSubTypeColumn}) LIKE ?)`;
|
|
const propType = `%${property_type.toLowerCase().trim()}%`;
|
|
params.push(propType, propType);
|
|
}
|
|
|
|
// ============================================
|
|
// FILTER: Size (area) - Different logic for rent vs sale
|
|
// ============================================
|
|
if (isRent) {
|
|
// For rents: only size_min and size_max (no legacy 'size')
|
|
const minNum = size_min !== undefined && size_min !== null ? parseFloat(size_min) : undefined;
|
|
const maxNum = size_max !== undefined && size_max !== null ? parseFloat(size_max) : undefined;
|
|
const hasMin = minNum !== undefined && !isNaN(minNum) && minNum >= 0;
|
|
const hasMax = maxNum !== undefined && !isNaN(maxNum) && maxNum >= 0;
|
|
|
|
if (hasMin && hasMax) {
|
|
if (minNum <= maxNum) {
|
|
baseWhere += ' AND actual_area BETWEEN ? AND ?';
|
|
params.push(minNum, maxNum);
|
|
} else {
|
|
// Invalid range: min > max
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'size_min must be less than or equal to size_max'
|
|
});
|
|
}
|
|
} else if (hasMin) {
|
|
baseWhere += ' AND actual_area >= ?';
|
|
params.push(minNum);
|
|
} else if (hasMax) {
|
|
baseWhere += ' AND actual_area <= ?';
|
|
params.push(maxNum);
|
|
}
|
|
} else {
|
|
// For sales: support size_min/size_max AND legacy 'size' as upper bound
|
|
const minNum = size_min !== undefined && size_min !== null ? parseFloat(size_min) : undefined;
|
|
const maxNum = size_max !== undefined && size_max !== null
|
|
? parseFloat(size_max)
|
|
: (size !== undefined && size !== null ? parseFloat(size) : undefined);
|
|
const hasMin = minNum !== undefined && !isNaN(minNum) && minNum >= 0;
|
|
const hasMax = maxNum !== undefined && !isNaN(maxNum) && maxNum >= 0;
|
|
|
|
if (hasMin && hasMax) {
|
|
if (minNum <= maxNum) {
|
|
baseWhere += ' AND actual_area BETWEEN ? AND ?';
|
|
params.push(minNum, maxNum);
|
|
} else {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'size_min must be less than or equal to size_max (or size)'
|
|
});
|
|
}
|
|
} else if (hasMin) {
|
|
baseWhere += ' AND actual_area >= ?';
|
|
params.push(minNum);
|
|
} else if (hasMax) {
|
|
baseWhere += ' AND actual_area <= ?';
|
|
params.push(maxNum);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FILTER: Rooms/Beds - Different handling for rent vs sale
|
|
// ============================================
|
|
// For sale: accept both 'rooms' and 'beds' for backward compatibility
|
|
// For rent: only accept 'rooms'
|
|
const roomsOrBeds = isRent ? rooms : (rooms || beds);
|
|
|
|
if (roomsOrBeds !== undefined &&
|
|
roomsOrBeds !== null &&
|
|
roomsOrBeds !== '' &&
|
|
roomsOrBeds.toString().toLowerCase().trim() !== 'all') {
|
|
|
|
if (isRent) {
|
|
// For rents: use decimal value (2.0, 3.0, etc.) - exact match
|
|
const roomsNum = parseFloat(roomsOrBeds);
|
|
if (!isNaN(roomsNum) && roomsNum >= 0) {
|
|
baseWhere += ` AND ${config.roomsColumn} = ?`;
|
|
params.push(roomsNum);
|
|
}
|
|
// If invalid, silently ignore (matches original behavior)
|
|
} else {
|
|
// For sales (transactions): use string format handling like beds
|
|
const bedsStr = roomsOrBeds.toString().trim();
|
|
const bedsLower = bedsStr.toLowerCase();
|
|
|
|
if (bedsLower === 'studio') {
|
|
baseWhere += ` AND LOWER(${config.roomsColumn}) = ?`;
|
|
params.push('studio');
|
|
} else if (bedsLower === 'null' || bedsStr === '') {
|
|
baseWhere += ` AND (${config.roomsColumn} IS NULL OR ${config.roomsColumn} = "")`;
|
|
} else {
|
|
// Match formats like "3 b/r", "3.0", "3"
|
|
baseWhere += ` AND (${config.roomsColumn} LIKE ? OR ${config.roomsColumn} = ? OR ${config.roomsColumn} = ?)`;
|
|
params.push(`%${bedsStr}%`, bedsStr, `${bedsStr}.0`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FILTER: Project name (case-insensitive, partial match)
|
|
// ============================================
|
|
if (project !== undefined &&
|
|
project !== null &&
|
|
typeof project === 'string' &&
|
|
project.trim() !== '' &&
|
|
project.toLowerCase().trim() !== 'all') {
|
|
baseWhere += ' AND (LOWER(project_en) LIKE ? OR LOWER(master_project_en) LIKE ?)';
|
|
const projectName = `%${project.toLowerCase().trim()}%`;
|
|
params.push(projectName, projectName);
|
|
}
|
|
|
|
// ============================================
|
|
// PAGINATION: Calculate pagination parameters
|
|
// ============================================
|
|
// Match original endpoints exactly
|
|
const rawPageSize = page_size ?? undefined;
|
|
const wantsPagination = (page !== undefined) || (rawPageSize !== undefined) || (limitNum !== undefined);
|
|
const defaultPage = 1;
|
|
const defaultPageSize = 30;
|
|
|
|
// Parse page number with validation
|
|
let pageNum = defaultPage;
|
|
if (page !== undefined && page !== null) {
|
|
const parsed = parseInt(page, 10);
|
|
if (!isNaN(parsed) && parsed >= 1) {
|
|
pageNum = parsed;
|
|
}
|
|
}
|
|
|
|
// Parse page size with validation
|
|
let pageSizeNum = defaultPageSize;
|
|
if (wantsPagination) {
|
|
if (rawPageSize !== undefined && rawPageSize !== null) {
|
|
const parsed = parseInt(rawPageSize, 10);
|
|
if (!isNaN(parsed) && parsed >= 1) {
|
|
pageSizeNum = parsed;
|
|
}
|
|
} else if (limitNum !== undefined) {
|
|
pageSizeNum = limitNum;
|
|
}
|
|
}
|
|
|
|
const offsetNum = (pageNum - 1) * pageSizeNum;
|
|
|
|
// ============================================
|
|
// SQL GENERATION: Build query with pagination
|
|
// ============================================
|
|
const orderBy = ` ORDER BY ${config.dateColumn} DESC, ${config.idColumn} DESC`;
|
|
let dataSql;
|
|
let total = null;
|
|
|
|
if (!wantsPagination) {
|
|
// No pagination: return all results
|
|
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy}`;
|
|
} else if (limitNum !== undefined && page === undefined && page_size === undefined) {
|
|
// Legacy behavior: only limit provided (no paging), apply LIMIT without OFFSET
|
|
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy} LIMIT ${limitNum}`;
|
|
} else {
|
|
// Full pagination with COUNT
|
|
const countSql = `SELECT COUNT(*) AS total FROM ${config.tableName} ${baseWhere}`;
|
|
|
|
try {
|
|
const countRows = await database.query(countSql, params);
|
|
total = countRows && countRows[0] && countRows[0].total !== undefined
|
|
? parseInt(countRows[0].total, 10)
|
|
: 0;
|
|
} catch (countError) {
|
|
console.error('❌ Count query error:', countError);
|
|
// Continue without total count if count fails
|
|
total = 0;
|
|
}
|
|
|
|
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy} LIMIT ${pageSizeNum} OFFSET ${offsetNum}`;
|
|
}
|
|
|
|
// ============================================
|
|
// LOGGING: Debug information
|
|
// ============================================
|
|
const logData = {
|
|
type: normalizedType,
|
|
area_name: area_name || null,
|
|
property_type: property_type || null,
|
|
rooms: rooms || null,
|
|
beds: beds || null,
|
|
project: project || null,
|
|
limit: limitNum ?? null,
|
|
page: pageNum,
|
|
page_size: pageSizeNum
|
|
};
|
|
|
|
if (isRent) {
|
|
logData.size_min = size_min || null;
|
|
logData.size_max = size_max || null;
|
|
} else {
|
|
logData.size = size || null;
|
|
logData.size_min = size_min || null;
|
|
logData.size_max = size_max || null;
|
|
}
|
|
|
|
console.log(`🔍 Fetching recent ${normalizedType} properties with filters:`, logData);
|
|
|
|
// ============================================
|
|
// EXECUTE QUERY: Fetch data
|
|
// ============================================
|
|
const results = await database.query(dataSql, params);
|
|
|
|
// ============================================
|
|
// CALCULATE PAGINATION METADATA
|
|
// ============================================
|
|
const totalPages = (total !== null && total > 0)
|
|
? Math.max(1, Math.ceil(total / pageSizeNum))
|
|
: 1;
|
|
|
|
// ============================================
|
|
// BUILD FILTERS OBJECT: Match original endpoints exactly
|
|
// ============================================
|
|
const filters = {
|
|
type: normalizedType,
|
|
area_name: area_name || null,
|
|
property_type: property_type || null,
|
|
project: project || null,
|
|
limit: limitNum ?? null,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null)
|
|
};
|
|
|
|
// Add type-specific filters
|
|
if (isRent) {
|
|
filters.size_min = size_min || null;
|
|
filters.size_max = size_max || null;
|
|
filters.rooms = rooms || null;
|
|
} else {
|
|
filters.size = size || null;
|
|
filters.size_min = size_min || null;
|
|
filters.size_max = size_max || null;
|
|
filters.rooms = rooms || null;
|
|
filters.beds = beds || null;
|
|
}
|
|
|
|
// ============================================
|
|
// RESPONSE: Return formatted data
|
|
// ============================================
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
[config.dataKey]: results,
|
|
type: normalizedType,
|
|
count: results.length,
|
|
total: total !== null ? total : results.length,
|
|
page: total !== null ? pageNum : null,
|
|
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null),
|
|
total_pages: total !== null ? totalPages : 1,
|
|
has_next: total !== null ? pageNum < totalPages : false,
|
|
has_prev: total !== null ? pageNum > 1 : false,
|
|
filters: filters
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
// ============================================
|
|
// ERROR HANDLING: Comprehensive error response
|
|
// ============================================
|
|
const queryType = (req.query?.type || 'properties').toLowerCase();
|
|
console.error(`❌ ${queryType} query error:`, error);
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Failed to retrieve ${queryType}`,
|
|
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|