diff --git a/apps/api/package.json b/apps/api/package.json
index 9e4f399..15e89e6 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -25,7 +25,6 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
- "@nestjs/serve-static": "^5.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.6",
diff --git a/apps/api/public/intel/app.js b/apps/api/public/intel/app.js
deleted file mode 100644
index 9abfba2..0000000
--- a/apps/api/public/intel/app.js
+++ /dev/null
@@ -1,156 +0,0 @@
-// Intaleq Intelligence Dashboard Control
-// المتحكم البرمجي في لوحة ذكاء الخرائط
-
-const API_BASE = '/api';
-
-// Initialize
-document.addEventListener('DOMContentLoaded', () => {
- fetchSummary();
- fetchCandidates();
- fetchClosures();
-
- // Event Listeners
- document.getElementById('run-analysis-btn').addEventListener('click', runAnalysis);
- document.getElementById('discover-closures-btn').addEventListener('click', discoverClosures);
-});
-
-// --- API FETCHERS ---
-
-async function fetchSummary() {
- try {
- const res = await fetch(`${API_BASE}/map-refinement/summary`);
- const data = await res.json();
-
- document.getElementById('stat-telemetry').innerText = Number(data.telemetry.total).toLocaleString();
- document.getElementById('stat-roads').innerText = Number(data.roads.analyzed).toLocaleString();
- document.getElementById('stat-pending').innerText = Number(data.candidates.pending).toLocaleString();
- document.getElementById('stat-closed').innerText = Number(data.roads.closed || 0).toLocaleString();
- } catch (e) {
- showToast('Failed to fetch summary');
- }
-}
-
-async function fetchCandidates() {
- const tableBody = document.querySelector('#candidates-table tbody');
- try {
- const res = await fetch(`${API_BASE}/map-refinement/candidates?status=pending`);
- const data = await res.json();
-
- if (!data || data.length === 0) {
- tableBody.innerHTML = '
| No pending discoveries 🏜️ |
';
- return;
- }
-
- tableBody.innerHTML = data.map(c => `
-
- | ${c.id.substring(0,8)}... |
- ${c.uniqueDriverCount} |
- ${c.totalPoints} |
- ${Math.round(c.lengthMeters)}m |
- ${Math.round(c.confidence * 100)}% |
-
-
-
- |
-
- `).join('');
- } catch (e) {
- tableBody.innerHTML = '| Error loading candidates |
';
- }
-}
-
-async function fetchClosures() {
- const tableBody = document.querySelector('#closures-table tbody');
- try {
- const res = await fetch(`${API_BASE}/map-refinement/closures`);
- const data = await res.json();
-
- if (!data || data.length === 0) {
- tableBody.innerHTML = '| No segments flagged |
';
- return;
- }
-
- tableBody.innerHTML = data.map(s => `
-
- | ${s.segmentId} |
- ${s.sampleCount} pts |
- CLOSED |
-
- `).join('');
- } catch (e) {
- tableBody.innerHTML = '| Error loading closures |
';
- }
-}
-
-// --- ACTIONS ---
-
-async function approveCandidate(id) {
- if (!confirm('Add this path to the approved map queue?')) return;
- try {
- await fetch(`${API_BASE}/map-refinement/candidates/${id}/approve`, { method: 'PATCH' });
- showToast('Road Approved ✅');
- fetchCandidates();
- fetchSummary();
- } catch (e) {
- showToast('Approval failed');
- }
-}
-
-async function rejectCandidate(id) {
- try {
- await fetch(`${API_BASE}/map-refinement/candidates/${id}/reject`, { method: 'PATCH' });
- showToast('Discovery Rejected ❌');
- fetchCandidates();
- fetchSummary();
- } catch (e) {
- showToast('Rejection failed');
- }
-}
-
-async function runAnalysis() {
- const btn = document.getElementById('run-analysis-btn');
- btn.disabled = true;
- btn.innerHTML = ' Analyzing...';
-
- try {
- await fetch(`${API_BASE}/telemetry/process-intelligence?days=2`, { method: 'POST' });
- showToast('Deep Intelligence started 🧠. Check Telegram for report soon!');
- } catch (e) {
- showToast('Analysis trigger failed');
- } finally {
- setTimeout(() => {
- btn.disabled = false;
- btn.innerHTML = ' Run Intelligence';
- fetchSummary();
- }, 3000);
- }
-}
-
-async function discoverClosures() {
- showToast('Scanning for closures... 🚧');
- try {
- await fetch(`${API_BASE}/map-refinement/discover-closures`, { method: 'POST' });
- showToast('Closure scan complete');
- fetchClosures();
- fetchSummary();
- } catch (e) {
- showToast('Closure discovery failed');
- }
-}
-
-// --- HELPERS ---
-
-function getConfClass(conf) {
- if (conf > 0.8) return 'high';
- if (conf > 0.5) return 'med';
- return 'low';
-}
-
-function showToast(msg) {
- const container = document.getElementById('toast-container');
- const toast = document.createElement('div');
- toast.className = 'toast';
- toast.innerText = msg;
- container.appendChild(toast);
- setTimeout(() => toast.remove(), 4000);
-}
diff --git a/apps/api/public/intel/index.html b/apps/api/public/intel/index.html
deleted file mode 100644
index ca6a605..0000000
--- a/apps/api/public/intel/index.html
+++ /dev/null
@@ -1,149 +0,0 @@
-
-
-
-
-
- Intaleq | Map Intelligence Dashboard
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/api/public/intel/styles.css b/apps/api/public/intel/styles.css
deleted file mode 100644
index 0653a93..0000000
--- a/apps/api/public/intel/styles.css
+++ /dev/null
@@ -1,365 +0,0 @@
-:root {
- --bg-dark: #0f172a;
- --bg-navbar: #1e293b;
- --card-bg: rgba(30, 41, 59, 0.7);
- --border-color: #334155;
- --text-primary: #f8fafc;
- --text-secondary: #94a3b8;
- --accent-purple: #8b5cf6;
- --accent-blue: #3b82f6;
- --accent-orange: #f59e0b;
- --accent-red: #ef4444;
- --accent-green: #10b981;
- --sidebar-width: 260px;
- --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
- background-color: var(--bg-dark);
- color: var(--text-primary);
- overflow: hidden;
- line-height: 1.5;
-}
-
-.app-container {
- display: flex;
- height: 100vh;
-}
-
-/* Sidebar Styling */
-.sidebar {
- width: var(--sidebar-width);
- background-color: var(--bg-navbar);
- border-right: 1px solid var(--border-color);
- display: flex;
- flex-direction: column;
- padding: 1.5rem;
- z-index: 10;
-}
-
-.logo-container {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding-bottom: 2rem;
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border-color);
-}
-
-.logo-icon {
- width: 40px;
- height: 40px;
- background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: white;
- font-size: 1.25rem;
- box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
-}
-
-.logo-text {
- font-weight: 700;
- font-size: 1.25rem;
- letter-spacing: -0.5px;
-}
-
-.logo-text span {
- display: block;
- font-size: 0.75rem;
- color: var(--text-secondary);
- font-weight: 400;
- margin-top: -2px;
-}
-
-.main-nav {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.nav-item {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.75rem 1rem;
- border-radius: 8px;
- color: var(--text-secondary);
- text-decoration: none;
- font-weight: 500;
- transition: var(--transition);
-}
-
-.nav-item i {
- width: 20px;
- text-align: center;
-}
-
-.nav-item:hover, .nav-item.active {
- background-color: rgba(255, 255, 255, 0.05);
- color: white;
-}
-
-.nav-item.active {
- background-color: rgba(139, 92, 246, 0.1);
- color: var(--accent-purple);
-}
-
-.status-indicator {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 1rem;
- background: rgba(0, 0, 0, 0.2);
- border-radius: 12px;
- font-size: 0.85rem;
-}
-
-.dot {
- width: 8px;
- height: 8px;
- background-color: var(--accent-green);
- border-radius: 50%;
-}
-
-.pulse {
- box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
- animation: pulse 1.5s infinite;
-}
-
-@keyframes pulse {
- 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
- 70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
- 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
-}
-
-/* Main Content Area */
-.main-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- background-color: var(--bg-dark);
- position: relative;
- overflow: hidden;
-}
-
-.top-bar {
- height: 70px;
- padding: 0 2rem;
- background-color: rgba(15, 23, 42, 0.8);
- backdrop-filter: blur(8px);
- border-bottom: 1px solid var(--border-color);
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.search-container {
- position: relative;
- width: 400px;
-}
-
-.search-container i {
- position: absolute;
- left: 1rem;
- top: 50%;
- transform: translateY(-50%);
- color: var(--text-secondary);
-}
-
-.search-container input {
- width: 100%;
- padding: 0.6rem 1rem 0.6rem 2.8rem;
- background-color: var(--bg-navbar);
- border: 1px solid var(--border-color);
- border-radius: 10px;
- color: white;
- outline: none;
-}
-
-.action-buttons {
- display: flex;
- align-items: center;
- gap: 1.5rem;
-}
-
-.user-profile {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- overflow: hidden;
- border: 2px solid var(--border-color);
-}
-
-.user-profile img {
- width: 100%;
-}
-
-/* Dashboard Content */
-.dashboard-scroll {
- flex: 1;
- padding: 2rem;
- overflow-y: auto;
-}
-
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 1.5rem;
- margin-bottom: 2rem;
-}
-
-.stat-card {
- background-color: var(--card-bg);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- padding: 1.5rem;
- display: flex;
- align-items: center;
- gap: 1.25rem;
-}
-
-.stat-icon {
- width: 45px;
- height: 45px;
- border-radius: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.25rem;
-}
-
-.stat-icon.purple { background: rgba(139, 92, 246, 0.15); color: var(--accent-purple); }
-.stat-icon.blue { background: rgba(59, 130, 246, 0.15); color: var(--accent-blue); }
-.stat-icon.orange { background: rgba(245, 158, 11, 0.15); color: var(--accent-orange); }
-.stat-icon.red { background: rgba(239, 68, 68, 0.15); color: var(--accent-red); }
-
-.stat-label {
- display: block;
- color: var(--text-secondary);
- font-size: 0.85rem;
- font-weight: 500;
-}
-
-.stat-info h2 {
- font-size: 1.5rem;
- font-weight: 700;
-}
-
-/* Content Sections */
-.content-grid {
- display: grid;
- grid-template-columns: 1.5fr 1fr;
- gap: 1.5rem;
-}
-
-.card {
- background-color: var(--card-bg);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- overflow: hidden;
-}
-
-.card-header {
- padding: 1.25rem 1.5rem;
- border-bottom: 1px solid var(--border-color);
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.card-header h3 {
- font-size: 1.1rem;
- font-weight: 600;
- display: flex;
- align-items: center;
- gap: 0.75rem;
-}
-
-/* Table Styling */
-.table-container {
- max-height: 500px;
- overflow-y: auto;
-}
-
-table {
- width: 100%;
- border-collapse: collapse;
- text-align: left;
-}
-
-th {
- padding: 0.75rem 1.5rem;
- background-color: rgba(0, 0, 0, 0.1);
- color: var(--text-secondary);
- font-size: 0.75rem;
- text-transform: uppercase;
- font-weight: 700;
- letter-spacing: 0.5px;
-}
-
-td {
- padding: 1rem 1.5rem;
- border-bottom: 1px solid var(--border-color);
- font-size: 0.9rem;
-}
-
-tr:last-child td { border-bottom: none; }
-
-.empty-state { text-align: center; color: var(--text-secondary); padding: 3rem; }
-
-/* Buttons */
-.btn {
- padding: 0.6rem 1.2rem;
- border-radius: 8px;
- font-weight: 600;
- cursor: pointer;
- border: none;
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- transition: var(--transition);
-}
-
-.btn-primary { background-color: var(--accent-purple); color: white; }
-.btn-primary:hover { background-color: #7c3aed; filter: brightness(1.1); }
-
-.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
-.btn-ghost:hover { border-color: var(--text-primary); color: white; }
-
-.btn-secondary-sm { padding: 0.4rem 0.8rem; font-size: 0.8rem; background: var(--bg-navbar); color: var(--accent-blue); border: 1px solid var(--accent-blue); }
-
-.btn-sm-success { background: rgba(16, 185, 129, 0.1); color: var(--accent-green); padding: 0.3rem 0.7rem; font-size: 0.75rem; }
-.btn-sm-danger { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); padding: 0.3rem 0.7rem; font-size: 0.75rem; }
-
-/* Confidence Badge */
-.badge-conf {
- padding: 0.2rem 0.6rem;
- border-radius: 20px;
- font-size: 0.75rem;
- font-weight: 700;
-}
-
-.high { background: rgba(16, 185, 129, 0.2); color: var(--accent-green); }
-.med { background: rgba(245, 158, 11, 0.2); color: var(--accent-orange); }
-.low { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
-
-/* Custom Scrollbar */
-::-webkit-scrollbar { width: 6px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 10px; }
-::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
-
-/* Toasts */
-#toast-container { position: fixed; bottom: 2rem; right: 2rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; }
-.toast { background: var(--bg-navbar); color: white; padding: 1rem 1.5rem; border-radius: 10px; border-left: 4px solid var(--accent-purple); box-shadow: 0 10px 15px rgba(0,0,0,0.5); animation: slideIn 0.3s ease-out; }
-@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 6215d40..e2be9aa 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -2,8 +2,6 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
-import { ServeStaticModule } from '@nestjs/serve-static';
-import { join } from 'path';
import { TelemetryModule } from './telemetry/telemetry.module';
import { MapsModule } from './maps/maps.module';
import { GeocodingModule } from './geocoding/geocoding.module';
@@ -14,10 +12,6 @@ import { GeocodingModule } from './geocoding/geocoding.module';
isGlobal: true,
}),
ScheduleModule.forRoot(),
- ServeStaticModule.forRoot({
- rootPath: join(__dirname, '..', 'public', 'intel'),
- serveRoot: '/api/intel',
- }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
diff --git a/apps/api/src/common/guards/api-key.guard.ts b/apps/api/src/common/guards/api-key.guard.ts
index 9af5d51..26205f3 100644
--- a/apps/api/src/common/guards/api-key.guard.ts
+++ b/apps/api/src/common/guards/api-key.guard.ts
@@ -13,7 +13,7 @@ export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const apiKeyHeader = request.headers['x-api-key'];
- const validApiKey = this.configService.get('MAP_API_KEY') || 'intaleq_secret_2026';
+ const validApiKey = this.configService.get('MAP_API_KEY') || 'intaleq_premium_saas_2026_secure_key_x';
// Enforce API key match
if (apiKeyHeader !== validApiKey) {
diff --git a/apps/api/src/geocoding/admin-boundaries.service.ts b/apps/api/src/geocoding/admin-boundaries.service.ts
new file mode 100644
index 0000000..9251728
--- /dev/null
+++ b/apps/api/src/geocoding/admin-boundaries.service.ts
@@ -0,0 +1,130 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { AdminBoundary } from './entities/admin-boundary.entity';
+
+@Injectable()
+export class AdminBoundariesService {
+ private readonly logger = new Logger(AdminBoundariesService.name);
+
+ constructor(
+ @InjectRepository(AdminBoundary)
+ private adminBoundaryRepo: Repository,
+ ) {}
+
+ /**
+ * Import GeoJSON features into the admin_boundaries table.
+ * Expects feature.properties to contain 'admin_level', 'name:ar'/'name', 'name:en'
+ */
+ async importGeoJSON(
+ targetCountryCode: string,
+ geoJson: any
+ ): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
+ this.logger.log(`Importing admin boundaries for ${targetCountryCode}...`);
+
+ let imported = 0;
+ let errors = 0;
+ let skipped = 0;
+
+ if (!geoJson || !geoJson.features || !Array.isArray(geoJson.features)) {
+ this.logger.error('Invalid GeoJSON format');
+ return { success: false, imported: 0, errors: 1, skipped: 0 };
+ }
+
+ for (const feature of geoJson.features) {
+ try {
+ const props = feature.properties || {};
+ const countryCode = props.country || props.iso_3166_1_alpha2;
+
+ // 1. Filter by country code to avoid importing overlapping data (e.g. Hebrew names in Jordan)
+ if (targetCountryCode && countryCode && countryCode.toUpperCase() !== targetCountryCode.toUpperCase()) {
+ skipped++;
+ continue;
+ }
+
+ // 2. Map Overture Subtype to our Admin Level
+ // Overture: country, region, county, localadmin, locality, neighborhood
+ const subtype = (props.subtype || '').toLowerCase();
+ let adminLevel: number;
+
+ switch (subtype) {
+ case 'country': adminLevel = 2; break;
+ case 'region': adminLevel = 4; break; // Governorate (Muhafazah)
+ case 'county': adminLevel = 6; break; // District (Liwa)
+ case 'localadmin': adminLevel = 8; break; // Sub-district (Qada)
+ case 'locality': adminLevel = 8; break; // City/Town
+ case 'neighborhood': adminLevel = 10; break;
+ case 'macrohood': adminLevel = 9; break; // Intermediate
+ case 'microhood': adminLevel = 11; break; // Sub-neighborhood
+ default:
+ adminLevel = parseInt(props.admin_level || props.adminLevel, 10);
+ }
+
+ if (isNaN(adminLevel)) {
+ skipped++;
+ continue;
+ }
+
+ // 3. Name Handling (Prioritize Arabic)
+ const nameAr = props['names']?.['primary'] || props['names']?.['common'] || props['name_ar'] || props['name'];
+ const nameEn = props['names']?.['en'] || props['name_en'];
+
+ // Simple check to skip obviously non-Arabic primary names if the target is an Arabic country
+ const isArabic = (text: string) => /[\u0600-\u06FF]/.test(text);
+ if (['JO', 'SY', 'EG'].includes(targetCountryCode.toUpperCase()) && nameAr && !isArabic(nameAr)) {
+ // If primary name isn't Arabic, try to find an Arabic variant in names map
+ const alternativeAr = Object.values(props['names'] || {}).find(v => typeof v === 'string' && isArabic(v));
+ if (!alternativeAr) {
+ skipped++;
+ continue;
+ }
+ }
+
+ let geom = feature.geometry;
+ if (geom.type === 'Polygon') {
+ geom = { type: 'MultiPolygon', coordinates: [geom.coordinates] };
+ } else if (geom.type !== 'MultiPolygon') {
+ skipped++;
+ continue;
+ }
+
+ const boundary = this.adminBoundaryRepo.create({
+ country_code: targetCountryCode.toUpperCase(),
+ admin_level: adminLevel,
+ name_ar: nameAr,
+ name_en: nameEn,
+ geom: geom,
+ });
+
+ await this.adminBoundaryRepo.save(boundary);
+ imported++;
+ } catch (err) {
+ this.logger.error(`Error importing feature: ${err.message}`);
+ errors++;
+ }
+ }
+
+ this.logger.log(`Import finished. Imported: ${imported}, Errors: ${errors}, Skipped: ${skipped}`);
+ return { success: true, imported, errors, skipped };
+ }
+
+ /**
+ * Import GeoJSON from a local file on the server.
+ */
+ async importFromFile(countryCode: string, filePath: string): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
+ const fs = require('fs');
+ if (!fs.existsSync(filePath)) {
+ this.logger.error(`File not found: ${filePath}`);
+ return { success: false, imported: 0, errors: 1, skipped: 0 };
+ }
+
+ try {
+ const data = fs.readFileSync(filePath, 'utf8');
+ const geoJson = JSON.parse(data);
+ return this.importGeoJSON(countryCode, geoJson);
+ } catch (err) {
+ this.logger.error(`Failed to read or parse GeoJSON file: ${err.message}`);
+ return { success: false, imported: 0, errors: 1, skipped: 0 };
+ }
+ }
+}
diff --git a/apps/api/src/geocoding/entities/admin-boundary.entity.ts b/apps/api/src/geocoding/entities/admin-boundary.entity.ts
new file mode 100644
index 0000000..e7bc3fc
--- /dev/null
+++ b/apps/api/src/geocoding/entities/admin-boundary.entity.ts
@@ -0,0 +1,26 @@
+import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
+
+@Entity('admin_boundaries')
+export class AdminBoundary {
+ @PrimaryGeneratedColumn()
+ id: number;
+
+ @Column({ length: 3 })
+ @Index()
+ country_code: string;
+
+ @Column({ type: 'int' })
+ @Index()
+ admin_level: number;
+
+ @Column({ nullable: true })
+ @Index()
+ name_ar: string;
+
+ @Column({ nullable: true })
+ name_en: string;
+
+ @Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true })
+ @Index({ spatial: true })
+ geom: any;
+}
diff --git a/apps/api/src/geocoding/entities/base-place.entity.ts b/apps/api/src/geocoding/entities/base-place.entity.ts
index 953720e..15add15 100644
--- a/apps/api/src/geocoding/entities/base-place.entity.ts
+++ b/apps/api/src/geocoding/entities/base-place.entity.ts
@@ -45,4 +45,20 @@ export abstract class BasePlace {
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
@Index({ spatial: true })
location: any;
+
+ @Column({ type: 'int', nullable: true })
+ @Index()
+ admin_level4_id: number;
+
+ @Column({ type: 'int', nullable: true })
+ @Index()
+ admin_level6_id: number;
+
+ @Column({ type: 'int', nullable: true })
+ @Index()
+ admin_level8_id: number;
+
+ @Column({ type: 'int', nullable: true })
+ @Index()
+ admin_level10_id: number;
}
diff --git a/apps/api/src/geocoding/geocoding-init.service.ts b/apps/api/src/geocoding/geocoding-init.service.ts
index 5aee233..9f99e48 100644
--- a/apps/api/src/geocoding/geocoding-init.service.ts
+++ b/apps/api/src/geocoding/geocoding-init.service.ts
@@ -25,6 +25,11 @@ export class GeocodingInitService implements OnModuleInit {
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);
+
+ NEW.admin_level4_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
+ NEW.admin_level6_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
+ NEW.admin_level8_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
+ NEW.admin_level10_id := (SELECT id FROM admin_boundaries WHERE admin_level = 10 AND ST_Contains(geom, NEW.location) LIMIT 1);
END IF;
RETURN NEW;
END;
diff --git a/apps/api/src/geocoding/geocoding.controller.ts b/apps/api/src/geocoding/geocoding.controller.ts
index 5a72676..155ca61 100644
--- a/apps/api/src/geocoding/geocoding.controller.ts
+++ b/apps/api/src/geocoding/geocoding.controller.ts
@@ -1,12 +1,16 @@
import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { GeocodingService } from './geocoding.service';
+import { AdminBoundariesService } from './admin-boundaries.service';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@ApiTags('geocoding')
@Controller('geocoding')
export class GeocodingController {
- constructor(private readonly geocodingService: GeocodingService) {}
+ constructor(
+ private readonly geocodingService: GeocodingService,
+ private readonly adminBoundariesService: AdminBoundariesService,
+ ) {}
@Get('search')
@ApiOperation({ summary: 'Search for locations (Forward Geocoding)' })
@@ -104,4 +108,16 @@ export class GeocodingController {
async getGeoJSON() {
return this.geocodingService.getAllPlacesGeoJSON();
}
+
+ @Post('import-boundaries')
+ @UseGuards(ApiKeyGuard)
+ @ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
+ @ApiQuery({ name: 'country', required: true })
+ @ApiQuery({ name: 'filePath', required: true })
+ async importBoundaries(
+ @Query('country') country: string,
+ @Query('filePath') filePath: string,
+ ) {
+ return this.adminBoundariesService.importFromFile(country, filePath);
+ }
}
diff --git a/apps/api/src/geocoding/geocoding.module.ts b/apps/api/src/geocoding/geocoding.module.ts
index 252d0c0..ae52476 100644
--- a/apps/api/src/geocoding/geocoding.module.ts
+++ b/apps/api/src/geocoding/geocoding.module.ts
@@ -8,7 +8,8 @@ import { PlaceJordan } from './entities/place-jordan.entity';
import { PlaceEgypt } from './entities/place-egypt.entity';
import { OsmArea } from './entities/osm-area.entity';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
-
+import { AdminBoundary } from './entities/admin-boundary.entity';
+import { AdminBoundariesService } from './admin-boundaries.service';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -16,10 +17,11 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
PlaceJordan,
PlaceEgypt,
OsmArea,
- OsmPointWithArea
+ OsmPointWithArea,
+ AdminBoundary
]),
],
controllers: [GeocodingController],
- providers: [GeocodingService, GeocodingInitService],
+ providers: [GeocodingService, GeocodingInitService, AdminBoundariesService],
})
export class GeocodingModule {}
diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts
index 0beb044..776f7b4 100644
--- a/apps/api/src/geocoding/geocoding.service.ts
+++ b/apps/api/src/geocoding/geocoding.service.ts
@@ -67,12 +67,21 @@ export class GeocodingService {
for (const tableName of userTables) {
let repo: Repository = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository);
const userQuery = `
- SELECT id, name, name_ar, name_en, category, neighbourhood, latitude, longitude, address, '${tableName.replace('places_', '')}' as region, 'user_submitted' as source,
- 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) + similarity(COALESCE(neighbourhood, ''), $1) as relevance
- FROM ${tableName}
- WHERE (name_ar % $1 OR name % $1 OR neighbourhood % $1 OR name_ar ILIKE $4 OR name ILIKE $4 OR neighbourhood ILIKE $4)
- ${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)` : ''}
+ SELECT
+ p.id, p.name, p.name_ar, p.name_en, p.category,
+ p.neighbourhood as original_neighbourhood,
+ n.name_ar as neighbourhood,
+ d.name_ar as district,
+ g.name_ar as governorate,
+ p.latitude, p.longitude, p.address, '${tableName.replace('places_', '')}' as region, p.source,
+ CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
+ similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
+ FROM ${tableName} p
+ LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
+ LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
+ LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
+ WHERE (p.name_ar % $1 OR p.name % $1 OR p.neighbourhood % $1 OR p.name_ar ILIKE $4 OR p.name ILIKE $4 OR p.neighbourhood ILIKE $4)
+ ${hasLocation ? `AND (p.location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(p.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]);
@@ -98,9 +107,48 @@ export class GeocodingService {
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...osmResults);
+ // --- Consolidation: Overture Maps Integration (Now in primary DB) ---
+ const overtureQuery = `
+ (SELECT id::text,
+ COALESCE(names->>'primary', names->>'common', 'Building') as name,
+ COALESCE(names->>'primary', names->>'common', '') as name_ar,
+ NULL as name_en,
+ 'building' as category,
+ ST_Y(ST_Centroid(location)) as latitude,
+ ST_X(ST_Centroid(location)) as longitude,
+ '' as address,
+ 'overture_buildings' as source,
+ 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,
+ 0.6 as relevance
+ FROM overture_building
+ WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
+ LIMIT 15)
+ UNION ALL
+ (SELECT id::text,
+ COALESCE(names->>'primary', names->>'common', 'Street') as name,
+ COALESCE(names->>'primary', names->>'common', '') as name_ar,
+ NULL as name_en,
+ 'transportation' as category,
+ ST_Y(ST_Centroid(location)) as latitude,
+ ST_X(ST_Centroid(location)) as longitude,
+ '' as address,
+ 'overture_streets' as source,
+ 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,
+ 0.6 as relevance
+ FROM overture_segment
+ WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
+ LIMIT 15)
+ `;
+ try {
+ const overtureResults = await this.osmPointsRepository.query(overtureQuery, [cleanQuery, lat || null, lon || null, ILikeQuery]);
+ allResults.push(...overtureResults);
+ } catch (err) {
+ this.logger.warn('Overture tables search failed, logging error.', err);
+ }
+
const sortedResults = allResults
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
- .slice(0, 20)
+ .slice(0, 25)
.map(r => ({
...r,
latitude: parseFloat(r.latitude),
@@ -111,7 +159,7 @@ export class GeocodingService {
return { results: sortedResults };
} catch (e) {
- this.logger.error('Optimized Spatial Search failed:', e);
+ this.logger.error('Search failed:', e);
return { results: [] };
}
}
@@ -121,10 +169,19 @@ export class GeocodingService {
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::float, $2::float), 4326)) as distance
- FROM ${tableName} WHERE location IS NOT NULL
- ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
+ SELECT
+ p.id, p.name, p.name_ar, p.category,
+ n.name_ar as neighbourhood,
+ d.name_ar as district,
+ g.name_ar as governorate,
+ p.latitude, p.longitude, p.address, 'user_place' as source,
+ ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
+ FROM ${tableName} p
+ LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
+ LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
+ LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
+ WHERE p.location IS NOT NULL
+ ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
`;
return await repo.query(query, [lng, lat]);
} catch (error) {
@@ -173,11 +230,23 @@ export class GeocodingService {
async getAllPlacesGeoJSON() {
try {
- 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 q = `
+ SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
+ UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
+ UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
+ UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
+ UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
+ `;
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 }}));
+ 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: [] }; }
+ } catch (e) {
+ return { type: 'FeatureCollection', features: [] };
+ }
}
async deletePlacesByName(name: string, country: string) {
diff --git a/apps/web/public/map-demo.html b/apps/web/public/map-demo.html
index 94acb02..d226505 100644
--- a/apps/web/public/map-demo.html
+++ b/apps/web/public/map-demo.html
@@ -281,6 +281,21 @@
+
+
+
@@ -325,8 +340,8 @@
/* ───────────────────────────────────────────
CONFIG
─────────────────────────────────────────── */
- const API_BASE = 'https://map-saas.intaleqapp.com';
- const API_KEY = 'intaleq_secret_2026';
+ const API_BASE = window.location.origin; // Dynamically use the current host
+ const API_KEY = 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
const HEADERS = { 'x-api-key': API_KEY };
const ROUTES = {
@@ -650,6 +665,27 @@
info.textContent = '❌ خطأ في الاتصال بالسيرفر.';
}
}
+
+ /* ───────────────────────────────────────────
+ LAYER CONTROLS
+ ─────────────────────────────────────────── */
+ function updateLayers() {
+ const show3D = document.getElementById('toggle-3d').checked;
+ const showPOIs = document.getElementById('toggle-pois').checked;
+
+ if (map.getLayer('3d-buildings')) {
+ map.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
+ }
+
+ if (map.getLayer('intaleq-db-label')) {
+ map.setLayoutProperty('intaleq-db-label', 'visibility', showPOIs ? 'visible' : 'none');
+ }
+
+ // Also toggle the dynamic POIs icons layer if exists
+ if (map.getLayer('intaleq_pois')) {
+ map.setLayoutProperty('intaleq_pois', 'visibility', showPOIs ? 'visible' : 'none');
+ }
+ }