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, @InjectRepository(PlaceJordan) private placesJordanRepository: Repository, @InjectRepository(PlaceEgypt) private placesEgyptRepository: Repository, @InjectRepository(PlaceIraq) private placesIraqRepository: Repository, @InjectRepository(OsmArea) private osmAreasRepository: Repository, @InjectRepository(OsmPointWithArea) private osmPointsRepository: Repository, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} /** * تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية */ private getRepositoryForCoords(lat: number, lng: number): Repository { // 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; } 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; return (this.placesJordanRepository as unknown) as Repository; } if (lat >= 22 && lat <= 32 && lng >= 24.5 && lng <= 37) { return (this.placesEgyptRepository as unknown) as Repository; } return (this.placesSyriaRepository as unknown) as Repository; } 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): 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((_, 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(); // 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 { 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[] = []; 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) { 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) { 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[]) { 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); } } }