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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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) {
File diff suppressed because it is too large Load Diff