fix(roads): fix road approval & deletion persistence, add robust endpoints, delete candidate from DB upon reject/delete, and add clear all pending button
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
@@ -199,7 +199,7 @@ export class RoadRefinementController {
|
||||
@ApiParam({ name: 'id', description: 'Candidate UUID' })
|
||||
async approveCandidate(@Param('id') id: string) {
|
||||
try {
|
||||
// Ensure approved_roads table exists
|
||||
// 1. Ensure approved_roads table exists
|
||||
await this.dataSource.query(`
|
||||
CREATE TABLE IF NOT EXISTS approved_roads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -218,164 +218,103 @@ export class RoadRefinementController {
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS approved_roads_cand_uidx ON approved_roads(candidate_id) WHERE candidate_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// Update candidate status
|
||||
// 2. Direct insert/upsert into approved_roads immediately
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO approved_roads (
|
||||
candidate_id, geometry, name, highway, confidence, "uniqueDriverCount", oneway, approved_at
|
||||
)
|
||||
SELECT
|
||||
id, geometry, COALESCE(name, 'Unnamed Road'), COALESCE(highway, 'residential'), confidence, "uniqueDriverCount", COALESCE(oneway, 0), NOW()
|
||||
FROM candidate_roads WHERE id = $1
|
||||
ON CONFLICT (candidate_id) DO UPDATE SET
|
||||
geometry = EXCLUDED.geometry,
|
||||
name = EXCLUDED.name,
|
||||
highway = EXCLUDED.highway,
|
||||
confidence = EXCLUDED.confidence;
|
||||
`, [id]);
|
||||
|
||||
// 3. Mark candidate as approved
|
||||
await this.candidateRepo.update(id, {
|
||||
status: 'approved',
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
|
||||
// Snap geometry & resolve OSM start_node / end_node for routing
|
||||
await this.dataSource.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
cand RECORD;
|
||||
start_pt GEOMETRY;
|
||||
end_pt GEOMETRY;
|
||||
snapped_geom GEOMETRY;
|
||||
s_node BIGINT := NULL;
|
||||
e_node BIGINT := NULL;
|
||||
node_rec RECORD;
|
||||
line_rec RECORD;
|
||||
BEGIN
|
||||
SELECT id, geometry::geometry as geom, name, COALESCE(highway, 'residential') as highway, confidence, "uniqueDriverCount", oneway
|
||||
INTO cand
|
||||
FROM candidate_roads WHERE id = $1;
|
||||
|
||||
IF FOUND THEN
|
||||
snapped_geom := cand.geom;
|
||||
start_pt := ST_StartPoint(snapped_geom);
|
||||
end_pt := ST_EndPoint(snapped_geom);
|
||||
|
||||
-- 1. Start node & geometry snapping
|
||||
BEGIN
|
||||
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
|
||||
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
|
||||
INTO node_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
|
||||
JOIN planet_osm_nodes n ON n.id = wn.node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) < 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
s_node := node_rec.id;
|
||||
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
|
||||
ELSE
|
||||
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
|
||||
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
|
||||
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
|
||||
INTO line_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, start_pt::geography) < 30
|
||||
ORDER BY ST_Distance(l.way, p.pt) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
|
||||
END IF;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- 2. End node & geometry snapping
|
||||
BEGIN
|
||||
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
|
||||
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
|
||||
INTO node_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
|
||||
JOIN planet_osm_nodes n ON n.id = wn.node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) < 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
e_node := node_rec.id;
|
||||
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
|
||||
ELSE
|
||||
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
|
||||
SELECT ST_X(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lon,
|
||||
ST_Y(ST_Transform(ST_ClosestPoint(l.way, p.pt), 4326)) as lat
|
||||
INTO line_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
WHERE ST_Distance(ST_Transform(l.way, 4326)::geography, end_pt::geography) < 30
|
||||
ORDER BY ST_Distance(l.way, p.pt) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(line_rec.lon, line_rec.lat), 4326));
|
||||
END IF;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- Update candidate_roads
|
||||
UPDATE candidate_roads SET geometry = snapped_geom WHERE id = cand.id;
|
||||
|
||||
-- Insert or update approved_roads
|
||||
INSERT INTO approved_roads (
|
||||
candidate_id, geometry, name, highway, confidence, "uniqueDriverCount", oneway, start_node, end_node
|
||||
)
|
||||
VALUES (
|
||||
cand.id, snapped_geom, cand.name, cand.highway, cand.confidence, cand."uniqueDriverCount", cand.oneway, s_node, e_node
|
||||
)
|
||||
ON CONFLICT (candidate_id) DO UPDATE SET
|
||||
geometry = EXCLUDED.geometry,
|
||||
start_node = EXCLUDED.start_node,
|
||||
end_node = EXCLUDED.end_node;
|
||||
|
||||
END IF;
|
||||
END $$;
|
||||
`, [id]);
|
||||
|
||||
// If road has a name, index it into places geocoding table
|
||||
// 4. Safely attempt topology node snapping (Non-blocking)
|
||||
try {
|
||||
await this.dataSource.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
target_table TEXT := 'places_jordan';
|
||||
center_lat FLOAT;
|
||||
center_lng FLOAT;
|
||||
cand RECORD;
|
||||
start_pt GEOMETRY;
|
||||
end_pt GEOMETRY;
|
||||
snapped_geom GEOMETRY;
|
||||
s_node BIGINT := NULL;
|
||||
e_node BIGINT := NULL;
|
||||
node_rec RECORD;
|
||||
BEGIN
|
||||
SELECT name, ST_Y(ST_Centroid(geometry::geometry)) as lat, ST_X(ST_Centroid(geometry::geometry)) as lng
|
||||
INTO r
|
||||
FROM candidate_roads WHERE id = $1 AND name IS NOT NULL AND trim(name) != '';
|
||||
|
||||
IF FOUND THEN
|
||||
center_lat := r.lat;
|
||||
center_lng := r.lng;
|
||||
|
||||
IF center_lat >= 29 AND center_lat <= 37.5 AND center_lng >= 38.7 AND center_lng <= 48.8 THEN
|
||||
target_table := 'places_iraq';
|
||||
ELSIF center_lat >= 29 AND center_lat <= 37.5 AND center_lng >= 34.5 AND center_lng <= 42.5 THEN
|
||||
IF center_lat > 32.5 AND center_lng > 35.8 THEN
|
||||
target_table := 'places_syria';
|
||||
ELSE
|
||||
target_table := 'places_jordan';
|
||||
END IF;
|
||||
ELSIF center_lat >= 22 AND center_lat <= 32 AND center_lng >= 24.5 AND center_lng <= 37 THEN
|
||||
target_table := 'places_egypt';
|
||||
END IF;
|
||||
SELECT id, geometry::geometry as geom, name, COALESCE(highway, 'residential') as highway, confidence, "uniqueDriverCount", oneway
|
||||
INTO cand
|
||||
FROM candidate_roads WHERE id = $1;
|
||||
|
||||
EXECUTE format('
|
||||
INSERT INTO %I (name, name_ar, category, latitude, longitude, location, source)
|
||||
VALUES ($1, $1, $2, $3, $4, ST_SetSRID(ST_MakePoint($4, $3), 4326), $5)
|
||||
', target_table) USING r.name, 'street', center_lat, center_lng, 'approved_road';
|
||||
IF FOUND THEN
|
||||
snapped_geom := cand.geom;
|
||||
start_pt := ST_StartPoint(snapped_geom);
|
||||
end_pt := ST_EndPoint(snapped_geom);
|
||||
|
||||
-- Snap start
|
||||
BEGIN
|
||||
WITH p AS (SELECT ST_Transform(start_pt, 3857) AS pt)
|
||||
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
|
||||
INTO node_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
|
||||
JOIN planet_osm_nodes n ON n.id = wn.node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) < 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, start_pt::geography) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
s_node := node_rec.id;
|
||||
snapped_geom := ST_SetPoint(snapped_geom, 0, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
-- Snap end
|
||||
BEGIN
|
||||
WITH p AS (SELECT ST_Transform(end_pt, 3857) AS pt)
|
||||
SELECT n.id, n.lon/1e7 as lon, n.lat/1e7 as lat
|
||||
INTO node_rec
|
||||
FROM p
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(p.pt, 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS wn(node_id)
|
||||
JOIN planet_osm_nodes n ON n.id = wn.node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) < 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon/1e7, n.lat/1e7), 4326)::geography, end_pt::geography) ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
e_node := node_rec.id;
|
||||
snapped_geom := ST_SetPoint(snapped_geom, ST_NPoints(snapped_geom) - 1, ST_SetSRID(ST_MakePoint(node_rec.lon, node_rec.lat), 4326));
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
UPDATE approved_roads
|
||||
SET geometry = snapped_geom,
|
||||
start_node = COALESCE(s_node, start_node),
|
||||
end_node = COALESCE(e_node, end_node)
|
||||
WHERE candidate_id = cand.id;
|
||||
END IF;
|
||||
END $$;
|
||||
`, [id]);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Could not index approved road to places: ${err.message}`);
|
||||
} catch (snapErr: any) {
|
||||
this.logger.warn(`Topology snapping skipped for ${id}: ${snapErr.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(`✅ Road candidate ${id} approved & published.`);
|
||||
@@ -395,6 +334,7 @@ export class RoadRefinementController {
|
||||
status: 'rejected',
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
await this.dataSource.query(`DELETE FROM approved_roads WHERE candidate_id = $1`, [id]).catch(() => {});
|
||||
return { success: true, id, status: 'rejected' };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to reject candidate road: ${e.message}`);
|
||||
@@ -402,6 +342,51 @@ export class RoadRefinementController {
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('candidate/:id')
|
||||
@ApiOperation({ summary: 'Delete candidate road by ID 🗑️' })
|
||||
async deleteCandidateAlias(@Param('id') id: string) {
|
||||
return this.deleteCandidate(id);
|
||||
}
|
||||
|
||||
@Delete('candidates/:id')
|
||||
@ApiOperation({ summary: 'Delete candidate road by ID 🗑️' })
|
||||
async deleteCandidate(@Param('id') id: string) {
|
||||
try {
|
||||
await this.dataSource.query(`DELETE FROM approved_roads WHERE candidate_id = $1`, [id]).catch(() => {});
|
||||
await this.dataSource.query(`DELETE FROM candidate_roads WHERE id = $1`, [id]);
|
||||
return { success: true, id, message: 'Candidate road deleted' };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to delete candidate road: ${e.message}`);
|
||||
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('approved/:id')
|
||||
@ApiOperation({ summary: 'Delete approved road by ID 🗑️' })
|
||||
async deleteApprovedRoad(@Param('id') id: string) {
|
||||
try {
|
||||
await this.dataSource.query(`DELETE FROM approved_roads WHERE id = $1 OR candidate_id = $1`, [id]);
|
||||
await this.dataSource.query(`UPDATE candidate_roads SET status = 'rejected' WHERE id = $1`, [id]);
|
||||
return { success: true, id, message: 'Approved road deleted' };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to delete approved road: ${e.message}`);
|
||||
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('candidates')
|
||||
@ApiOperation({ summary: 'Clear all candidate roads by status 🧹' })
|
||||
async clearAllCandidates(@Query('status') status?: string) {
|
||||
const s = status || 'pending';
|
||||
try {
|
||||
const res = await this.dataSource.query(`DELETE FROM candidate_roads WHERE status = $1 RETURNING id`, [s]);
|
||||
return { success: true, deletedCount: res.length, message: `Cleared ${res.length} candidate roads with status '${s}'` };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to clear candidate roads: ${e.message}`);
|
||||
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('closures')
|
||||
@ApiOperation({ summary: 'Get active road closures 🚧' })
|
||||
async getClosures() {
|
||||
|
||||
@@ -413,20 +413,49 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
if (a === 'approve') {
|
||||
const d = await res.json().catch(() => ({} as any));
|
||||
setMsg(d.connected ? '✅ Approved & wired into the network.' : '✅ Approved — drawn on the map.');
|
||||
candsRef.current = candsRef.current.map(c => c.id === id ? { ...c, status: 'approved' } : c);
|
||||
pushMapData();
|
||||
} else {
|
||||
setMsg('🚫 Rejected.');
|
||||
candsRef.current = candsRef.current.filter(c => c.id !== id);
|
||||
pushMapData();
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const d = await res.json().catch(() => ({} as any));
|
||||
if (a === 'approve') {
|
||||
setMsg(d.connected ? '✅ Approved & wired into the network.' : '✅ Approved — published on the map.');
|
||||
} else {
|
||||
setMsg('🚫 Rejected & removed.');
|
||||
}
|
||||
|
||||
// Immediately remove from pending candidate list in UI
|
||||
setCands(prev => prev.filter(c => c.id !== id));
|
||||
candsRef.current = candsRef.current.filter(c => c.id !== id);
|
||||
pushMapData();
|
||||
|
||||
setSel(null);
|
||||
(leftMapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] });
|
||||
load();
|
||||
} catch { setMsg('Action failed.'); }
|
||||
} catch (e: any) {
|
||||
setMsg(`Action failed: ${e?.message || 'Server error'}`);
|
||||
}
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
const clearAllCandidates = async () => {
|
||||
if (!window.confirm('Are you sure you want to clear and delete all pending candidate roads?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/map-refinement/roads/candidates?status=pending`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': apiKey }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to clear candidates');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMsg(`🧹 Cleared ${data.deletedCount ?? ''} candidate roads.`);
|
||||
setCands([]);
|
||||
candsRef.current = [];
|
||||
pushMapData();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setMsg(`Clear failed: ${e?.message || 'Server error'}`);
|
||||
}
|
||||
setTimeout(() => setMsg(''), 5000);
|
||||
};
|
||||
|
||||
@@ -438,10 +467,12 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete');
|
||||
setMsg(`🗑️ Deleted ${type} road successfully.`);
|
||||
setCands(prev => prev.filter(c => c.id !== id));
|
||||
candsRef.current = candsRef.current.filter(c => c.id !== id);
|
||||
pushMapData();
|
||||
setSel(null);
|
||||
(leftMapRef.current?.getSource('hl') as maplibregl.GeoJSONSource)?.setData({ type: 'FeatureCollection', features: [] });
|
||||
load();
|
||||
// Force MapLibre to drop cached vector tiles completely for approved roads
|
||||
if (type === 'approved') {
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
@@ -611,6 +642,26 @@ const IntelligenceDashboard: React.FC = () => {
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.8rem' }}>
|
||||
{tab === 'candidates' && (
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{cands.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: '#94a3b8' }}>Pending ({cands.length})</span>
|
||||
<button
|
||||
onClick={clearAllCandidates}
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
color: '#f87171',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🗑️ Clear All ({cands.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{cands.length === 0
|
||||
? <div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#475569' }}>
|
||||
<Activity size={36} style={{ opacity: 0.2, display: 'block', margin: '0 auto 0.8rem' }} />
|
||||
|
||||
Reference in New Issue
Block a user