dld_backend/src/routes/api.js

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;