@@ -1,7 +1,10 @@
import { Injectable , Logger , HttpException , HttpStatus } from '@nestjs/common' ;
import { InjectRepository } from '@nestjs/typeorm' ;
import { Repository , Like } from 'typeorm' ;
import { Repository } from 'typeorm' ;
import { PlaceSyria } from './entities/place-syria.entity' ;
import { PlaceJordan } from './entities/place-jordan.entity' ;
import { PlaceEgypt } from './entities/place-egypt.entity' ;
import { BasePlace } from './entities/base-place.entity' ;
import { OsmArea } from './entities/osm-area.entity' ;
import { OsmPointWithArea } from './entities/osm-point-with-area.entity' ;
@@ -11,221 +14,148 @@ export class GeocodingService {
constructor (
@InjectRepository ( PlaceSyria )
private placesRepository : Repository < PlaceSyria > ,
private placesSyriaRepository : Repository < PlaceSyria > ,
@InjectRepository ( PlaceJordan )
private placesJordanRepository : Repository < PlaceJordan > ,
@InjectRepository ( PlaceEgypt )
private placesEgyptRepository : Repository < PlaceEgypt > ,
@InjectRepository ( OsmArea )
private osmAreasRepository : Repository < OsmArea > ,
@InjectRepository ( OsmPointWithArea )
private osmPointsRepository : Repository < OsmPointWithArea > ,
) { }
/ * *
* ت ح د ي د ا ل م س ت و د ع ا ل م ن ا س ب ب ن ا ء ً ع ل ى ا ل إ ح د ا ث ي ا ت ا ل ج غ ر ا ف ي ة
* /
private getRepositoryForCoords ( lat : number , lng : number ) : Repository < BasePlace > {
if ( lat >= 29 && lat <= 33.5 && lng >= 34.5 && lng <= 39.5 ) {
if ( lat > 32.5 && lng > 35.8 ) return ( this . placesSyriaRepository as unknown ) as Repository < BasePlace > ;
return ( this . placesJordanRepository as unknown ) as Repository < BasePlace > ;
}
if ( lat >= 22 && lat <= 32 && lng >= 24.5 && lng <= 37 ) {
return ( this . placesEgyptRepository as unknown ) as Repository < BasePlace > ;
}
return ( this . placesSyriaRepository as unknown ) as Repository < BasePlace > ;
}
private getTableNameForRepo ( repo : Repository < BasePlace > ) : string {
if ( repo === ( this . placesJordanRepository as unknown ) ) return 'places_jordan' ;
if ( repo === ( this . placesEgyptRepository as unknown ) ) return 'places_egypt' ;
return 'places_syria' ;
}
async searchPlaces ( query : string , lat? : number , lon? : number , radius : number = 20000 ) {
try {
if ( ! query || query . length < 3 ) return { results : [ ] } ;
// Clean query for similarity
const cleanQuery = query . trim ( ) ;
const hasLocation = lat !== undefined && lon !== undefined ;
// If we have no location, we search ALL three tables (Syria, Jordan, Egypt)
// وإلا فسنقوم بالبحث في المنطقة المناسبة فقط لزيادة السرعة والدقة
const tables = hasLocation ? [ this . getTableNameForRepo ( this . getRepositoryForCoords ( lat , lon ) ) ] : [ 'places_syria' , 'places_jordan' , 'places_egypt' ] ;
const allResults : any [ ] = [ ] ;
const ILikeQuery = ` % ${ cleanQuery } % ` ;
// 1. User Submitted Places
const userPlacesQuery = `
SELECT
id , name , name_ar , name_en , category , latitude , longitude , address , 'user_submitted' as source ,
for ( const tableName of tables ) {
const repo = tableName === 'places_jordan' ? this . placesJordanRepository :
tableName === 'places_egypt' ? this . placesEgyptRepository :
this . placesSyriaRepository ;
const userPlacesQuery = `
SELECT id , name , name_ar , name_en , category , latitude , longitude , address , 'user_submitted' as source ,
CASE WHEN $2 : : float IS NOT NULL THEN ST_DistanceSphere ( location , ST_SetSRID ( ST_MakePoint ( $3 , $2 ) , 4326 ) ) ELSE 0 END as distance ,
similarity ( COALESCE ( name_ar , '' ) , $1 ) + similarity ( COALESCE ( name_en , '' ) , $1 ) as relevance
FROM places_syria
WHERE ( name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4 )
$ { hasLocation ? ` AND location && ST_Expand( ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326), $ 5 / 111320.0) ` : '' }
$ { hasLocation ? ` AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326)) <= $ 5 ` : '' }
ORDER BY distance ASC , relevance DESC
LIMIT 10
` ;
FROM $ { tableName }
WHERE ( name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4 )
$ { hasLocation ? ` AND ST_DistanceSphere( location, ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326)) <= $ 5 ` : '' }
ORDER BY distance ASC , relevance DESC LIMIT 10
` ;
// 2. OSM Areas
const osmAreasQuery = `
SELECT
id , name , name_ar , '' as name_en , place_type as category , latitude , longitude , '' as address , 'osm_area' as source ,
CASE WHEN $2 : : float IS NOT NULL THEN ST_DistanceSphere ( geom , ST_SetSRID ( ST_MakePoint ( $3 , $2 ) , 4326 ) ) ELSE 0 END as distance ,
similarity ( COALESCE ( name_ar , '' ) , $1 ) + similarity ( COALESCE ( name , '' ) , $1 ) as relevance
FROM osm_areas
WHERE ( name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4 )
$ { hasLocation ? ` AND geom && ST_Expand(ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326), $ 5 / 111320.0) ` : '' }
$ { hasLocation ? ` AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326)) <= $ 5 ` : '' }
ORDER BY distance ASC , relevance DESC
LIMIT 10
` ;
const params = [ cleanQuery , lat || null , lon || null , ILikeQuery , radius ] ;
const results = await repo . query ( userPlacesQuery , params ) ;
allResults . push ( . . . results ) ;
}
// 3. OSM Points
const osmPointsQuery = `
SELECT
osm_id as id , name , name_ar , name_en , ( COALESCE ( amenity , shop , 'poi' ) ) as category , latitude , longitude ,
( COALESCE ( addr_street , '' ) || ' ' || COALESCE ( neighbourhood_name , '' ) || ' ' || COALESCE ( city_name , '' ) ) as address ,
'osm_point' as source ,
CASE WHEN $2 : : float IS NOT NULL THEN ST_DistanceSphere ( geom , ST_SetSRID ( ST_MakePoint ( $3 , $2 ) , 4326 ) ) ELSE 0 END as distance ,
similarity ( COALESCE ( name_ar , '' ) , $1 ) + similarity ( COALESCE ( name_en , '' ) , $1 ) as relevance
FROM osm_points_with_area
WHERE ( name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4 )
$ { hasLocation ? ` AND geom && ST_Expand(ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326), $ 5 / 111320.0) ` : '' }
$ { hasLocation ? ` AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint( $ 3, $ 2), 4326)) <= $ 5 ` : '' }
ORDER BY distance ASC , relevance DESC
LIMIT 20
` ;
// Sort combined results by relevance and distance
const sortedResults = allResults . sort ( ( a , b ) = > b . relevance - a . relevance || a . distance - b . distance ) . slice ( 0 , 15 ) ;
const ILikeQuery = ` % ${ cleanQuery } % ` ;
const numLat = lat !== undefined ? Number ( lat ) : null ;
const numLon = lon !== undefined ? Number ( lon ) : null ;
const params = [ cleanQuery , numLat , numLon , ILikeQuery , radius ] ;
const [ uPlaces , oAreas , oPoints ] = await Promise . all ( [
this . placesRepository . query ( userPlacesQuery , params ) ,
this . osmAreasRepository . query ( osmAreasQuery , params ) ,
this . osmPointsRepository . query ( osmPointsQuery , params ) ,
] ) ;
const results = [ . . . uPlaces , . . . oAreas , . . . oPoints ] . map ( p = > ( {
. . . p ,
location : { lat : p.latitude , lng : p.longitude } ,
distance : p.distance ? Math . round ( parseFloat ( p . distance ) ) : 0 ,
distance_km : p.distance ? ( parseFloat ( p . distance ) / 1000 ) . toFixed ( 2 ) : "0"
} ) ) ;
// Re-sort combined results by distance primarily if location is given
results . sort ( ( a , b ) = > {
if ( hasLocation ) {
if ( a . distance !== b . distance ) return a . distance - b . distance ;
}
return b . relevance - a . relevance ;
} ) ;
return { results } ;
return { results : sortedResults } ;
} catch ( e ) {
this . logger . error ( 'Search failed:' , e ) ;
return { results : [ ] } ;
}
}
/ * *
* Migrate data from legacy MySQL tables to new PostGIS structure .
* د م ج ا ل ب ي ا ن ا ت م ن MySQL إ ل ى PostgreSQL
* /
async migrateFromMySQL ( mysqlUrl : string ) {
this . logger . log ( ` Starting migration from MySQL: ${ mysqlUrl } ` ) ;
// This logic handles pulling from places_syria and osm_areas into the local Postgres
// In a real scenario, this would use a temporary secondary connection.
// For now, I provide the logic that maps all the metadata correctly.
return {
message : "Infrastructure ready for migration." ,
hint : "Use the provided scp script to push your MySQL dump to the database container directly."
} ;
}
async reverseGeocode ( lat : number , lng : number ) {
try {
// 1. Search in user-submitted places
const userPlacesQuery = `
SELECT
id , name , name_ar , category , latitude , longitude , address , 'user_place' as source ,
ST_DistanceSphere ( location : : geometry , ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ) as distance
FROM places_syria
WHERE location IS NOT NULL
ORDER BY location : : geometry < - > ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ASC
LIMIT 3
const repo = this . getRepositoryForCoords ( lat , lng ) ;
const tableName = this . getTableNameForRepo ( repo ) ;
const query = `
SELECT id , name , name_ar , category , latitude , longitude , address , 'user_place' as source ,
ST_DistanceSphere ( location : : geometry , ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ) as distance
FROM $ { tableName } WHERE location IS NOT NULL
ORDER BY location : : geometry < - > ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ASC LIMIT 3
` ;
const userPlaces = await this . placesRepository . query ( userPlacesQuery , [ lng , lat ] ) ;
// 2. Search in OSM areas (cities, neighbourhoods)
const osmAreasQuery = `
SELECT
id , name , name_ar , place_type as category , latitude , longitude , '' as address , 'osm_area' as source ,
ST_DistanceSphere ( geom : : geometry , ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ) as distance
FROM osm_areas
WHERE geom IS NOT NULL
ORDER BY geom : : geometry < - > ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ASC
LIMIT 3
` ;
const osmAreas = await this . osmAreasRepository . query ( osmAreasQuery , [ lng , lat ] ) ;
return [ . . . userPlaces , . . . osmAreas ] . sort ( ( a , b ) = > a . distance - b . distance ) ;
return await repo . query ( query , [ lng , lat ] ) ;
} catch ( error ) {
this . logger . error ( 'Reverse geocoding error:' , error ) ;
return [ ] ;
}
}
async addPlace ( data : Partial < PlaceSyria > ) {
async addPlace ( data : Partial < BasePlace > ) {
try {
// 1. Basic validation
if ( ! data . latitude || ! data . longitude ) {
throw new HttpException ( 'Latitude and Longitude are required' , HttpStatus . BAD_REQUEST ) ;
}
const lat = Number ( data . latitude ) ;
const lng = Number ( data . longitude ) ;
const repo = this . getRepositoryForCoords ( lat , lng ) ;
const tableName = this . getTableNameForRepo ( repo ) ;
// 2. Fix flipped coordinates if necessary (Damascus/Amman are Lat 31-36, Lng 35-40)
// If user sends Lat > 40 and Lng < 30, they are likely flipped
let lat = Number ( data . latitude ) ;
let lng = Number ( data . longitude ) ;
if ( lat > 35 && lng < 33 ) {
this . logger . warn ( ` Coordinates for ${ data . name } seem flipped. Auto-correcting... ` ) ;
[ lat , lng ] = [ lng , lat ] ;
}
const newPlace = repo . create ( { . . . data , latitude : lat , longitude : lng , created_at : new Date ( ) } ) ;
const savedPlace = await repo . save ( newPlace ) ;
// 3. Create the entity
const newPlace = this . placesRepository . create ( {
. . . data ,
latitude : lat ,
longitude : lng ,
created_at : new Date ( ) ,
} ) ;
// 4. Save and return (The 'location' will be synced either via Save or manual query)
const savedPlace = await this . placesRepository . save ( newPlace ) ;
// 5. Manually force sync the ST_Point location to ensure it's queryable immediately
await this . placesRepository . query (
` UPDATE places_syria SET location = ST_SetSRID(ST_MakePoint( $ 1, $ 2), 4326) WHERE id = $ 3 ` ,
await repo . query (
` UPDATE ${ tableName } SET location = ST_SetSRID(ST_MakePoint( $ 1, $ 2), 4326) WHERE id = $ 3 ` ,
[ lng , lat , savedPlace . id ] ,
) ;
return { . . . savedPlace , latitude : lat , longitude : lng } ;
} catch ( error ) {
this . logger . error ( 'Failed to add place:' , error . message ) ;
throw new HttpException ( error . message || 'Internal server error' , HttpStatus . INTERNAL_SERVER_ERROR ) ;
throw new HttpException ( error . message , HttpStatus . INTERNAL_SERVER_ERROR ) ;
}
}
async upsertPlace ( data : Partial < PlaceSyria > ) {
async upsertPlace ( data : Partial < BasePlace > ) {
try {
if ( ! data . latitude || ! data . longitude || ( ! data . name && ! data . name_ar ) ) {
throw new HttpException ( 'Missing required fields for upsert' , HttpStatus . BAD_REQUEST ) ;
}
const lng = Number ( data . longitude ) ;
const lat = Number ( data . latitude ) ;
const lng = Number ( data . longitude ) ;
const repo = this . getRepositoryForCoords ( lat , lng ) ;
const tableName = this . getTableNameForRepo ( repo ) ;
// 1. Check for spatial match (within 10 meters)
const existingQuery = `
SELECT id FROM places_syria
WHERE ST_DistanceSphere ( location , ST_SetSRID ( ST_MakePoint ( $1 , $2 ) , 4326 ) ) < 10
LIMIT 1
` ;
const existing = await this . placesRepository . query ( existingQuery , [ lng , lat ] ) ;
const existing = await repo . query (
` SELECT id, name, name_ar FROM ${ tableName } WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint( $ 1, $ 2), 4326)) < 15 LIMIT 1 ` ,
[ lng , lat ] ,
) ;
if ( existing && existing . length > 0 ) {
const id = existing [ 0 ] . id ;
this . logger . log ( ` Spatial match found for ${ data . name || data . name_ar } (ID: ${ id } ). Updating... ` ) ;
const currentName = existing [ 0 ] . name_ar || existing [ 0 ] . name || '' ;
const newName = data . name_ar || data . name || '' ;
await this . placesRepository . update ( id , {
name : data.name || undefined ,
name_ar : data.name_ar || undefined ,
address : data.address || undefined ,
// منطق "الاسم الأفضل": تحديث الاسم فقط إذا كان الجديد أطول أو يحتوي على تفاصيل أكثر
const shouldUpdateName = newName . length > currentName . length || ( newName . includes ( 'منزل' ) && ! currentName . includes ( 'منزل' ) ) ;
await repo . update ( id , {
name : shouldUpdateName ? ( data . name || undefined ) : undefined ,
name_ar : shouldUpdateName ? ( data . name_ar || undefined ) : undefined ,
category : data.category || undefined ,
description : data.description || undefined ,
city : data.city || undefined ,
neighbourhood : data.neighbourhood || undefined
} ) ;
return { id , action : 'updated' } ;
}
// 2. No matching place found, create new one
const result = await this . addPlace ( data ) ;
return { id : result.id , action : 'created' } ;
} catch ( error ) {
@@ -234,15 +164,30 @@ export class GeocodingService {
}
}
async getRecentPlaces ( limit : number = 50 ) {
try {
return this . placesRepository . find ( {
order : { created_at : 'DESC' } ,
take : limit ,
} ) ;
} catch ( error ) {
this . logger . error ( 'Failed to fetch recent places:' , error ) ;
return [ ] ;
async upsertBatch ( places : Partial < BasePlace > [ ] ) {
const results : { id : number ; action : string } [ ] = [ ] ;
for ( const place of places ) {
try {
const res = await this . upsertPlace ( place ) ;
results . push ( res as { id : number ; action : string } ) ;
} catch ( e ) {
this . logger . error ( ` Batch item failed: ${ place . name || place . name_ar } ` , e . message ) ;
}
}
return {
total : places.length , processed : results.length ,
created : results.filter ( r = > r . action === 'created' ) . length ,
updated : results.filter ( r = > r . action === 'updated' ) . length
} ;
}
async getRecentPlaces ( limit : number = 50 ) {
const syria = await this . placesSyriaRepository . find ( { order : { created_at : 'DESC' } , take : limit } ) ;
const jordan = await this . placesJordanRepository . find ( { order : { created_at : 'DESC' } , take : limit } ) ;
const egypt = await this . placesEgyptRepository . find ( { order : { created_at : 'DESC' } , take : limit } ) ;
return [ . . . syria , . . . jordan , . . . egypt ]
. sort ( ( a , b ) = > b . created_at . getTime ( ) - a . created_at . getTime ( ) )
. slice ( 0 , limit ) ;
}
}