diff --git a/apps/api/src/geocoding/geocoding.controller.ts b/apps/api/src/geocoding/geocoding.controller.ts index 2e504f8..888a9b0 100644 --- a/apps/api/src/geocoding/geocoding.controller.ts +++ b/apps/api/src/geocoding/geocoding.controller.ts @@ -8,6 +8,7 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard'; @ApiTags('geocoding') @Controller('geocoding') +@UseGuards(ApiKeyGuard) export class GeocodingController { constructor( private readonly geocodingService: GeocodingService, @@ -61,14 +62,12 @@ export class GeocodingController { } @Post('places') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Add a new location (User Submitted)' }) async addPlace(@Body() placeData: any) { return this.geocodingService.addPlace(placeData); } @Delete('places') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Delete a place by name or ID' }) @ApiQuery({ name: 'name', required: false }) @ApiQuery({ name: 'id', required: false, type: Number }) @@ -88,14 +87,12 @@ export class GeocodingController { } @Post('upsert-place') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' }) async upsertPlace(@Body() placeData: any) { return this.geocodingService.upsertPlace(placeData); } @Post('upsert-batch') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Add or Update multiple locations in bulk' }) async upsertBatch(@Body() body: { places: any[] }) { return this.geocodingService.upsertBatch(body.places); @@ -114,7 +111,6 @@ export class GeocodingController { } @Post('import-boundaries') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' }) @ApiQuery({ name: 'country', required: true }) @ApiQuery({ name: 'filePath', required: true }) @@ -132,7 +128,6 @@ export class GeocodingController { } @Post('admin/sync-neighborhoods') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' }) @ApiQuery({ name: 'bbox', required: false }) @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] }) @@ -141,7 +136,6 @@ export class GeocodingController { } @Post('admin/generate-voronoi') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' }) @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] }) async generateVoronoi(@Query('country') country?: string) { @@ -149,7 +143,6 @@ export class GeocodingController { } @Post('admin/link-places') - @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Link places to administrative hierarchy' }) @ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] }) async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt') { diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index f099bd8..8f52507 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -49,118 +49,127 @@ export class GeocodingService { try { const cleanQuery = query.trim(); const hasLocation = lat !== undefined && lon !== undefined; - const ILikeQuery = `%${cleanQuery}%`; const allResults: any[] = []; - const radiusInDegrees = radius / 111000; - + + // 1. Identify Target Region let targetRegion = country?.toLowerCase(); if (!targetRegion && hasLocation) { const repo = this.getRepositoryForCoords(lat!, lon!); const tableName = this.getTableNameForRepo(repo); targetRegion = tableName.replace('places_', ''); } - - const userTables = targetRegion + + const primaryTables = targetRegion ? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : []) - : ['places_syria', 'places_egypt', 'places_jordan']; + : ['places_jordan', 'places_syria', 'places_egypt']; - for (const tableName of userTables) { - let repo: Repository = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository); - const userQuery = ` + // PHASE 1: High Quality Search in Country-Specific Tables + for (const tableName of primaryTables) { + const repo = this.getRepoByTableName(tableName); + const results = await repo.query(` SELECT p.id, p.name, p.name_ar, p.name_en, p.category, - p.neighborhood_id as db_neighborhood_id, - p.neighbourhood as original_neighbourhood, - n.name_ar as neighbourhood, - d.name_ar as district, - g.name_ar as governorate, + 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, p.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, - similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance + 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.sub_district_id = d.id LEFT JOIN admin_boundaries g ON p.governorate_id = g.id - ORDER BY relevance DESC, distance ASC LIMIT 25 - `; - const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null]); + WHERE (similarity(COALESCE(p.name_ar, ''), $1) > 0.2 OR similarity(COALESCE(p.name, ''), $1) > 0.2) + AND ($2::float IS NULL OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4) + ORDER BY relevance DESC, distance ASC LIMIT 20 + `, [cleanQuery, lat || null, lon || null, radius]); allResults.push(...results); } - let osmBoundFilter = ''; - if (targetRegion === 'jordan') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(34.5, 29.0, 39.5, 33.5, 4326), geom)'; - else if (targetRegion === 'syria') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(35.5, 32.3, 42.4, 37.5, 4326), geom)'; - else if (targetRegion === 'egypt') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(24.5, 22.0, 37.0, 31.8, 4326), geom)'; + // Check if we have enough results after formatting (deduplication) + let formatted = this.formatResults(allResults, hasLocation); + if (formatted.length >= 4) return { results: formatted }; - const osmQuery = ` + // PHASE 2: High Quality OSM Point Search (if needed) + const osmResults = await this.osmPointsRepository.query(` 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, - n.name_ar as neighbourhood, - d.name_ar as district, - g.name_ar as governorate, 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, - similarity(COALESCE(o.name_ar, ''), $1) + similarity(COALESCE(o.name_en, ''), $1) as relevance + GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance FROM osm_points_with_area o - LEFT JOIN LATERAL ( - SELECT name_ar FROM neighborhood_polygons np - ORDER BY o.geom <-> np.geometry - LIMIT 1 - ) n ON true - LEFT JOIN LATERAL ( - SELECT name_ar FROM admin_boundaries ab - WHERE ab.admin_level = 8 AND ST_Contains(ab.geom, o.geom) - LIMIT 1 - ) d ON true - LEFT JOIN LATERAL ( - SELECT name_ar FROM admin_boundaries ab - WHERE ab.admin_level = 4 AND ST_Contains(ab.geom, o.geom) - LIMIT 1 - ) g ON true - ORDER BY relevance DESC, distance ASC LIMIT 30 - `; - const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null]); + WHERE (similarity(COALESCE(o.name, ''), $1) > 0.2 OR similarity(COALESCE(o.name_ar, ''), $1) > 0.2) + AND ($2::float IS NULL OR ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4) + ORDER BY relevance DESC, distance ASC LIMIT 20 + `, [cleanQuery, lat || null, lon || null, radius]); allResults.push(...osmResults); - // Note: We removed the raw query to 'overture_building' because raw overture tables - // do not have administrative linking, causing empty full_addresses, and - // scanning them with ILIKE without trigram indices causes a 2-second latency spike. - // Overture data is already properly ingested via the Scraper into places_jordan. + formatted = this.formatResults(allResults, hasLocation); + if (formatted.length >= 4) return { results: formatted }; - const seenStreets = new Set(); + // PHASE 3: Fallback (Lower threshold / Area search) + if (formatted.length < 4) { + const polyResults = await this.osmPointsRepository.query(` + SELECT + osm_id::text as id, name, NULL as name_ar, NULL as name_en, COALESCE(landuse, amenity, 'area') as category, + ST_Y(ST_Transform(ST_Centroid(way), 4326)) as latitude, ST_X(ST_Transform(ST_Centroid(way), 4326)) as longitude, + '' as address, 'osm_area' as source, + CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(ST_Transform(way, 4326), ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance, + similarity(COALESCE(name, ''), $1) as relevance + FROM planet_osm_polygon + WHERE name IS NOT NULL AND similarity(COALESCE(name, ''), $1) > 0.15 + AND ($2::float IS NULL OR ST_DistanceSphere(ST_Transform(way, 4326), ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4) + ORDER BY relevance DESC, distance ASC LIMIT 10 + `, [cleanQuery, lat || null, lon || null, radius]); + allResults.push(...polyResults); + } - const sortedResults = allResults - .sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance))) - .filter(r => { - if (r.category === 'street') { - const key = `${r.name_ar || r.name}_${r.district}_${r.governorate}`; - if (seenStreets.has(key)) return false; - seenStreets.add(key); - } - return true; - }) - .slice(0, 25) - .map(r => { - // Build full administrative address from admin_boundaries - const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean); - const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || ''); - return { - ...r, - latitude: parseFloat(r.latitude), - longitude: parseFloat(r.longitude), - distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null, - location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) }, - full_address, - }; - }); - - return { results: sortedResults }; + return { results: this.formatResults(allResults, hasLocation) }; } catch (e) { this.logger.error('Search failed:', e); return { results: [] }; } } + private formatResults(results: any[], hasLocation: boolean) { + const seenStreets = new Set(); + return results + .map(r => { + // Weighted scoring: 70% Name Similarity, 30% Geographic Proximity + // 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); + return { ...r, totalScore }; + }) + .sort((a, b) => b.totalScore - a.totalScore) + .filter(r => { + if (r.category === 'street') { + const key = `${r.name_ar || r.name}_${r.district}_${r.governorate}`; + if (seenStreets.has(key)) return false; + seenStreets.add(key); + } + return true; + }) + .slice(0, 4) + .map(r => { + const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean); + const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || ''); + return { + ...r, + latitude: parseFloat(r.latitude), + longitude: parseFloat(r.longitude), + distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null, + location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) }, + full_address, + }; + }); + } + + + private getRepoByTableName(tableName: string): Repository { + if (tableName === 'places_egypt') return this.placesEgyptRepository; + if (tableName === 'places_jordan') return this.placesJordanRepository; + return this.placesSyriaRepository; + } + async reverseGeocode(lat: number, lng: number) { try { const repo = this.getRepositoryForCoords(lat, lng);