2026-04-14-7
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<PlaceSyria>,
|
||||
@@ -23,6 +27,8 @@ export class GeocodingService {
|
||||
private osmAreasRepository: Repository<OsmArea>,
|
||||
@InjectRepository(OsmPointWithArea)
|
||||
private osmPointsRepository: Repository<OsmPointWithArea>,
|
||||
@Inject(CACHE_MANAGER)
|
||||
private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,6 +45,18 @@ export class GeocodingService {
|
||||
return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
||||
}
|
||||
|
||||
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<BasePlace>): 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<any[]>[] = [];
|
||||
|
||||
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<any>((_, 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: [] };
|
||||
|
||||
Reference in New Issue
Block a user