📘 دليل تعليمي تفاعلي — من الصفر إلى الإنتاج

تعلّم بناء منصة خرائط ذكية بتقنيات حقيقية.

هذا الدليل يأخذك في رحلة عملية داخل كود منصة "انطلق" — من تصميم قاعدة البيانات المكانية إلى نشر الحاويات. كل سطر كود هنا مأخوذ من المشروع الفعلي.

4
وحدات NestJS
6
حاويات Docker
3
دول مدعومة
∞
نقاط تتبع
01

فلسفة NestJS — لماذا هذا الإطار؟

NestJS يقوم على ثلاثة مبادئ أساسية: الوحدات (Modules) لتنظيم الكود، المتحكمات (Controllers) لاستقبال الطلبات، والخدمات (Services) لمنطق العمل. كل شيء مربوط بـ حقن التبعيات (Dependency Injection).

💡 المبدأ الأساسي كل وحدة (Module) هي صندوق مستقل يحتوي على متحكماته وخدماته. الوحدة لا تعرف شيئاً عن الوحدات الأخرى إلا إذا تم "تصديرها" (Export) و"استيرادها" (Import) بشكل صريح.

نقطة البداية هي ملف main.ts — هنا يبدأ كل شيء:

main.ts
TypeScript
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // تفعيل تبادل الموارد مع الواجهة الأمامية
  app.enableCors();
  app.setGlobalPrefix('api');  // كل المسارات تبدأ بـ /api

  // إعداد وثائق Swagger التلقائية
  const config = new DocumentBuilder()
    .setTitle('Jordan Map Platform API')
    .setVersion('1.0').build();

  const port = process.env.API_PORT || 3000;
  await app.listen(port);
  console.log(`🚀 API is running on port ${port}`);
}
bootstrap();
🔗 العلاقة: main.ts → يُنشئ التطبيق من AppModule → الذي يستورد TelemetryModule + MapsModule + GeocodingModule.
02

الوحدات والتبعيات — كيف يُنظَّم الكود؟

المشروع مقسم لـ 4 وحدات رئيسية. كل وحدة تعريفها في ملف *.module.ts:

🏗️ خريطة الوحدات
AppModule
←
TelemetryModule
←
MapsModule
←
GeocodingModule
app.module.ts
TypeScript
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    ScheduleModule.forRoot(),          // لتشغيل المهام الدورية (Cron)
    TypeOrmModule.forRootAsync({       // اتصال قاعدة البيانات
      useFactory: (config) => ({
        type: 'postgres',
        url: config.get('DATABASE_URL'),
        autoLoadEntities: true,
      }),
    }),
    TelemetryModule,                   // وحدة تتبع السائقين
    MapsModule,                        // وحدة التوجيه والخرائط
    GeocodingModule,                   // وحدة البحث المكاني
  ],
})
export class AppModule {}
telemetry.module.ts
TypeScript
@Module({
  imports: [
    TypeOrmModule.forFeature([          // ← تسجيل الكيانات
      TelemetryLog,
      RoadSegmentStat,
      CandidateRoad
    ]),
    RedisModule,                          // ← استيراد خدمة Redis
  ],
  controllers: [TelemetryController, MapRefinementController],
  providers: [TelemetryService, TelemetryAnalyzerService,
              RedisService, ExternalTelemetryService],
  exports: [TelemetryService, TelemetryAnalyzerService],
})
export class TelemetryModule {}
⚠️ قاعدة ذهبية: أي خدمة (Service) يجب أن تكون مُسجلة في providers وأي خدمة تريد مشاركتها يجب وضعها في exports.
03

الكيانات و TypeORM — تصميم قاعدة البيانات

كل جدول في PostgreSQL يُمثَّل بكيان (Entity). الكيان هو كلاس TypeScript مزيّن بـ @Entity() يُعرِّف الأعمدة والفهارس:

📍

TelemetryLog

يخزن نقاط GPS من السائقين. يحتوي عمود location من نوع PostGIS geography(Point) مع فهرس مكاني.

🛣️

RoadSegmentStat

إحصائيات السرعة لكل مقطع طريق. يحتوي congestionFactor وgeometry من نوع LineString.

🔍

CandidateRoad

طرق مُكتشفة من تحليل DBSCAN. تحتوي confidence (0-1) وstatus (pending/approved/rejected).

