2026-04-14-6

This commit is contained in:
Hamza-Ayed
2026-04-14 17:24:01 +03:00
parent dd606a4cba
commit dd93a0a807
2 changed files with 86 additions and 84 deletions
@@ -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') {
+83 -74
View File
@@ -49,10 +49,9 @@ 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!);
@@ -60,107 +59,117 @@ export class GeocodingService {
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);