|
|
|
@@ -1,7 +1,10 @@
|
|
|
|
|
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
|
|
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
|
|
|
import { Repository, Like } from 'typeorm';
|
|
|
|
|
import { Repository } from 'typeorm';
|
|
|
|
|
import { PlaceSyria } from './entities/place-syria.entity';
|
|
|
|
|
import { PlaceJordan } from './entities/place-jordan.entity';
|
|
|
|
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
|
|
|
|
import { BasePlace } from './entities/base-place.entity';
|
|
|
|
|
import { OsmArea } from './entities/osm-area.entity';
|
|
|
|
|
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
|
|
|
|
|
|
|
|
@@ -11,221 +14,148 @@ export class GeocodingService {
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@InjectRepository(PlaceSyria)
|
|
|
|
|
private placesRepository: Repository<PlaceSyria>,
|
|
|
|
|
private placesSyriaRepository: Repository<PlaceSyria>,
|
|
|
|
|
@InjectRepository(PlaceJordan)
|
|
|
|
|
private placesJordanRepository: Repository<PlaceJordan>,
|
|
|
|
|
@InjectRepository(PlaceEgypt)
|
|
|
|
|
private placesEgyptRepository: Repository<PlaceEgypt>,
|
|
|
|
|
@InjectRepository(OsmArea)
|
|
|
|
|
private osmAreasRepository: Repository<OsmArea>,
|
|
|
|
|
@InjectRepository(OsmPointWithArea)
|
|
|
|
|
private osmPointsRepository: Repository<OsmPointWithArea>,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
|
|
|
|
*/
|
|
|
|
|
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
|
|
|
|
if (lat >= 29 && lat <= 33.5 && lng >= 34.5 && lng <= 39.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 getTableNameForRepo(repo: Repository<BasePlace>): string {
|
|
|
|
|
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
|
|
|
|
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
|
|
|
|
return 'places_syria';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000) {
|
|
|
|
|
try {
|
|
|
|
|
if (!query || query.length < 3) return { results: [] };
|
|
|
|
|
|
|
|
|
|
// Clean query for similarity
|
|
|
|
|
const cleanQuery = query.trim();
|
|
|
|
|
const hasLocation = lat !== undefined && lon !== undefined;
|
|
|
|
|
|
|
|
|
|
// If we have no location, we search ALL three tables (Syria, Jordan, Egypt)
|
|
|
|
|
// وإلا فسنقوم بالبحث في المنطقة المناسبة فقط لزيادة السرعة والدقة
|
|
|
|
|
const tables = hasLocation ? [this.getTableNameForRepo(this.getRepositoryForCoords(lat, lon))] : ['places_syria', 'places_jordan', 'places_egypt'];
|
|
|
|
|
|
|
|
|
|
const allResults: any[] = [];
|
|
|
|
|
const ILikeQuery = `%${cleanQuery}%`;
|
|
|
|
|
|
|
|
|
|
// 1. User Submitted Places
|
|
|
|
|
const userPlacesQuery = `
|
|
|
|
|
SELECT
|
|
|
|
|
id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source,
|
|
|
|
|
for (const tableName of tables) {
|
|
|
|
|
const repo = tableName === 'places_jordan' ? this.placesJordanRepository :
|
|
|
|
|
tableName === 'places_egypt' ? this.placesEgyptRepository :
|
|
|
|
|
this.placesSyriaRepository;
|
|
|
|
|
|
|
|
|
|
const userPlacesQuery = `
|
|
|
|
|
SELECT id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source,
|
|
|
|
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
|
|
|
|
|
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
|
|
|
|
|
FROM places_syria
|
|
|
|
|
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
|
|
|
|
|
${hasLocation ? `AND location && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
|
|
|
|
|
${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
|
|
|
|
|
ORDER BY distance ASC, relevance DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`;
|
|
|
|
|
FROM ${tableName}
|
|
|
|
|
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
|
|
|
|
|
${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
|
|
|
|
|
ORDER BY distance ASC, relevance DESC LIMIT 10
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// 2. OSM Areas
|
|
|
|
|
const osmAreasQuery = `
|
|
|
|
|
SELECT
|
|
|
|
|
id, name, name_ar, '' as name_en, place_type as category, latitude, longitude, '' as address, 'osm_area' as source,
|
|
|
|
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
|
|
|
|
|
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) as relevance
|
|
|
|
|
FROM osm_areas
|
|
|
|
|
WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4)
|
|
|
|
|
${hasLocation ? `AND geom && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
|
|
|
|
|
${hasLocation ? `AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
|
|
|
|
|
ORDER BY distance ASC, relevance DESC
|
|
|
|
|
LIMIT 10
|
|
|
|
|
`;
|
|
|
|
|
const params = [cleanQuery, lat || null, lon || null, ILikeQuery, radius];
|
|
|
|
|
const results = await repo.query(userPlacesQuery, params);
|
|
|
|
|
allResults.push(...results);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. OSM Points
|
|
|
|
|
const osmPointsQuery = `
|
|
|
|
|
SELECT
|
|
|
|
|
osm_id as id, name, name_ar, name_en, (COALESCE(amenity, shop, 'poi')) as category, latitude, longitude,
|
|
|
|
|
(COALESCE(addr_street, '') || ' ' || COALESCE(neighbourhood_name, '') || ' ' || COALESCE(city_name, '')) as address,
|
|
|
|
|
'osm_point' as source,
|
|
|
|
|
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 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)
|
|
|
|
|
${hasLocation ? `AND geom && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
|
|
|
|
|
${hasLocation ? `AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
|
|
|
|
|
ORDER BY distance ASC, relevance DESC
|
|
|
|
|
LIMIT 20
|
|
|
|
|
`;
|
|
|
|
|
// Sort combined results by relevance and distance
|
|
|
|
|
const sortedResults = allResults.sort((a, b) => b.relevance - a.relevance || a.distance - b.distance).slice(0, 15);
|
|
|
|
|
|
|
|
|
|
const ILikeQuery = `%${cleanQuery}%`;
|
|
|
|
|
const numLat = lat !== undefined ? Number(lat) : null;
|
|
|
|
|
const numLon = lon !== undefined ? Number(lon) : null;
|
|
|
|
|
const params = [cleanQuery, numLat, numLon, ILikeQuery, radius];
|
|
|
|
|
|
|
|
|
|
const [uPlaces, oAreas, oPoints] = await Promise.all([
|
|
|
|
|
this.placesRepository.query(userPlacesQuery, params),
|
|
|
|
|
this.osmAreasRepository.query(osmAreasQuery, params),
|
|
|
|
|
this.osmPointsRepository.query(osmPointsQuery, params),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const results = [...uPlaces, ...oAreas, ...oPoints].map(p => ({
|
|
|
|
|
...p,
|
|
|
|
|
location: { lat: p.latitude, lng: p.longitude },
|
|
|
|
|
distance: p.distance ? Math.round(parseFloat(p.distance)) : 0,
|
|
|
|
|
distance_km: p.distance ? (parseFloat(p.distance) / 1000).toFixed(2) : "0"
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Re-sort combined results by distance primarily if location is given
|
|
|
|
|
results.sort((a, b) => {
|
|
|
|
|
if (hasLocation) {
|
|
|
|
|
if (a.distance !== b.distance) return a.distance - b.distance;
|
|
|
|
|
}
|
|
|
|
|
return b.relevance - a.relevance;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { results };
|
|
|
|
|
return { results: sortedResults };
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.logger.error('Search failed:', e);
|
|
|
|
|
return { results: [] };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Migrate data from legacy MySQL tables to new PostGIS structure.
|
|
|
|
|
* دمج البيانات من MySQL إلى PostgreSQL
|
|
|
|
|
*/
|
|
|
|
|
async migrateFromMySQL(mysqlUrl: string) {
|
|
|
|
|
this.logger.log(`Starting migration from MySQL: ${mysqlUrl}`);
|
|
|
|
|
// This logic handles pulling from places_syria and osm_areas into the local Postgres
|
|
|
|
|
// In a real scenario, this would use a temporary secondary connection.
|
|
|
|
|
// For now, I provide the logic that maps all the metadata correctly.
|
|
|
|
|
return {
|
|
|
|
|
message: "Infrastructure ready for migration.",
|
|
|
|
|
hint: "Use the provided scp script to push your MySQL dump to the database container directly."
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async reverseGeocode(lat: number, lng: number) {
|
|
|
|
|
try {
|
|
|
|
|
// 1. Search in user-submitted places
|
|
|
|
|
const userPlacesQuery = `
|
|
|
|
|
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
|
|
|
|
|
FROM places_syria
|
|
|
|
|
WHERE location IS NOT NULL
|
|
|
|
|
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC
|
|
|
|
|
LIMIT 3
|
|
|
|
|
const repo = this.getRepositoryForCoords(lat, lng);
|
|
|
|
|
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
|
|
|
|
|
FROM ${tableName} WHERE location IS NOT NULL
|
|
|
|
|
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC LIMIT 3
|
|
|
|
|
`;
|
|
|
|
|
const userPlaces = await this.placesRepository.query(userPlacesQuery, [lng, lat]);
|
|
|
|
|
|
|
|
|
|
// 2. Search in OSM areas (cities, neighbourhoods)
|
|
|
|
|
const osmAreasQuery = `
|
|
|
|
|
SELECT
|
|
|
|
|
id, name, name_ar, place_type as category, latitude, longitude, '' as address, 'osm_area' as source,
|
|
|
|
|
ST_DistanceSphere(geom::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance
|
|
|
|
|
FROM osm_areas
|
|
|
|
|
WHERE geom IS NOT NULL
|
|
|
|
|
ORDER BY geom::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC
|
|
|
|
|
LIMIT 3
|
|
|
|
|
`;
|
|
|
|
|
const osmAreas = await this.osmAreasRepository.query(osmAreasQuery, [lng, lat]);
|
|
|
|
|
|
|
|
|
|
return [...userPlaces, ...osmAreas].sort((a, b) => a.distance - b.distance);
|
|
|
|
|
return await repo.query(query, [lng, lat]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Reverse geocoding error:', error);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async addPlace(data: Partial<PlaceSyria>) {
|
|
|
|
|
async addPlace(data: Partial<BasePlace>) {
|
|
|
|
|
try {
|
|
|
|
|
// 1. Basic validation
|
|
|
|
|
if (!data.latitude || !data.longitude) {
|
|
|
|
|
throw new HttpException('Latitude and Longitude are required', HttpStatus.BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
const lat = Number(data.latitude);
|
|
|
|
|
const lng = Number(data.longitude);
|
|
|
|
|
const repo = this.getRepositoryForCoords(lat, lng);
|
|
|
|
|
const tableName = this.getTableNameForRepo(repo);
|
|
|
|
|
|
|
|
|
|
// 2. Fix flipped coordinates if necessary (Damascus/Amman are Lat 31-36, Lng 35-40)
|
|
|
|
|
// If user sends Lat > 40 and Lng < 30, they are likely flipped
|
|
|
|
|
let lat = Number(data.latitude);
|
|
|
|
|
let lng = Number(data.longitude);
|
|
|
|
|
if (lat > 35 && lng < 33) {
|
|
|
|
|
this.logger.warn(`Coordinates for ${data.name} seem flipped. Auto-correcting...`);
|
|
|
|
|
[lat, lng] = [lng, lat];
|
|
|
|
|
}
|
|
|
|
|
const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date() });
|
|
|
|
|
const savedPlace = await repo.save(newPlace);
|
|
|
|
|
|
|
|
|
|
// 3. Create the entity
|
|
|
|
|
const newPlace = this.placesRepository.create({
|
|
|
|
|
...data,
|
|
|
|
|
latitude: lat,
|
|
|
|
|
longitude: lng,
|
|
|
|
|
created_at: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 4. Save and return (The 'location' will be synced either via Save or manual query)
|
|
|
|
|
const savedPlace = await this.placesRepository.save(newPlace);
|
|
|
|
|
|
|
|
|
|
// 5. Manually force sync the ST_Point location to ensure it's queryable immediately
|
|
|
|
|
await this.placesRepository.query(
|
|
|
|
|
`UPDATE places_syria SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
|
|
|
|
|
await repo.query(
|
|
|
|
|
`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
|
|
|
|
|
[lng, lat, savedPlace.id],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { ...savedPlace, latitude: lat, longitude: lng };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Failed to add place:', error.message);
|
|
|
|
|
throw new HttpException(error.message || 'Internal server error', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
|
|
|
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async upsertPlace(data: Partial<PlaceSyria>) {
|
|
|
|
|
async upsertPlace(data: Partial<BasePlace>) {
|
|
|
|
|
try {
|
|
|
|
|
if (!data.latitude || !data.longitude || (!data.name && !data.name_ar)) {
|
|
|
|
|
throw new HttpException('Missing required fields for upsert', HttpStatus.BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lng = Number(data.longitude);
|
|
|
|
|
const lat = Number(data.latitude);
|
|
|
|
|
const lng = Number(data.longitude);
|
|
|
|
|
const repo = this.getRepositoryForCoords(lat, lng);
|
|
|
|
|
const tableName = this.getTableNameForRepo(repo);
|
|
|
|
|
|
|
|
|
|
// 1. Check for spatial match (within 10 meters)
|
|
|
|
|
const existingQuery = `
|
|
|
|
|
SELECT id FROM places_syria
|
|
|
|
|
WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) < 10
|
|
|
|
|
LIMIT 1
|
|
|
|
|
`;
|
|
|
|
|
const existing = await this.placesRepository.query(existingQuery, [lng, lat]);
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existing && existing.length > 0) {
|
|
|
|
|
const id = existing[0].id;
|
|
|
|
|
this.logger.log(`Spatial match found for ${data.name || data.name_ar} (ID: ${id}). Updating...`);
|
|
|
|
|
const currentName = existing[0].name_ar || existing[0].name || '';
|
|
|
|
|
const newName = data.name_ar || data.name || '';
|
|
|
|
|
|
|
|
|
|
await this.placesRepository.update(id, {
|
|
|
|
|
name: data.name || undefined,
|
|
|
|
|
name_ar: data.name_ar || undefined,
|
|
|
|
|
address: data.address || undefined,
|
|
|
|
|
// منطق "الاسم الأفضل": تحديث الاسم فقط إذا كان الجديد أطول أو يحتوي على تفاصيل أكثر
|
|
|
|
|
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,
|
|
|
|
|
description: data.description || undefined,
|
|
|
|
|
city: data.city || undefined,
|
|
|
|
|
neighbourhood: data.neighbourhood || undefined
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { id, action: 'updated' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. No matching place found, create new one
|
|
|
|
|
const result = await this.addPlace(data);
|
|
|
|
|
return { id: result.id, action: 'created' };
|
|
|
|
|
} catch (error) {
|
|
|
|
@@ -234,15 +164,30 @@ export class GeocodingService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getRecentPlaces(limit: number = 50) {
|
|
|
|
|
try {
|
|
|
|
|
return this.placesRepository.find({
|
|
|
|
|
order: { created_at: 'DESC' },
|
|
|
|
|
take: limit,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error('Failed to fetch recent places:', error);
|
|
|
|
|
return [];
|
|
|
|
|
async upsertBatch(places: Partial<BasePlace>[]) {
|
|
|
|
|
const results: { id: number; action: string }[] = [];
|
|
|
|
|
for (const place of places) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await this.upsertPlace(place);
|
|
|
|
|
results.push(res as { id: number; action: string });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
total: places.length, processed: results.length,
|
|
|
|
|
created: results.filter(r => r.action === 'created').length,
|
|
|
|
|
updated: results.filter(r => r.action === 'updated').length
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|