2026-04-13-4

This commit is contained in:
Hamza-Ayed
2026-04-13 20:02:57 +03:00
parent 5bbb5d8d8b
commit b36e197e09
21 changed files with 2236 additions and 899 deletions
-1
View File
@@ -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",
-156
View File
@@ -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 = '<tr class="empty-state"><td colspan="6">No pending discoveries 🏜️</td></tr>';
return;
}
tableBody.innerHTML = data.map(c => `
<tr>
<td title="${c.id}">${c.id.substring(0,8)}...</td>
<td><i class="fas fa-user-tag"></i> ${c.uniqueDriverCount}</td>
<td>${c.totalPoints}</td>
<td>${Math.round(c.lengthMeters)}m</td>
<td><span class="badge-conf ${getConfClass(c.confidence)}">${Math.round(c.confidence * 100)}%</span></td>
<td>
<button class="btn btn-sm-success" onclick="approveCandidate('${c.id}')"><i class="fas fa-check"></i></button>
<button class="btn btn-sm-danger" onclick="rejectCandidate('${c.id}')"><i class="fas fa-times"></i></button>
</td>
</tr>
`).join('');
} catch (e) {
tableBody.innerHTML = '<tr class="error-state"><td colspan="6">Error loading candidates</td></tr>';
}
}
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 = '<tr class="empty-state"><td colspan="3">No segments flagged</td></tr>';
return;
}
tableBody.innerHTML = data.map(s => `
<tr>
<td>${s.segmentId}</td>
<td>${s.sampleCount} pts</td>
<td><span class="badge-conf low">CLOSED</span></td>
</tr>
`).join('');
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="3">Error loading closures</td></tr>';
}
}
// --- 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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-play"></i> 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);
}
-149
View File
@@ -1,149 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intaleq | Map Intelligence Dashboard</title>
<link rel="stylesheet" href="styles.css">
<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=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="dark-theme">
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="logo-container">
<div class="logo-icon"><i class="fas fa-brain-circuit"></i></div>
<div class="logo-text">Intaleq<span>Map AI</span></div>
</div>
<nav class="main-nav">
<a href="#" class="nav-item active"><i class="fas fa-chart-line"></i> Summary</a>
<a href="#" class="nav-item"><i class="fas fa-road"></i> Candidates</a>
<a href="#" class="nav-item"><i class="fas fa-barrier-solid"></i> Closures</a>
<a href="/api/docs" target="_blank" class="nav-item"><i class="fas fa-book"></i> API Docs</a>
</nav>
<div class="sidebar-footer">
<div class="status-indicator">
<span class="dot pulse"></span>
<span>Live Traffic Engine</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-bar">
<div class="search-container">
<i class="fas fa-search"></i>
<input type="text" placeholder="Search segments or drivers...">
</div>
<div class="action-buttons">
<button id="run-analysis-btn" class="btn btn-primary">
<i class="fas fa-play"></i> Run Intelligence
</button>
<div class="user-profile">
<img src="https://ui-avatars.com/api/?name=Admin&background=6366f1&color=fff" alt="Admin">
</div>
</div>
</header>
<div class="dashboard-scroll">
<!-- Stats Overview -->
<section class="stats-grid">
<div class="stat-card">
<div class="stat-icon purple"><i class="fas fa-satellite-dish"></i></div>
<div class="stat-info">
<span class="stat-label">Telemetry Points</span>
<h2 id="stat-telemetry">0</h2>
</div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-route"></i></div>
<div class="stat-info">
<span class="stat-label">Analyzed Segments</span>
<h2 id="stat-roads">0</h2>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><i class="fas fa-sparkles"></i></div>
<div class="stat-info">
<span class="stat-label">Pending Roads</span>
<h2 id="stat-pending">0</h2>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red"><i class="fas fa-road-barrier"></i></div>
<div class="stat-info">
<span class="stat-label">Active Closures</span>
<h2 id="stat-closed">0</h2>
</div>
</div>
</section>
<div class="content-grid">
<!-- Road Candidates Table -->
<section class="card candidate-section">
<div class="card-header">
<h3><i class="fas fa-map-location-dot"></i> Discovered Road Candidates</h3>
<button class="btn btn-ghost" onclick="fetchCandidates()">Refresh</button>
</div>
<div class="table-container">
<table id="candidates-table">
<thead>
<tr>
<th>ID</th>
<th>Drivers</th>
<th>Points</th>
<th>L (m)</th>
<th>Confidence</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr class="empty-state">
<td colspan="6">Loading discoveries...</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Road Closures -->
<section class="card closure-section">
<div class="card-header">
<h3><i class="fas fa-road-lock"></i> Road Closures</h3>
<div class="card-actions">
<button class="btn btn-ghost" onclick="fetchClosures()">Refresh</button>
<button id="discover-closures-btn" class="btn btn-secondary-sm">Check Now</button>
</div>
</div>
<div class="table-container">
<table id="closures-table">
<thead>
<tr>
<th>Segment ID</th>
<th>Historical Vol</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr class="empty-state">
<td colspan="3">No closures detected</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</main>
</div>
<!-- Notification Container -->
<div id="toast-container"></div>
<script src="app.js"></script>
</body>
</html>
-365
View File
@@ -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; } }
-6
View File
@@ -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],
+1 -1
View File
@@ -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<string>('MAP_API_KEY') || 'intaleq_secret_2026';
const validApiKey = this.configService.get<string>('MAP_API_KEY') || 'intaleq_premium_saas_2026_secure_key_x';
// Enforce API key match
if (apiKeyHeader !== validApiKey) {
@@ -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<AdminBoundary>,
) {}
/**
* 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 };
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
+17 -1
View File
@@ -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);
}
}
+5 -3
View File
@@ -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 {}
+84 -15
View File
@@ -67,12 +67,21 @@ export class GeocodingService {
for (const tableName of userTables) {
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, 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) {