From c136cee04f6a8defd132592820cdbf9dccd13e01 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 29 Mar 2026 23:30:12 +0300 Subject: [PATCH] fix(geocoding): fix query param string parsing, location field mismatch, and distance_km normalization - controller: @Query() params arrive as strings in NestJS - now explicitly parseFloat() all numeric params (lat, lng, radius) before passing to service. Also validates against NaN before hitting PostGIS. - controller: reverse geocode now validates and throws 400 on invalid lat/lng. - service: searchPlaces now normalizes all results to include: - location: { lat, lng } nested object (frontend was crashing on place.location.lat) - distance_km: pre-computed string field (frontend was reading undefined distance_km) - latitude/longitude as actual floats (not decimal strings from DB) - frontend (App.tsx): fixed map.flyTo() to read place.latitude/place.longitude instead of the non-existent place.location.lat/lng. - frontend (App.tsx): fixed search result click handler same way. - frontend (App.tsx): fixed distance display to compute from res.distance (meters). - entity: added missing source column to BasePlace entity. --- .../geocoding/entities/base-place.entity.ts | 3 + .../api/src/geocoding/geocoding.controller.ts | 37 ++++++++--- apps/api/src/geocoding/geocoding.service.ts | 64 +++++++++++++------ apps/web/src/App.tsx | 11 +++- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/apps/api/src/geocoding/entities/base-place.entity.ts b/apps/api/src/geocoding/entities/base-place.entity.ts index c1bbc6e..953720e 100644 --- a/apps/api/src/geocoding/entities/base-place.entity.ts +++ b/apps/api/src/geocoding/entities/base-place.entity.ts @@ -39,6 +39,9 @@ export abstract class BasePlace { @CreateDateColumn() created_at: Date; + @Column({ nullable: true }) + source: string; + @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true }) @Index({ spatial: true }) location: any; diff --git a/apps/api/src/geocoding/geocoding.controller.ts b/apps/api/src/geocoding/geocoding.controller.ts index 8113cbd..5bb623e 100644 --- a/apps/api/src/geocoding/geocoding.controller.ts +++ b/apps/api/src/geocoding/geocoding.controller.ts @@ -18,20 +18,39 @@ export class GeocodingController { @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] }) async search( @Query('q') query: string, - @Query('lat') lat?: number, - @Query('lng') lng?: number, - @Query('radius') radius?: number, + @Query('lat') lat?: string, + @Query('lng') lng?: string, + @Query('radius') radius?: string, @Query('country') country?: string, ) { - return this.geocodingService.searchPlaces(query, lat, lng, radius, country); + // NestJS @Query() always receives strings — must parse explicitly for numeric types + const parsedLat = lat !== undefined ? parseFloat(lat) : undefined; + const parsedLng = lng !== undefined ? parseFloat(lng) : undefined; + const parsedRadius = radius !== undefined ? parseFloat(radius) : 20000; + + // Guard against malformed float values (NaN breaks PostGIS) + const safeLat = parsedLat !== undefined && !isNaN(parsedLat) ? parsedLat : undefined; + const safeLng = parsedLng !== undefined && !isNaN(parsedLng) ? parsedLng : undefined; + + return this.geocodingService.searchPlaces(query, safeLat, safeLng, parsedRadius, country); } @Get('reverse') @ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' }) @ApiQuery({ name: 'lat', required: true }) @ApiQuery({ name: 'lng', required: true }) - async reverse(@Query('lat') lat: number, @Query('lng') lng: number) { - return this.geocodingService.reverseGeocode(lat, lng); + async reverse( + @Query('lat') lat: string, + @Query('lng') lng: string, + ) { + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + + if (isNaN(parsedLat) || isNaN(parsedLng)) { + throw new HttpException('Invalid lat/lng values', HttpStatus.BAD_REQUEST); + } + + return this.geocodingService.reverseGeocode(parsedLat, parsedLng); } @Post('places') @@ -48,7 +67,7 @@ export class GeocodingController { async deletePlace( @Query('country') country: string, @Query('name') name?: string, - @Query('id') id?: number, + @Query('id') id?: string, ) { if (id) { return this.geocodingService.deletePlaceById(Number(id), country); @@ -73,8 +92,8 @@ export class GeocodingController { @Get('places') @ApiOperation({ summary: 'Get recent user submitted places' }) - async getPlaces(@Query('limit') limit?: number) { - return this.geocodingService.getRecentPlaces(limit); + async getPlaces(@Query('limit') limit?: string) { + return this.geocodingService.getRecentPlaces(limit ? parseInt(limit, 10) : 50); } @Get('geojson') diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index f3a479c..f83e7b6 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -29,7 +29,9 @@ export class GeocodingService { * تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية */ private getRepositoryForCoords(lat: number, lng: number): Repository { - if (lat >= 29 && lat <= 33.5 && lng >= 34.5 && lng <= 39.5) { + // سوريا والأردن (توسيع الحدود لتشمل كامل دمشق وشمال سوريا) + if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) { + // تمييز سوريا عن الأردن (الأردن جنوب 32.5) if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository; return (this.placesJordanRepository as unknown) as Repository; } @@ -70,22 +72,26 @@ export class GeocodingService { targetRegion = tableName.replace('places_', ''); } - const userPointSql = hasLocation ? `ST_SetSRID(ST_MakePoint(${lon}, ${lat}), 4326)` : 'NULL'; + const userPointSql = hasLocation ? `ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)` : 'NULL'; - // 2. البحث في جداول المستخدم (Syria, Egypt only) + // 2. البحث في جداول المستخدم (Syria, Egypt, Jordan) const userTables = targetRegion - ? (['syria', 'egypt'].includes(targetRegion) ? [`places_${targetRegion}`] : []) - : ['places_syria', 'places_egypt']; + ? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : []) + : ['places_syria', 'places_egypt', 'places_jordan']; for (const tableName of userTables) { - const repo = tableName === 'places_egypt' ? this.placesEgyptRepository : this.placesSyriaRepository; + let repo: Repository; + if (tableName === 'places_egypt') repo = this.placesEgyptRepository; + else if (tableName === 'places_jordan') repo = this.placesJordanRepository; + else repo = this.placesSyriaRepository; + const userQuery = ` - SELECT id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source, + SELECT id, name, name_ar, name_en, category, latitude, longitude, address, '${tableName.replace('places_', '')}' as region, 'user_submitted' as source, CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ${userPointSql}) ELSE 0 END as distance, - similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance + similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) as relevance 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 location && ST_Expand(${userPointSql}, $5) AND ST_DistanceSphere(location, ${userPointSql}) <= $6` : ''} + WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4) + ${hasLocation ? `AND (location && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(location, ${userPointSql}) <= $6::float)` : ''} ORDER BY relevance DESC, distance ASC LIMIT 15 `; const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]); @@ -110,7 +116,7 @@ export class GeocodingService { 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) ${osmBoundFilter} - ${hasLocation ? `AND geom && ST_Expand(${userPointSql}, $5) AND ST_DistanceSphere(geom, ${userPointSql}) <= $6` : ''} + ${hasLocation ? `AND (geom && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(geom, ${userPointSql}) <= $6::float)` : ''} ORDER BY relevance DESC, distance ASC LIMIT 20 `; const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]); @@ -124,7 +130,18 @@ export class GeocodingService { } return (b.relevance - a.relevance) || (a.distance - b.distance); }) - .slice(0, 20); + .slice(0, 20) + // 5. تطبيع البيانات: إضافة حقول location و distance_km للتوافق مع الواجهة الأمامية + .map(r => ({ + ...r, + latitude: parseFloat(r.latitude), + longitude: parseFloat(r.longitude), + distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null, + location: { + lat: parseFloat(r.latitude), + lng: parseFloat(r.longitude), + }, + })); this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`); return { results: sortedResults }; @@ -158,15 +175,22 @@ export class GeocodingService { const repo = this.getRepositoryForCoords(lat, lng); const tableName = this.getTableNameForRepo(repo); - const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date() }); + const newPlace = repo.create({ + ...data, + latitude: lat, + longitude: lng, + created_at: new Date(), + location: { type: 'Point', coordinates: [lng, lat] } + }); const savedPlace = await repo.save(newPlace); + // تأكيد SRID في قاعدة البيانات 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 }; + return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` }; } catch (error) { this.logger.error('Failed to add place:', error.message); throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); @@ -215,16 +239,18 @@ export class GeocodingService { 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 }); + // الاستراتيجية الجديدة: حفظ كل ما يصل فوراً لضمان السرعة القصوى (Raw Ingestion) + const result = await this.addPlace(place); + results.push({ id: result.id, action: 'created' }); } 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 + total: places.length, + processed: results.length, + created: results.length, + updated: 0 }; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4fb15c7..2a3d5ae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -65,7 +65,12 @@ function App() { if (data.results && data.results.length > 0 && map) { const place = data.results[0]; - map.flyTo({ center: [place.location.lng, place.location.lat], zoom: 15 }); + // API returns flat latitude/longitude fields (not a nested location object) + const placeLng = parseFloat(place.longitude); + const placeLat = parseFloat(place.latitude); + if (!isNaN(placeLat) && !isNaN(placeLng)) { + map.flyTo({ center: [placeLng, placeLat], zoom: 15 }); + } } } catch (e) { console.error("Search failed", e); @@ -208,14 +213,14 @@ function App() { {searchResults.map((res) => (
map?.flyTo({ center: [res.location.lng, res.location.lat], zoom: 16 })} + onClick={() => { const rLng = parseFloat(res.longitude); const rLat = parseFloat(res.latitude); if (!isNaN(rLat) && !isNaN(rLng)) map?.flyTo({ center: [rLng, rLat], zoom: 16 }); }} style={{ padding: '8px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }} className="search-result-item" >
{res.name_ar || res.name}
{res.address &&
{res.address}
}
- {res.distance_km} km away | {res.source.replace('_', ' ')} + {res.distance ? (Number(res.distance) / 1000).toFixed(1) + ' km away' : ''} | {(res.source || '').replace('_', ' ')}
))}