From 3773ab56a472d504e0936f908ccef2e63bc78096 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 16 Jul 2026 14:31:07 +0300 Subject: [PATCH] feat: implement geocoding evaluation scripts and enhance routing service with traffic-aware path processing --- apps/api/package.json | 3 +- apps/api/scripts/evaluate_geocoding.ts | 141 ++++++++ apps/api/scripts/load_test_geocoding.ts | 91 +++++ apps/api/scripts/test_autocomplete.ts | 36 ++ apps/api/scripts/test_did_you_mean.ts | 38 ++ apps/api/scripts/test_failed_searches.ts | 39 +++ apps/api/scripts/test_poi_gates.ts | 45 +++ apps/api/scripts/test_relative_queries.ts | 44 +++ apps/api/src/auth/auth.service.ts | 4 +- apps/api/src/auth/entities/tenant.entity.ts | 14 + .../api/src/geocoding/geocoding.controller.ts | 9 + apps/api/src/geocoding/geocoding.module.ts | 4 +- apps/api/src/geocoding/geocoding.service.ts | 326 ++++++++++++------ .../src/geocoding/index-refresh.service.ts | 37 ++ apps/api/src/maps/maps.service.ts | 80 ++++- apps/api/src/usage/usage.interceptor.ts | 17 +- apps/api/tsconfig.build.json | 2 +- data/golden_tests/queries_amman.json | 44 +++ data/golden_tests/queries_syria.json | 47 +++ sync_to_server.sh | 1 + 20 files changed, 885 insertions(+), 137 deletions(-) create mode 100644 apps/api/scripts/evaluate_geocoding.ts create mode 100644 apps/api/scripts/load_test_geocoding.ts create mode 100644 apps/api/scripts/test_autocomplete.ts create mode 100644 apps/api/scripts/test_did_you_mean.ts create mode 100644 apps/api/scripts/test_failed_searches.ts create mode 100644 apps/api/scripts/test_poi_gates.ts create mode 100644 apps/api/scripts/test_relative_queries.ts create mode 100644 apps/api/src/geocoding/index-refresh.service.ts create mode 100644 data/golden_tests/queries_amman.json create mode 100644 data/golden_tests/queries_syria.json diff --git a/apps/api/package.json b/apps/api/package.json index a9605a6..9bef1e0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "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": { "@nestjs/cache-manager": "^3.0.0", diff --git a/apps/api/scripts/evaluate_geocoding.ts b/apps/api/scripts/evaluate_geocoding.ts new file mode 100644 index 0000000..a0b17b3 --- /dev/null +++ b/apps/api/scripts/evaluate_geocoding.ts @@ -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); diff --git a/apps/api/scripts/load_test_geocoding.ts b/apps/api/scripts/load_test_geocoding.ts new file mode 100644 index 0000000..727e8e5 --- /dev/null +++ b/apps/api/scripts/load_test_geocoding.ts @@ -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[] = []; + + 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); diff --git a/apps/api/scripts/test_autocomplete.ts b/apps/api/scripts/test_autocomplete.ts new file mode 100644 index 0000000..c009f0c --- /dev/null +++ b/apps/api/scripts/test_autocomplete.ts @@ -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); diff --git a/apps/api/scripts/test_did_you_mean.ts b/apps/api/scripts/test_did_you_mean.ts new file mode 100644 index 0000000..c599efd --- /dev/null +++ b/apps/api/scripts/test_did_you_mean.ts @@ -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); diff --git a/apps/api/scripts/test_failed_searches.ts b/apps/api/scripts/test_failed_searches.ts new file mode 100644 index 0000000..9267cc6 --- /dev/null +++ b/apps/api/scripts/test_failed_searches.ts @@ -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); diff --git a/apps/api/scripts/test_poi_gates.ts b/apps/api/scripts/test_poi_gates.ts new file mode 100644 index 0000000..e221871 --- /dev/null +++ b/apps/api/scripts/test_poi_gates.ts @@ -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); diff --git a/apps/api/scripts/test_relative_queries.ts b/apps/api/scripts/test_relative_queries.ts new file mode 100644 index 0000000..ae8c615 --- /dev/null +++ b/apps/api/scripts/test_relative_queries.ts @@ -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); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 4caff88..732de76 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { createHash } from 'crypto'; 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'; @Injectable() @@ -48,7 +48,7 @@ export class AuthService { const result = { tenant: apiKey.tenant, 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) diff --git a/apps/api/src/auth/entities/tenant.entity.ts b/apps/api/src/auth/entities/tenant.entity.ts index 676fa85..9d6fb03 100644 --- a/apps/api/src/auth/entities/tenant.entity.ts +++ b/apps/api/src/auth/entities/tenant.entity.ts @@ -13,6 +13,20 @@ export enum TenantRole { ADMIN = 'ADMIN', } +export const QUOTA_LIMITS: Record = { + [TenantPlan.FREE]: 8000, + [TenantPlan.STARTER]: 25000, + [TenantPlan.PRO]: 100000, + [TenantPlan.ENTERPRISE]: 500000, +}; + +export const RATE_LIMITS: Record = { + [TenantPlan.FREE]: 5, + [TenantPlan.STARTER]: 100, + [TenantPlan.PRO]: 500, + [TenantPlan.ENTERPRISE]: 50000, +}; + @Entity('tenants') export class Tenant { @PrimaryGeneratedColumn('uuid') diff --git a/apps/api/src/geocoding/geocoding.controller.ts b/apps/api/src/geocoding/geocoding.controller.ts index 6eef3dc..cee4a9f 100644 --- a/apps/api/src/geocoding/geocoding.controller.ts +++ b/apps/api/src/geocoding/geocoding.controller.ts @@ -33,6 +33,15 @@ export class GeocodingController { 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') @ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' }) async reverse(@Query() reverseDto: ReverseGeocodeDto) { diff --git a/apps/api/src/geocoding/geocoding.module.ts b/apps/api/src/geocoding/geocoding.module.ts index 64c8d5d..21913aa 100644 --- a/apps/api/src/geocoding/geocoding.module.ts +++ b/apps/api/src/geocoding/geocoding.module.ts @@ -19,6 +19,7 @@ import { MapRefinementService } from './map-refinement.service'; import { MapRefinementController } from './map-refinement.controller'; import { CacheModule } from '@nestjs/cache-manager'; import * as redisStore from 'cache-manager-redis-store'; +import { IndexRefreshService } from './index-refresh.service'; @Module({ imports: [ @@ -47,7 +48,8 @@ import * as redisStore from 'cache-manager-redis-store'; AdminBoundariesService, JordanResearchService, AdministrativeLinkingService, - MapRefinementService + MapRefinementService, + IndexRefreshService ], exports: [GeocodingService, MapRefinementService], }) diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index 6e2fcbc..2a3350d 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -65,8 +65,17 @@ export class GeocodingService { async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) { try { - const cleanQuery = query.trim(); + let cleanQuery = query.trim(); 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 geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global'; @@ -76,112 +85,110 @@ export class GeocodingService { if (cached) return { results: cached, source: 'cache_hit' }; 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[] = []; + 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 => { - const repo = this.getRepoByTableName(tableName); - queryPromises.push(repo.query(` - 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])); - }); + let regionCondition = ''; + if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) { + regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`; + } - queryPromises.push(this.osmPointsRepository.query(` + const sqlQuery = ` SELECT - o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as 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, + id, name, name_ar, category, '' 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, - 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, - similarity(COALESCE(name_ar, ''), $1) as relevance - FROM admin_boundaries - WHERE name_ar % $1 - AND ($2::float IS NULL OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4) - ORDER BY (name_ar <-> $1) ASC LIMIT 5 - `, [cleanQuery, lat || null, lon || null, radius])); + latitude, longitude, address, region, source, popularity_score, + ${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance, + similarity(normalized_name, $1) as relevance + FROM unified_search_index + WHERE normalized_name % $1 + ${locationCondition} + ${regionCondition} + ORDER BY (normalized_name <-> $1) ASC + LIMIT 50 + `; - queryPromises.push(this.osmPointsRepository.query(` - SELECT - 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, - '' 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((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS)) + const allResults = await Promise.race([ + this.osmPointsRepository.query(sqlQuery, queryParams), + new Promise((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS)) ]).catch(e => { this.logger.warn(`Search optimization threshold hit: ${e.message}`); return [] as any[]; }); - let allResults: any[] = []; - if (Array.isArray(executionResults)) { - (executionResults as any[]).forEach(res => { - if (res.status === 'fulfilled' && res.value) allResults.push(...res.value); - }); + const formatted = this.formatResults(allResults, hasLocation, relativePrefix); + + // --- POI GATES (Clustering) --- + 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) { 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 }; @@ -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(); + + // Normalize popularity to a 0-1 scale + const maxPopularity = Math.max(...results.map(r => r.popularity_score || 10), 100); + return results .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. 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 }; }) .sort((a, b) => b.totalScore - a.totalScore) @@ -214,8 +233,12 @@ export class GeocodingService { .map(r => { const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean); const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || ''); + const nameAr = r.name_ar || r.name; + const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr; return { ...r, + name: displayName, + name_ar: displayName, latitude: parseFloat(r.latitude), longitude: parseFloat(r.longitude), distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null, @@ -232,6 +255,87 @@ export class GeocodingService { 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) { try { const repo = this.getRepositoryForCoords(lat, lng); @@ -307,11 +411,33 @@ export class GeocodingService { return allResults .sort((a, b) => Number(a.distance) - Number(b.distance)) .slice(0, 5) - .map(r => ({ - ...r, - latitude: parseFloat(r.latitude), - longitude: parseFloat(r.longitude) - })); + .map(r => { + const distance = Number(r.distance); + const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean); + + 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) { this.logger.error('Reverse geocoding error:', error); return []; diff --git a/apps/api/src/geocoding/index-refresh.service.ts b/apps/api/src/geocoding/index-refresh.service.ts new file mode 100644 index 0000000..ad64735 --- /dev/null +++ b/apps/api/src/geocoding/index-refresh.service.ts @@ -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, // 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); + } + } + } +} diff --git a/apps/api/src/maps/maps.service.ts b/apps/api/src/maps/maps.service.ts index fe6efdd..cc2ae39 100644 --- a/apps/api/src/maps/maps.service.ts +++ b/apps/api/src/maps/maps.service.ts @@ -66,10 +66,10 @@ export class MapsService { const payload: any = { points: ghPoints, profile: profile, - locale: locale, + locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback calc_points: true, points_encoded: true, - instructions: steps, + instructions: steps || true, // Always request instructions to extract route name }; // ── Closure-Aware Routing ───────────────────────────────────────────── @@ -164,25 +164,47 @@ export class MapsService { const baseDuration = route.time / 1000; const trafficAwareDuration = baseDuration * trafficFactor; - // Process alternative routes if any without breaking existing frontend variables - const altRoutes = paths.slice(1).map(alt => ({ - distance: alt.distance, - duration: Math.round(alt.time / 1000), - points: alt.points, - bbox: alt.bbox, - instructions: alt.instructions // Include instructions for alternatives if requested - })); + // Process all paths to add metadata (Names, Tags) + const processedPaths = paths.map((p: any, index: number) => { + const pCoords = this.decodePolyline(p.points); + const pTrafficFactor = this.trafficGrid.getTrafficFactor(pCoords, hr, dow); + const pDuration = Math.round((p.time / 1000) * pTrafficFactor); + const routeName = this.getRouteName(p.instructions); + + // 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 { - distance: route.distance, - duration: Math.round(baseDuration), - trafficAwareDuration: Math.round(trafficAwareDuration), + routeName: mainRoute.routeName, + tags: mainRoute.tags, + distance: mainRoute.distance, + duration: mainRoute.duration, trafficFactor: Math.round(trafficFactor * 100) / 100, startName, endName, - points: route.points, - bbox: route.bbox, - instructions: route.instructions, // Added: turn-by-turn maneuvers + points: mainRoute.points, + bbox: mainRoute.bbox, + instructions: mainRoute.instructions, alternatives: altRoutes }; } 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 = {}; + 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) */ diff --git a/apps/api/src/usage/usage.interceptor.ts b/apps/api/src/usage/usage.interceptor.ts index 8e7ab8f..3e42f4f 100644 --- a/apps/api/src/usage/usage.interceptor.ts +++ b/apps/api/src/usage/usage.interceptor.ts @@ -9,22 +9,7 @@ import { } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import { UsageService } from './usage.service'; -import { TenantPlan } from '../auth/entities/tenant.entity'; - -// Quota Limits per Plan -const QUOTA_LIMITS: Record = { - [TenantPlan.FREE]: 8000, - [TenantPlan.STARTER]: 25000, - [TenantPlan.PRO]: 100000, - [TenantPlan.ENTERPRISE]: 500000, -}; - -const RATE_LIMITS: Record = { - [TenantPlan.FREE]: 5, - [TenantPlan.STARTER]: 100, - [TenantPlan.PRO]: 500, - [TenantPlan.ENTERPRISE]: 5000, -}; +import { TenantPlan, QUOTA_LIMITS, RATE_LIMITS } from '../auth/entities/tenant.entity'; @Injectable() export class UsageInterceptor implements NestInterceptor { diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 64f86c6..763e062 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "scripts", "**/*spec.ts"] } diff --git a/data/golden_tests/queries_amman.json b/data/golden_tests/queries_amman.json new file mode 100644 index 0000000..3ad357e --- /dev/null +++ b/data/golden_tests/queries_amman.json @@ -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 + } +] diff --git a/data/golden_tests/queries_syria.json b/data/golden_tests/queries_syria.json new file mode 100644 index 0000000..e4eb5b8 --- /dev/null +++ b/data/golden_tests/queries_syria.json @@ -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 + } +] diff --git a/sync_to_server.sh b/sync_to_server.sh index 82fcf1d..531640f 100755 --- a/sync_to_server.sh +++ b/sync_to_server.sh @@ -34,6 +34,7 @@ rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \ apps \ packages \ infrastructure \ + data \ $SERVER_USER@$SERVER_IP:$REMOTE_PATH/ if [ $? -eq 0 ]; then