2026-04-14-6
This commit is contained in:
@@ -8,6 +8,7 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@Controller('geocoding')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class GeocodingController {
|
||||
constructor(
|
||||
private readonly geocodingService: GeocodingService,
|
||||
@@ -61,14 +62,12 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add a new location (User Submitted)' })
|
||||
async addPlace(@Body() placeData: any) {
|
||||
return this.geocodingService.addPlace(placeData);
|
||||
}
|
||||
|
||||
@Delete('places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Delete a place by name or ID' })
|
||||
@ApiQuery({ name: 'name', required: false })
|
||||
@ApiQuery({ name: 'id', required: false, type: Number })
|
||||
@@ -88,14 +87,12 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('upsert-place')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
|
||||
async upsertPlace(@Body() placeData: any) {
|
||||
return this.geocodingService.upsertPlace(placeData);
|
||||
}
|
||||
|
||||
@Post('upsert-batch')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add or Update multiple locations in bulk' })
|
||||
async upsertBatch(@Body() body: { places: any[] }) {
|
||||
return this.geocodingService.upsertBatch(body.places);
|
||||
@@ -114,7 +111,6 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('import-boundaries')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
|
||||
@ApiQuery({ name: 'country', required: true })
|
||||
@ApiQuery({ name: 'filePath', required: true })
|
||||
@@ -132,7 +128,6 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('admin/sync-neighborhoods')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
|
||||
@ApiQuery({ name: 'bbox', required: false })
|
||||
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
||||
@@ -141,7 +136,6 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('admin/generate-voronoi')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
|
||||
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
||||
async generateVoronoi(@Query('country') country?: string) {
|
||||
@@ -149,7 +143,6 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('admin/link-places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
|
||||
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
|
||||
async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt') {
|
||||
|
||||
@@ -49,118 +49,127 @@ export class GeocodingService {
|
||||
try {
|
||||
const cleanQuery = query.trim();
|
||||
const hasLocation = lat !== undefined && lon !== undefined;
|
||||
const ILikeQuery = `%${cleanQuery}%`;
|
||||
const allResults: any[] = [];
|
||||
const radiusInDegrees = radius / 111000;
|
||||
|
||||
|
||||
// 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 userTables = targetRegion
|
||||
|
||||
const primaryTables = targetRegion
|
||||
? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
|
||||
: ['places_syria', 'places_egypt', 'places_jordan'];
|
||||
: ['places_jordan', 'places_syria', 'places_egypt'];
|
||||
|
||||
for (const tableName of userTables) {
|
||||
let repo: Repository<any> = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository);
|
||||
const userQuery = `
|
||||
// PHASE 1: High Quality Search in Country-Specific Tables
|
||||
for (const tableName of primaryTables) {
|
||||
const repo = this.getRepoByTableName(tableName);
|
||||
const results = await repo.query(`
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
||||
p.neighborhood_id as db_neighborhood_id,
|
||||
p.neighbourhood as original_neighbourhood,
|
||||
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,
|
||||
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,
|
||||
similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
|
||||
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 g ON p.governorate_id = g.id
|
||||
ORDER BY relevance DESC, distance ASC LIMIT 25
|
||||
`;
|
||||
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null]);
|
||||
WHERE (similarity(COALESCE(p.name_ar, ''), $1) > 0.2 OR similarity(COALESCE(p.name, ''), $1) > 0.2)
|
||||
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);
|
||||
}
|
||||
|
||||
let osmBoundFilter = '';
|
||||
if (targetRegion === 'jordan') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(34.5, 29.0, 39.5, 33.5, 4326), geom)';
|
||||
else if (targetRegion === 'syria') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(35.5, 32.3, 42.4, 37.5, 4326), geom)';
|
||||
else if (targetRegion === 'egypt') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(24.5, 22.0, 37.0, 31.8, 4326), geom)';
|
||||
// Check if we have enough results after formatting (deduplication)
|
||||
let formatted = this.formatResults(allResults, hasLocation);
|
||||
if (formatted.length >= 4) return { results: formatted };
|
||||
|
||||
const osmQuery = `
|
||||
// PHASE 2: High Quality OSM Point Search (if needed)
|
||||
const osmResults = await 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,
|
||||
n.name_ar as neighbourhood,
|
||||
d.name_ar as district,
|
||||
g.name_ar as governorate,
|
||||
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,
|
||||
similarity(COALESCE(o.name_ar, ''), $1) + similarity(COALESCE(o.name_en, ''), $1) as relevance
|
||||
GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance
|
||||
FROM osm_points_with_area o
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT name_ar FROM neighborhood_polygons np
|
||||
ORDER BY o.geom <-> np.geometry
|
||||
LIMIT 1
|
||||
) n ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT name_ar FROM admin_boundaries ab
|
||||
WHERE ab.admin_level = 8 AND ST_Contains(ab.geom, o.geom)
|
||||
LIMIT 1
|
||||
) d ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT name_ar FROM admin_boundaries ab
|
||||
WHERE ab.admin_level = 4 AND ST_Contains(ab.geom, o.geom)
|
||||
LIMIT 1
|
||||
) g ON true
|
||||
ORDER BY relevance DESC, distance ASC LIMIT 30
|
||||
`;
|
||||
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null]);
|
||||
WHERE (similarity(COALESCE(o.name, ''), $1) > 0.2 OR similarity(COALESCE(o.name_ar, ''), $1) > 0.2)
|
||||
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);
|
||||
|
||||
// Note: We removed the raw query to 'overture_building' because raw overture tables
|
||||
// do not have administrative linking, causing empty full_addresses, and
|
||||
// scanning them with ILIKE without trigram indices causes a 2-second latency spike.
|
||||
// Overture data is already properly ingested via the Scraper into places_jordan.
|
||||
formatted = this.formatResults(allResults, hasLocation);
|
||||
if (formatted.length >= 4) return { results: formatted };
|
||||
|
||||
const seenStreets = new Set<string>();
|
||||
// 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);
|
||||
}
|
||||
|
||||
const sortedResults = allResults
|
||||
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
|
||||
.filter(r => {
|
||||
if (r.category === 'street') {
|
||||
const key = `${r.name_ar || r.name}_${r.district}_${r.governorate}`;
|
||||
if (seenStreets.has(key)) return false;
|
||||
seenStreets.add(key);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, 25)
|
||||
.map(r => {
|
||||
// Build full administrative address from admin_boundaries
|
||||
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
||||
return {
|
||||
...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) },
|
||||
full_address,
|
||||
};
|
||||
});
|
||||
|
||||
return { results: sortedResults };
|
||||
return { results: this.formatResults(allResults, hasLocation) };
|
||||
} catch (e) {
|
||||
this.logger.error('Search failed:', e);
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private formatResults(results: any[], hasLocation: boolean) {
|
||||
const seenStreets = new Set<string>();
|
||||
return results
|
||||
.map(r => {
|
||||
// Weighted scoring: 70% Name Similarity, 30% Geographic Proximity
|
||||
// Proximity bonus is 1.0 at 0m, decaying linearly to 0.0 at 10km.
|
||||
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
|
||||
const totalScore = (Number(r.relevance) * 0.7) + (proximityBonus * 0.3);
|
||||
return { ...r, totalScore };
|
||||
})
|
||||
.sort((a, b) => b.totalScore - a.totalScore)
|
||||
.filter(r => {
|
||||
if (r.category === 'street') {
|
||||
const key = `${r.name_ar || r.name}_${r.district}_${r.governorate}`;
|
||||
if (seenStreets.has(key)) return false;
|
||||
seenStreets.add(key);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map(r => {
|
||||
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
||||
return {
|
||||
...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) },
|
||||
full_address,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private getRepoByTableName(tableName: string): Repository<any> {
|
||||
if (tableName === 'places_egypt') return this.placesEgyptRepository;
|
||||
if (tableName === 'places_jordan') return this.placesJordanRepository;
|
||||
return this.placesSyriaRepository;
|
||||
}
|
||||
|
||||
async reverseGeocode(lat: number, lng: number) {
|
||||
try {
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
|
||||
Reference in New Issue
Block a user