chore: save state before importing syria data

This commit is contained in:
Hamza-Ayed
2026-04-06 01:21:03 +03:00
parent c136cee04f
commit 7f812c6929
4610 changed files with 3745 additions and 370 deletions
@@ -5,7 +5,6 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
@ApiTags('geocoding')
@Controller('geocoding')
@UseGuards(ApiKeyGuard)
export class GeocodingController {
constructor(private readonly geocodingService: GeocodingService) {}
@@ -24,13 +23,13 @@ export class GeocodingController {
@Query('country') country?: string,
) {
// 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 parsedLat = lat !== undefined ? parseFloat(lat) : Number.NaN;
const parsedLng = lng !== undefined ? parseFloat(lng) : Number.NaN;
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;
const safeLat = !isNaN(parsedLat) ? parsedLat : undefined;
const safeLng = !isNaN(parsedLng) ? parsedLng : undefined;
return this.geocodingService.searchPlaces(query, safeLat, safeLng, parsedRadius, country);
}
@@ -54,12 +53,14 @@ 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 })
@@ -79,12 +80,14 @@ 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);
+42 -182
View File
@@ -29,9 +29,7 @@ export class GeocodingService {
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
*/
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
// سوريا والأردن (توسيع الحدود لتشمل كامل دمشق وشمال سوريا)
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>;
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
}
@@ -47,103 +45,70 @@ export class GeocodingService {
return 'places_syria';
}
async searchPlaces(
query: string,
lat?: number,
lon?: number,
radius: number = 20000,
country?: string,
) {
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
try {
const cleanQuery = query.trim();
const hasLocation = lat !== undefined && lon !== undefined;
const ILikeQuery = `%${cleanQuery}%`;
const allResults: any[] = [];
// تحويل المسافة بالامتار الى درجات جغرافية تقريبية للفلترة السريعة
const radiusInDegrees = radius / 111000;
// 1. تحديد المنطقة (الأردن، سوريا، مصر)
let targetRegion = country?.toLowerCase();
if (!targetRegion && hasLocation) {
const repo = this.getRepositoryForCoords(lat!, lon!);
const tableName = this.getTableNameForRepo(repo);
targetRegion = tableName.replace('places_', '');
}
const userPointSql = hasLocation ? `ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)` : 'NULL';
// 2. البحث في جداول المستخدم (Syria, Egypt, Jordan)
const userTables = targetRegion
? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
: ['places_syria', 'places_egypt', 'places_jordan'];
for (const tableName of userTables) {
let repo: Repository<any>;
if (tableName === 'places_egypt') repo = this.placesEgyptRepository;
else if (tableName === 'places_jordan') repo = this.placesJordanRepository;
else repo = this.placesSyriaRepository;
let repo: Repository<any> = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository);
const userQuery = `
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, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) as relevance
FROM ${tableName}
WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4)
${hasLocation ? `AND (location && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(location, ${userPointSql}) <= $6::float)` : ''}
${hasLocation ? `AND (location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 15
`;
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...results);
}
// 3. البحث في قاعدة بيانات OSM (Jordan and Regional)
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)';
}
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)';
const osmQuery = `
SELECT osm_id as id, name, name_ar, name_en, COALESCE(amenity, shop, 'place') as category,
latitude, longitude, addr_street as address, 'osm_global' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ${userPointSql}) ELSE 0 END as distance,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
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)
${osmBoundFilter}
${hasLocation ? `AND (geom && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(geom, ${userPointSql}) <= $6::float)` : ''}
${hasLocation ? `AND (geom && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 20
`;
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...osmResults);
// 4. التصفية والترتيب النهائي (الأولوية للمسافة إذا كان الموقع معروفاً)
const sortedResults = allResults
.sort((a, b) => {
if (hasLocation) {
return (a.distance - b.distance) || (b.relevance - a.relevance);
}
return (b.relevance - a.relevance) || (a.distance - b.distance);
})
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
.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),
},
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
}));
this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`);
return { results: sortedResults };
} catch (e) {
this.logger.error('Optimized Spatial Search failed:', e);
@@ -157,9 +122,9 @@ export class GeocodingService {
const tableName = this.getTableNameForRepo(repo);
const query = `
SELECT id, name, name_ar, category, latitude, longitude, address, 'user_place' as source,
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM ${tableName} WHERE location IS NOT NULL
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC LIMIT 3
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
`;
return await repo.query(query, [lng, lat]);
} catch (error) {
@@ -170,165 +135,60 @@ export class GeocodingService {
async addPlace(data: Partial<BasePlace>) {
try {
const lat = Number(data.latitude);
const lng = Number(data.longitude);
const repo = this.getRepositoryForCoords(lat, lng);
const tableName = this.getTableNameForRepo(repo);
const newPlace = repo.create({
...data,
latitude: lat,
longitude: lng,
created_at: new Date(),
location: { type: 'Point', coordinates: [lng, lat] }
});
const lat = Number(data.latitude), lng = Number(data.longitude);
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);
// تأكيد SRID في قاعدة البيانات
await repo.query(
`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
[lng, lat, savedPlace.id],
);
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) {
this.logger.error('Failed to add place:', error.message);
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
} catch (error) { throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); }
}
async upsertPlace(data: Partial<BasePlace>) {
try {
const lat = Number(data.latitude);
const lng = Number(data.longitude);
const repo = this.getRepositoryForCoords(lat, lng);
const tableName = this.getTableNameForRepo(repo);
const existing = await repo.query(
`SELECT id, name, name_ar FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) < 15 LIMIT 1`,
[lng, lat],
);
const lat = Number(data.latitude), lng = Number(data.longitude);
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) {
const id = existing[0].id;
const currentName = existing[0].name_ar || existing[0].name || '';
const newName = data.name_ar || data.name || '';
// منطق "الاسم الأفضل": تحديث الاسم فقط إذا كان الجديد أطول أو يحتوي على تفاصيل أكثر
const shouldUpdateName = newName.length > currentName.length || (newName.includes('منزل') && !currentName.includes('منزل'));
await repo.update(id, {
name: shouldUpdateName ? (data.name || undefined) : undefined,
name_ar: shouldUpdateName ? (data.name_ar || undefined) : undefined,
category: data.category || undefined,
city: data.city || undefined,
});
return { id, action: 'updated' };
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) {
this.logger.error('Upsert failed:', error.message);
throw error;
}
} catch (error) { throw error; }
}
async upsertBatch(places: Partial<BasePlace>[]) {
const results: { id: number; action: string }[] = [];
for (const place of places) {
try {
// الاستراتيجية الجديدة: حفظ كل ما يصل فوراً لضمان السرعة القصوى (Raw Ingestion)
const result = await this.addPlace(place);
results.push({ id: result.id, action: 'created' });
} catch (e) {
this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
}
}
return {
total: places.length,
processed: results.length,
created: results.length,
updated: 0
};
const results: any[] = [];
for (const place of places) { try { const r = await this.addPlace(place); results.push({ id: r.id, action: 'created' }); } catch (e) {} }
return { total: places.length, processed: results.length, created: results.length, updated: 0 };
}
async getRecentPlaces(limit: number = 50) {
const syria = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
const jordan = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
const egypt = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
return [...syria, ...jordan, ...egypt]
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, limit);
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 });
return [...s, ...j, ...e].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
}
async getAllPlacesGeoJSON() {
try {
const query = `
SELECT id, name_ar as name, category, latitude, longitude, address, 'syria' as region
FROM places_syria WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
UNION ALL
SELECT id, name_ar as name, category, latitude, longitude, address, 'jordan' as region
FROM places_jordan WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
UNION ALL
SELECT id, name_ar as name, category, latitude, longitude, address, 'egypt' as region
FROM places_egypt WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
`;
const allResults = await this.placesSyriaRepository.query(query);
const features = allResults.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,
icon: p.category === 'mosque' ? 'mosque' : (p.category === 'hospital' ? 'hospital' : 'marker')
}
}));
return {
type: 'FeatureCollection',
features
};
} catch (e) {
this.logger.error('Failed to generate GeoJSON for all places:', e);
return { type: 'FeatureCollection', features: [] };
}
const q = `SELECT id, name_ar as name, category, latitude, longitude, address, 'syria' as region FROM places_syria UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'jordan' as region FROM places_jordan UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'egypt' as region FROM places_egypt`;
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) {
try {
const repo = this.getRepoByCountry(country);
const result = await repo.delete({ name_ar: name });
const resultEn = await repo.delete({ name_en: name });
const resultGeneric = await repo.delete({ name: name });
const totalAffected = (result.affected || 0) + (resultEn.affected || 0) + (resultGeneric.affected || 0);
this.logger.debug(`Deleted ${totalAffected} places named "${name}" in ${country}`);
return { success: true, affected: totalAffected };
} catch (e) {
this.logger.error(`Failed to delete places named "${name}" in ${country}:`, e);
throw new HttpException('Deletion failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
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) {
try {
const repo = this.getRepoByCountry(country);
const result = await repo.delete(id);
this.logger.debug(`Deleted place ID ${id} in ${country}`);
return { success: true, affected: result.affected };
} catch (e) {
this.logger.error(`Failed to delete place ID ${id} in ${country}:`, e);
throw new HttpException('Deletion failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
const repo = this.getRepoByCountry(country);
return { success: true, affected: (await repo.delete(id)).affected };
}
private getRepoByCountry(country: string) {