2026-04-14-7

This commit is contained in:
Hamza-Ayed
2026-04-14 18:32:40 +03:00
parent dd93a0a807
commit be7dcc2652
3 changed files with 70 additions and 50 deletions
+2
View File
@@ -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: [
+60 -50
View File
@@ -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: [] };