feat: implement geocoding evaluation scripts and enhance routing service with traffic-aware path processing
This commit is contained in:
@@ -17,7 +17,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"test:geocoding": "ts-node scripts/evaluate_geocoding.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/cache-manager": "^3.0.0",
|
"@nestjs/cache-manager": "^3.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'https://map-saas.intaleqapp.com/api/geocoding/search';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
let DATA_FILE = path.join(__dirname, '../../../data/golden_tests/queries_syria.json');
|
||||||
|
if (!fs.existsSync(DATA_FILE) && fs.existsSync('/data/data/golden_tests/queries_syria.json')) {
|
||||||
|
DATA_FILE = '/data/data/golden_tests/queries_syria.json'; // Docker environment fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for Test Query
|
||||||
|
interface TestQuery {
|
||||||
|
query: string;
|
||||||
|
expected_location: {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
};
|
||||||
|
expected_category?: string;
|
||||||
|
tolerance_meters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for API Result
|
||||||
|
interface SearchResult {
|
||||||
|
place_id: number;
|
||||||
|
name: string;
|
||||||
|
latitude: number | string;
|
||||||
|
longitude: number | string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haversine Formula to calculate distance in meters
|
||||||
|
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371e3; // Earth radius in meters
|
||||||
|
const φ1 = (lat1 * Math.PI) / 180;
|
||||||
|
const φ2 = (lat2 * Math.PI) / 180;
|
||||||
|
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEvaluation() {
|
||||||
|
console.log('🚀 Starting Geocoding Evaluation (Golden Test Set)...\n');
|
||||||
|
|
||||||
|
if (!fs.existsSync(DATA_FILE)) {
|
||||||
|
console.error(`❌ Data file not found at ${DATA_FILE}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData = fs.readFileSync(DATA_FILE, 'utf8');
|
||||||
|
const queries: TestQuery[] = JSON.parse(rawData);
|
||||||
|
|
||||||
|
let top1Hits = 0;
|
||||||
|
let top3Hits = 0;
|
||||||
|
let zeroResults = 0;
|
||||||
|
let latencies: number[] = [];
|
||||||
|
|
||||||
|
for (const test of queries) {
|
||||||
|
console.log(`Testing query: "${test.query}"`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q: test.query, country: 'syria' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
latencies.push(latency);
|
||||||
|
|
||||||
|
const results: SearchResult[] = response.data.results || response.data;
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
console.log(` ❌ Zero results returned.`);
|
||||||
|
zeroResults++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundInTop1 = false;
|
||||||
|
let foundInTop3 = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(results.length, 3); i++) {
|
||||||
|
const res = results[i];
|
||||||
|
const resLat = Number(res.latitude || (res as any).lat);
|
||||||
|
const resLon = Number(res.longitude || (res as any).lon);
|
||||||
|
|
||||||
|
const dist = calculateDistance(test.expected_location.lat, test.expected_location.lon, resLat, resLon);
|
||||||
|
const isMatch = dist <= test.tolerance_meters;
|
||||||
|
|
||||||
|
console.log(` [Rank ${i + 1}] Name: "${res.name_ar || res.name}" | Dist: ${Math.round(dist)}m | Source: ${res.source} | Category: ${res.category}`);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
if (i === 0) foundInTop1 = true;
|
||||||
|
foundInTop3 = true;
|
||||||
|
console.log(` ✅ Match found at rank ${i + 1} (Distance: ${Math.round(dist)}m)`);
|
||||||
|
// We found the match, but we still print the rest for debugging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundInTop3) {
|
||||||
|
const topDist = calculateDistance(test.expected_location.lat, test.expected_location.lon, Number(results[0].latitude || (results[0] as any).lat), Number(results[0].longitude || (results[0] as any).lon));
|
||||||
|
console.log(` ❌ No match in Top-3. Top result distance: ${Math.round(topDist)}m`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundInTop1) top1Hits++;
|
||||||
|
if (foundInTop3) top3Hits++;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(` ❌ API Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
latencies.sort((a, b) => a - b);
|
||||||
|
const p50 = latencies[Math.floor(latencies.length * 0.5)] || 0;
|
||||||
|
const p95 = latencies[Math.floor(latencies.length * 0.95)] || 0;
|
||||||
|
|
||||||
|
const total = queries.length;
|
||||||
|
|
||||||
|
console.log('\n=======================================');
|
||||||
|
console.log('📊 EVALUATION RESULTS');
|
||||||
|
console.log('=======================================');
|
||||||
|
console.log(`Total Queries : ${total}`);
|
||||||
|
console.log(`Top-1 Accuracy : ${((top1Hits / total) * 100).toFixed(1)}% (${top1Hits}/${total})`);
|
||||||
|
console.log(`Top-3 Accuracy : ${((top3Hits / total) * 100).toFixed(1)}% (${top3Hits}/${total})`);
|
||||||
|
console.log(`Zero Results : ${((zeroResults / total) * 100).toFixed(1)}% (${zeroResults}/${total})`);
|
||||||
|
console.log(`p50 Latency : ${p50} ms`);
|
||||||
|
console.log(`p95 Latency : ${p95} ms`);
|
||||||
|
console.log('=======================================\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
runEvaluation().catch(console.error);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
const TOTAL_REQUESTS = 5000;
|
||||||
|
const CONCURRENCY = 50; // Number of parallel requests
|
||||||
|
|
||||||
|
const keywords = ["مستشفى", "شارع", "مدرسة", "جامعة", "مطعم", "فندق", "مسجد", "مقهى", "صيدلية", "مول", "دوار", "عيادة", "بنك", "مخبز"];
|
||||||
|
const names = ["السلام", "التخصصي", "المدينة", "النور", "اليرموك", "العربي", "الحديث", "الوطني", "المركزي", "الواحة", "الاموي", "المواساة"];
|
||||||
|
|
||||||
|
function generateRandomQuery(): string {
|
||||||
|
const keyword = keywords[Math.floor(Math.random() * keywords.length)];
|
||||||
|
const name = names[Math.floor(Math.random() * names.length)];
|
||||||
|
const randomSuffix = Math.random() > 0.5 ? ` ${Math.floor(Math.random() * 100)}` : '';
|
||||||
|
return `${keyword} ${name}${randomSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLoadTest() {
|
||||||
|
console.log(`🚀 Starting Load Test: ${TOTAL_REQUESTS} requests with concurrency of ${CONCURRENCY}...`);
|
||||||
|
console.log(`📡 Target API: ${API_URL}\n`);
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
let successful = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const latencies: number[] = [];
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Worker function to process a chunk of requests
|
||||||
|
async function worker(requestsCount: number) {
|
||||||
|
for (let i = 0; i < requestsCount; i++) {
|
||||||
|
const query = generateRandomQuery();
|
||||||
|
const reqStart = Date.now();
|
||||||
|
try {
|
||||||
|
await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q: query, country: 'syria' } // targeting syria since data exists there
|
||||||
|
});
|
||||||
|
successful++;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (failed === 0) {
|
||||||
|
console.error('First failure reason:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
latencies.push(Date.now() - reqStart);
|
||||||
|
completed++;
|
||||||
|
|
||||||
|
if (completed % 500 === 0) {
|
||||||
|
console.log(`⏳ Progress: ${completed}/${TOTAL_REQUESTS} requests completed...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the total requests among the concurrent workers
|
||||||
|
const requestsPerWorker = Math.floor(TOTAL_REQUESTS / CONCURRENCY);
|
||||||
|
const workers: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < CONCURRENCY; i++) {
|
||||||
|
// The last worker takes any remainder
|
||||||
|
const count = (i === CONCURRENCY - 1) ? requestsPerWorker + (TOTAL_REQUESTS % CONCURRENCY) : requestsPerWorker;
|
||||||
|
workers.push(worker(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
const totalTimeSec = (Date.now() - startTime) / 1000;
|
||||||
|
const rps = TOTAL_REQUESTS / totalTimeSec;
|
||||||
|
|
||||||
|
latencies.sort((a, b) => a - b);
|
||||||
|
const p50 = latencies[Math.floor(latencies.length * 0.50)] || 0;
|
||||||
|
const p95 = latencies[Math.floor(latencies.length * 0.95)] || 0;
|
||||||
|
const p99 = latencies[Math.floor(latencies.length * 0.99)] || 0;
|
||||||
|
|
||||||
|
console.log('\n=======================================');
|
||||||
|
console.log('📊 LOAD TEST RESULTS');
|
||||||
|
console.log('=======================================');
|
||||||
|
console.log(`Total Requests : ${TOTAL_REQUESTS}`);
|
||||||
|
console.log(`Successful : ${successful} (${((successful/TOTAL_REQUESTS)*100).toFixed(1)}%)`);
|
||||||
|
console.log(`Failed : ${failed} (${((failed/TOTAL_REQUESTS)*100).toFixed(1)}%)`);
|
||||||
|
console.log(`Total Time : ${totalTimeSec.toFixed(2)} seconds`);
|
||||||
|
console.log(`Throughput (RPS): ${rps.toFixed(2)} req/sec`);
|
||||||
|
console.log('---------------------------------------');
|
||||||
|
console.log(`p50 Latency : ${p50} ms`);
|
||||||
|
console.log(`p95 Latency : ${p95} ms`);
|
||||||
|
console.log(`p99 Latency : ${p99} ms`);
|
||||||
|
console.log('=======================================\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
runLoadTest().catch(console.error);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
|
||||||
|
const testQueries = [
|
||||||
|
"مستش",
|
||||||
|
"شارع ال",
|
||||||
|
"جامعة",
|
||||||
|
"بنك ا",
|
||||||
|
"صيد"
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testAutocomplete() {
|
||||||
|
console.log(`🚀 Testing Autocomplete Latency`);
|
||||||
|
|
||||||
|
for (const q of testQueries) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q, country: 'syria' }
|
||||||
|
});
|
||||||
|
const time = Date.now() - start;
|
||||||
|
const count = res.data.results?.length || 0;
|
||||||
|
console.log(`✅ [${time}ms] Query: "${q}" -> ${count} results`);
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(` First result: ${res.data.results[0].name} (${res.data.results[0].category})`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ Query: "${q}" failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAutocomplete().catch(console.error);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
|
||||||
|
const testQueries = [
|
||||||
|
"السمساني", // Should suggest "الشميساني"
|
||||||
|
"جبل اللويبضة", // Wait, misspelled: "جبل اللوبيدة" ?
|
||||||
|
"الاستفلال", // Should suggest "الاستقلال"
|
||||||
|
"مستشفا الاوردن" // Should suggest "مستشفى الأردن"
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testDidYouMean() {
|
||||||
|
console.log(`\n🚑 Testing 'Did You Mean?' (Spell Checker)\n`);
|
||||||
|
|
||||||
|
for (const q of testQueries) {
|
||||||
|
try {
|
||||||
|
const searchRes = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q, country: 'jordan' }
|
||||||
|
});
|
||||||
|
console.log(`🔍 Search for misspelled: "${q}"`);
|
||||||
|
|
||||||
|
if (searchRes.data.results?.length > 0) {
|
||||||
|
console.log(` ✅ Found results: ${searchRes.data.results[0].name}`);
|
||||||
|
} else if (searchRes.data.did_you_mean) {
|
||||||
|
console.log(` ⚠️ 0 Results. Did you mean: ✨ "${searchRes.data.did_you_mean}" ✨ ?`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ 0 Results. No suggestions found.`);
|
||||||
|
}
|
||||||
|
console.log('--------------------------------------------------');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ Query "${q}" failed:`, e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testDidYouMean().catch(console.error);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
const DB_URL = process.env.DATABASE_URL || 'postgresql://mapuser:TestMapPass123!@localhost:5432/mapdb';
|
||||||
|
|
||||||
|
async function testFailedLogging() {
|
||||||
|
console.log(`🚀 Testing Failed Searches Logging`);
|
||||||
|
const fakeQuery = 'مكان_غير_موجود_في_العالم_ابدا';
|
||||||
|
|
||||||
|
// 1. Trigger the search (should return 0 results)
|
||||||
|
try {
|
||||||
|
const res = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q: fakeQuery, country: 'syria' }
|
||||||
|
});
|
||||||
|
console.log(`✅ Search triggered. Results count: ${res.data.results?.length}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ Search failed:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a second for async DB insert
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
// 2. Check the database directly
|
||||||
|
const client = new Client({ connectionString: DB_URL });
|
||||||
|
await client.connect();
|
||||||
|
const dbRes = await client.query('SELECT * FROM failed_searches WHERE query_text = $1', [fakeQuery]);
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
if (dbRes.rows.length > 0) {
|
||||||
|
console.log(`✅ SUCCESS! Log found in DB:`, dbRes.rows[0].query_text, `(Count: ${dbRes.rows[0].search_count})`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ FAILURE! No log found in DB for the query.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testFailedLogging().catch(console.error);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
|
||||||
|
const testQueries = [
|
||||||
|
"سيتي مول",
|
||||||
|
"مستشفى التخصصي"
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testPoiGates() {
|
||||||
|
console.log(`\n🏢 Testing POI Gates (Clustering Integration)\n`);
|
||||||
|
|
||||||
|
for (const q of testQueries) {
|
||||||
|
try {
|
||||||
|
const searchRes = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q, country: 'jordan' }
|
||||||
|
});
|
||||||
|
console.log(`🔍 Search for "${q}" -> ${searchRes.data.results?.length} results`);
|
||||||
|
|
||||||
|
if (searchRes.data.results?.length > 0) {
|
||||||
|
const topResult = searchRes.data.results[0];
|
||||||
|
console.log(` 🏆 Top Result: ${topResult.name}`);
|
||||||
|
console.log(` Coordinates: ${topResult.latitude}, ${topResult.longitude} (Centroid)`);
|
||||||
|
|
||||||
|
if (topResult.gates && topResult.gates.length > 0) {
|
||||||
|
console.log(` ✅ Found ${topResult.gates.length} Gates!`);
|
||||||
|
topResult.gates.forEach((gate: any, idx: number) => {
|
||||||
|
const mainMarker = gate.is_main_gate ? '⭐ MAIN GATE' : '🚪 SIDE GATE';
|
||||||
|
console.log(` ${idx + 1}. [${mainMarker}] ${gate.name_ar} (${gate.name_en})`);
|
||||||
|
console.log(` -> Navigate to: ${gate.latitude}, ${gate.longitude}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ No gates found. Sero drops will default to centroid.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('--------------------------------------------------');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ Query "${q}" failed:`, e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testPoiGates().catch(console.error);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
|
const AUTO_URL = process.env.TEST_AUTO_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||||
|
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||||
|
|
||||||
|
const testQueries = [
|
||||||
|
"قرب مستشفى",
|
||||||
|
"بجانب بنك",
|
||||||
|
"مقابل جامعة",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testRelativeQueries() {
|
||||||
|
console.log(`🚀 Testing Relative Queries (Search & Autocomplete)`);
|
||||||
|
|
||||||
|
for (const q of testQueries) {
|
||||||
|
try {
|
||||||
|
// Test Search
|
||||||
|
const searchRes = await axios.get(API_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q, country: 'syria' }
|
||||||
|
});
|
||||||
|
console.log(`\n🔍 Search for "${q}" -> ${searchRes.data.results?.length} results`);
|
||||||
|
if (searchRes.data.results?.length > 0) {
|
||||||
|
console.log(` Top result name: ${searchRes.data.results[0].name}`);
|
||||||
|
console.log(` Top result name_ar: ${searchRes.data.results[0].name_ar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Autocomplete
|
||||||
|
const autoRes = await axios.get(AUTO_URL, {
|
||||||
|
headers: { 'x-api-key': API_KEY },
|
||||||
|
params: { q, country: 'syria' }
|
||||||
|
});
|
||||||
|
console.log(`⚡ Autocomplete for "${q}" -> ${autoRes.data.results?.length} results`);
|
||||||
|
if (autoRes.data.results?.length > 0) {
|
||||||
|
console.log(` Top auto name: ${autoRes.data.results[0].name}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`❌ Query "${q}" failed:`, e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testRelativeQueries().catch(console.error);
|
||||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { ApiKey } from './entities/api-key.entity';
|
import { ApiKey } from './entities/api-key.entity';
|
||||||
import { Tenant, TenantPlan } from './entities/tenant.entity';
|
import { Tenant, TenantPlan, RATE_LIMITS } from './entities/tenant.entity';
|
||||||
import { RedisService } from '../common/redis.service';
|
import { RedisService } from '../common/redis.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -48,7 +48,7 @@ export class AuthService {
|
|||||||
const result = {
|
const result = {
|
||||||
tenant: apiKey.tenant,
|
tenant: apiKey.tenant,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
rateLimit: apiKey.rateLimit || 100, // Default 100 req/min
|
rateLimit: apiKey.rateLimit || RATE_LIMITS[apiKey.tenant.plan] || 100, // Default to plan limit
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Update Cache (TTL 1 hour)
|
// 4. Update Cache (TTL 1 hour)
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export enum TenantRole {
|
|||||||
ADMIN = 'ADMIN',
|
ADMIN = 'ADMIN',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const QUOTA_LIMITS: Record<TenantPlan, number> = {
|
||||||
|
[TenantPlan.FREE]: 8000,
|
||||||
|
[TenantPlan.STARTER]: 25000,
|
||||||
|
[TenantPlan.PRO]: 100000,
|
||||||
|
[TenantPlan.ENTERPRISE]: 500000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RATE_LIMITS: Record<TenantPlan, number> = {
|
||||||
|
[TenantPlan.FREE]: 5,
|
||||||
|
[TenantPlan.STARTER]: 100,
|
||||||
|
[TenantPlan.PRO]: 500,
|
||||||
|
[TenantPlan.ENTERPRISE]: 50000,
|
||||||
|
};
|
||||||
|
|
||||||
@Entity('tenants')
|
@Entity('tenants')
|
||||||
export class Tenant {
|
export class Tenant {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ export class GeocodingController {
|
|||||||
return this.geocodingService.searchPlaces(q, lat, lng, radius, country);
|
return this.geocodingService.searchPlaces(q, lat, lng, radius, country);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('autocomplete')
|
||||||
|
@ApiOperation({ summary: 'Fast autocomplete for live typing' })
|
||||||
|
@ApiQuery({ name: 'q', required: true })
|
||||||
|
@ApiQuery({ name: 'country', required: false })
|
||||||
|
async autocomplete(@Query('q') q: string, @Query('country') country?: string) {
|
||||||
|
if (!q || q.trim().length < 2) return { results: [] };
|
||||||
|
return this.geocodingService.autocomplete(q, country);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('reverse')
|
@Get('reverse')
|
||||||
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
|
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
|
||||||
async reverse(@Query() reverseDto: ReverseGeocodeDto) {
|
async reverse(@Query() reverseDto: ReverseGeocodeDto) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { MapRefinementService } from './map-refinement.service';
|
|||||||
import { MapRefinementController } from './map-refinement.controller';
|
import { MapRefinementController } from './map-refinement.controller';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
import { IndexRefreshService } from './index-refresh.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -47,7 +48,8 @@ import * as redisStore from 'cache-manager-redis-store';
|
|||||||
AdminBoundariesService,
|
AdminBoundariesService,
|
||||||
JordanResearchService,
|
JordanResearchService,
|
||||||
AdministrativeLinkingService,
|
AdministrativeLinkingService,
|
||||||
MapRefinementService
|
MapRefinementService,
|
||||||
|
IndexRefreshService
|
||||||
],
|
],
|
||||||
exports: [GeocodingService, MapRefinementService],
|
exports: [GeocodingService, MapRefinementService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,8 +65,17 @@ export class GeocodingService {
|
|||||||
|
|
||||||
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
|
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
|
||||||
try {
|
try {
|
||||||
const cleanQuery = query.trim();
|
let cleanQuery = query.trim();
|
||||||
if (!cleanQuery) return { results: [] };
|
if (!cleanQuery) return { results: [] };
|
||||||
|
|
||||||
|
let relativePrefix = '';
|
||||||
|
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
|
||||||
|
const match = cleanQuery.match(relativeQueryRegex);
|
||||||
|
if (match) {
|
||||||
|
relativePrefix = match[1];
|
||||||
|
cleanQuery = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
const hasLocation = lat !== undefined && lon !== undefined;
|
const hasLocation = lat !== undefined && lon !== undefined;
|
||||||
|
|
||||||
const geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global';
|
const geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global';
|
||||||
@@ -76,112 +85,110 @@ export class GeocodingService {
|
|||||||
if (cached) return { results: cached, source: 'cache_hit' };
|
if (cached) return { results: cached, source: 'cache_hit' };
|
||||||
|
|
||||||
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
|
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
|
||||||
const primaryTables = targetRegion && ['syria', 'egypt', 'jordan'].includes(targetRegion)
|
|
||||||
? [`places_${targetRegion}`] : ['places_jordan', 'places_syria', 'places_egypt'];
|
// Normalize the search query using the DB function
|
||||||
|
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
|
||||||
|
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
|
||||||
|
|
||||||
const queryPromises: Promise<any[]>[] = [];
|
let queryParams: any[] = [normalizedQuery];
|
||||||
|
let locationCondition = '';
|
||||||
|
if (hasLocation) {
|
||||||
|
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4`;
|
||||||
|
queryParams.push(lat, lon, radius);
|
||||||
|
}
|
||||||
|
|
||||||
primaryTables.forEach(tableName => {
|
let regionCondition = '';
|
||||||
const repo = this.getRepoByTableName(tableName);
|
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
||||||
queryPromises.push(repo.query(`
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||||
SELECT
|
}
|
||||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
|
||||||
n.name_ar as neighbourhood, d.name_ar as district, g.name_ar as governorate,
|
|
||||||
p.latitude, p.longitude, p.address, '${tableName.replace('places_', '')}' as region, 'user_place' as source,
|
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
|
||||||
GREATEST(similarity(COALESCE(p.name_ar, ''), $1), similarity(COALESCE(p.name, ''), $1)) as relevance
|
|
||||||
FROM ${tableName} p
|
|
||||||
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
|
|
||||||
LEFT JOIN admin_boundaries d ON p.district_id = d.id
|
|
||||||
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
|
|
||||||
WHERE (p.name_ar % $1 OR p.name % $1)
|
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
|
||||||
ORDER BY (p.name_ar <-> $1) ASC LIMIT 10
|
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
|
||||||
});
|
|
||||||
|
|
||||||
queryPromises.push(this.osmPointsRepository.query(`
|
const sqlQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category,
|
id, name, name_ar, category,
|
||||||
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
|
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
|
||||||
GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance
|
|
||||||
FROM osm_points_with_area o
|
|
||||||
WHERE (o.name % $1 OR o.name_ar % $1)
|
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
|
||||||
ORDER BY (o.name <-> $1) ASC LIMIT 10
|
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
|
||||||
|
|
||||||
queryPromises.push(this.osmPointsRepository.query(`
|
|
||||||
SELECT
|
|
||||||
id, name_ar as name, name_ar, name_en, 'admin' as category,
|
|
||||||
'' as neighbourhood, '' as district, '' as governorate,
|
'' as neighbourhood, '' as district, '' as governorate,
|
||||||
ST_Y(ST_Centroid(geom))::text as latitude, ST_X(ST_Centroid(geom))::text as longitude, '' as address, 'admin_boundary' as region, 'admin' as source,
|
latitude, longitude, address, region, source, popularity_score,
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
|
||||||
similarity(COALESCE(name_ar, ''), $1) as relevance
|
similarity(normalized_name, $1) as relevance
|
||||||
FROM admin_boundaries
|
FROM unified_search_index
|
||||||
WHERE name_ar % $1
|
WHERE normalized_name % $1
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
${locationCondition}
|
||||||
ORDER BY (name_ar <-> $1) ASC LIMIT 5
|
${regionCondition}
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
ORDER BY (normalized_name <-> $1) ASC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
queryPromises.push(this.osmPointsRepository.query(`
|
const allResults = await Promise.race([
|
||||||
SELECT
|
this.osmPointsRepository.query(sqlQuery, queryParams),
|
||||||
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, '' as name_en, 'street' as category,
|
new Promise<any[]>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
|
||||||
'' as neighbourhood, '' as district, '' as governorate,
|
|
||||||
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
|
||||||
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
|
||||||
FROM overture_segment
|
|
||||||
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
|
||||||
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
|
||||||
|
|
||||||
queryPromises.push(this.osmPointsRepository.query(`
|
|
||||||
SELECT
|
|
||||||
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, '' as name_en, 'building' as category,
|
|
||||||
'' as neighbourhood, '' as district, '' as governorate,
|
|
||||||
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
|
||||||
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
|
||||||
FROM overture_building
|
|
||||||
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
|
||||||
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
|
||||||
|
|
||||||
queryPromises.push(this.osmPointsRepository.query(`
|
|
||||||
SELECT
|
|
||||||
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, '' as name_en, 'place' as category,
|
|
||||||
'' as neighbourhood, '' as district, '' as governorate,
|
|
||||||
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' as source,
|
|
||||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
|
||||||
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
|
|
||||||
FROM overture_place
|
|
||||||
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
|
|
||||||
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
|
|
||||||
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
|
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]));
|
|
||||||
|
|
||||||
const executionResults = await Promise.race([
|
|
||||||
Promise.allSettled(queryPromises),
|
|
||||||
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
|
|
||||||
]).catch(e => {
|
]).catch(e => {
|
||||||
this.logger.warn(`Search optimization threshold hit: ${e.message}`);
|
this.logger.warn(`Search optimization threshold hit: ${e.message}`);
|
||||||
return [] as any[];
|
return [] as any[];
|
||||||
});
|
});
|
||||||
|
|
||||||
let allResults: any[] = [];
|
const formatted = this.formatResults(allResults, hasLocation, relativePrefix);
|
||||||
if (Array.isArray(executionResults)) {
|
|
||||||
(executionResults as any[]).forEach(res => {
|
// --- POI GATES (Clustering) ---
|
||||||
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
const placeIds = formatted.map(r => r.id);
|
||||||
});
|
if (placeIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const gates = await this.osmPointsRepository.query(
|
||||||
|
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
|
||||||
|
FROM place_gates
|
||||||
|
WHERE place_id = ANY($1)`,
|
||||||
|
[placeIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gates.length > 0) {
|
||||||
|
formatted.forEach(r => {
|
||||||
|
const placeGates = gates.filter((g: any) => g.place_id === r.id).map((g: any) => ({
|
||||||
|
name_ar: g.gate_name_ar,
|
||||||
|
name_en: g.gate_name_en,
|
||||||
|
latitude: parseFloat(g.latitude),
|
||||||
|
longitude: parseFloat(g.longitude),
|
||||||
|
is_main_gate: g.is_main_gate
|
||||||
|
}));
|
||||||
|
if (placeGates.length > 0) {
|
||||||
|
r.gates = placeGates;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('Failed to fetch POI gates, table might not exist yet.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
const formatted = this.formatResults(allResults, hasLocation);
|
|
||||||
if (formatted.length > 0) {
|
if (formatted.length > 0) {
|
||||||
await this.cacheManager.set(cacheKey, formatted, 3600000);
|
await this.cacheManager.set(cacheKey, formatted, 3600000);
|
||||||
|
} else {
|
||||||
|
// Log zero-result query asynchronously
|
||||||
|
this.logFailedSearch(cleanQuery, normalizedQuery, targetRegion, lat, lon).catch(err => {
|
||||||
|
this.logger.error('Failed to log zero-result search:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- DID YOU MEAN? (Safety Net) ---
|
||||||
|
try {
|
||||||
|
const whereClause = regionCondition ? `WHERE ${regionCondition.substring(4)}` : '';
|
||||||
|
const fallbackQuery = `
|
||||||
|
SELECT name_ar, name, (normalized_name <-> $1) as dist
|
||||||
|
FROM unified_search_index
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY normalized_name <-> $1 ASC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const suggestions = await this.osmPointsRepository.query(fallbackQuery, [normalizedQuery]);
|
||||||
|
|
||||||
|
if (suggestions.length > 0 && suggestions[0].dist < 0.6) {
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
did_you_mean: suggestions[0].name_ar || suggestions[0].name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('Did You Mean fallback failed: ' + err.message);
|
||||||
|
}
|
||||||
|
// -----------------------------------
|
||||||
}
|
}
|
||||||
|
|
||||||
return { results: formatted };
|
return { results: formatted };
|
||||||
@@ -191,14 +198,26 @@ export class GeocodingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatResults(results: any[], hasLocation: boolean) {
|
private formatResults(results: any[], hasLocation: boolean, relativePrefix: string = '') {
|
||||||
const seenStreets = new Set<string>();
|
const seenStreets = new Set<string>();
|
||||||
|
|
||||||
|
// Normalize popularity to a 0-1 scale
|
||||||
|
const maxPopularity = Math.max(...results.map(r => r.popularity_score || 10), 100);
|
||||||
|
|
||||||
return results
|
return results
|
||||||
.map(r => {
|
.map(r => {
|
||||||
// Weighted scoring: 70% Name Similarity, 30% Geographic Proximity
|
// Weighted scoring:
|
||||||
|
// 50% Text Match (relevance)
|
||||||
|
// 30% Popularity
|
||||||
|
// 20% Geographic Proximity
|
||||||
|
|
||||||
|
const textScore = Number(r.relevance);
|
||||||
|
const popularityScore = (r.popularity_score || 10) / maxPopularity;
|
||||||
|
|
||||||
// Proximity bonus is 1.0 at 0m, decaying linearly to 0.0 at 10km.
|
// Proximity bonus is 1.0 at 0m, decaying linearly to 0.0 at 10km.
|
||||||
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
|
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
|
||||||
const totalScore = (Number(r.relevance) * 0.7) + (proximityBonus * 0.3);
|
|
||||||
|
const totalScore = (textScore * 0.5) + (popularityScore * 0.3) + (proximityBonus * 0.2);
|
||||||
return { ...r, totalScore };
|
return { ...r, totalScore };
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.totalScore - a.totalScore)
|
.sort((a, b) => b.totalScore - a.totalScore)
|
||||||
@@ -214,8 +233,12 @@ export class GeocodingService {
|
|||||||
.map(r => {
|
.map(r => {
|
||||||
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||||
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
||||||
|
const nameAr = r.name_ar || r.name;
|
||||||
|
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
name: displayName,
|
||||||
|
name_ar: displayName,
|
||||||
latitude: parseFloat(r.latitude),
|
latitude: parseFloat(r.latitude),
|
||||||
longitude: parseFloat(r.longitude),
|
longitude: parseFloat(r.longitude),
|
||||||
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
||||||
@@ -232,6 +255,87 @@ export class GeocodingService {
|
|||||||
return this.placesSyriaRepository;
|
return this.placesSyriaRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log zero-result queries to the failed_searches table for mining missing places.
|
||||||
|
*/
|
||||||
|
private async logFailedSearch(query: string, normalizedQuery: string, country?: string, lat?: number, lon?: number) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO failed_searches (query_text, normalized_query, country, latitude, longitude)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (normalized_query, COALESCE(country, 'global'))
|
||||||
|
DO UPDATE SET search_count = failed_searches.search_count + 1, last_seen_at = CURRENT_TIMESTAMP;
|
||||||
|
`;
|
||||||
|
await this.osmPointsRepository.query(q, [query, normalizedQuery, country || null, lat || null, lon || null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast Autocomplete using prefix matching
|
||||||
|
*/
|
||||||
|
async autocomplete(query: string, country?: string) {
|
||||||
|
try {
|
||||||
|
let cleanQuery = query.trim();
|
||||||
|
if (cleanQuery.length < 2) return { results: [] };
|
||||||
|
|
||||||
|
let relativePrefix = '';
|
||||||
|
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
|
||||||
|
const match = cleanQuery.match(relativeQueryRegex);
|
||||||
|
if (match) {
|
||||||
|
relativePrefix = match[1];
|
||||||
|
cleanQuery = match[2];
|
||||||
|
if (cleanQuery.length < 2) return { results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRegion = country?.toLowerCase();
|
||||||
|
const cacheKey = `geo_auto:${targetRegion || 'auto'}:${cleanQuery.toLowerCase()}`;
|
||||||
|
|
||||||
|
const cached: any = await this.cacheManager.get(cacheKey);
|
||||||
|
if (cached) return { results: cached, source: 'cache_hit' };
|
||||||
|
|
||||||
|
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
|
||||||
|
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
|
||||||
|
|
||||||
|
let queryParams: any[] = [`${normalizedQuery}%`];
|
||||||
|
let regionCondition = '';
|
||||||
|
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
||||||
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using the specialized btree index on varchar_pattern_ops
|
||||||
|
const sqlQuery = `
|
||||||
|
SELECT
|
||||||
|
id, name, name_ar, category, region, source, address
|
||||||
|
FROM unified_search_index
|
||||||
|
WHERE normalized_name LIKE $1
|
||||||
|
${regionCondition}
|
||||||
|
ORDER BY LENGTH(normalized_name) ASC
|
||||||
|
LIMIT 7
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.osmPointsRepository.query(sqlQuery, queryParams);
|
||||||
|
|
||||||
|
const formatted = results.map(r => {
|
||||||
|
const nameAr = r.name_ar || r.name;
|
||||||
|
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: displayName,
|
||||||
|
category: r.category,
|
||||||
|
region: r.region,
|
||||||
|
address: r.address
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (formatted.length > 0) {
|
||||||
|
await this.cacheManager.set(cacheKey, formatted, 3600000); // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results: formatted };
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Autocomplete failed:', e);
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async reverseGeocode(lat: number, lng: number) {
|
async reverseGeocode(lat: number, lng: number) {
|
||||||
try {
|
try {
|
||||||
const repo = this.getRepositoryForCoords(lat, lng);
|
const repo = this.getRepositoryForCoords(lat, lng);
|
||||||
@@ -307,11 +411,33 @@ export class GeocodingService {
|
|||||||
return allResults
|
return allResults
|
||||||
.sort((a, b) => Number(a.distance) - Number(b.distance))
|
.sort((a, b) => Number(a.distance) - Number(b.distance))
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(r => ({
|
.map(r => {
|
||||||
...r,
|
const distance = Number(r.distance);
|
||||||
latitude: parseFloat(r.latitude),
|
const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||||
longitude: parseFloat(r.longitude)
|
|
||||||
}));
|
let humanReadable = r.name_ar || r.name;
|
||||||
|
if (distance <= 20) {
|
||||||
|
// Very close: just the name
|
||||||
|
humanReadable = r.name_ar || r.name;
|
||||||
|
} else if (distance <= 70 && r.category !== 'street') {
|
||||||
|
// Close: "أمام [اسم المعلم]" (In front of)
|
||||||
|
const streetName = r.address ? `، ${r.address}` : '';
|
||||||
|
humanReadable = `أمام ${r.name_ar || r.name}${streetName}`;
|
||||||
|
} else if (r.category === 'street' || distance > 70) {
|
||||||
|
// Far or Street: "شارع كذا، الحي"
|
||||||
|
const streetPart = r.category === 'street' ? (r.name_ar || r.name) : (r.address || r.name_ar || r.name);
|
||||||
|
const districtPart = r.neighbourhood || r.district || '';
|
||||||
|
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
latitude: parseFloat(r.latitude),
|
||||||
|
longitude: parseFloat(r.longitude),
|
||||||
|
human_readable_address: humanReadable,
|
||||||
|
full_address: fullAddressParts.join('، ')
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Reverse geocoding error:', error);
|
this.logger.error('Reverse geocoding error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IndexRefreshService {
|
||||||
|
private readonly logger = new Logger(IndexRefreshService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(OsmPointWithArea)
|
||||||
|
private readonly repo: Repository<OsmPointWithArea>, // Use any repository to execute raw SQL
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Run every 5 minutes
|
||||||
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
|
async handleCron() {
|
||||||
|
this.logger.log('Starting background refresh for unified_search_index...');
|
||||||
|
try {
|
||||||
|
// CONCURRENTLY allows reading while refreshing (requires a unique index on the view)
|
||||||
|
await this.repo.query('REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;');
|
||||||
|
this.logger.log('Successfully refreshed unified_search_index.');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to refresh unified_search_index', error);
|
||||||
|
|
||||||
|
// Fallback if CONCURRENTLY fails (e.g. unique index missing or view is totally unpopulated)
|
||||||
|
try {
|
||||||
|
this.logger.log('Attempting standard refresh (blocking)...');
|
||||||
|
await this.repo.query('REFRESH MATERIALIZED VIEW unified_search_index;');
|
||||||
|
this.logger.log('Successfully refreshed unified_search_index (standard).');
|
||||||
|
} catch (fallbackError) {
|
||||||
|
this.logger.error('Fallback standard refresh also failed', fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,10 +66,10 @@ export class MapsService {
|
|||||||
const payload: any = {
|
const payload: any = {
|
||||||
points: ghPoints,
|
points: ghPoints,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
locale: locale,
|
locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback
|
||||||
calc_points: true,
|
calc_points: true,
|
||||||
points_encoded: true,
|
points_encoded: true,
|
||||||
instructions: steps,
|
instructions: steps || true, // Always request instructions to extract route name
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Closure-Aware Routing ─────────────────────────────────────────────
|
// ── Closure-Aware Routing ─────────────────────────────────────────────
|
||||||
@@ -164,25 +164,47 @@ export class MapsService {
|
|||||||
const baseDuration = route.time / 1000;
|
const baseDuration = route.time / 1000;
|
||||||
const trafficAwareDuration = baseDuration * trafficFactor;
|
const trafficAwareDuration = baseDuration * trafficFactor;
|
||||||
|
|
||||||
// Process alternative routes if any without breaking existing frontend variables
|
// Process all paths to add metadata (Names, Tags)
|
||||||
const altRoutes = paths.slice(1).map(alt => ({
|
const processedPaths = paths.map((p: any, index: number) => {
|
||||||
distance: alt.distance,
|
const pCoords = this.decodePolyline(p.points);
|
||||||
duration: Math.round(alt.time / 1000),
|
const pTrafficFactor = this.trafficGrid.getTrafficFactor(pCoords, hr, dow);
|
||||||
points: alt.points,
|
const pDuration = Math.round((p.time / 1000) * pTrafficFactor);
|
||||||
bbox: alt.bbox,
|
const routeName = this.getRouteName(p.instructions);
|
||||||
instructions: alt.instructions // Include instructions for alternatives if requested
|
|
||||||
}));
|
// Tags assignment
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (index === 0) tags.push('FASTEST');
|
||||||
|
if (paths.length > 1) {
|
||||||
|
const isShortest = paths.every((other: any) => p.distance <= other.distance);
|
||||||
|
if (isShortest) tags.push('SHORTEST');
|
||||||
|
if (index > 0 && !isShortest) tags.push('ALTERNATIVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeName: routeName ? `عبر ${routeName}` : `المسار ${index + 1}`,
|
||||||
|
tags,
|
||||||
|
distance: p.distance,
|
||||||
|
duration: pDuration,
|
||||||
|
points: p.points,
|
||||||
|
bbox: p.bbox,
|
||||||
|
instructions: steps ? p.instructions : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainRoute = processedPaths[0];
|
||||||
|
const altRoutes = processedPaths.slice(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distance: route.distance,
|
routeName: mainRoute.routeName,
|
||||||
duration: Math.round(baseDuration),
|
tags: mainRoute.tags,
|
||||||
trafficAwareDuration: Math.round(trafficAwareDuration),
|
distance: mainRoute.distance,
|
||||||
|
duration: mainRoute.duration,
|
||||||
trafficFactor: Math.round(trafficFactor * 100) / 100,
|
trafficFactor: Math.round(trafficFactor * 100) / 100,
|
||||||
startName,
|
startName,
|
||||||
endName,
|
endName,
|
||||||
points: route.points,
|
points: mainRoute.points,
|
||||||
bbox: route.bbox,
|
bbox: mainRoute.bbox,
|
||||||
instructions: route.instructions, // Added: turn-by-turn maneuvers
|
instructions: mainRoute.instructions,
|
||||||
alternatives: altRoutes
|
alternatives: altRoutes
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,6 +214,32 @@ export class MapsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the most significant street name from instructions to name the route.
|
||||||
|
*/
|
||||||
|
private getRouteName(instructions: any[]): string | null {
|
||||||
|
if (!instructions || instructions.length === 0) return null;
|
||||||
|
|
||||||
|
const streetDistances: Record<string, number> = {};
|
||||||
|
for (const inst of instructions) {
|
||||||
|
if (inst.street_name && inst.street_name.trim() !== '') {
|
||||||
|
streetDistances[inst.street_name] = (streetDistances[inst.street_name] || 0) + (inst.distance || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let longestStreet: string | null = null;
|
||||||
|
let maxDist = 0;
|
||||||
|
|
||||||
|
for (const [street, dist] of Object.entries(streetDistances)) {
|
||||||
|
if (dist > maxDist) {
|
||||||
|
maxDist = dist;
|
||||||
|
longestStreet = street;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return longestStreet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual decoder for Google Polyline algorithm (Server-side spatial matching)
|
* Manual decoder for Google Polyline algorithm (Server-side spatial matching)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,22 +9,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, tap } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
import { UsageService } from './usage.service';
|
import { UsageService } from './usage.service';
|
||||||
import { TenantPlan } from '../auth/entities/tenant.entity';
|
import { TenantPlan, QUOTA_LIMITS, RATE_LIMITS } from '../auth/entities/tenant.entity';
|
||||||
|
|
||||||
// Quota Limits per Plan
|
|
||||||
const QUOTA_LIMITS: Record<TenantPlan, number> = {
|
|
||||||
[TenantPlan.FREE]: 8000,
|
|
||||||
[TenantPlan.STARTER]: 25000,
|
|
||||||
[TenantPlan.PRO]: 100000,
|
|
||||||
[TenantPlan.ENTERPRISE]: 500000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const RATE_LIMITS: Record<TenantPlan, number> = {
|
|
||||||
[TenantPlan.FREE]: 5,
|
|
||||||
[TenantPlan.STARTER]: 100,
|
|
||||||
[TenantPlan.PRO]: 500,
|
|
||||||
[TenantPlan.ENTERPRISE]: 5000,
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsageInterceptor implements NestInterceptor {
|
export class UsageInterceptor implements NestInterceptor {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "scripts", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"query": "مستشفى التخصصي",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 31.9686,
|
||||||
|
"lon": 35.8973
|
||||||
|
},
|
||||||
|
"expected_category": "hospital",
|
||||||
|
"tolerance_meters": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "مكة مول",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 31.9793,
|
||||||
|
"lon": 35.8457
|
||||||
|
},
|
||||||
|
"expected_category": "mall",
|
||||||
|
"tolerance_meters": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "شارع الجاردنز",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 31.9856,
|
||||||
|
"lon": 35.8741
|
||||||
|
},
|
||||||
|
"tolerance_meters": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "دوار الواحة",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 31.9880,
|
||||||
|
"lon": 35.8696
|
||||||
|
},
|
||||||
|
"tolerance_meters": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "جامعة العلوم التطبيقية",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 32.0390,
|
||||||
|
"lon": 35.8975
|
||||||
|
},
|
||||||
|
"tolerance_meters": 200
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"query": "الجامع الأموي",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 33.5113,
|
||||||
|
"lon": 36.3066
|
||||||
|
},
|
||||||
|
"expected_category": "place_of_worship",
|
||||||
|
"tolerance_meters": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "سوق الحميدية",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 33.5108,
|
||||||
|
"lon": 36.3008
|
||||||
|
},
|
||||||
|
"expected_category": "commercial",
|
||||||
|
"tolerance_meters": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "قلعة دمشق",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 33.5102,
|
||||||
|
"lon": 36.2995
|
||||||
|
},
|
||||||
|
"expected_category": "tourism",
|
||||||
|
"tolerance_meters": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "مستشفى المواساة",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 33.5122,
|
||||||
|
"lon": 36.2633
|
||||||
|
},
|
||||||
|
"expected_category": "hospital",
|
||||||
|
"tolerance_meters": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "جامعة دمشق",
|
||||||
|
"expected_location": {
|
||||||
|
"lat": 33.5102,
|
||||||
|
"lon": 36.2908
|
||||||
|
},
|
||||||
|
"expected_category": "university",
|
||||||
|
"tolerance_meters": 1000
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -34,6 +34,7 @@ rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \
|
|||||||
apps \
|
apps \
|
||||||
packages \
|
packages \
|
||||||
infrastructure \
|
infrastructure \
|
||||||
|
data \
|
||||||
$SERVER_USER@$SERVER_IP:$REMOTE_PATH/
|
$SERVER_USER@$SERVER_IP:$REMOTE_PATH/
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user