diff --git a/apps/api/package.json b/apps/api/package.json index 15e89e6..b6e80b5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,8 @@ "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", "@nestjs/typeorm": "^11.0.0", + "@nestjs/cache-manager": "^3.0.0", + "cache-manager-redis-store": "^2.0.0", "axios": "^1.13.6", "mysql2": "^3.20.0", "pg": "^8.20.0", diff --git a/apps/api/src/geocoding/geocoding.module.ts b/apps/api/src/geocoding/geocoding.module.ts index 5b13b80..80d923f 100644 --- a/apps/api/src/geocoding/geocoding.module.ts +++ b/apps/api/src/geocoding/geocoding.module.ts @@ -14,6 +14,8 @@ import { JordanResearchService } from './jordan-research.service'; import { AdministrativeLinkingService } from './administrative-linking.service'; import { NeighborhoodPoint } from './entities/neighborhood-point.entity'; import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity'; +import { CacheModule } from '@nestjs/cache-manager'; +import * as redisStore from 'cache-manager-redis-store'; @Module({ imports: [ @@ -27,6 +29,12 @@ import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity'; NeighborhoodPoint, NeighborhoodPolygon ]), + CacheModule.register({ + store: redisStore, + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + ttl: 3600, // 1 hour in seconds for version 1-2, or ms for v3 + }), ], controllers: [GeocodingController], providers: [ diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index 8f52507..2e28b1c 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -1,6 +1,8 @@ -import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; import { PlaceSyria } from './entities/place-syria.entity'; import { PlaceJordan } from './entities/place-jordan.entity'; import { PlaceEgypt } from './entities/place-egypt.entity'; @@ -12,6 +14,8 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity'; export class GeocodingService { private readonly logger = new Logger(GeocodingService.name); + private readonly DB_TIMEOUT_MS = 1100; + constructor( @InjectRepository(PlaceSyria) private placesSyriaRepository: Repository, @@ -23,6 +27,8 @@ export class GeocodingService { private osmAreasRepository: Repository, @InjectRepository(OsmPointWithArea) private osmPointsRepository: Repository, + @Inject(CACHE_MANAGER) + private cacheManager: Cache, ) {} /** @@ -39,6 +45,18 @@ export class GeocodingService { return (this.placesSyriaRepository as unknown) as Repository; } + private identifyRegion(lat?: number, lng?: number): string | undefined { + if (lat === undefined || lng === undefined) return undefined; + if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) { + if (lat > 32.5 && lng > 35.8) return 'syria'; + return 'jordan'; + } + if (lat >= 22 && lat <= 32 && lng >= 24.5 && lng <= 37) { + return 'egypt'; + } + return undefined; + } + private getTableNameForRepo(repo: Repository): string { if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan'; if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt'; @@ -48,81 +66,73 @@ export class GeocodingService { async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) { try { const cleanQuery = query.trim(); + if (!cleanQuery) return { results: [] }; const hasLocation = lat !== undefined && lon !== undefined; - const allResults: any[] = []; - // 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 geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global'; + const cacheKey = `geo_search:${country || 'auto'}:${geoSegment}:${cleanQuery.toLowerCase()}`; - const primaryTables = targetRegion - ? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : []) - : ['places_jordan', 'places_syria', 'places_egypt']; + const cached: any = await this.cacheManager.get(cacheKey); + if (cached) return { results: cached, source: 'cache_hit' }; - // PHASE 1: High Quality Search in Country-Specific Tables - for (const tableName of primaryTables) { + let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon); + const primaryTables = targetRegion && ['syria', 'egypt', 'jordan'].includes(targetRegion) + ? [`places_${targetRegion}`] : ['places_jordan', 'places_syria', 'places_egypt']; + + const queryPromises: Promise[] = []; + + primaryTables.forEach(tableName => { const repo = this.getRepoByTableName(tableName); - const results = await repo.query(` + 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, p.source, + 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.sub_district_id = d.id + LEFT JOIN admin_boundaries d ON p.district_id = d.id LEFT JOIN admin_boundaries g ON p.governorate_id = g.id - WHERE (similarity(COALESCE(p.name_ar, ''), $1) > 0.2 OR similarity(COALESCE(p.name, ''), $1) > 0.2) + 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 relevance DESC, distance ASC LIMIT 20 - `, [cleanQuery, lat || null, lon || null, radius]); - allResults.push(...results); - } + ORDER BY (p.name_ar <-> $1) ASC LIMIT 10 + `, [cleanQuery, lat || null, lon || null, radius])); + }); - // Check if we have enough results after formatting (deduplication) - let formatted = this.formatResults(allResults, hasLocation); - if (formatted.length >= 4) return { results: formatted }; - - // PHASE 2: High Quality OSM Point Search (if needed) - const osmResults = await this.osmPointsRepository.query(` + queryPromises.push(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, 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 (similarity(COALESCE(o.name, ''), $1) > 0.2 OR similarity(COALESCE(o.name_ar, ''), $1) > 0.2) + 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 relevance DESC, distance ASC LIMIT 20 - `, [cleanQuery, lat || null, lon || null, radius]); - allResults.push(...osmResults); + ORDER BY (o.name <-> $1) ASC LIMIT 10 + `, [cleanQuery, lat || null, lon || null, radius])); - formatted = this.formatResults(allResults, hasLocation); - if (formatted.length >= 4) return { results: formatted }; + const executionResults = await Promise.race([ + Promise.allSettled(queryPromises), + 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[]; + }); - // 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); + let allResults: any[] = []; + if (Array.isArray(executionResults)) { + (executionResults as any[]).forEach(res => { + if (res.status === 'fulfilled' && res.value) allResults.push(...res.value); + }); } - return { results: this.formatResults(allResults, hasLocation) }; + const formatted = this.formatResults(allResults, hasLocation); + if (formatted.length > 0) { + await this.cacheManager.set(cacheKey, formatted, 3600000); + } + + return { results: formatted }; } catch (e) { this.logger.error('Search failed:', e); return { results: [] };