🌍

BasePlace

الكيان الأب لجداول places_syria, places_jordan, places_egypt. يُعرّف الأعمدة المشتركة.

telemetry.entity.ts
TypeScript
@Entity('telemetry_logs')
export class TelemetryLog {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()  @Index()
  driverId: string;

  @Column('decimal', { precision: 10, scale: 7 })
  latitude: number;

  @Column('float')
  speed: number;

  // نقطة جغرافية PostGIS للبحث المكاني السريع
  @Column({ type: 'geography', spatialFeatureType: 'Point', srid: 4326 })
  @Index({ spatial: true })   // ← فهرس GIST للبحث بالمسافة
  location: any;
}
04

نظام البحث المكاني — Forward & Reverse Geocoding

الخدمة تبحث في 3 جداول إقليمية + جدول OSM العالمي. المفتاح: استخدام ::float في استعلامات SQL لتجنب خطأ "النتائج الفارغة":

geocoding.service.ts — searchPlaces()
SQL + TypeScript
// تحديد الجدول المناسب بناءً على الإحداثيات
private getRepositoryForCoords(lat, lng) {
  if (lat >= 29 && lat <= 37.5 && lng >= 34.5) {
    if (lat > 32.5 && lng > 35.8) return placesSyriaRepo;
    return placesJordanRepo;
  }
  if (lat >= 22 && lat <= 32) return placesEgyptRepo;
}

// الاستعلام المكاني — لاحظ ::float في كل مكان
const query = `
  SELECT id, name_ar, latitude, longitude,
    ST_DistanceSphere(
      location,
      ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)
    ) as distance
  FROM ${tableName}
  WHERE name_ar % $1  -- بحث تشابهي (Trigram)
  ORDER BY distance ASC LIMIT 15
`;
🐛 الخطأ الذي حللناه: PostGIS كان يقرأ الإحداثيات كـ text بدلاً من float، مما يُنتج نتائج فارغة. الحل: إضافة ::float لكل مُعامل رقمي.
05

تتبع السائقين — من GPS إلى قاعدة البيانات

كل 3 ثوانٍ، يرسل تطبيق السائق بيانات الموقع. المتحكم يستقبلها والخدمة تحفظها:

telemetry.service.ts — ingest()
TypeScript
async ingest(data: DriverTelemetryDto) {
  const log = this.telemetryRepo.create({
    driverId: data.driver_id,
    latitude: data.latitude, longitude: data.longitude,
    speed: data.speed, heading: data.heading,
    location: {
      type: 'Point',
      coordinates: [data.longitude, data.latitude],  // GeoJSON: [lng, lat]
    },
  });
  await this.telemetryRepo.save(log);
  return { success: true, timestamp: new Date() };
}
🔗 سلسلة الاستدعاء: Flutter App → POST /api/telemetry → TelemetryController.ingest() → TelemetryService.ingest() → PostgreSQL + PostGIS
06

دورة الذكاء المكاني — كل 10 أيام

هذا هو قلب المنصة. الخدمة TelemetryAnalyzerService تعمل تلقائياً عبر @Cron كل فجر لتحليل البيانات:

🧠 دورة المعالجة الذكية
1. مزامنة البيانات
→
2. تحليل السرعات
→
3. اكتشاف الطرق
→
4. تحديث Redis
telemetry-analyzer.service.ts — discoverNewRoads()
PostGIS SQL
-- الخطوة 1: إيجاد نقاط بعيدة عن أي طريق معروف (> 15 متر)
WITH off_road_points AS (
  SELECT t.id, t.location, t.timestamp, t."driverId"
  FROM telemetry_logs t
  WHERE t.speed > 5  -- متحرك وليس متوقف
    AND NOT EXISTS (
      SELECT 1 FROM planet_osm_line l
      WHERE ST_DWithin(t.location::geography,
            ST_Transform(l.way, 4326)::geography, 15)
    )
),
-- الخطوة 2: تجميع النقاط القريبة باستخدام DBSCAN
clustered AS (
  SELECT *,
    ST_ClusterDBSCAN(location::geometry,
      eps := 0.0003, minpoints := 5) OVER () AS cluster_id
  FROM off_road_points
)
-- الخطوة 3: تحويل كل تجمع إلى خط طريق مرشح
SELECT cluster_id,
  COUNT(DISTINCT "driverId") AS unique_drivers,
  ST_AsGeoJSON(ST_MakeLine(location ORDER BY timestamp)) AS geojson
