stable: finalized GIS aesthetics and Syria/Jordan routing synchronization
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TelemetryModule } from './telemetry/telemetry.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
import { GeocodingModule } from './geocoding/geocoding.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
@@ -22,6 +25,7 @@ import { MapsModule } from './maps/maps.module';
|
||||
}),
|
||||
TelemetryModule,
|
||||
MapsModule,
|
||||
GeocodingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const apiKeyHeader = request.headers['x-api-key'];
|
||||
const validApiKey = this.configService.get<string>('API_KEY');
|
||||
|
||||
// If API_KEY is set in environment, enforce it
|
||||
if (validApiKey && apiKeyHeader !== validApiKey) {
|
||||
throw new UnauthorizedException('Invalid or missing API Key');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createClient } from 'redis';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
/**
|
||||
* Global Redis Module - موديول Redis العالمي
|
||||
*
|
||||
* Provides a high-performance in-memory key-value store connection.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const url = configService.get<string>('REDIS_URL', 'redis://redis:6379');
|
||||
const client = createClient({ url });
|
||||
await client.connect();
|
||||
return client;
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: ['REDIS_CLIENT', RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common';
|
||||
import type { RedisClientType } from 'redis';
|
||||
|
||||
/**
|
||||
* Redis Service - خدمة التعامل مع الذاكرة العشوائية (Redis)
|
||||
*
|
||||
* Used for caching traffic snapshots and common routes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set a value in Redis with optional TTL (Time To Live).
|
||||
* حفظ قيمة في ذاكرة Redis مع وقت انتهاء اختياري
|
||||
*/
|
||||
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.redisClient.set(key, stringValue, { EX: ttlSeconds });
|
||||
} else {
|
||||
await this.redisClient.set(key, stringValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from Redis and parse it as JSON if possible.
|
||||
* جلب قيمة من ذاكرة Redis وتحويلها إلى JSON إذا لزم الأمر
|
||||
*/
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const value = await this.redisClient.get(key);
|
||||
if (!value) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key from Redis.
|
||||
* حذف مفتاح من ذاكرة Redis
|
||||
*/
|
||||
async del(key: string): Promise<void> {
|
||||
await this.redisClient.del(key);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redisClient.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity('osm_areas')
|
||||
export class OsmArea {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
osm_id: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||
longitude: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
place_type: string;
|
||||
|
||||
@Column({ type: 'geometry', nullable: true })
|
||||
geom: any;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
other_tags: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('osm_points_with_area')
|
||||
export class OsmPointWithArea {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
osm_id: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||
longitude: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
amenity: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
shop: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
addr_street: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
neighbourhood_name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
city_name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
other_tags: string;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', nullable: true })
|
||||
geom: any;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('places_syria')
|
||||
export class PlaceSyria {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||
longitude: number;
|
||||
|
||||
@Column({ nullable: true, unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true, unique: true })
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
address: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
neighbourhood: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
city: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||
location: any;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GeocodingInitService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeocodingInitService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlaceSyria)
|
||||
private readonly repo: Repository<PlaceSyria>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('Checking for PostGIS extensions and triggers...');
|
||||
try {
|
||||
// Ensure PostGIS and Trigram extensions are enabled
|
||||
await this.repo.query('CREATE EXTENSION IF NOT EXISTS postgis;');
|
||||
await this.repo.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;');
|
||||
|
||||
// Cleanup: Create a trigger to automatically update 'location' when lat/lng changes
|
||||
await this.repo.query(`
|
||||
CREATE OR REPLACE FUNCTION sync_place_location() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN
|
||||
NEW.location := ST_SetSRID(ST_MakePoint(CAST(NEW.longitude AS FLOAT), CAST(NEW.latitude AS FLOAT)), 4326);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
|
||||
await this.repo.query(`
|
||||
DROP TRIGGER IF EXISTS trg_sync_place_location ON places_syria;
|
||||
CREATE TRIGGER trg_sync_place_location
|
||||
BEFORE INSERT OR UPDATE ON places_syria
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
|
||||
`);
|
||||
|
||||
// 1. GIST Geometry Indexes (for fast proximity search)
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_location ON places_syria USING gist (location);');
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_geom ON osm_areas USING gist (geom);');
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_geom ON osm_points_with_area USING gist (geom);');
|
||||
|
||||
// 2. GIST Trigram Indexes (for fast fuzzy name search and low server load)
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_names_trgm ON places_syria USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_names_trgm ON osm_areas USING gist (name_ar gist_trgm_ops);');
|
||||
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_names_trgm ON osm_points_with_area USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
|
||||
|
||||
this.logger.log('Geocoding database triggers and optimized indexes initialized.');
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to initialize database geocoding triggers:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@Controller('geocoding')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class GeocodingController {
|
||||
constructor(private readonly geocodingService: GeocodingService) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search for locations (Forward Geocoding)' })
|
||||
@ApiQuery({ name: 'q', required: true })
|
||||
@ApiQuery({ name: 'lat', required: false, type: Number })
|
||||
@ApiQuery({ name: 'lng', required: false, type: Number })
|
||||
@ApiQuery({ name: 'radius', required: false, type: Number })
|
||||
async search(
|
||||
@Query('q') query: string,
|
||||
@Query('lat') lat?: number,
|
||||
@Query('lng') lng?: number,
|
||||
@Query('radius') radius?: number,
|
||||
) {
|
||||
return this.geocodingService.searchPlaces(query, lat, lng, radius);
|
||||
}
|
||||
|
||||
@Get('reverse')
|
||||
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
|
||||
@ApiQuery({ name: 'lat', required: true })
|
||||
@ApiQuery({ name: 'lng', required: true })
|
||||
async reverse(@Query('lat') lat: number, @Query('lng') lng: number) {
|
||||
return this.geocodingService.reverseGeocode(lat, lng);
|
||||
}
|
||||
|
||||
@Post('places')
|
||||
@ApiOperation({ summary: 'Add a new location (User Submitted)' })
|
||||
async addPlace(@Body() placeData: any) {
|
||||
return this.geocodingService.addPlace(placeData);
|
||||
}
|
||||
|
||||
@Get('places')
|
||||
@ApiOperation({ summary: 'Get recent user submitted places' })
|
||||
async getPlaces(@Query('limit') limit?: number) {
|
||||
return this.geocodingService.getRecentPlaces(limit);
|
||||
}
|
||||
|
||||
@Post('migrate')
|
||||
@ApiOperation({ summary: 'Migrate legacy MySQL data to PostGIS' })
|
||||
async migrate() {
|
||||
return this.geocodingService.migrateFromMySQL('LEGACY_DB');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { GeocodingInitService } from './geocoding-init.service';
|
||||
import { GeocodingController } from './geocoding.controller';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
PlaceSyria,
|
||||
OsmArea,
|
||||
OsmPointWithArea
|
||||
]),
|
||||
],
|
||||
controllers: [GeocodingController],
|
||||
providers: [GeocodingService, GeocodingInitService],
|
||||
})
|
||||
export class GeocodingModule {}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like } from 'typeorm';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GeocodingService {
|
||||
private readonly logger = new Logger(GeocodingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlaceSyria)
|
||||
private placesRepository: Repository<PlaceSyria>,
|
||||
@InjectRepository(OsmArea)
|
||||
private osmAreasRepository: Repository<OsmArea>,
|
||||
@InjectRepository(OsmPointWithArea)
|
||||
private osmPointsRepository: Repository<OsmPointWithArea>,
|
||||
) {}
|
||||
|
||||
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;
|
||||
|
||||
// 1. User Submitted Places
|
||||
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
|
||||
`;
|
||||
|
||||
// 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
|
||||
`;
|
||||
|
||||
// 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
|
||||
`;
|
||||
|
||||
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 };
|
||||
} 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 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);
|
||||
} catch (error) {
|
||||
this.logger.error('Reverse geocoding error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async addPlace(data: Partial<PlaceSyria>) {
|
||||
try {
|
||||
// 1. Basic validation
|
||||
if (!data.latitude || !data.longitude) {
|
||||
throw new HttpException('Latitude and Longitude are required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// 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`,
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,20 @@ async function bootstrap() {
|
||||
// تفعيل تبادل الموارد مع الواجهة الأمامية
|
||||
app.enableCors();
|
||||
|
||||
// Set global API prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Swagger Documentation Setup
|
||||
// إعداد توثيق API (Swagger)
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Jordan Map Platform API')
|
||||
.setDescription('Backend API for map services, routing, and driver telemetry for Jordan')
|
||||
.setVersion('1.0')
|
||||
.addTag('maps')
|
||||
.addTag('telemetry')
|
||||
.addTag('telemetry') // Corrected line to add 'telemetry' tag
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
const port = process.env.API_PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Candidate Road Entity - طرق مرشحة مكتشفة من بيانات التتبع
|
||||
*
|
||||
* Stores potential new roads discovered from telemetry data where
|
||||
* multiple drivers traveled off-road in a consistent linear pattern.
|
||||
*/
|
||||
@Entity('candidate_roads')
|
||||
export class CandidateRoad {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
// GeoJSON LineString representing the discovered road path
|
||||
// خط يمثل المسار المكتشف
|
||||
@Column({
|
||||
type: 'geography',
|
||||
spatialFeatureType: 'LineString',
|
||||
srid: 4326,
|
||||
nullable: false,
|
||||
})
|
||||
@Index({ spatial: true })
|
||||
geometry: any;
|
||||
|
||||
// Number of unique drivers who used this path
|
||||
// عدد السائقين الفريدين الذين استخدموا هذا المسار
|
||||
@Column('int', { default: 0 })
|
||||
uniqueDriverCount: number;
|
||||
|
||||
// Total telemetry points that contributed to this candidate
|
||||
// عدد النقاط الإجمالي المساهم في اكتشاف هذا المسار
|
||||
@Column('int', { default: 0 })
|
||||
totalPoints: number;
|
||||
|
||||
// Average speed observed on this candidate road (km/h)
|
||||
// متوسط السرعة المسجلة (كم/ساعة)
|
||||
@Column('float', { default: 0 })
|
||||
averageSpeed: number;
|
||||
|
||||
// Estimated length in meters
|
||||
// الطول التقريبي بالمتر
|
||||
@Column('float', { default: 0 })
|
||||
lengthMeters: number;
|
||||
|
||||
// Confidence score (0-1): higher = more likely a real road
|
||||
// درجة الثقة (0-1): كلما زادت كلما كان احتمال كونها طريق حقيقي أعلى
|
||||
@Column('float', { default: 0 })
|
||||
confidence: number;
|
||||
|
||||
// Review status: 'pending', 'approved', 'rejected'
|
||||
// حالة المراجعة: pending=بانتظار المراجعة، approved=تمت الموافقة، rejected=مرفوض
|
||||
@Column({ default: 'pending' })
|
||||
status: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
discoveredAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
reviewedAt: Date;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { MapsService } from './maps.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('maps')
|
||||
@Controller('maps')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class MapsController {
|
||||
constructor(private readonly mapsService: MapsService) {}
|
||||
|
||||
|
||||
@@ -3,9 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MapsService } from './maps.service';
|
||||
import { MapsController } from './maps.controller';
|
||||
import { RoadSegmentStat } from './road-stat.entity';
|
||||
import { CandidateRoad } from './candidate-road.entity';
|
||||
import { RedisModule } from '../common/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([RoadSegmentStat])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]),
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService],
|
||||
exports: [MapsService],
|
||||
|
||||
Reference in New Issue
Block a user