2026-04-14-7
This commit is contained in:
@@ -27,6 +27,8 @@
|
|||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@nestjs/cache-manager": "^3.0.0",
|
||||||
|
"cache-manager-redis-store": "^2.0.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { JordanResearchService } from './jordan-research.service';
|
|||||||
import { AdministrativeLinkingService } from './administrative-linking.service';
|
import { AdministrativeLinkingService } from './administrative-linking.service';
|
||||||
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
|
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
|
||||||
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -27,6 +29,12 @@ import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
|||||||
NeighborhoodPoint,
|
NeighborhoodPoint,
|
||||||
NeighborhoodPolygon
|
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],
|
controllers: [GeocodingController],
|
||||||
providers: [
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { PlaceSyria } from './entities/place-syria.entity';
|
||||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||||
@@ -12,6 +14,8 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
|||||||
export class GeocodingService {
|
export class GeocodingService {
|
||||||
private readonly logger = new Logger(GeocodingService.name);
|
private readonly logger = new Logger(GeocodingService.name);
|
||||||
|
|
||||||
|
private readonly DB_TIMEOUT_MS = 1100;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(PlaceSyria)
|
@InjectRepository(PlaceSyria)
|
||||||
private placesSyriaRepository: Repository<PlaceSyria>,
|
private placesSyriaRepository: Repository<PlaceSyria>,
|
||||||
@@ -23,6 +27,8 @@ export class GeocodingService {
|
|||||||
private osmAreasRepository: Repository<OsmArea>,
|
private osmAreasRepository: Repository<OsmArea>,
|
||||||
@InjectRepository(OsmPointWithArea)
|
@InjectRepository(OsmPointWithArea)
|
||||||
private osmPointsRepository: Repository<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>;
|
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 {
|
private getTableNameForRepo(repo: Repository<BasePlace>): string {
|
||||||
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
||||||
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
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) {
|
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
|
||||||
try {
|
try {
|
||||||
const cleanQuery = query.trim();
|
const cleanQuery = query.trim();
|
||||||
|
if (!cleanQuery) return { results: [] };
|
||||||
const hasLocation = lat !== undefined && lon !== undefined;
|
const hasLocation = lat !== undefined && lon !== undefined;
|
||||||
const allResults: any[] = [];
|
|
||||||
|
|
||||||
// 1. Identify Target Region
|
const geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global';
|
||||||
let targetRegion = country?.toLowerCase();
|
const cacheKey = `geo_search:${country || 'auto'}:${geoSegment}:${cleanQuery.toLowerCase()}`;
|
||||||
if (!targetRegion && hasLocation) {
|
|
||||||
const repo = this.getRepositoryForCoords(lat!, lon!);
|
|
||||||
const tableName = this.getTableNameForRepo(repo);
|
|
||||||
targetRegion = tableName.replace('places_', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryTables = targetRegion
|
const cached: any = await this.cacheManager.get(cacheKey);
|
||||||
? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
|
if (cached) return { results: cached, source: 'cache_hit' };
|
||||||
: ['places_jordan', 'places_syria', 'places_egypt'];
|
|
||||||
|
|
||||||
// PHASE 1: High Quality Search in Country-Specific Tables
|
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
|
||||||
for (const tableName of primaryTables) {
|
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 repo = this.getRepoByTableName(tableName);
|
||||||
const results = await repo.query(`
|
queryPromises.push(repo.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
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,
|
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,
|
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
|
GREATEST(similarity(COALESCE(p.name_ar, ''), $1), similarity(COALESCE(p.name, ''), $1)) as relevance
|
||||||
FROM ${tableName} p
|
FROM ${tableName} p
|
||||||
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
|
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
|
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)
|
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
|
ORDER BY (p.name_ar <-> $1) ASC LIMIT 10
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]);
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
allResults.push(...results);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have enough results after formatting (deduplication)
|
queryPromises.push(this.osmPointsRepository.query(`
|
||||||
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(`
|
|
||||||
SELECT
|
SELECT
|
||||||
o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category,
|
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,
|
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,
|
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
|
GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance
|
||||||
FROM osm_points_with_area o
|
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)
|
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
|
ORDER BY (o.name <-> $1) ASC LIMIT 10
|
||||||
`, [cleanQuery, lat || null, lon || null, radius]);
|
`, [cleanQuery, lat || null, lon || null, radius]));
|
||||||
allResults.push(...osmResults);
|
|
||||||
|
|
||||||
formatted = this.formatResults(allResults, hasLocation);
|
const executionResults = await Promise.race([
|
||||||
if (formatted.length >= 4) return { results: formatted };
|
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)
|
let allResults: any[] = [];
|
||||||
if (formatted.length < 4) {
|
if (Array.isArray(executionResults)) {
|
||||||
const polyResults = await this.osmPointsRepository.query(`
|
(executionResults as any[]).forEach(res => {
|
||||||
SELECT
|
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (e) {
|
||||||
this.logger.error('Search failed:', e);
|
this.logger.error('Search failed:', e);
|
||||||
return { results: [] };
|
return { results: [] };
|
||||||
|
|||||||
Reference in New Issue
Block a user