diff --git a/Dubai_DLD_Properties_API_Complete.postman_collection.json b/Dubai_DLD_Properties_API_Complete.postman_collection.json new file mode 100644 index 0000000..4035182 --- /dev/null +++ b/Dubai_DLD_Properties_API_Complete.postman_collection.json @@ -0,0 +1,1144 @@ +{ + "info": { + "_postman_id": "dubai-dld-properties-api-v1", + "name": "Dubai DLD Properties API - Complete Collection", + "description": "Comprehensive Postman collection for Dubai DLD Analytics API including the new unified Properties endpoint and all existing endpoints.\n\n## New Endpoint\n- **Properties Recent**: Unified endpoint for both rent and sale queries (`/api/properties/recent`)\n\n## Features\n- Natural Language Query Processing\n- Transaction & Rental Filtering\n- Property Search (Rent/Sale)\n- Analytics & Reporting\n- Database Information", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "dubai-dld-api" + }, + "item": [ + { + "name": "🏘️ Properties (Unified Endpoint)", + "item": [ + { + "name": "Get All Properties - Rent", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent", + "description": "Property type: rent or sale" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get all rental properties with pagination" + }, + "response": [] + }, + { + "name": "Get All Properties - Sale", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get all sale/transaction properties with pagination" + }, + "response": [] + }, + { + "name": "Rent - Filter by Area", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&area_name=business+bay&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "area_name", + "value": "business bay", + "description": "Case-insensitive partial match" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get rental properties in Business Bay area" + }, + "response": [] + }, + { + "name": "Sale - Filter by Area", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&area_name=dubai+marina&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "area_name", + "value": "dubai marina" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get sale properties in Dubai Marina area" + }, + "response": [] + }, + { + "name": "Rent - Filter by Property Type", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&property_type=apartment&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "property_type", + "value": "apartment" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get rental apartments" + }, + "response": [] + }, + { + "name": "Sale - Filter by Property Type", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&property_type=villa&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "property_type", + "value": "villa" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get sale villas" + }, + "response": [] + }, + { + "name": "Rent - Filter by Rooms", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&rooms=2.0&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "rooms", + "value": "2.0", + "description": "Decimal value: 1.0, 2.0, 3.0, etc." + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get 2-bedroom rental properties" + }, + "response": [] + }, + { + "name": "Sale - Filter by Bedrooms", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&beds=3&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "beds", + "value": "3", + "description": "String format: '3', 'studio', '3 b/r', etc." + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get 3-bedroom sale properties (can also use 'rooms' parameter)" + }, + "response": [] + }, + { + "name": "Sale - Filter by Studio", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&beds=studio&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "beds", + "value": "studio" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get studio sale properties" + }, + "response": [] + }, + { + "name": "Rent - Filter by Size Range", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&size_min=1000&size_max=2000&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "size_min", + "value": "1000", + "description": "Minimum area in sq. ft" + }, + { + "key": "size_max", + "value": "2000", + "description": "Maximum area in sq. ft" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get rental properties between 1000-2000 sq. ft" + }, + "response": [] + }, + { + "name": "Sale - Filter by Size Range", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&size_min=1500&size_max=3000&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "size_min", + "value": "1500" + }, + { + "key": "size_max", + "value": "3000" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get sale properties between 1500-3000 sq. ft" + }, + "response": [] + }, + { + "name": "Sale - Legacy Size Parameter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&size=2500&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "size", + "value": "2500", + "description": "Legacy parameter: upper bound for area (only for sale)" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get sale properties with size <= 2500 sq. ft (legacy size parameter)" + }, + "response": [] + }, + { + "name": "Rent - Filter by Project", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&project=emaar&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "project", + "value": "emaar", + "description": "Case-insensitive partial match" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get rental properties in Emaar projects" + }, + "response": [] + }, + { + "name": "Sale - Filter by Project", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&project=dubai+marina&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "project", + "value": "dubai marina" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get sale properties in Dubai Marina projects" + }, + "response": [] + }, + { + "name": "Rent - Combined Filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&area_name=business+bay&property_type=apartment&rooms=2.0&size_min=1000&size_max=1500&page=1&page_size=20", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "area_name", + "value": "business bay" + }, + { + "key": "property_type", + "value": "apartment" + }, + { + "key": "rooms", + "value": "2.0" + }, + { + "key": "size_min", + "value": "1000" + }, + { + "key": "size_max", + "value": "1500" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + }, + "description": "Get 2-bedroom apartments for rent in Business Bay, 1000-1500 sq. ft" + }, + "response": [] + }, + { + "name": "Sale - Combined Filters", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&area_name=dubai+marina&property_type=villa&beds=3&size_min=2000&size_max=3000&project=emaar&page=1&page_size=20", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "area_name", + "value": "dubai marina" + }, + { + "key": "property_type", + "value": "villa" + }, + { + "key": "beds", + "value": "3" + }, + { + "key": "size_min", + "value": "2000" + }, + { + "key": "size_max", + "value": "3000" + }, + { + "key": "project", + "value": "emaar" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + }, + "description": "Get 3-bedroom villas for sale in Dubai Marina, 2000-3000 sq. ft, Emaar projects" + }, + "response": [] + }, + { + "name": "Rent - Simple Limit", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&limit=10", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "limit", + "value": "10", + "description": "Legacy limit parameter (no pagination metadata)" + } + ] + }, + "description": "Get first 10 rental properties (legacy limit)" + }, + "response": [] + }, + { + "name": "Sale - Simple Limit", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=sale&limit=50", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "sale" + }, + { + "key": "limit", + "value": "50" + } + ] + }, + "description": "Get first 50 sale properties (legacy limit)" + }, + "response": [] + }, + { + "name": "Rent - Pagination Page 2", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=rent&page=2&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "rent" + }, + { + "key": "page", + "value": "2" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "Get second page of rental properties" + }, + "response": [] + }, + { + "name": "Error - Missing Type Parameter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "This should return 400 error - type parameter is required" + }, + "response": [] + }, + { + "name": "Error - Invalid Type", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/properties/recent?type=invalid&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "properties", "recent"], + "query": [ + { + "key": "type", + "value": "invalid" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + }, + "description": "This should return 400 error - type must be 'rent' or 'sale'" + }, + "response": [] + } + ] + }, + { + "name": "🏢 Transactions", + "item": [ + { + "name": "Get Recent Transactions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/transactions/recent?page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "transactions", "recent"], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter by Area", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/transactions/recent?area_name=business+bay&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "transactions", "recent"], + "query": [ + { + "key": "area_name", + "value": "business bay" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter by Property Type and Beds", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/transactions/recent?property_type=apartment&beds=3&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "transactions", "recent"], + "query": [ + { + "key": "property_type", + "value": "apartment" + }, + { + "key": "beds", + "value": "3" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🏠 Rents", + "item": [ + { + "name": "Get Recent Rents", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/rents/recent?page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "rents", "recent"], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter by Area and Rooms", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/rents/recent?area_name=dubai+marina&rooms=2.0&page=1&page_size=30", + "host": ["{{baseUrl}}"], + "path": ["api", "rents", "recent"], + "query": [ + { + "key": "area_name", + "value": "dubai marina" + }, + { + "key": "rooms", + "value": "2.0" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "30" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "🤖 Natural Language Queries", + "item": [ + { + "name": "Rental Price Trend", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"Give me the last 6 months rental price trend for Business Bay\",\n \"sessionId\": \"test-session-123\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/query", + "host": ["{{baseUrl}}"], + "path": ["api", "query"] + }, + "description": "Natural language query for rental price trends" + }, + "response": [] + }, + { + "name": "Top Areas for Rental Transactions", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"Which area is having more rental transactions?\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/query", + "host": ["{{baseUrl}}"], + "path": ["api", "query"] + } + }, + "response": [] + }, + { + "name": "Commercial Leasing", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"Top 5 areas for Commercial leasing and why?\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/query", + "host": ["{{baseUrl}}"], + "path": ["api", "query"] + } + }, + "response": [] + }, + { + "name": "Average Price 3BHK", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"query\": \"Avg price of 3BHK apartment by area in last 6 months, group it by month. Show top 5 areas only.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/query", + "host": ["{{baseUrl}}"], + "path": ["api", "query"] + } + }, + "response": [] + } + ] + }, + { + "name": "📊 Predefined Analytics", + "item": [ + { + "name": "Rental Trend by Area", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/rental-trend/business-bay", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "rental-trend", "business-bay"] + } + }, + "response": [] + }, + { + "name": "Top Areas", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/top-areas", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "top-areas"] + } + }, + "response": [] + }, + { + "name": "Commercial Leasing", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/commercial-leasing", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "commercial-leasing"] + } + }, + "response": [] + }, + { + "name": "Residential Leasing", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/residential-leasing", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "residential-leasing"] + } + }, + "response": [] + }, + { + "name": "Fast Moving Projects", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/fast-moving-projects", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "fast-moving-projects"] + } + }, + "response": [] + }, + { + "name": "Off-Plan Uptick", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/offplan-uptick", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "offplan-uptick"] + } + }, + "response": [] + }, + { + "name": "Project Summary", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/project-summary", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "project-summary"] + } + }, + "response": [] + }, + { + "name": "Project Summary Detail", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/project-summary-detail", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "project-summary-detail"] + } + }, + "response": [] + } + ] + }, + { + "name": "🔧 System & Info", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/health", + "host": ["{{baseUrl}}"], + "path": ["health"] + }, + "description": "Check if the API server is running" + }, + "response": [] + }, + { + "name": "Database Info", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/database/info", + "host": ["{{baseUrl}}"], + "path": ["api", "database", "info"] + }, + "description": "Get database table information and record counts" + }, + "response": [] + }, + { + "name": "Available Queries", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/queries/available", + "host": ["{{baseUrl}}"], + "path": ["api", "queries", "available"] + }, + "description": "List all available query endpoints and examples" + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000", + "type": "string" + } + ] +} + diff --git a/public/js/properties.js b/public/js/properties.js new file mode 100644 index 0000000..02c3f29 --- /dev/null +++ b/public/js/properties.js @@ -0,0 +1,539 @@ +// Properties page logic for unified rent/sale endpoint + +const API_BASE = '/api'; + +// Combined area dropdown values (from both transactions and rents) +const areas = [ + 'abu hail', 'al athbah', 'al aweer first', 'al aweer second', 'al bada', 'al baraha', + 'al barari', 'al barsha first', 'al barsha second', 'al barsha third', 'al barshaa south first', + 'al barshaa south second', 'al barshaa south third', 'al buteen', 'al dhagaya', 'al eyas', + 'al furjan', 'al garhoud', 'al goze first', 'al goze fourth', 'al goze industrial first', + 'al goze industrial fourth', 'al goze industrial second', 'al goze industrial third', 'al goze third', + 'al hamriya', 'al hebiah fifth', 'al hebiah first', 'al hebiah fourth', 'al hebiah sixth', + 'al hebiah third', 'al hudaiba', 'al jadaf', 'al jafliya', 'al karama', 'al khabeesi', + 'al khail heights', 'al khairan first', 'al khawaneej first', 'al khawaneej second', 'al kheeran', + 'al kifaf', 'al lusaily', 'al mamzer', 'al manara', 'al mararr', 'al merkadh', + 'al mizhar first', 'al mizhar fourth', 'al mizhar second', 'al mizhar third', 'al murqabat', + 'al muteena', 'al nahda first', 'al nahda second', 'al qusais', 'al qusais industrial fifth', + 'al qusais industrial first', 'al qusais industrial fourth', 'al qusais industrial third', 'al raffa', + 'al ras', 'al rashidiya', 'al rega', 'al rowaiyah third', 'al saffa first', 'al saffa second', + 'al safouh first', 'al satwa', 'al suq al kabeer', 'al thanyah fifth', 'al thanyah third', + 'al ttay', 'al twar fifth', 'al twar first', 'al twar fourth', 'al twar second', 'al twar third', + 'al waha', 'al waheda', 'al warqa first', 'al warqa fourth', 'al warqa second', 'al warqa third', + 'al warsan second', 'al warsan third', 'al wasl', 'al yelayiss 1', 'al yelayiss 2', 'al yelayiss 5', + 'al yufrah 1', 'arabian ranches i', 'arabian ranches ii', 'arabian ranches iii', + 'arabian ranches polo club', 'arjan', 'barsha heights', 'bluewaters', 'bukadra', 'burj khalifa', + 'business bay', 'business park', 'cherrywoods', 'city of arabia', 'city walk', 'damac hills', + 'discovery gardens', 'dmcc-ez2', 'down town jabal ali', 'dubai creek harbour', 'dubai design district', + 'dubai golf city', 'dubai harbour', 'dubai healthcare city - phase 1', 'dubai healthcare city - phase 2', + 'dubai hills', 'dubai industrial city', 'dubai international airport', 'dubai investment park first', + 'dubai investment park second', 'dubai land residence complex', 'dubai lifestyle city', 'dubai marina', + 'dubai maritime city', 'dubai production city', 'dubai science park', 'dubai south', 'dubai sports city', + 'dubai studio city', 'dubai water canal', 'dubai water front', 'emaar south', 'emirate living', + 'eyal nasser', 'falcon city of wonders', 'ghadeer al tair', 'ghadeer barashy', 'grand hills dubai', + 'grand views', 'hadaeq sheikh mohammed bin rashid', 'hessyan second', 'hor al anz', 'hor al anz east', + 'horizon', 'international city ph 1', 'international city ph 2 & 3', 'island 2', 'jabal ali first', + 'jabal ali industrial first', 'jabal ali industrial second', 'jabel ali hills', 'jaddaf waterfront', + 'jumeira bay', 'jumeirah beach residence', 'jumeirah first', 'jumeirah golf', 'jumeirah heights', + 'jumeirah islands', 'jumeirah lakes towers', 'jumeirah living', 'jumeirah park', 'jumeirah second', + 'jumeirah third', 'jumeirah village circle', 'jumeirah village triangle', 'la mer', 'lehbab first', + 'lehbab second', 'living legends', 'liwan', 'liwan 2', 'madinat al mataar', 'madinat dubai almelaheyah', + 'madinat hind 3', 'madinat hind 4', 'madinat latifa', 'majan', 'mankhool', 'margham', 'marsa dubai', + 'mbr district 1', 'mbr district 7', "me'aisem first", "me'aisem second", 'medyan race course villas', + 'mena jabal ali', 'meydan avenue', 'meydan one', 'millennium', 'mina rashid', 'mira', 'mirdif', + 'motor city', 'mudon', 'muhaisanah first', 'muhaisanah fourth', 'muhaisanah second', 'muhaisanah third', + 'mushrif', 'nad al hamar', 'nad al sheba gardens', 'nad al shiba first', 'nad al shiba fourth', + 'nad al shiba second', 'nad al shiba third', 'nad shamma', 'nadd hessa', 'naif', 'nazwah', 'oud metha', + 'palm deira', 'palm jabal ali', 'palm jumeirah', 'palmarosa', 'pearl jumeira', 'polo townhouses igo', + 'port saeed', 'ras al khor', 'ras al khor industrial first', 'ras al khor industrial second', + 'ras al khor industrial third', 'rega al buteen', 'remraam', 'rukan', 'saih shuaib 1', 'saih shuaib 2', + 'saih shuaib 3', 'saih shuaib 4', 'sama al jadaf', 'serena', 'silicon oasis', 'sobha heartland', + 'sufouh gardens', 'sustainable city', 'tecom site a', 'tecom site d', 'the beach', 'the field', + 'the greens', 'the lakes', 'the valley', 'the villa', 'the world', 'tilal al ghaf', 'town square', + 'trade center first', 'trade center second', 'um al sheif', 'um hurair first', 'um hurair second', + 'um ramool', 'um suqaim first', 'um suqaim second', 'um suqaim third', 'umm addamin', 'villanova', + 'wadi al amardi', 'wadi al safa 2', 'wadi al safa 3', 'wadi al safa 4', 'wadi al safa 5', + 'wadi al safa 6', 'wadi al safa 7', 'warsan first', 'warsan fourth', 'yaraah', 'zaabeel first', 'zaabeel second' +]; + +// Property types for rent +const rentPropertyTypes = [ + { value: 'all', label: 'All Types' }, + { value: 'unit', label: 'Unit' }, + { value: 'villa', label: 'Villa' }, + { value: 'virtual unit', label: 'Virtual Unit' }, + { value: 'land', label: 'Land' }, + { value: 'building', label: 'Building' } +]; + +// Property types for sale +const salePropertyTypes = [ + { value: 'all', label: 'All Types' }, + { value: 'building', label: 'Building' }, + { value: 'land', label: 'Land' }, + { value: 'unit', label: 'Unit' } +]; + +// Combined projects list (simplified - you may want to load dynamically) +const projects = [ + '014 tower', '08 life residences', '1 residences', '10 oxford by iman', '105 residences by kamdar', + '11 hills park', '15 cascade', '15 northside', '161 jumeirah lane', '17 icon bay', + '171 garden heights', '1wood residence', '2020 marquis', '23 marina', + '29 boulevard', '310 riverside crescent', '311 boulevard by bam eskan', + 'empire estates', 'empire heights', 'starz tower by danube', 'azizi feirouz i', + 'candace acacia hotel apartments', 'burj al nujoom', 'azizi riviera 35' + // Add more projects as needed +]; + +let currentType = 'rent'; +let currentPage = 1; +let lastPaging = { total: 0, page: null, page_size: 30, total_pages: 1, has_next: false, has_prev: false }; + +function populateAreas() { + const areaSelect = document.getElementById('area_name'); + areas.forEach(area => { + const option = document.createElement('option'); + option.value = area; + option.textContent = area.charAt(0).toUpperCase() + area.slice(1).replace(/\b\w/g, l => l.toUpperCase()); + areaSelect.appendChild(option); + }); +} + +function loadProjects() { + const projectSelect = document.getElementById('project'); + const sortedProjects = projects.slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + sortedProjects.forEach(project => { + const option = document.createElement('option'); + option.value = project; + option.textContent = project.length > 70 ? project.substring(0, 70) + '...' : project; + projectSelect.appendChild(option); + }); +} + +function updatePropertyTypes() { + const propertyTypeSelect = document.getElementById('property_type'); + propertyTypeSelect.innerHTML = ''; + + const types = currentType === 'rent' ? rentPropertyTypes : salePropertyTypes; + types.forEach(type => { + const option = document.createElement('option'); + option.value = type.value; + option.textContent = type.label; + propertyTypeSelect.appendChild(option); + }); +} + +function updateTypeDependentFields() { + const typeInput = document.getElementById('type'); + typeInput.value = currentType; + + // Show/hide rooms vs beds + const roomsGroup = document.getElementById('roomsGroup'); + const bedsGroup = document.getElementById('bedsGroup'); + + if (currentType === 'rent') { + roomsGroup.style.display = 'flex'; + bedsGroup.style.display = 'none'; + document.getElementById('beds').value = 'all'; + } else { + roomsGroup.style.display = 'none'; + bedsGroup.style.display = 'flex'; + document.getElementById('rooms').value = 'all'; + } + + updatePropertyTypes(); + updateTableHeaders(); +} + +function updateTableHeaders() { + const tableHead = document.getElementById('tableHead'); + + if (currentType === 'rent') { + tableHead.innerHTML = ` + + rent_id + registration_date + start_date + end_date + version_en + area_en + contract_amount + annual_amount + is_free_hold_en + actual_area + prop_type_en + prop_sub_type_en + rooms + usage_en + nearest_metro_en + nearest_mall_en + nearest_landmark_en + parking + total_properties + master_project_en + project_en + created_at + updated_at + + `; + } else { + tableHead.innerHTML = ` + + transaction_id + transaction_number + instance_date + group_en + procedure_en + is_offplan_en + is_free_hold_en + usage_en + area_en + prop_type_en + prop_sb_type_en + trans_value + procedure_area + actual_area + rooms_en + parking + nearest_metro_en + nearest_mall_en + nearest_landmark_en + total_buyer + total_seller + master_project_en + project_en + created_at + updated_at + + `; + } +} + +function showLoading(show) { + document.getElementById('loading').style.display = show ? 'block' : 'none'; +} + +function showError(message) { + const errorDiv = document.getElementById('error'); + errorDiv.textContent = message; + errorDiv.style.display = 'block'; +} + +function hideError() { + document.getElementById('error').style.display = 'none'; +} + +function showResults() { + document.getElementById('resultsSection').style.display = 'block'; +} + +function hideResults() { + document.getElementById('resultsSection').style.display = 'none'; +} + +function formatDate(dateString) { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function formatNumber(num, decimals = 0) { + if (num === null || num === undefined) return 'N/A'; + return parseFloat(num).toLocaleString('en-US', { maximumFractionDigits: decimals }); +} + +function formatCurrency(amount) { + if (!amount) return 'N/A'; + return 'AED ' + parseFloat(amount).toLocaleString('en-US', { maximumFractionDigits: 0 }); +} + +async function searchProperties(pageOverride) { + const formData = new FormData(document.getElementById('filtersForm')); + const params = new URLSearchParams(); + + const type = formData.get('type') || currentType; + const area_name = formData.get('area_name'); + const property_type = formData.get('property_type'); + const size_min = formData.get('size_min'); + const size_max = formData.get('size_max'); + const rooms = formData.get('rooms'); + const beds = formData.get('beds'); + const project = formData.get('project'); + const pageSize = formData.get('page_size') || '30'; + const page = pageOverride != null ? pageOverride : (formData.get('page') || currentPage || 1); + + params.append('type', type); + if (area_name) params.append('area_name', area_name); + if (property_type && property_type !== 'all') params.append('property_type', property_type); + if (size_min && !isNaN(parseFloat(size_min))) params.append('size_min', size_min); + if (size_max && !isNaN(parseFloat(size_max))) params.append('size_max', size_max); + if (type === 'rent' && rooms && rooms !== 'all') { + params.append('rooms', rooms); + } else if (type === 'sale' && beds && beds !== 'all') { + params.append('beds', beds); + } + if (project && project !== 'all') params.append('project', project); + if (page) params.append('page', page); + if (pageSize) params.append('page_size', pageSize); + + showLoading(true); + hideError(); + hideResults(); + + try { + const response = await fetch(`${API_BASE}/properties/recent?${params.toString()}`); + const data = await response.json(); + if (data.success) { + currentPage = data.data.page || 1; + lastPaging = { + total: data.data.total ?? data.data.count, + page: data.data.page, + page_size: data.data.page_size ?? parseInt(pageSize, 10), + total_pages: data.data.total_pages ?? 1, + has_next: !!data.data.has_next, + has_prev: !!data.data.has_prev + }; + displayResults(data.data); + } else { + showError(data.message || 'Failed to fetch properties'); + } + } catch (error) { + showError('Network error: ' + error.message); + } finally { + showLoading(false); + } +} + +function displayResults(data) { + const dataKey = currentType === 'rent' ? 'rents' : 'transactions'; + const results = data[dataKey] || []; + const { count, total, page, page_size, total_pages, has_next, has_prev } = data; + const tbody = document.getElementById('propertiesBody'); + const resultsCount = document.getElementById('resultsCount'); + const showing = count; + const grandTotal = total ?? count; + + resultsCount.textContent = `Showing ${showing} of ${grandTotal} ${currentType === 'rent' ? 'rents' : 'transactions'}`; + + const pageInfo = document.getElementById('pageInfo'); + if (pageInfo) { + if (page && total_pages) { + pageInfo.textContent = `Page ${page} of ${total_pages}`; + } else { + pageInfo.textContent = ''; + } + } + + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + if (prevBtn) prevBtn.disabled = !(has_prev); + if (nextBtn) nextBtn.disabled = !(has_next); + + if (results.length === 0) { + const colCount = document.querySelectorAll('#propertiesTable thead th').length; + tbody.innerHTML = ( + '' + + `` + + '' + + '' + + '' + + `
No ${currentType === 'rent' ? 'rents' : 'transactions'} found matching your filters
` + + '' + + '' + ); + showResults(); + return; + } + + if (currentType === 'rent') { + tbody.innerHTML = results.map(r => ( + '' + + `${r.rent_id ?? 'N/A'}` + + `${formatDate(r.registration_date)}` + + `${formatDate(r.start_date)}` + + `${formatDate(r.end_date)}` + + `${r.version_en ?? 'N/A'}` + + `${r.area_en ?? 'N/A'}` + + `${formatCurrency(r.contract_amount)}` + + `${formatCurrency(r.annual_amount)}` + + `${r.is_free_hold_en ?? 'N/A'}` + + `${formatNumber(r.actual_area)}` + + `${r.prop_type_en ?? 'N/A'}` + + `${r.prop_sub_type_en ?? 'N/A'}` + + `${r.rooms != null ? formatNumber(r.rooms, 1) : 'N/A'}` + + `${r.usage_en ?? 'N/A'}` + + `${r.nearest_metro_en ?? 'N/A'}` + + `${r.nearest_mall_en ?? 'N/A'}` + + `${r.nearest_landmark_en ?? 'N/A'}` + + `${r.parking != null ? formatNumber(r.parking, 1) : 'N/A'}` + + `${r.total_properties ?? '0'}` + + `${r.master_project_en ?? 'N/A'}` + + `${r.project_en ?? 'N/A'}` + + `${formatDate(r.created_at)}` + + `${formatDate(r.updated_at)}` + + '' + )).join(''); + } else { + tbody.innerHTML = results.map(t => ( + '' + + `${t.transaction_id ?? 'N/A'}` + + `${t.transaction_number ?? 'N/A'}` + + `${formatDate(t.instance_date)}` + + `${t.group_en ?? 'N/A'}` + + `${t.procedure_en ?? 'N/A'}` + + `${t.is_offplan_en ?? 'N/A'}` + + `${t.is_free_hold_en ?? 'N/A'}` + + `${t.usage_en ?? 'N/A'}` + + `${t.area_en ?? 'N/A'}` + + `${t.prop_type_en ?? 'N/A'}` + + `${t.prop_sb_type_en ?? 'N/A'}` + + `${formatCurrency(t.trans_value)}` + + `${t.procedure_area != null ? formatNumber(t.procedure_area) : 'N/A'}` + + `${t.actual_area != null ? formatNumber(t.actual_area) : 'N/A'}` + + `${t.rooms_en ?? 'N/A'}` + + `${t.parking ?? 'N/A'}` + + `${t.nearest_metro_en ?? 'N/A'}` + + `${t.nearest_mall_en ?? 'N/A'}` + + `${t.nearest_landmark_en ?? 'N/A'}` + + `${t.total_buyer ?? '0'}` + + `${t.total_seller ?? '0'}` + + `${t.master_project_en ?? 'N/A'}` + + `${t.project_en ?? 'N/A'}` + + `${formatDate(t.created_at)}` + + `${formatDate(t.updated_at)}` + + '' + )).join(''); + } + + showResults(); +} + +function resetFilters() { + document.getElementById('filtersForm').reset(); + document.getElementById('size_min').value = ''; + document.getElementById('size_max').value = ''; + document.getElementById('sizeValue').textContent = 'All Sizes'; + document.getElementById('type_rent').checked = true; + currentType = 'rent'; + updateTypeDependentFields(); + hideResults(); + hideError(); +} + +document.addEventListener('DOMContentLoaded', () => { + populateAreas(); + loadProjects(); + updateTypeDependentFields(); + + // Type selector radio buttons + document.querySelectorAll('input[name="type"]').forEach(radio => { + radio.addEventListener('change', (e) => { + currentType = e.target.value; + updateTypeDependentFields(); + hideResults(); + hideError(); + }); + }); + + // Size slider setup + const sizeMinHidden = document.getElementById('size_min'); + const sizeMaxHidden = document.getElementById('size_max'); + const sizeMinRange = document.getElementById('size_min_range'); + const sizeMaxRange = document.getElementById('size_max_range'); + const sizeFill = document.getElementById('sizeFill'); + const sizeValue = document.getElementById('sizeValue'); + const MIN_VAL = parseFloat(sizeMinRange.min); + const MAX_VAL = parseFloat(sizeMinRange.max); + const STEP = parseFloat(sizeMinRange.step) || 100; + + function updateSizeLabel() { + const min = parseFloat(sizeMinRange.value); + const max = parseFloat(sizeMaxRange.value); + const hasMin = !isNaN(min); + const hasMax = !isNaN(max); + if (!hasMin && !hasMax) { + sizeValue.textContent = 'All Sizes'; + } else if (hasMin && hasMax) { + sizeValue.textContent = `${min.toLocaleString('en-US')} - ${max.toLocaleString('en-US')} sq.ft`; + } else if (hasMin) { + sizeValue.textContent = `≥ ${min.toLocaleString('en-US')} sq.ft`; + } else { + sizeValue.textContent = `≤ ${max.toLocaleString('en-US')} sq.ft`; + } + sizeMinHidden.value = (min > MIN_VAL) ? min : ''; + sizeMaxHidden.value = (max < MAX_VAL) ? max : ''; + const left = ((Math.max(MIN_VAL, Math.min(min, max)) - MIN_VAL) / (MAX_VAL - MIN_VAL)) * 100; + const right = ((Math.max(MIN_VAL, Math.max(min, max)) - MIN_VAL) / (MAX_VAL - MIN_VAL)) * 100; + sizeFill.style.left = `${left}%`; + sizeFill.style.width = `${Math.max(0, right - left)}%`; + } + + function clampRanges() { + if (parseFloat(sizeMinRange.value) > parseFloat(sizeMaxRange.value) - STEP) { + sizeMinRange.value = (parseFloat(sizeMaxRange.value) - STEP).toString(); + } + if (parseFloat(sizeMaxRange.value) < parseFloat(sizeMinRange.value) + STEP) { + sizeMaxRange.value = (parseFloat(sizeMinRange.value) + STEP).toString(); + } + } + + sizeMinRange.addEventListener('input', () => { clampRanges(); updateSizeLabel(); }); + sizeMaxRange.addEventListener('input', () => { clampRanges(); updateSizeLabel(); }); + + function bringMinToFront() { + sizeMinRange.style.zIndex = '6'; + sizeMaxRange.style.zIndex = '5'; + } + function bringMaxToFront() { + sizeMinRange.style.zIndex = '5'; + sizeMaxRange.style.zIndex = '6'; + } + ['mousedown','pointerdown','touchstart'].forEach(evt => { + sizeMinRange.addEventListener(evt, bringMinToFront, { passive: true }); + sizeMaxRange.addEventListener(evt, bringMaxToFront, { passive: true }); + }); + + sizeMinRange.value = MIN_VAL; + sizeMaxRange.value = MAX_VAL; + updateSizeLabel(); + + // Form submission + document.getElementById('filtersForm').addEventListener('submit', async (e) => { + e.preventDefault(); + currentPage = 1; + const pageInput = document.getElementById('page'); + if (pageInput) pageInput.value = '1'; + await searchProperties(1); + }); + + // Reset button + const resetBtn = document.getElementById('resetBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', resetFilters); + } + + // Pagination buttons + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + if (prevBtn) { + prevBtn.addEventListener('click', async () => { + if (lastPaging.has_prev && currentPage > 1) { + currentPage -= 1; + const pageInput2 = document.getElementById('page'); + if (pageInput2) pageInput2.value = String(currentPage); + await searchProperties(currentPage); + } + }); + } + if (nextBtn) { + nextBtn.addEventListener('click', async () => { + if (lastPaging.has_next) { + currentPage += 1; + const pageInput3 = document.getElementById('page'); + if (pageInput3) pageInput3.value = String(currentPage); + await searchProperties(currentPage); + } + }); + } +}); + diff --git a/public/properties.html b/public/properties.html new file mode 100644 index 0000000..348b354 --- /dev/null +++ b/public/properties.html @@ -0,0 +1,542 @@ + + + + + + Properties - Dubai DLD Analytics + + + +
+
+

🏘️ Properties Search

+

Filter and explore properties for rent or sale in Dubai

+
+ +
+

Select Property Type

+
+
+ + +
+
+ + +
+
+
+ +
+

Filters

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + + +
+ +
+ +
+
+
+
+
+
+ + +
+
+
All Sizes
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ 🔍 Fetching properties... +
+ +
+ + +
+ + + + + diff --git a/src/routes/api.js b/src/routes/api.js index c59b805..a1aba49 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -605,4 +605,368 @@ router.get('/rents/recent', async (req, res) => { } }); +// 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; diff --git a/src/services/chartFormatter.js b/src/services/chartFormatter.js index 5a21107..d025682 100644 --- a/src/services/chartFormatter.js +++ b/src/services/chartFormatter.js @@ -102,12 +102,17 @@ class ChartFormatter { if (fallbackReason) { description += ` (${fallbackReason})`; } - } else if (intent === 'average') { + } else if (intent === 'average') { description = `Average analysis for ${areas && areas.length > 0 ? areas.join(', ') : 'all areas'}`; } else if (intent === 'compare') { description = `Comparison analysis across ${sqlResult.length} areas`; } else if (intent === 'summary') { - description = `Summary of ${sqlResult.length} projects`; + // Check if this is a project summary query with metric-based data + if (sqlResult && sqlResult.length > 0 && sqlResult[0].metric_name) { + description = this.generateProjectSummaryDescription(sqlResult); + } else { + description = `Summary of ${sqlResult.length} projects`; + } } else { description = `Analysis results for: ${original_query}`; } @@ -845,6 +850,87 @@ class ChartFormatter { `${areaName} demonstrates strong commercial leasing activity with ${count} transactions, averaging ${this.formatCurrency(avgRent)} annually. The area offers diverse commercial opportunities with good connectivity and business-friendly infrastructure.`; } + generateProjectSummaryDescription(sqlResult) { + if (!sqlResult || sqlResult.length === 0) { + return 'Project summary data is not available.'; + } + + // Extract key metrics from the metric-based data + const metrics = {}; + sqlResult.forEach(row => { + const metricName = row.metric_name?.toLowerCase() || ''; + const value = row.metric_value || 0; + + // Match metric names (they may have numbers like "1. Total Transactions") + if (metricName.includes('total transactions') && !metricName.includes('off-plan') && !metricName.includes('ready') && !metricName.includes('residential')) { + metrics.totalTransactions = value; + } else if (metricName.includes('total transaction value')) { + metrics.totalValue = value; + } else if (metricName.includes('average transaction value')) { + metrics.avgValue = value; + } else if (metricName.includes('unique projects') || metricName.includes('project count')) { + metrics.uniqueProjects = value; + } else if (metricName.includes('off-plan')) { + metrics.offPlan = value; + } else if (metricName.includes('ready properties') || metricName.includes('ready')) { + metrics.ready = value; + } else if (metricName.includes('residential transactions')) { + metrics.residential = value; + } else if (metricName.includes('average area')) { + metrics.avgArea = value; + } else if (metricName.includes('unique areas') || metricName.includes('area count')) { + metrics.uniqueAreas = value; + } + }); + + let description = 'Project Overview: '; + + // Total transactions + if (metrics.totalTransactions) { + const totalTxns = Math.round(metrics.totalTransactions); + description += `The database contains ${totalTxns.toLocaleString()} total transactions `; + } + + // Total value + if (metrics.totalValue) { + description += `with a combined transaction value of ${this.formatCurrency(metrics.totalValue)}. `; + } + + // Unique projects + if (metrics.uniqueProjects) { + const uniqueProj = Math.round(metrics.uniqueProjects); + description += `There are ${uniqueProj.toLocaleString()} unique projects `; + } + + // Unique areas + if (metrics.uniqueAreas) { + const uniqueAreas = Math.round(metrics.uniqueAreas); + description += `across ${uniqueAreas} different areas. `; + } + + // Average transaction value + if (metrics.avgValue) { + description += `The average transaction value is ${this.formatCurrency(metrics.avgValue)}. `; + } + + // Off-plan vs Ready + if (metrics.offPlan && metrics.ready && metrics.totalTransactions) { + const offPlanPercent = Math.round((metrics.offPlan / metrics.totalTransactions) * 100); + const readyPercent = Math.round((metrics.ready / metrics.totalTransactions) * 100); + description += `The market shows ${offPlanPercent}% off-plan transactions and ${readyPercent}% ready properties, `; + description += `indicating a balanced mix of development stages. `; + } + + // Residential focus + if (metrics.residential && metrics.totalTransactions) { + const residentialPercent = Math.round((metrics.residential / metrics.totalTransactions) * 100); + description += `Residential transactions represent ${residentialPercent}% of all transactions, `; + description += `highlighting the strong residential market focus.`; + } + + return description; + } + getResidentialLeasingExplanation(areaName, count, avgRent, avgArea) { const explanations = { 'business bay': `Business Bay attracts residents due to its modern residential towers, waterfront living, and proximity to major business districts. The area offers luxury apartments with world-class amenities and excellent connectivity.`,