feat: implement manual routing sync system, update map tile URLs to HTTPS, and migrate admin session storage to localStorage

This commit is contained in:
Hamza-Ayed
2026-07-15 23:01:18 +03:00
parent 78fe6c48cc
commit c97c961c96
14 changed files with 281 additions and 18 deletions
+117 -4
View File
@@ -112,6 +112,58 @@ export class GeocodingService {
ORDER BY (o.name <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id, name_ar as name, name_ar, name_en, 'admin' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(geom))::text as latitude, ST_X(ST_Centroid(geom))::text as longitude, '' as address, 'admin_boundary' as region, 'admin' as source,
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) as relevance
FROM admin_boundaries
WHERE name_ar % $1
AND ($2::float IS NULL OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (name_ar <-> $1) ASC LIMIT 5
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, '' as name_en, 'street' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_segment
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, '' as name_en, 'building' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_building
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, '' as name_en, 'place' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_place
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
const executionResults = await Promise.race([
Promise.allSettled(queryPromises),
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
@@ -184,7 +236,9 @@ export class GeocodingService {
try {
const repo = this.getRepositoryForCoords(lat, lng);
const tableName = this.getTableNameForRepo(repo);
const query = `
const queryPromises: Promise<any[]>[] = [];
queryPromises.push(repo.query(`
SELECT
p.id, p.name, p.name_ar, p.category,
n.name_ar as neighbourhood,
@@ -197,9 +251,67 @@ export class GeocodingService {
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_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]);
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
o.osm_id::text as id, o.name, o.name_ar, COALESCE(o.amenity, o.shop, 'place') as category,
'' as neighbourhood, '' as district, '' as governorate,
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM osm_points_with_area o
WHERE o.geom IS NOT NULL AND (o.name IS NOT NULL OR o.name_ar IS NOT NULL)
ORDER BY o.geom <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, 'street' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_segment
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, 'building' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_building
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, 'place' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_place
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
const results = await Promise.allSettled(queryPromises);
let allResults: any[] = [];
results.forEach(res => {
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
});
return allResults
.sort((a, b) => Number(a.distance) - Number(b.distance))
.slice(0, 5)
.map(r => ({
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude)
}));
} catch (error) {
this.logger.error('Reverse geocoding error:', error);
return [];
@@ -252,6 +364,7 @@ export class GeocodingService {
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
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
`;
const res = await this.placesSyriaRepository.query(q);
const features = res.map(p => ({
+7 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Query, UseGuards, Res } from '@nestjs/common';
import { Controller, Get, Post, Query, UseGuards, Res } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import type { Response } from 'express';
import * as fs from 'fs';
@@ -12,6 +12,12 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
export class MapsController {
constructor(private readonly mapsService: MapsService) { }
@Post('sync-routes')
@ApiOperation({ summary: 'Request GraphHopper routing sync 🔄' })
async syncRoutes() {
return this.mapsService.requestRoutingSync();
}
@Get('style.json')
@ApiOperation({ summary: 'Get MapLibre style JSON 🎨' })
async getStyleJson(@Query('theme') theme: string, @Res() res: Response) {
+7
View File
@@ -7,6 +7,7 @@ import axios from 'axios';
import { RoadSegmentStat } from './road-stat.entity';
import { TrafficGridService } from './traffic-grid.service';
import { GeocodingService } from '../geocoding/geocoding.service';
import { RedisService } from '../common/redis.service';
@Injectable()
export class MapsService {
@@ -19,10 +20,16 @@ export class MapsService {
private trafficGrid: TrafficGridService,
private geocodingService: GeocodingService,
private dataSource: DataSource,
private redisService: RedisService,
) {
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
}
async requestRoutingSync() {
await this.redisService.set('routing_sync_requested', '1');
return { success: true, message: 'Routing sync requested and scheduled for the next minute.' };
}
async getRoute(waypoints: [number, number][], profile: string = 'car', steps: boolean = false, locale: string = 'en', alternatives: boolean = false) {
if (waypoints.length < 2) {
+1 -1
View File
@@ -74,7 +74,7 @@
"approved_roads": {
"type": "vector",
"tiles": [
"http://188.68.36.205:3202/approved_roads/{z}/{x}/{y}"
"https://tiles.intaleqapp.com/approved_roads/{z}/{x}/{y}"
],
"minzoom": 8,
"maxzoom": 18
+1 -1
View File
@@ -18,7 +18,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// Martin vector-tile host (serves planet_osm_*, approved_roads, …). This is a
// DIFFERENT host from where the app + its style.json are served, so it must be
// absolute — an empty default resolved to the app host, which serves no tiles.
const TILES = (import.meta as any).env.VITE_TILES_URL || 'http://188.68.36.205:3202';
const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com';
type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm';
const REFS: Record<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
+23 -7
View File
@@ -30,8 +30,8 @@ const IntelligenceDashboard: React.FC = () => {
const [msg, setMsg] = useState('');
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
const [loading, setLoading] = useState(true);
const [apiKey, setApiKey] = useState(sessionStorage.getItem('map_admin_key') || '');
const [needsAuth, setNeedsAuth] = useState(!sessionStorage.getItem('map_admin_key'));
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || '');
const [needsAuth, setNeedsAuth] = useState(!localStorage.getItem('map_admin_key'));
const [sel, setSel] = useState<string | null>(null);
// Right Map State
@@ -111,8 +111,8 @@ const IntelligenceDashboard: React.FC = () => {
if (!sr.ok || !cr.ok || !clr.ok) {
if (sr.status === 401 || sr.status === 403) {
localStorage.removeItem('map_admin_key');
setNeedsAuth(true);
sessionStorage.removeItem('map_admin_key');
setApiKey('');
throw new Error('Invalid or unauthorized API Key');
}
@@ -371,6 +371,17 @@ const IntelligenceDashboard: React.FC = () => {
setTimeout(() => setMsg(''), 5000);
};
const syncRoutes = async () => {
if (!confirm('This will request a full routing rebuild on the server which takes 5-10 minutes. Continue?')) return;
try {
setMsg('Requesting route sync...');
const r = await fetch(`${API}/maps/sync-routes`, { method: 'POST', headers: { 'x-api-key': apiKey } });
if (!r.ok) throw new Error('Failed');
setMsg('✅ Routing sync requested! Check back in 5-10 min.');
} catch (e: any) { setMsg(e.message || 'Error'); }
setTimeout(() => setMsg(''), 5000);
};
const cancelDrawing = () => { pointsRef.current = []; redrawDrawing(); setDrawing(false); };
if (needsAuth) {
@@ -389,7 +400,7 @@ const IntelligenceDashboard: React.FC = () => {
if (e.key === 'Enter') {
const val = e.currentTarget.value;
if (val) {
sessionStorage.setItem('map_admin_key', val);
localStorage.setItem('map_admin_key', val);
setApiKey(val);
}
}
@@ -416,9 +427,14 @@ const IntelligenceDashboard: React.FC = () => {
<div style={{ fontSize: '0.7rem', color: '#64748b' }}>Intaleq SaaS v2 · Split Compare</div>
</div>
</div>
<button onClick={runAI} style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid #6366f1', color: '#818cf8', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }}>
<RefreshCw size={13} /> Run
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={syncRoutes} style={{ background: 'rgba(16,185,129,0.15)', border: '1px solid #10b981', color: '#34d399', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }} title="Sync Approved Roads to Router">
<RefreshCw size={13} /> Sync Routes
</button>
<button onClick={runAI} style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid #6366f1', color: '#818cf8', padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.78rem' }}>
<RefreshCw size={13} /> Run
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '1rem' }}>