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.
This commit is contained in:
@@ -39,6 +39,9 @@ export abstract class BasePlace {
|
|||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
source: string;
|
||||||
|
|
||||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||||
@Index({ spatial: true })
|
@Index({ spatial: true })
|
||||||
location: any;
|
location: any;
|
||||||
|
|||||||
@@ -18,20 +18,39 @@ export class GeocodingController {
|
|||||||
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
||||||
async search(
|
async search(
|
||||||
@Query('q') query: string,
|
@Query('q') query: string,
|
||||||
@Query('lat') lat?: number,
|
@Query('lat') lat?: string,
|
||||||
@Query('lng') lng?: number,
|
@Query('lng') lng?: string,
|
||||||
@Query('radius') radius?: number,
|
@Query('radius') radius?: string,
|
||||||
@Query('country') country?: 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')
|
@Get('reverse')
|
||||||
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
|
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
|
||||||
@ApiQuery({ name: 'lat', required: true })
|
@ApiQuery({ name: 'lat', required: true })
|
||||||
@ApiQuery({ name: 'lng', required: true })
|
@ApiQuery({ name: 'lng', required: true })
|
||||||
async reverse(@Query('lat') lat: number, @Query('lng') lng: number) {
|
async reverse(
|
||||||
return this.geocodingService.reverseGeocode(lat, lng);
|
@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')
|
@Post('places')
|
||||||
@@ -48,7 +67,7 @@ export class GeocodingController {
|
|||||||
async deletePlace(
|
async deletePlace(
|
||||||
@Query('country') country: string,
|
@Query('country') country: string,
|
||||||
@Query('name') name?: string,
|
@Query('name') name?: string,
|
||||||
@Query('id') id?: number,
|
@Query('id') id?: string,
|
||||||
) {
|
) {
|
||||||
if (id) {
|
if (id) {
|
||||||
return this.geocodingService.deletePlaceById(Number(id), country);
|
return this.geocodingService.deletePlaceById(Number(id), country);
|
||||||
@@ -73,8 +92,8 @@ export class GeocodingController {
|
|||||||
|
|
||||||
@Get('places')
|
@Get('places')
|
||||||
@ApiOperation({ summary: 'Get recent user submitted places' })
|
@ApiOperation({ summary: 'Get recent user submitted places' })
|
||||||
async getPlaces(@Query('limit') limit?: number) {
|
async getPlaces(@Query('limit') limit?: string) {
|
||||||
return this.geocodingService.getRecentPlaces(limit);
|
return this.geocodingService.getRecentPlaces(limit ? parseInt(limit, 10) : 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('geojson')
|
@Get('geojson')
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export class GeocodingService {
|
|||||||
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
||||||
*/
|
*/
|
||||||
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
||||||
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<BasePlace>;
|
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
||||||
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
||||||
}
|
}
|
||||||
@@ -70,22 +72,26 @@ export class GeocodingService {
|
|||||||
targetRegion = tableName.replace('places_', '');
|
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
|
const userTables = targetRegion
|
||||||
? (['syria', 'egypt'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
|
? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
|
||||||
: ['places_syria', 'places_egypt'];
|
: ['places_syria', 'places_egypt', 'places_jordan'];
|
||||||
|
|
||||||
for (const tableName of userTables) {
|
for (const tableName of userTables) {
|
||||||
const repo = tableName === 'places_egypt' ? this.placesEgyptRepository : this.placesSyriaRepository;
|
let repo: Repository<any>;
|
||||||
|
if (tableName === 'places_egypt') repo = this.placesEgyptRepository;
|
||||||
|
else if (tableName === 'places_jordan') repo = this.placesJordanRepository;
|
||||||
|
else repo = this.placesSyriaRepository;
|
||||||
|
|
||||||
const userQuery = `
|
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,
|
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}
|
FROM ${tableName}
|
||||||
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
|
WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4)
|
||||||
${hasLocation ? `AND location && ST_Expand(${userPointSql}, $5) AND ST_DistanceSphere(location, ${userPointSql}) <= $6` : ''}
|
${hasLocation ? `AND (location && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(location, ${userPointSql}) <= $6::float)` : ''}
|
||||||
ORDER BY relevance DESC, distance ASC LIMIT 15
|
ORDER BY relevance DESC, distance ASC LIMIT 15
|
||||||
`;
|
`;
|
||||||
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
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
|
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)
|
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
|
||||||
${osmBoundFilter}
|
${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
|
ORDER BY relevance DESC, distance ASC LIMIT 20
|
||||||
`;
|
`;
|
||||||
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
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);
|
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}`);
|
this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`);
|
||||||
return { results: sortedResults };
|
return { results: sortedResults };
|
||||||
@@ -158,15 +175,22 @@ export class GeocodingService {
|
|||||||
const repo = this.getRepositoryForCoords(lat, lng);
|
const repo = this.getRepositoryForCoords(lat, lng);
|
||||||
const tableName = this.getTableNameForRepo(repo);
|
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);
|
const savedPlace = await repo.save(newPlace);
|
||||||
|
|
||||||
|
// تأكيد SRID في قاعدة البيانات
|
||||||
await repo.query(
|
await repo.query(
|
||||||
`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
|
`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
|
||||||
[lng, lat, savedPlace.id],
|
[lng, lat, savedPlace.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ...savedPlace, latitude: lat, longitude: lng };
|
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to add place:', error.message);
|
this.logger.error('Failed to add place:', error.message);
|
||||||
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
@@ -215,16 +239,18 @@ export class GeocodingService {
|
|||||||
const results: { id: number; action: string }[] = [];
|
const results: { id: number; action: string }[] = [];
|
||||||
for (const place of places) {
|
for (const place of places) {
|
||||||
try {
|
try {
|
||||||
const res = await this.upsertPlace(place);
|
// الاستراتيجية الجديدة: حفظ كل ما يصل فوراً لضمان السرعة القصوى (Raw Ingestion)
|
||||||
results.push(res as { id: number; action: string });
|
const result = await this.addPlace(place);
|
||||||
|
results.push({ id: result.id, action: 'created' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
|
this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
total: places.length, processed: results.length,
|
total: places.length,
|
||||||
created: results.filter(r => r.action === 'created').length,
|
processed: results.length,
|
||||||
updated: results.filter(r => r.action === 'updated').length
|
created: results.length,
|
||||||
|
updated: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ function App() {
|
|||||||
|
|
||||||
if (data.results && data.results.length > 0 && map) {
|
if (data.results && data.results.length > 0 && map) {
|
||||||
const place = data.results[0];
|
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) {
|
} catch (e) {
|
||||||
console.error("Search failed", e);
|
console.error("Search failed", e);
|
||||||
@@ -208,14 +213,14 @@ function App() {
|
|||||||
{searchResults.map((res) => (
|
{searchResults.map((res) => (
|
||||||
<div
|
<div
|
||||||
key={res.id}
|
key={res.id}
|
||||||
onClick={() => 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' }}
|
style={{ padding: '8px', borderBottom: '1px solid var(--glass-border)', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||||
className="search-result-item"
|
className="search-result-item"
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600 }}>{res.name_ar || res.name}</div>
|
<div style={{ fontWeight: 600 }}>{res.name_ar || res.name}</div>
|
||||||
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7 }}>{res.address}</div>}
|
{res.address && <div style={{ fontSize: '0.75rem', opacity: 0.7 }}>{res.address}</div>}
|
||||||
<div style={{ fontSize: '0.7rem', color: '#3b82f6', marginTop: '2px' }}>
|
<div style={{ fontSize: '0.7rem', color: '#3b82f6', marginTop: '2px' }}>
|
||||||
{res.distance_km} km away | {res.source.replace('_', ' ')}
|
{res.distance ? (Number(res.distance) / 1000).toFixed(1) + ' km away' : ''} | {(res.source || '').replace('_', ' ')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user