diff --git a/apps/api/src/maps/road-refinement.controller.ts b/apps/api/src/maps/road-refinement.controller.ts index 5e49691..8ee9b7b 100644 --- a/apps/api/src/maps/road-refinement.controller.ts +++ b/apps/api/src/maps/road-refinement.controller.ts @@ -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() { diff --git a/apps/web/src/pages/IntelligenceDashboard.tsx b/apps/web/src/pages/IntelligenceDashboard.tsx index fcba6d5..b2bb4d9 100644 --- a/apps/web/src/pages/IntelligenceDashboard.tsx +++ b/apps/web/src/pages/IntelligenceDashboard.tsx @@ -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 = () => {
{tab === 'candidates' && (
+ {cands.length > 0 && ( +
+ Pending ({cands.length}) + +
+ )} {cands.length === 0 ?