706 lines
32 KiB
TypeScript
706 lines
32 KiB
TypeScript
import { Injectable, Logger, HttpException, HttpStatus, Inject } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|
import type { Cache } from 'cache-manager';
|
|
import { PlaceSyria } from './entities/place-syria.entity';
|
|
import { PlaceJordan } from './entities/place-jordan.entity';
|
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
|
import { PlaceIraq } from './entities/place-iraq.entity';
|
|
import { BasePlace } from './entities/base-place.entity';
|
|
import { OsmArea } from './entities/osm-area.entity';
|
|
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
|
import { getElevationMeters } from '../common/gis.utils';
|
|
|
|
@Injectable()
|
|
export class GeocodingService {
|
|
private readonly logger = new Logger(GeocodingService.name);
|
|
|
|
private readonly DB_TIMEOUT_MS = 1100;
|
|
|
|
constructor(
|
|
@InjectRepository(PlaceSyria)
|
|
private placesSyriaRepository: Repository<PlaceSyria>,
|
|
@InjectRepository(PlaceJordan)
|
|
private placesJordanRepository: Repository<PlaceJordan>,
|
|
@InjectRepository(PlaceEgypt)
|
|
private placesEgyptRepository: Repository<PlaceEgypt>,
|
|
@InjectRepository(PlaceIraq)
|
|
private placesIraqRepository: Repository<PlaceIraq>,
|
|
@InjectRepository(OsmArea)
|
|
private osmAreasRepository: Repository<OsmArea>,
|
|
@InjectRepository(OsmPointWithArea)
|
|
private osmPointsRepository: Repository<OsmPointWithArea>,
|
|
@Inject(CACHE_MANAGER)
|
|
private cacheManager: Cache,
|
|
) {}
|
|
|
|
/**
|
|
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
|
*/
|
|
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
|
// Iraq checked first: its western desert (Anbar) extends to lng ~38.7, which
|
|
// overlaps the Jordan/Syria box below (lng <= 42.5). Order matters here.
|
|
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
|
return (this.placesIraqRepository as unknown) as Repository<BasePlace>;
|
|
}
|
|
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.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 identifyRegion(lat?: number, lng?: number): string | undefined {
|
|
if (lat === undefined || lng === undefined) return undefined;
|
|
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
|
return 'iraq';
|
|
}
|
|
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
|
if (lat > 32.5 && lng > 35.8) return 'syria';
|
|
return 'jordan';
|
|
}
|
|
if (lat >= 22 && lat <= 32 && lng >= 24.5 && lng <= 37) {
|
|
return 'egypt';
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private getTableNameForRepo(repo: Repository<BasePlace>): string {
|
|
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
|
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
|
if (repo === (this.placesIraqRepository as unknown)) return 'places_iraq';
|
|
return 'places_syria';
|
|
}
|
|
|
|
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
|
|
try {
|
|
let cleanQuery = query.trim();
|
|
if (!cleanQuery) return { results: [] };
|
|
|
|
let relativePrefix = '';
|
|
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
|
|
const match = cleanQuery.match(relativeQueryRegex);
|
|
if (match) {
|
|
relativePrefix = match[1];
|
|
cleanQuery = match[2];
|
|
}
|
|
|
|
const hasLocation = lat !== undefined && lon !== undefined;
|
|
|
|
const geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global';
|
|
const cacheKey = `geo_search:${country || 'auto'}:${geoSegment}:${cleanQuery.toLowerCase()}`;
|
|
|
|
const cached: any = await this.cacheManager.get(cacheKey);
|
|
if (cached) return { results: cached, source: 'cache_hit' };
|
|
|
|
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
|
|
|
|
// Normalize the search query using the DB function
|
|
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
|
|
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
|
|
|
|
let queryParams: any[] = [normalizedQuery];
|
|
let locationCondition = '';
|
|
if (hasLocation) {
|
|
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4`;
|
|
queryParams.push(lat, lon, radius);
|
|
}
|
|
|
|
let regionCondition = '';
|
|
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
|
}
|
|
|
|
const sqlQuery = `
|
|
SELECT
|
|
id, name, name_ar, category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
latitude, longitude, address, region, source, popularity_score,
|
|
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
|
|
similarity(normalized_name, $1) as relevance
|
|
FROM unified_search_index
|
|
WHERE normalized_name % $1
|
|
${locationCondition}
|
|
${regionCondition}
|
|
ORDER BY ${hasLocation ? 'distance ASC, (normalized_name <-> $1) ASC' : '(normalized_name <-> $1) ASC'}
|
|
LIMIT 60
|
|
`;
|
|
|
|
const allResults = await Promise.race([
|
|
this.osmPointsRepository.query(sqlQuery, queryParams),
|
|
new Promise<any[]>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
|
|
]).catch(e => {
|
|
this.logger.warn(`Search optimization threshold hit: ${e.message}`);
|
|
return [] as any[];
|
|
});
|
|
|
|
const formatted = this.formatResults(allResults, hasLocation, relativePrefix);
|
|
|
|
// --- POI GATES (Clustering) ---
|
|
const placeIds = formatted.map(r => r.id);
|
|
if (placeIds.length > 0) {
|
|
try {
|
|
const gates = await this.osmPointsRepository.query(
|
|
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
|
|
FROM place_gates
|
|
WHERE place_id = ANY($1)`,
|
|
[placeIds]
|
|
);
|
|
|
|
if (gates.length > 0) {
|
|
formatted.forEach(r => {
|
|
const placeGates = gates.filter((g: any) => g.place_id === r.id).map((g: any) => ({
|
|
name_ar: g.gate_name_ar,
|
|
name_en: g.gate_name_en,
|
|
latitude: parseFloat(g.latitude),
|
|
longitude: parseFloat(g.longitude),
|
|
is_main_gate: g.is_main_gate
|
|
}));
|
|
if (placeGates.length > 0) {
|
|
r.gates = placeGates;
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn('Failed to fetch POI gates, table might not exist yet.');
|
|
}
|
|
}
|
|
// -----------------------------
|
|
|
|
if (formatted.length > 0) {
|
|
await this.cacheManager.set(cacheKey, formatted, 3600000);
|
|
} else {
|
|
// Log zero-result query asynchronously
|
|
this.logFailedSearch(cleanQuery, normalizedQuery, targetRegion, lat, lon).catch(err => {
|
|
this.logger.error('Failed to log zero-result search:', err);
|
|
});
|
|
|
|
// --- DID YOU MEAN? (Safety Net) ---
|
|
try {
|
|
const whereClause = regionCondition ? `WHERE ${regionCondition.substring(4)}` : '';
|
|
const fallbackQuery = `
|
|
SELECT name_ar, name, (normalized_name <-> $1) as dist
|
|
FROM unified_search_index
|
|
${whereClause}
|
|
ORDER BY normalized_name <-> $1 ASC
|
|
LIMIT 1
|
|
`;
|
|
|
|
const suggestions = await this.osmPointsRepository.query(fallbackQuery, [normalizedQuery]);
|
|
|
|
if (suggestions.length > 0 && suggestions[0].dist < 0.6) {
|
|
return {
|
|
results: [],
|
|
did_you_mean: suggestions[0].name_ar || suggestions[0].name
|
|
};
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn('Did You Mean fallback failed: ' + err.message);
|
|
}
|
|
// -----------------------------------
|
|
}
|
|
|
|
return { results: formatted };
|
|
} catch (e) {
|
|
this.logger.error('Search failed:', e);
|
|
return { results: [] };
|
|
}
|
|
}
|
|
|
|
private formatResults(results: any[], hasLocation: boolean, relativePrefix: string = '') {
|
|
const seenStreets = new Set<string>();
|
|
|
|
// Normalize popularity to a 0-1 scale
|
|
const maxPopularity = Math.max(...results.map(r => r.popularity_score || 10), 100);
|
|
|
|
return results
|
|
.map(r => {
|
|
const textScore = Number(r.relevance) || 0;
|
|
const popularityScore = (r.popularity_score || 10) / maxPopularity;
|
|
|
|
// Proximity score: steep inverse decay so closer points get massive boost
|
|
// e.g. at 200m -> 0.91, 1km -> 0.67, 5km -> 0.28, 20km -> 0.09
|
|
const distKm = hasLocation ? (Number(r.distance) / 1000) : 0;
|
|
const proximityScore = hasLocation ? (1.0 / (1.0 + distKm * 0.5)) : 0;
|
|
|
|
// When location is available, proximity is heavily prioritized (60%)
|
|
const totalScore = hasLocation
|
|
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
|
|
: (textScore * 0.65) + (popularityScore * 0.35);
|
|
|
|
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, 20)
|
|
.map(r => {
|
|
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
|
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
|
const nameAr = r.name_ar || r.name;
|
|
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
|
|
const lat = parseFloat(r.latitude);
|
|
const lng = parseFloat(r.longitude);
|
|
const elevationMeters = r.elevation_meters !== undefined && r.elevation_meters !== null
|
|
? Number(r.elevation_meters)
|
|
: getElevationMeters(lat, lng);
|
|
|
|
return {
|
|
...r,
|
|
name: displayName,
|
|
name_ar: displayName,
|
|
latitude: lat,
|
|
longitude: lng,
|
|
elevation_meters: elevationMeters,
|
|
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
|
location: { lat, lng, elevation: elevationMeters },
|
|
full_address,
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
private getRepoByTableName(tableName: string): Repository<any> {
|
|
if (tableName === 'places_egypt') return this.placesEgyptRepository;
|
|
if (tableName === 'places_iraq') return this.placesIraqRepository;
|
|
if (tableName === 'places_jordan') return this.placesJordanRepository;
|
|
return this.placesSyriaRepository;
|
|
}
|
|
|
|
/**
|
|
* Log zero-result queries to the failed_searches table for mining missing places.
|
|
*/
|
|
private async logFailedSearch(query: string, normalizedQuery: string, country?: string, lat?: number, lon?: number) {
|
|
const q = `
|
|
INSERT INTO failed_searches (query_text, normalized_query, country, latitude, longitude)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (normalized_query, COALESCE(country, 'global'))
|
|
DO UPDATE SET search_count = failed_searches.search_count + 1, last_seen_at = CURRENT_TIMESTAMP;
|
|
`;
|
|
await this.osmPointsRepository.query(q, [query, normalizedQuery, country || null, lat || null, lon || null]);
|
|
}
|
|
|
|
/**
|
|
* Fast Autocomplete using prefix matching
|
|
*/
|
|
async autocomplete(query: string, country?: string) {
|
|
try {
|
|
let cleanQuery = query.trim();
|
|
if (cleanQuery.length < 2) return { results: [] };
|
|
|
|
let relativePrefix = '';
|
|
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
|
|
const match = cleanQuery.match(relativeQueryRegex);
|
|
if (match) {
|
|
relativePrefix = match[1];
|
|
cleanQuery = match[2];
|
|
if (cleanQuery.length < 2) return { results: [] };
|
|
}
|
|
|
|
const targetRegion = country?.toLowerCase();
|
|
const cacheKey = `geo_auto:${targetRegion || 'auto'}:${cleanQuery.toLowerCase()}`;
|
|
|
|
const cached: any = await this.cacheManager.get(cacheKey);
|
|
if (cached) return { results: cached, source: 'cache_hit' };
|
|
|
|
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
|
|
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
|
|
|
|
let queryParams: any[] = [`${normalizedQuery}%`];
|
|
let regionCondition = '';
|
|
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
|
}
|
|
|
|
// Using the specialized btree index on varchar_pattern_ops
|
|
const sqlQuery = `
|
|
SELECT
|
|
id, name, name_ar, category, region, source, address
|
|
FROM unified_search_index
|
|
WHERE normalized_name LIKE $1
|
|
${regionCondition}
|
|
ORDER BY LENGTH(normalized_name) ASC
|
|
LIMIT 7
|
|
`;
|
|
|
|
const results = await this.osmPointsRepository.query(sqlQuery, queryParams);
|
|
|
|
const formatted = results.map(r => {
|
|
const nameAr = r.name_ar || r.name;
|
|
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
|
|
return {
|
|
id: r.id,
|
|
name: displayName,
|
|
category: r.category,
|
|
region: r.region,
|
|
address: r.address
|
|
};
|
|
});
|
|
|
|
if (formatted.length > 0) {
|
|
await this.cacheManager.set(cacheKey, formatted, 3600000); // 1 hour
|
|
}
|
|
|
|
return { results: formatted };
|
|
} catch (e) {
|
|
this.logger.error('Autocomplete failed:', e);
|
|
return { results: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* العنوان العراقي بنية رقمية: محافظة ← منطقة ← محلة ← زقاق ← دار.
|
|
* المحلة والزقاق هما ما يستعمله الناس فعلياً، لا اسم الشارع.
|
|
*
|
|
* في OSM يُخزَّن الزقاق كاسم الطريق نفسه بصيغة "647-19" (محلة-زقاق) داخل
|
|
* planet_osm_line — وهو الجدول الوحيد الذي يحملها، ولم يكن reverseGeocode
|
|
* يستعلمه إطلاقاً. لذا كان السائق يقف داخل زقاق 19 ولا نملك ما نسمّيه به.
|
|
*
|
|
* هندسة planet_osm_line بـ SRID 3857 (افتراضي osm2pgsql بلا -E)، لذا نحوّل
|
|
* نقطة البحث إليها ليعمل فهرس GIST؛ ثم نقيس المسافة الحقيقية على geography
|
|
* للفائز وحده — فمسافات 3857 منتفخة بنحو 19% عند خط عرض بغداد.
|
|
*/
|
|
private async findIraqiAddress(lat: number, lng: number) {
|
|
const MAX_DISTANCE_M = 150; // أبعد من ذلك لم يعد الزقاق وصفاً للموقع
|
|
try {
|
|
const rows = await this.osmPointsRepository.query(
|
|
`
|
|
WITH p AS (
|
|
SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326), 3857) AS g3857,
|
|
ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)::geography AS geog
|
|
)
|
|
SELECT l.name,
|
|
ST_Distance(ST_Transform(l.way, 4326)::geography, p.geog) AS distance
|
|
FROM planet_osm_line l, p
|
|
WHERE l.name ~ '^[0-9]{2,4}-[0-9]{1,3}$'
|
|
ORDER BY l.way <-> p.g3857
|
|
LIMIT 1
|
|
`,
|
|
[lng, lat],
|
|
);
|
|
|
|
const row = rows?.[0];
|
|
if (!row || Number(row.distance) > MAX_DISTANCE_M) return null;
|
|
|
|
const [mahalla, zuqaq] = String(row.name).split('-');
|
|
return {
|
|
mahalla,
|
|
zuqaq,
|
|
distance: Number(row.distance),
|
|
text: `محلة ${mahalla}، زقاق ${zuqaq}`,
|
|
};
|
|
} catch (e) {
|
|
this.logger.warn(`Iraqi address lookup failed: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async reverseGeocode(lat: number, lng: number) {
|
|
try {
|
|
const repo = this.getRepositoryForCoords(lat, lng);
|
|
const tableName = this.getTableNameForRepo(repo);
|
|
// نطلقه بالتوازي مع بقية الاستعلامات؛ العراق وحده يدفع تكلفته
|
|
const iraqiAddressPromise =
|
|
this.identifyRegion(lat, lng) === 'iraq'
|
|
? this.findIraqiAddress(lat, lng)
|
|
: Promise.resolve(null);
|
|
const queryPromises: Promise<any[]>[] = [];
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
p.id, p.name, p.name_ar, p.category,
|
|
n.name_ar as neighbourhood,
|
|
d.name_ar as district,
|
|
g.name_ar as governorate,
|
|
p.latitude, p.longitude, p.address, 'user_place' as source,
|
|
ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
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
|
|
WHERE p.location IS NOT NULL
|
|
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]));
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
o.osm_id::text as id, o.name, o.name_ar, COALESCE(o.amenity, o.shop, 'place') as category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
|
|
ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
FROM osm_points_with_area o
|
|
WHERE o.geom IS NOT NULL AND (o.name IS NOT NULL OR o.name_ar IS NOT NULL)
|
|
ORDER BY o.geom <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]));
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, 'street' as category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
FROM overture_segment
|
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
|
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]));
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, 'building' as category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
FROM overture_building
|
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
|
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]));
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, 'place' as category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
|
|
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
FROM overture_place
|
|
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
|
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]));
|
|
|
|
queryPromises.push(repo.query(`
|
|
SELECT
|
|
id::text, COALESCE(name, 'Street') as name, COALESCE(name, 'طريق معتمد') as name_ar, 'street' as category,
|
|
'' as neighbourhood, '' as district, '' as governorate,
|
|
ST_Y(ST_Centroid(geometry::geometry))::text as latitude, ST_X(ST_Centroid(geometry::geometry))::text as longitude, '' as address, 'approved_road' as source,
|
|
ST_DistanceSphere(geometry::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
|
FROM approved_roads
|
|
WHERE geometry IS NOT NULL AND name IS NOT NULL AND trim(name) != ''
|
|
ORDER BY geometry::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
|
`, [lng, lat]).catch(() => []));
|
|
|
|
const [results, iraqiAddress] = await Promise.all([
|
|
Promise.allSettled(queryPromises),
|
|
iraqiAddressPromise,
|
|
]);
|
|
let allResults: any[] = [];
|
|
results.forEach(res => {
|
|
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
|
});
|
|
|
|
return allResults
|
|
.sort((a, b) => Number(a.distance) - Number(b.distance))
|
|
.slice(0, 5)
|
|
.map(r => {
|
|
const distance = Number(r.distance);
|
|
const ZUQAQ_NAME_RE = /^[0-9]{2,4}-[0-9]{1,3}$/;
|
|
const rawName = r.name_ar || r.name;
|
|
const nameIsZuqaq = typeof rawName === 'string' && ZUQAQ_NAME_RE.test(rawName);
|
|
const fullAddressParts = [
|
|
nameIsZuqaq ? null : rawName, // "605-9" كنتيجة بحث لا يضيف شيئاً فوق "زقاق 9"
|
|
r.address,
|
|
iraqiAddress?.text,
|
|
r.neighbourhood,
|
|
r.district,
|
|
r.governorate,
|
|
].filter(Boolean);
|
|
|
|
let humanReadable = r.name_ar || r.name;
|
|
if (distance <= 20) {
|
|
// Very close: just the name
|
|
humanReadable = r.name_ar || r.name;
|
|
} else if (distance <= 70 && r.category !== 'street') {
|
|
// Close: "أمام [اسم المعلم]" (In front of)
|
|
const streetName = r.address ? `، ${r.address}` : '';
|
|
humanReadable = `أمام ${r.name_ar || r.name}${streetName}`;
|
|
} else if (r.category === 'street' || distance > 70) {
|
|
// Far or Street: "شارع كذا، الحي"
|
|
const streetPart = r.category === 'street' ? (r.name_ar || r.name) : (r.address || r.name_ar || r.name);
|
|
const districtPart = r.neighbourhood || r.district || '';
|
|
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
|
|
}
|
|
|
|
// في العراق المحلة والزقاق هما العنوان الفعلي، فيتصدّران الوصف
|
|
// ويبقى المعلم القريب لاحقةً توضيحية — إلا إذا كان "المعلم" نفسه
|
|
// مجرد اسم زقاق آخر (مثل "605-9")، فتكرار الرقم لا يفيد أحداً.
|
|
if (iraqiAddress) {
|
|
const landmark = distance <= 70 && !nameIsZuqaq ? rawName : null;
|
|
humanReadable = [iraqiAddress.text, landmark].filter(Boolean).join(' — ');
|
|
}
|
|
|
|
const resLat = parseFloat(r.latitude);
|
|
const resLng = parseFloat(r.longitude);
|
|
const elevMeters = r.elevation_meters !== undefined && r.elevation_meters !== null
|
|
? Number(r.elevation_meters)
|
|
: getElevationMeters(resLat, resLng);
|
|
|
|
return {
|
|
...r,
|
|
mahalla: iraqiAddress?.mahalla,
|
|
zuqaq: iraqiAddress?.zuqaq,
|
|
latitude: resLat,
|
|
longitude: resLng,
|
|
elevation_meters: elevMeters,
|
|
location: { lat: resLat, lng: resLng, elevation: elevMeters },
|
|
human_readable_address: humanReadable,
|
|
full_address: fullAddressParts.join('، ')
|
|
};
|
|
});
|
|
} catch (error) {
|
|
this.logger.error('Reverse geocoding error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* يقبل lat/lng أو latitude/longitude ويرفض ما ليس رقماً.
|
|
*
|
|
* سبب وجوده: العملاء يرسلون {lat, lng} (كما في POST /geocoding/places)، بينما
|
|
* addPlace كان يقرأ data.latitude فقط → Number(undefined) = NaN. عندها تفشل كل
|
|
* مقارنات getRepositoryForCoords فيسقط الاستدعاء على مستودع سوريا الافتراضي،
|
|
* فيظهر مكان أردني في دمشق بإحداثيات تالفة. الصمت هنا أسوأ من الخطأ.
|
|
*/
|
|
private extractCoords(data: any): { lat: number; lng: number } {
|
|
const lat = Number(data?.lat ?? data?.latitude);
|
|
const lng = Number(data?.lng ?? data?.longitude);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
|
throw new HttpException(
|
|
'Invalid coordinates: provide numeric lat/lng (or latitude/longitude)',
|
|
HttpStatus.BAD_REQUEST,
|
|
);
|
|
}
|
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
throw new HttpException(
|
|
`Coordinates out of range: lat=${lat}, lng=${lng}`,
|
|
HttpStatus.BAD_REQUEST,
|
|
);
|
|
}
|
|
return { lat, lng };
|
|
}
|
|
|
|
async addPlace(data: Partial<BasePlace>) {
|
|
try {
|
|
const { lat, lng } = this.extractCoords(data);
|
|
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
|
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);
|
|
await repo.query(`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) WHERE id = $3`, [lng, lat, savedPlace.id]);
|
|
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
|
} catch (error) {
|
|
// لا نبتلع أخطاء التحقق (400) ونحولها إلى 500
|
|
if (error instanceof HttpException) throw error;
|
|
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
async upsertPlace(data: Partial<BasePlace>) {
|
|
try {
|
|
const { lat, lng } = this.extractCoords(data);
|
|
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
|
const existing = await repo.query(`SELECT id, name FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) < 15 LIMIT 1`, [lng, lat]);
|
|
if (existing && existing.length > 0) {
|
|
await repo.update(existing[0].id, { name: data.name || undefined, name_ar: data.name_ar || undefined, category: data.category || undefined });
|
|
return { id: existing[0].id, action: 'updated' };
|
|
}
|
|
const result = await this.addPlace(data);
|
|
return { id: result.id, action: 'created' };
|
|
} catch (error) { throw error; }
|
|
}
|
|
|
|
/**
|
|
* الاستيراد الجملي (يستخدمه السكرابر). كان يبتلع كل خطأ بصمت
|
|
* (`catch (e) {}`) ويعيد عدد الناجحين فقط — فاستيراد 10 آلاف مكان يسقط منه
|
|
* 3 آلاف دون أثر ولا سبب. الآن نُعيد أول 50 خطأ مع رقم السطر.
|
|
*/
|
|
async upsertBatch(places: Partial<BasePlace>[]) {
|
|
const results: any[] = [];
|
|
const errors: { index: number; name?: string; reason: string }[] = [];
|
|
|
|
for (const [index, place] of places.entries()) {
|
|
try {
|
|
const r = await this.addPlace(place);
|
|
results.push({ id: r.id, action: 'created' });
|
|
} catch (e) {
|
|
if (errors.length < 50) {
|
|
errors.push({
|
|
index,
|
|
name: (place as any)?.name,
|
|
reason: e instanceof HttpException ? e.message : (e?.message ?? 'unknown'),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const failed = places.length - results.length;
|
|
return {
|
|
total: places.length,
|
|
processed: results.length,
|
|
created: results.length,
|
|
updated: 0,
|
|
failed,
|
|
errors,
|
|
errorsTruncated: failed > errors.length,
|
|
};
|
|
}
|
|
|
|
async getRecentPlaces(limit: number = 50) {
|
|
const s = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
|
const j = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
|
const e = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
|
const i = await this.placesIraqRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
|
return [...s, ...j, ...e, ...i].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
|
|
}
|
|
|
|
async getAllPlacesGeoJSON() {
|
|
try {
|
|
const q = `
|
|
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_iraq
|
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
|
|
`;
|
|
const res = await this.placesSyriaRepository.query(q);
|
|
const features = res.map(p => ({
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: [parseFloat(p.longitude), parseFloat(p.latitude)] },
|
|
properties: { id: p.id, name: p.name, category: p.category, address: p.address, region: p.region }
|
|
}));
|
|
return { type: 'FeatureCollection', features };
|
|
} catch (e) {
|
|
return { type: 'FeatureCollection', features: [] };
|
|
}
|
|
}
|
|
|
|
async deletePlacesByName(name: string, country: string) {
|
|
const repo = this.getRepoByCountry(country);
|
|
const r1 = await repo.delete({ name_ar: name }), r2 = await repo.delete({ name_en: name }), r3 = await repo.delete({ name: name });
|
|
return { success: true, affected: (r1.affected || 0) + (r2.affected || 0) + (r3.affected || 0) };
|
|
}
|
|
|
|
async deletePlaceById(id: number, country: string) {
|
|
const repo = this.getRepoByCountry(country);
|
|
return { success: true, affected: (await repo.delete(id)).affected };
|
|
}
|
|
|
|
private getRepoByCountry(country: string) {
|
|
switch (country?.toLowerCase()) {
|
|
case 'syria': return this.placesSyriaRepository;
|
|
case 'jordan': return this.placesJordanRepository;
|
|
case 'egypt': return this.placesEgyptRepository;
|
|
case 'iraq': return this.placesIraqRepository;
|
|
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
|
|
}
|
|
}
|
|
}
|