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
Vendored
BIN
View File
Binary file not shown.
+514
View File
@@ -0,0 +1,514 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>انطلق | الدليل الهندسي التعليمي الشامل</title>
<meta name="description" content="دليل تعليمي شامل لمنصة انطلق - تعلم NestJS و PostGIS و Docker من خلال مشروع حقيقي">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0b0f1a;--bg2:#111827;--bg3:#1e293b;
--fg:#f1f5f9;--muted:#94a3b8;--dim:#475569;
--teal:#00f5d4;--gold:#fee440;--blue:#818cf8;
--pink:#f472b6;--red:#fb7185;--green:#34d399;
--card:#151e2e;--border:#1e3a5f;--code-bg:#0d1117;
}
body{font-family:'IBM Plex Sans Arabic',system-ui,sans-serif;background:var(--bg);color:var(--fg);line-height:1.9;overflow-x:hidden}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--teal);border-radius:10px}
/* Layout */
.sidebar{position:fixed;top:0;right:0;width:300px;height:100vh;background:var(--bg2);border-left:1px solid var(--border);padding:1.5rem;z-index:100;overflow-y:auto;display:flex;flex-direction:column}
.main{margin-right:300px;min-height:100vh}
@media(max-width:1024px){.sidebar{display:none}.main{margin-right:0}}
/* Sidebar */
.logo{display:flex;align-items:center;gap:.75rem;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:1.5rem}
.logo-icon{width:42px;height:42px;background:linear-gradient(135deg,var(--teal),var(--blue));border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.2rem;font-weight:900;color:var(--bg)}
.logo h1{font-size:1.3rem;font-weight:700}.logo span{font-size:.65rem;color:var(--muted);display:block}
.nav-group{margin-bottom:1.5rem}
.nav-group-title{font-size:.65rem;text-transform:uppercase;letter-spacing:3px;color:var(--dim);font-weight:700;margin-bottom:.5rem;padding-right:.5rem}
.nav-link{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.85rem;font-weight:500;transition:all .2s;border:1px solid transparent}
.nav-link:hover,.nav-link.active{background:rgba(0,245,212,.06);color:var(--teal);border-color:rgba(0,245,212,.15)}
.nav-link .num{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim);min-width:20px}
/* Hero */
.hero{padding:5rem 4rem;background:linear-gradient(180deg,rgba(0,245,212,.03) 0%,transparent 60%);border-bottom:1px solid var(--border)}
.hero .badge{display:inline-flex;align-items:center;gap:.5rem;background:rgba(0,245,212,.08);border:1px solid rgba(0,245,212,.2);border-radius:999px;padding:.35rem 1rem;font-size:.75rem;color:var(--teal);margin-bottom:1.5rem}
.hero h1{font-size:3.2rem;font-weight:800;line-height:1.3;margin-bottom:1rem;max-width:700px}
.hero h1 em{font-style:normal;background:linear-gradient(135deg,var(--teal),var(--blue));background-clip:text;-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hero p{font-size:1.1rem;color:var(--muted);max-width:600px;line-height:1.8}
.hero-stats{display:flex;gap:2.5rem;margin-top:2.5rem;flex-wrap:wrap}
.hero-stat{text-align:center}.hero-stat .val{font-size:2rem;font-weight:800;color:var(--teal)}.hero-stat .lbl{font-size:.75rem;color:var(--muted)}
/* Sections */
.chapter{padding:4rem;border-bottom:1px solid var(--border);scroll-margin-top:2rem}
.chapter-header{display:flex;align-items:center;gap:1rem;margin-bottom:2rem}
.chapter-num{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--bg);background:var(--teal);width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700}
.chapter h2{font-size:1.8rem;font-weight:800}
.chapter p,.chapter li{color:var(--muted);font-size:.95rem}
.chapter ul,.chapter ol{padding-right:1.5rem}
.chapter li{margin-bottom:.5rem}
/* Code Blocks */
.code-card{background:var(--code-bg);border:1px solid var(--border);border-radius:16px;overflow:hidden;margin:1.5rem 0}
.code-header{display:flex;align-items:center;justify-content:space-between;padding:.6rem 1rem;background:rgba(255,255,255,.03);border-bottom:1px solid var(--border);font-size:.75rem}
.code-header .file{color:var(--muted);font-family:'JetBrains Mono',monospace;display:flex;align-items:center;gap:.5rem}
.code-header .file .dot{width:8px;height:8px;border-radius:50%}
.code-header .lang{color:var(--teal);font-family:'JetBrains Mono',monospace;font-weight:600}
pre.code{padding:1.25rem;font-family:'JetBrains Mono',monospace;font-size:.8rem;line-height:1.8;color:#c9d1d9;direction:ltr;text-align:left;overflow-x:auto;margin:0}
.hl-key{color:#ff7b72}.hl-str{color:#a5d6ff}.hl-fn{color:#d2a8ff}.hl-cmt{color:#8b949e;font-style:italic}.hl-dec{color:#79c0ff}.hl-type{color:#ffa657}.hl-num{color:var(--teal)}
/* Info boxes */
.callout{padding:1.25rem 1.5rem;border-radius:12px;margin:1.5rem 0;border-right:4px solid;font-size:.9rem}
.callout.info{background:rgba(129,140,248,.06);border-color:var(--blue);color:var(--blue)}
.callout.tip{background:rgba(0,245,212,.06);border-color:var(--teal)}
.callout.warn{background:rgba(254,228,64,.06);border-color:var(--gold);color:var(--gold)}
.callout strong{display:block;margin-bottom:.3rem}
/* Grid Cards */
.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.25rem;margin:1.5rem 0}
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:1.5rem;transition:all .3s}
.card:hover{border-color:var(--teal);transform:translateY(-4px);box-shadow:0 12px 40px rgba(0,0,0,.4)}
.card .icon{font-size:1.5rem;margin-bottom:.75rem;display:block}
.card h4{font-size:1rem;font-weight:700;margin-bottom:.5rem}
.card p{font-size:.85rem;color:var(--muted)}
/* Diagram */
.diagram{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:2rem;margin:1.5rem 0;text-align:center}
.diagram-flow{display:flex;align-items:center;justify-content:center;gap:.5rem;flex-wrap:wrap;margin:1rem 0}
.diagram-node{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:.75rem 1.25rem;font-size:.8rem;font-weight:600;min-width:100px}
.diagram-node.active{border-color:var(--teal);color:var(--teal);box-shadow:0 0 20px rgba(0,245,212,.15)}
.diagram-arrow{color:var(--dim);font-size:1.2rem}
/* API Table */
.api-table{width:100%;border-collapse:collapse;margin:1.5rem 0;font-size:.85rem}
.api-table th{text-align:right;padding:.75rem 1rem;background:var(--card);color:var(--teal);font-size:.7rem;text-transform:uppercase;letter-spacing:1px;border-bottom:2px solid var(--border)}
.api-table td{padding:.75rem 1rem;border-bottom:1px solid rgba(255,255,255,.05);color:var(--muted);vertical-align:top}
.api-table tr:hover td{background:rgba(255,255,255,.02)}
.method{font-family:'JetBrains Mono',monospace;font-size:.75rem;font-weight:700;padding:.15rem .5rem;border-radius:4px}
.method.get{color:#34d399;background:rgba(52,211,153,.1)}.method.post{color:#818cf8;background:rgba(129,140,248,.1)}.method.patch{color:#fbbf24;background:rgba(251,191,36,.1)}.method.delete{color:#fb7185;background:rgba(251,113,133,.1)}
.endpoint{font-family:'JetBrains Mono',monospace;font-size:.8rem;color:var(--fg);direction:ltr;display:inline-block}
/* Footer */
footer{padding:3rem 4rem;text-align:center;border-top:1px solid var(--border);color:var(--dim);font-size:.8rem}
/* Animations */
.fade-in{opacity:0;transform:translateY(25px);transition:all .7s cubic-bezier(.16,1,.3,1)}
.fade-in.visible{opacity:1;transform:translateY(0)}
</style>
</head>
<body>
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">🧭</div>
<div><h1>انطلق</h1><span>الدليل الهندسي التعليمي v3.0</span></div>
</div>
<nav>
<div class="nav-group">
<div class="nav-group-title">الأساسيات</div>
<a href="#hero" class="nav-link active"><span class="num">00</span> نظرة عامة</a>
<a href="#nestjs-paradigm" class="nav-link"><span class="num">01</span> فلسفة NestJS</a>
<a href="#modules" class="nav-link"><span class="num">02</span> الوحدات والتبعيات</a>
</div>
<div class="nav-group">
<div class="nav-group-title">قاعدة البيانات</div>
<a href="#entities" class="nav-link"><span class="num">03</span> الكيانات و TypeORM</a>
<a href="#geocoding" class="nav-link"><span class="num">04</span> نظام البحث المكاني</a>
</div>
<div class="nav-group">
<div class="nav-group-title">الذكاء المكاني</div>
<a href="#telemetry" class="nav-link"><span class="num">05</span> تتبع السائقين</a>
<a href="#intelligence" class="nav-link"><span class="num">06</span> دورة الـ 10 أيام</a>
</div>
<div class="nav-group">
<div class="nav-group-title">البنية التحتية</div>
<a href="#api-reference" class="nav-link"><span class="num">07</span> مرجع الـ API</a>
<a href="#docker" class="nav-link"><span class="num">08</span> بيئة Docker</a>
<a href="#nginx" class="nav-link"><span class="num">09</span> إعدادات NGINX</a>
</div>
</nav>
</aside>
<!-- Main Content -->
<main class="main">
<!-- Hero -->
<header class="hero fade-in" id="hero">
<div class="badge">📘 دليل تعليمي تفاعلي — من الصفر إلى الإنتاج</div>
<h1>تعلّم بناء <em>منصة خرائط ذكية</em> بتقنيات حقيقية.</h1>
<p>هذا الدليل يأخذك في رحلة عملية داخل كود منصة "انطلق" — من تصميم قاعدة البيانات المكانية إلى نشر الحاويات. كل سطر كود هنا مأخوذ من المشروع الفعلي.</p>
<div class="hero-stats">
<div class="hero-stat"><div class="val">4</div><div class="lbl">وحدات NestJS</div></div>
<div class="hero-stat"><div class="val">6</div><div class="lbl">حاويات Docker</div></div>
<div class="hero-stat"><div class="val">3</div><div class="lbl">دول مدعومة</div></div>
<div class="hero-stat"><div class="val">∞</div><div class="lbl">نقاط تتبع</div></div>
</div>
</header>
<!-- Chapter 01: NestJS Paradigm -->
<section class="chapter fade-in" id="nestjs-paradigm">
<div class="chapter-header"><div class="chapter-num">01</div><h2>فلسفة NestJS — لماذا هذا الإطار؟</h2></div>
<p>NestJS يقوم على ثلاثة مبادئ أساسية: <strong>الوحدات (Modules)</strong> لتنظيم الكود، <strong>المتحكمات (Controllers)</strong> لاستقبال الطلبات، و<strong>الخدمات (Services)</strong> لمنطق العمل. كل شيء مربوط بـ <strong>حقن التبعيات (Dependency Injection)</strong>.</p>
<div class="callout info"><strong>💡 المبدأ الأساسي</strong> كل وحدة (Module) هي صندوق مستقل يحتوي على متحكماته وخدماته. الوحدة لا تعرف شيئاً عن الوحدات الأخرى إلا إذا تم "تصديرها" (Export) و"استيرادها" (Import) بشكل صريح.</div>
<p style="margin-top:1rem">نقطة البداية هي ملف <code style="color:var(--teal)">main.ts</code> — هنا يبدأ كل شيء:</p>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--teal)"></span> main.ts</div><div class="lang">TypeScript</div></div>
<pre class="code"><span class="hl-key">import</span> { NestFactory } <span class="hl-key">from</span> <span class="hl-str">'@nestjs/core'</span>;
<span class="hl-key">import</span> { DocumentBuilder, SwaggerModule } <span class="hl-key">from</span> <span class="hl-str">'@nestjs/swagger'</span>;
<span class="hl-key">import</span> { AppModule } <span class="hl-key">from</span> <span class="hl-str">'./app.module'</span>;
<span class="hl-key">async function</span> <span class="hl-fn">bootstrap</span>() {
<span class="hl-key">const</span> app = <span class="hl-key">await</span> NestFactory.<span class="hl-fn">create</span>(AppModule);
<span class="hl-cmt">// تفعيل تبادل الموارد مع الواجهة الأمامية</span>
app.<span class="hl-fn">enableCors</span>();
app.<span class="hl-fn">setGlobalPrefix</span>(<span class="hl-str">'api'</span>); <span class="hl-cmt">// كل المسارات تبدأ بـ /api</span>
<span class="hl-cmt">// إعداد وثائق Swagger التلقائية</span>
<span class="hl-key">const</span> config = <span class="hl-key">new</span> <span class="hl-fn">DocumentBuilder</span>()
.<span class="hl-fn">setTitle</span>(<span class="hl-str">'Jordan Map Platform API'</span>)
.<span class="hl-fn">setVersion</span>(<span class="hl-str">'1.0'</span>).<span class="hl-fn">build</span>();
<span class="hl-key">const</span> port = process.env.API_PORT || <span class="hl-num">3000</span>;
<span class="hl-key">await</span> app.<span class="hl-fn">listen</span>(port);
console.<span class="hl-fn">log</span>(<span class="hl-str">`🚀 API is running on port ${port}`</span>);
}
<span class="hl-fn">bootstrap</span>();</pre>
</div>
<div class="callout tip"><strong>🔗 العلاقة:</strong> <code>main.ts</code> → يُنشئ التطبيق من <code>AppModule</code> → الذي يستورد <code>TelemetryModule</code> + <code>MapsModule</code> + <code>GeocodingModule</code>.</div>
</section>
<!-- Chapter 02: Modules -->
<section class="chapter fade-in" id="modules">
<div class="chapter-header"><div class="chapter-num">02</div><h2>الوحدات والتبعيات — كيف يُنظَّم الكود؟</h2></div>
<p>المشروع مقسم لـ 4 وحدات رئيسية. كل وحدة تعريفها في ملف <code style="color:var(--teal)">*.module.ts</code>:</p>
<div class="diagram">
<div style="color:var(--muted);font-size:.8rem;margin-bottom:1rem">🏗️ خريطة الوحدات</div>
<div class="diagram-flow">
<div class="diagram-node active">AppModule</div>
<div class="diagram-arrow">←</div>
<div class="diagram-node">TelemetryModule</div>
<div class="diagram-arrow">←</div>
<div class="diagram-node">MapsModule</div>
<div class="diagram-arrow">←</div>
<div class="diagram-node">GeocodingModule</div>
</div>
</div>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--blue)"></span> app.module.ts</div><div class="lang">TypeScript</div></div>
<pre class="code"><span class="hl-key">@Module</span>({
<span class="hl-type">imports</span>: [
ConfigModule.<span class="hl-fn">forRoot</span>({ isGlobal: <span class="hl-num">true</span> }),
ScheduleModule.<span class="hl-fn">forRoot</span>(), <span class="hl-cmt">// لتشغيل المهام الدورية (Cron)</span>
TypeOrmModule.<span class="hl-fn">forRootAsync</span>({ <span class="hl-cmt">// اتصال قاعدة البيانات</span>
<span class="hl-type">useFactory</span>: (config) => ({
type: <span class="hl-str">'postgres'</span>,
url: config.<span class="hl-fn">get</span>(<span class="hl-str">'DATABASE_URL'</span>),
autoLoadEntities: <span class="hl-num">true</span>,
}),
}),
TelemetryModule, <span class="hl-cmt">// وحدة تتبع السائقين</span>
MapsModule, <span class="hl-cmt">// وحدة التوجيه والخرائط</span>
GeocodingModule, <span class="hl-cmt">// وحدة البحث المكاني</span>
],
})
<span class="hl-key">export class</span> <span class="hl-type">AppModule</span> {}</pre>
</div>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--gold)"></span> telemetry.module.ts</div><div class="lang">TypeScript</div></div>
<pre class="code"><span class="hl-key">@Module</span>({
<span class="hl-type">imports</span>: [
TypeOrmModule.<span class="hl-fn">forFeature</span>([ <span class="hl-cmt">// ← تسجيل الكيانات</span>
TelemetryLog,
RoadSegmentStat,
CandidateRoad
]),
RedisModule, <span class="hl-cmt">// ← استيراد خدمة Redis</span>
],
<span class="hl-type">controllers</span>: [TelemetryController, MapRefinementController],
<span class="hl-type">providers</span>: [TelemetryService, TelemetryAnalyzerService,
RedisService, ExternalTelemetryService],
<span class="hl-type">exports</span>: [TelemetryService, TelemetryAnalyzerService],
})
<span class="hl-key">export class</span> <span class="hl-type">TelemetryModule</span> {}</pre>
</div>
<div class="callout warn"><strong>⚠️ قاعدة ذهبية:</strong> أي خدمة (Service) يجب أن تكون مُسجلة في <code>providers</code> وأي خدمة تريد مشاركتها يجب وضعها في <code>exports</code>.</div>
</section>
<!-- Chapter 03: Entities -->
<section class="chapter fade-in" id="entities">
<div class="chapter-header"><div class="chapter-num">03</div><h2>الكيانات و TypeORM — تصميم قاعدة البيانات</h2></div>
<p>كل جدول في PostgreSQL يُمثَّل بكيان (Entity). الكيان هو كلاس TypeScript مزيّن بـ <code style="color:var(--teal)">@Entity()</code> يُعرِّف الأعمدة والفهارس:</p>
<div class="card-grid">
<div class="card"><div class="icon">📍</div><h4>TelemetryLog</h4><p>يخزن نقاط GPS من السائقين. يحتوي عمود <code>location</code> من نوع PostGIS <code>geography(Point)</code> مع فهرس مكاني.</p></div>
<div class="card"><div class="icon">🛣️</div><h4>RoadSegmentStat</h4><p>إحصائيات السرعة لكل مقطع طريق. يحتوي <code>congestionFactor</code> و<code>geometry</code> من نوع <code>LineString</code>.</p></div>
<div class="card"><div class="icon">🔍</div><h4>CandidateRoad</h4><p>طرق مُكتشفة من تحليل DBSCAN. تحتوي <code>confidence</code> (0-1) و<code>status</code> (pending/approved/rejected).</p></div>
<div class="card"><div class="icon">🌍</div><h4>BasePlace</h4><p>الكيان الأب لجداول <code>places_syria</code>, <code>places_jordan</code>, <code>places_egypt</code>. يُعرّف الأعمدة المشتركة.</p></div>
</div>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--pink)"></span> telemetry.entity.ts</div><div class="lang">TypeScript</div></div>
<pre class="code"><span class="hl-key">@Entity</span>(<span class="hl-str">'telemetry_logs'</span>)
<span class="hl-key">export class</span> <span class="hl-type">TelemetryLog</span> {
<span class="hl-key">@PrimaryGeneratedColumn</span>()
id: <span class="hl-type">number</span>;
<span class="hl-key">@Column</span>() <span class="hl-key">@Index</span>()
driverId: <span class="hl-type">string</span>;
<span class="hl-key">@Column</span>(<span class="hl-str">'decimal'</span>, { precision: <span class="hl-num">10</span>, scale: <span class="hl-num">7</span> })
latitude: <span class="hl-type">number</span>;
<span class="hl-key">@Column</span>(<span class="hl-str">'float'</span>)
speed: <span class="hl-type">number</span>;
<span class="hl-cmt">// نقطة جغرافية PostGIS للبحث المكاني السريع</span>
<span class="hl-key">@Column</span>({ type: <span class="hl-str">'geography'</span>, spatialFeatureType: <span class="hl-str">'Point'</span>, srid: <span class="hl-num">4326</span> })
<span class="hl-key">@Index</span>({ spatial: <span class="hl-num">true</span> }) <span class="hl-cmt">// ← فهرس GIST للبحث بالمسافة</span>
location: <span class="hl-type">any</span>;
}</pre>
</div>
</section>
<!-- Chapter 04: Geocoding -->
<section class="chapter fade-in" id="geocoding">
<div class="chapter-header"><div class="chapter-num">04</div><h2>نظام البحث المكاني — Forward & Reverse Geocoding</h2></div>
<p>الخدمة تبحث في 3 جداول إقليمية + جدول OSM العالمي. المفتاح: استخدام <code style="color:var(--teal)">::float</code> في استعلامات SQL لتجنب خطأ "النتائج الفارغة":</p>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--green)"></span> geocoding.service.ts — searchPlaces()</div><div class="lang">SQL + TypeScript</div></div>
<pre class="code"><span class="hl-cmt">// تحديد الجدول المناسب بناءً على الإحداثيات</span>
<span class="hl-key">private</span> <span class="hl-fn">getRepositoryForCoords</span>(lat, lng) {
<span class="hl-key">if</span> (lat >= <span class="hl-num">29</span> && lat <= <span class="hl-num">37.5</span> && lng >= <span class="hl-num">34.5</span>) {
<span class="hl-key">if</span> (lat > <span class="hl-num">32.5</span> && lng > <span class="hl-num">35.8</span>) <span class="hl-key">return</span> placesSyriaRepo;
<span class="hl-key">return</span> placesJordanRepo;
}
<span class="hl-key">if</span> (lat >= <span class="hl-num">22</span> && lat <= <span class="hl-num">32</span>) <span class="hl-key">return</span> placesEgyptRepo;
}
<span class="hl-cmt">// الاستعلام المكاني — لاحظ ::float في كل مكان</span>
<span class="hl-key">const</span> query = <span class="hl-str">`
SELECT id, name_ar, latitude, longitude,
ST_DistanceSphere(
location,
ST_SetSRID(ST_MakePoint($3<span class="hl-dec">::float</span>, $2<span class="hl-dec">::float</span>), 4326)
) as distance
FROM ${tableName}
WHERE name_ar % $1 -- بحث تشابهي (Trigram)
ORDER BY distance ASC LIMIT 15
`</span>;</pre>
</div>
<div class="callout tip"><strong>🐛 الخطأ الذي حللناه:</strong> PostGIS كان يقرأ الإحداثيات كـ <code>text</code> بدلاً من <code>float</code>، مما يُنتج نتائج فارغة. الحل: إضافة <code>::float</code> لكل مُعامل رقمي.</div>
</section>
<!-- Chapter 05: Telemetry -->
<section class="chapter fade-in" id="telemetry">
<div class="chapter-header"><div class="chapter-num">05</div><h2>تتبع السائقين — من GPS إلى قاعدة البيانات</h2></div>
<p>كل 3 ثوانٍ، يرسل تطبيق السائق بيانات الموقع. المتحكم يستقبلها والخدمة تحفظها:</p>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--gold)"></span> telemetry.service.ts — ingest()</div><div class="lang">TypeScript</div></div>
<pre class="code"><span class="hl-key">async</span> <span class="hl-fn">ingest</span>(data: DriverTelemetryDto) {
<span class="hl-key">const</span> log = <span class="hl-key">this</span>.telemetryRepo.<span class="hl-fn">create</span>({
driverId: data.driver_id,
latitude: data.latitude, longitude: data.longitude,
speed: data.speed, heading: data.heading,
location: {
type: <span class="hl-str">'Point'</span>,
coordinates: [data.longitude, data.latitude], <span class="hl-cmt">// GeoJSON: [lng, lat]</span>
},
});
<span class="hl-key">await this</span>.telemetryRepo.<span class="hl-fn">save</span>(log);
<span class="hl-key">return</span> { success: <span class="hl-num">true</span>, timestamp: <span class="hl-key">new</span> Date() };
}</pre>
</div>
<div class="callout info"><strong>🔗 سلسلة الاستدعاء:</strong> <code>Flutter App</code> → <code>POST /api/telemetry</code> → <code>TelemetryController.ingest()</code> → <code>TelemetryService.ingest()</code> → <code>PostgreSQL + PostGIS</code></div>
</section>
<!-- Chapter 06: Intelligence Cycle -->
<section class="chapter fade-in" id="intelligence">
<div class="chapter-header"><div class="chapter-num">06</div><h2>دورة الذكاء المكاني — كل 10 أيام</h2></div>
<p>هذا هو قلب المنصة. الخدمة <code style="color:var(--teal)">TelemetryAnalyzerService</code> تعمل تلقائياً عبر <code>@Cron</code> كل فجر لتحليل البيانات:</p>
<div class="diagram">
<div style="color:var(--muted);font-size:.8rem;margin-bottom:1rem">🧠 دورة المعالجة الذكية</div>
<div class="diagram-flow">
<div class="diagram-node">1. مزامنة البيانات</div>
<div class="diagram-arrow">→</div>
<div class="diagram-node active">2. تحليل السرعات</div>
<div class="diagram-arrow">→</div>
<div class="diagram-node">3. اكتشاف الطرق</div>
<div class="diagram-arrow">→</div>
<div class="diagram-node">4. تحديث Redis</div>
</div>
</div>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--red)"></span> telemetry-analyzer.service.ts — discoverNewRoads()</div><div class="lang">PostGIS SQL</div></div>
<pre class="code"><span class="hl-cmt">-- الخطوة 1: إيجاد نقاط بعيدة عن أي طريق معروف (> 15 متر)</span>
<span class="hl-key">WITH</span> off_road_points <span class="hl-key">AS</span> (
<span class="hl-key">SELECT</span> t.id, t.location, t.timestamp, t."driverId"
<span class="hl-key">FROM</span> telemetry_logs t
<span class="hl-key">WHERE</span> t.speed > <span class="hl-num">5</span> <span class="hl-cmt">-- متحرك وليس متوقف</span>
<span class="hl-key">AND NOT EXISTS</span> (
<span class="hl-key">SELECT</span> <span class="hl-num">1</span> <span class="hl-key">FROM</span> planet_osm_line l
<span class="hl-key">WHERE</span> <span class="hl-fn">ST_DWithin</span>(t.location::geography,
<span class="hl-fn">ST_Transform</span>(l.way, <span class="hl-num">4326</span>)::geography, <span class="hl-num">15</span>)
)
),
<span class="hl-cmt">-- الخطوة 2: تجميع النقاط القريبة باستخدام DBSCAN</span>
clustered <span class="hl-key">AS</span> (
<span class="hl-key">SELECT</span> *,
<span class="hl-fn">ST_ClusterDBSCAN</span>(location::geometry,
eps := <span class="hl-num">0.0003</span>, minpoints := <span class="hl-num">5</span>) <span class="hl-key">OVER</span> () <span class="hl-key">AS</span> cluster_id
<span class="hl-key">FROM</span> off_road_points
)
<span class="hl-cmt">-- الخطوة 3: تحويل كل تجمع إلى خط طريق مرشح</span>
<span class="hl-key">SELECT</span> cluster_id,
<span class="hl-fn">COUNT</span>(<span class="hl-key">DISTINCT</span> "driverId") <span class="hl-key">AS</span> unique_drivers,
<span class="hl-fn">ST_AsGeoJSON</span>(<span class="hl-fn">ST_MakeLine</span>(location <span class="hl-key">ORDER BY</span> timestamp)) <span class="hl-key">AS</span> geojson
<span class="hl-key">FROM</span> clustered <span class="hl-key">WHERE</span> cluster_id <span class="hl-key">IS NOT NULL</span>
<span class="hl-key">GROUP BY</span> cluster_id
<span class="hl-key">HAVING COUNT</span>(<span class="hl-key">DISTINCT</span> "driverId") >= <span class="hl-num">2</span></pre>
</div>
</section>
<!-- Chapter 07: API Reference -->
<section class="chapter fade-in" id="api-reference">
<div class="chapter-header"><div class="chapter-num">07</div><h2>مرجع الـ API — كل نقاط النهاية</h2></div>
<p>جميع المسارات محمية بـ <code style="color:var(--teal)">ApiKeyGuard</code> عبر الترويسة <code>x-api-key</code>.</p>
<table class="api-table">
<thead><tr><th>الطريقة</th><th>المسار</th><th>الوصف</th><th>الملف المصدر</th></tr></thead>
<tbody>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry</span></td><td>استقبال بيانات تتبع السائقين كل 3 ثوانٍ</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry/sync</span></td><td>مزامنة يدوية من الخادم الخارجي</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry/process-intelligence</span></td><td>🚀 تنفيذ دورة الذكاء كاملة (مزامنة + تحليل)</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/maps/route?fromLat&fromLng&toLat&toLng</span></td><td>حساب أقصر مسار عبر GraphHopper</td><td style="color:var(--dim)">maps.controller.ts</td></tr>
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/geocoding/search?q&lat&lng&country</span></td><td>بحث مكاني متعدد الأقاليم</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/geocoding/reverse?lat&lng</span></td><td>تحويل إحداثيات → عنوان</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/geocoding/upsert-batch</span></td><td>إدخال أماكن بالجملة (Scraper)</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/map-refinement/analyze-speeds</span></td><td>تحليل سرعات الطرق</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/map-refinement/discover-roads</span></td><td>اكتشاف طرق جديدة (DBSCAN)</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/map-refinement/congestion?north&south&east&west</span></td><td>بيانات الازدحام للخريطة الحرارية</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><span class="endpoint">/api/map-refinement/candidates/:id/approve</span></td><td>الموافقة على طريق مكتشف</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
<tr><td><span class="method delete">DELETE</span></td><td><span class="endpoint">/api/geocoding/places?country&name</span></td><td>حذف مكان حسب الاسم أو المعرّف</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
</tbody>
</table>
</section>
<!-- Chapter 08: Docker -->
<section class="chapter fade-in" id="docker">
<div class="chapter-header"><div class="chapter-num">08</div><h2>بيئة Docker — حاويات مترابطة</h2></div>
<p>كل خدمة تعمل في حاوية معزولة. ملف <code style="color:var(--teal)">docker-compose.yml</code> يربطها عبر شبكة داخلية:</p>
<div class="card-grid">
<div class="card"><div class="icon">🐘</div><h4>db (PostGIS)</h4><p>postgis/postgis:15-3.3 — القلب. تخزين كل البيانات المكانية مع فهارس GIST.</p></div>
<div class="card"><div class="icon">🚀</div><h4>api (NestJS)</h4><p>node:20-alpine — العقل المدبر. يعالج الطلبات وينفذ التحليلات.</p></div>
<div class="card"><div class="icon">🗺️</div><h4>martin (Tiles)</h4><p>maplibre/martin — يولّد Vector Tiles مباشرة من PostGIS بسرعة فائقة.</p></div>
<div class="card"><div class="icon">🧭</div><h4>routing (GraphHopper)</h4><p>يحسب أقصر المسارات باستخدام ملف OSM محدث + بيانات الازدحام.</p></div>
<div class="card"><div class="icon">⚡</div><h4>redis (Cache)</h4><p>redis:7-alpine — تخزين مؤقت لحالة الازدحام اللحظية.</p></div>
<div class="card"><div class="icon">🌐</div><h4>web (React)</h4><p>لوحة تحكم الخرائط التفاعلية مع عرض البيانات.</p></div>
</div>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--blue)"></span> docker-compose.yml (مقتطف)</div><div class="lang">YAML</div></div>
<pre class="code"><span class="hl-type">services</span>:
<span class="hl-fn">db</span>:
image: <span class="hl-str">postgis/postgis:15-3.3</span>
volumes:
- <span class="hl-str">postgres_data:/var/lib/postgresql/data</span>
healthcheck:
test: [<span class="hl-str">"CMD-SHELL"</span>, <span class="hl-str">"pg_isready"</span>]
<span class="hl-fn">api</span>:
build: ./infrastructure/docker/api/Dockerfile
ports: [<span class="hl-str">"3200:3200"</span>]
environment:
- <span class="hl-str">DATABASE_URL=postgresql://user:pass@db:5432/mapdb</span>
- <span class="hl-str">REDIS_URL=redis://redis:6379</span>
depends_on:
db: { condition: <span class="hl-str">service_healthy</span> }
redis: { condition: <span class="hl-str">service_healthy</span> }
<span class="hl-fn">martin</span>:
image: <span class="hl-str">maplibre/martin:latest</span>
ports: [<span class="hl-str">"3202:3000"</span>]
command: <span class="hl-str">postgresql://user:pass@db:5432/mapdb</span></pre>
</div>
<div class="callout info"><strong>💻 أوامر التشغيل الأساسية:</strong><br>
<code style="color:var(--teal)">$ docker-compose up -d --build</code> — بناء وتشغيل كل الحاويات<br>
<code style="color:var(--teal)">$ docker-compose ps</code> — عرض حالة الحاويات<br>
<code style="color:var(--teal)">$ docker-compose logs -f api</code> — مراقبة سجلات الـ API
</div>
</section>
<!-- Chapter 09: NGINX -->
<section class="chapter fade-in" id="nginx">
<div class="chapter-header"><div class="chapter-num">09</div><h2>إعدادات NGINX — البوابة الأمامية</h2></div>
<p>NGINX يعمل كـ <strong>Reverse Proxy</strong> — يستقبل كل الطلبات على المنفذ 80 ويوجهها للحاوية المناسبة:</p>
<div class="code-card">
<div class="code-header"><div class="file"><span class="dot" style="background:var(--green)"></span> nginx.conf</div><div class="lang">NGINX</div></div>
<pre class="code"><span class="hl-key">server</span> {
listen <span class="hl-num">80</span>;
server_name portal.intaleq.map;
<span class="hl-cmt"># توجيه طلبات الـ API إلى حاوية NestJS</span>
<span class="hl-key">location</span> /api {
proxy_pass <span class="hl-str">http://api:3200</span>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
<span class="hl-cmt"># توجيه طلبات بلاط الخرائط إلى Martin</span>
<span class="hl-key">location</span> /tiles {
proxy_pass <span class="hl-str">http://martin:3000</span>;
}
<span class="hl-cmt"># توجيه طلبات التوجيه إلى GraphHopper</span>
<span class="hl-key">location</span> /routing {
proxy_pass <span class="hl-str">http://routing:8989</span>;
}
}</pre>
</div>
</section>
<footer>
<p>نظام "انطلق" — الدليل الهندسي التعليمي الشامل © 2026</p>
<p style="margin-top:.5rem">تم بناء هذا الدليل من الكود المصدري الفعلي للمنصة</p>
</footer>
<script>
const obs = new IntersectionObserver(e => e.forEach(el => {if(el.isIntersecting)el.target.classList.add('visible')}),{threshold:.1});
document.querySelectorAll('.fade-in').forEach(r => obs.observe(r));
// Active nav link tracking
const sections = document.querySelectorAll('.chapter, .hero');
const navLinks = document.querySelectorAll('.nav-link');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(s => { if(scrollY >= s.offsetTop - 200) current = s.id; });
navLinks.forEach(l => { l.classList.toggle('active', l.getAttribute('href') === '#' + current); });
});
</script>
</body>
</html>
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);
}
async deletePlacesByName(name: string, country: string) {
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

Some files were not shown because too many files have changed in this diff Show More