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:
Hamza-Ayed
2026-08-19 16:29:24 +03:00
parent 3d13d31101
commit b98ce56622
2 changed files with 193 additions and 157 deletions
+131 -146
View File
@@ -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() {