FROM clustered WHERE cluster_id IS NOT NULL
GROUP BY cluster_id
HAVING COUNT(DISTINCT "driverId") >= 2
07

مرجع الـ API — كل نقاط النهاية

جميع المسارات محمية بـ ApiKeyGuard عبر الترويسة x-api-key.

الطريقةالمسارالوصفالملف المصدر
POST/api/telemetryاستقبال بيانات تتبع السائقين كل 3 ثوانٍtelemetry.controller.ts
POST/api/telemetry/syncمزامنة يدوية من الخادم الخارجيtelemetry.controller.ts
POST/api/telemetry/process-intelligence🚀 تنفيذ دورة الذكاء كاملة (مزامنة + تحليل)telemetry.controller.ts
GET/api/maps/route?fromLat&fromLng&toLat&toLngحساب أقصر مسار عبر GraphHoppermaps.controller.ts
GET/api/geocoding/search?q&lat&lng&countryبحث مكاني متعدد الأقاليمgeocoding.controller.ts
GET/api/geocoding/reverse?lat&lngتحويل إحداثيات → عنوانgeocoding.controller.ts
POST/api/geocoding/upsert-batchإدخال أماكن بالجملة (Scraper)geocoding.controller.ts
POST/api/map-refinement/analyze-speedsتحليل سرعات الطرقmap-refinement.controller.ts
POST/api/map-refinement/discover-roadsاكتشاف طرق جديدة (DBSCAN)map-refinement.controller.ts
GET/api/map-refinement/congestion?north&south&east&westبيانات الازدحام للخريطة الحراريةmap-refinement.controller.ts
PATCH/api/map-refinement/candidates/:id/approveالموافقة على طريق مكتشفmap-refinement.controller.ts
DELETE/api/geocoding/places?country&nameحذف مكان حسب الاسم أو المعرّفgeocoding.controller.ts
08

بيئة Docker — حاويات مترابطة

كل خدمة تعمل في حاوية معزولة. ملف docker-compose.yml يربطها عبر شبكة داخلية:

🐘

db (PostGIS)

postgis/postgis:15-3.3 — القلب. تخزين كل البيانات المكانية مع فهارس GIST.

🚀

api (NestJS)

node:20-alpine — العقل المدبر. يعالج الطلبات وينفذ التحليلات.

🗺️

martin (Tiles)

maplibre/martin — يولّد Vector Tiles مباشرة من PostGIS بسرعة فائقة.

🧭

routing (GraphHopper)

يحسب أقصر المسارات باستخدام ملف OSM محدث + بيانات الازدحام.

⚡

redis (Cache)

redis:7-alpine — تخزين مؤقت لحالة الازدحام اللحظية.

🌐

web (React)

لوحة تحكم الخرائط التفاعلية مع عرض البيانات.

docker-compose.yml (مقتطف)
YAML
services:
  db:
    image: postgis/postgis:15-3.3
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]

  api:
    build: ./infrastructure/docker/api/Dockerfile
    ports: ["3200:3200"]
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mapdb
      - REDIS_URL=redis://redis:6379
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }

  martin:
    image: maplibre/martin:latest
    ports: ["3202:3000"]
    command: postgresql://user:pass@db:5432/mapdb
💻 أوامر التشغيل الأساسية:
$ docker-compose up -d --build — بناء وتشغيل كل الحاويات
$ docker-compose ps — عرض حالة الحاويات
$ docker-compose logs -f api — مراقبة سجلات الـ API
09

إعدادات NGINX — البوابة الأمامية

NGINX يعمل كـ Reverse Proxy — يستقبل كل الطلبات على المنفذ 80 ويوجهها للحاوية المناسبة:

nginx.conf
NGINX
server {
    listen 80;
    server_name portal.intaleq.map;

    # توجيه طلبات الـ API إلى حاوية NestJS
    location /api {
        proxy_pass http://api:3200;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # توجيه طلبات بلاط الخرائط إلى Martin
    location /tiles {
        proxy_pass http://martin:3000;
    }

    # توجيه طلبات التوجيه إلى GraphHopper
    location /routing {
        proxy_pass http://routing:8989;
    }
}