feat: introduce routing status tracking for approved roads and telemetry batch queue limits
This commit is contained in:
@@ -5,6 +5,7 @@ import { Repository, DataSource } from 'typeorm';
|
||||
import { CandidateRoad } from './candidate-road.entity';
|
||||
import { RoadSegmentStat } from './road-stat.entity';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@ApiTags('map-refinement-roads')
|
||||
@Controller('map-refinement/roads')
|
||||
@@ -18,6 +19,7 @@ export class RoadRefinementController {
|
||||
@InjectRepository(RoadSegmentStat)
|
||||
private readonly roadStatRepo: Repository<RoadSegmentStat>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly redisService: RedisService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
@@ -60,12 +62,15 @@ export class RoadRefinementController {
|
||||
const s = status || 'pending';
|
||||
try {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters",
|
||||
`SELECT c.id, c."uniqueDriverCount", c."totalPoints", c."averageSpeed", c."lengthMeters",
|
||||
confidence, status, source, name, highway, oneway, "discoveredAt", "reviewedAt",
|
||||
ST_AsGeoJSON(geometry) as geojson
|
||||
FROM candidate_roads
|
||||
WHERE status = $1
|
||||
ORDER BY confidence DESC, "discoveredAt" DESC
|
||||
ar.routing_status AS "routingStatus", ar.routing_error AS "routingError",
|
||||
ar.routing_requested_at AS "routingRequestedAt", ar.routed_at AS "routedAt",
|
||||
ST_AsGeoJSON(c.geometry) as geojson
|
||||
FROM candidate_roads c
|
||||
LEFT JOIN approved_roads ar ON ar.candidate_id = c.id
|
||||
WHERE c.status = $1
|
||||
ORDER BY c.confidence DESC, c."discoveredAt" DESC
|
||||
LIMIT 100`,
|
||||
[s]
|
||||
);
|
||||
@@ -75,10 +80,38 @@ export class RoadRefinementController {
|
||||
}));
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Error fetching candidates: ${e.message}`);
|
||||
return [];
|
||||
// Before the first approval the optional publication table may not exist;
|
||||
// pending-road review must remain available in that fresh installation.
|
||||
try {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters",
|
||||
confidence, status, source, name, highway, oneway, "discoveredAt", "reviewedAt",
|
||||
ST_AsGeoJSON(geometry) as geojson
|
||||
FROM candidate_roads WHERE status = $1
|
||||
ORDER BY confidence DESC, "discoveredAt" DESC LIMIT 100`,
|
||||
[s],
|
||||
);
|
||||
return rows.map((r: any) => ({ ...r, geometry: r.geojson ? JSON.parse(r.geojson) : null }));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Get('approved/:id/routing-status')
|
||||
@ApiOperation({ summary: 'Get approved road routing publication status' })
|
||||
@ApiParam({ name: 'id', description: 'Approved road or candidate UUID' })
|
||||
async getRoutingStatus(@Param('id') id: string) {
|
||||
const [road] = await this.dataSource.query(`
|
||||
SELECT id, candidate_id AS "candidateId", routing_status AS "routingStatus",
|
||||
routing_error AS "routingError", routing_requested_at AS "routingRequestedAt",
|
||||
routed_at AS "routedAt", start_node AS "startNode", end_node AS "endNode"
|
||||
FROM approved_roads WHERE id::text = $1 OR candidate_id::text = $1
|
||||
`, [id]);
|
||||
if (!road) throw new HttpException('Approved road not found', HttpStatus.NOT_FOUND);
|
||||
return road;
|
||||
}
|
||||
|
||||
@Post('candidates/manual')
|
||||
@ApiOperation({ summary: 'Submit manual candidate road drawn on map ✍️' })
|
||||
async submitManualCandidate(@Body() body: { geojson: any; name?: string; highway?: string }) {
|
||||
@@ -212,6 +245,10 @@ export class RoadRefinementController {
|
||||
oneway SMALLINT DEFAULT 0,
|
||||
start_node BIGINT,
|
||||
end_node BIGINT,
|
||||
routing_status VARCHAR(32) NOT NULL DEFAULT 'pending_connection',
|
||||
routing_error TEXT,
|
||||
routing_requested_at TIMESTAMP,
|
||||
routed_at TIMESTAMP,
|
||||
approved_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS candidate_id UUID;
|
||||
@@ -223,6 +260,10 @@ export class RoadRefinementController {
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS start_node BIGINT;
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS end_node BIGINT;
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP DEFAULT NOW();
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS routing_status VARCHAR(32) NOT NULL DEFAULT 'pending_connection';
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS routing_error TEXT;
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS routing_requested_at TIMESTAMP;
|
||||
ALTER TABLE approved_roads ADD COLUMN IF NOT EXISTS routed_at TIMESTAMP;
|
||||
CREATE INDEX IF NOT EXISTS approved_roads_geom_idx ON approved_roads USING GIST(geometry);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS approved_roads_cand_uidx ON approved_roads(candidate_id) WHERE candidate_id IS NOT NULL;
|
||||
`);
|
||||
@@ -244,86 +285,68 @@ export class RoadRefinementController {
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
|
||||
// 4. Safely attempt topology node snapping (Non-blocking)
|
||||
try {
|
||||
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;
|
||||
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);
|
||||
|
||||
-- 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 (snapErr: any) {
|
||||
this.logger.warn(`Topology snapping skipped for ${id}: ${snapErr.message}`);
|
||||
const [middleTables] = await this.dataSource.query(`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name IN ('planet_osm_line', 'planet_osm_ways', 'planet_osm_nodes')
|
||||
`);
|
||||
if (middleTables.count !== 3) {
|
||||
await this.dataSource.query(`UPDATE approved_roads
|
||||
SET routing_status = 'blocked_missing_network',
|
||||
routing_error = 'Routing network index is unavailable; retry after the map import completes.'
|
||||
WHERE candidate_id = $1::uuid`, [id]);
|
||||
return { success: true, id, status: 'approved', routingStatus: 'blocked_missing_network' };
|
||||
}
|
||||
|
||||
this.logger.log(`✅ Road candidate ${id} approved & published.`);
|
||||
return { success: true, id, status: 'approved' };
|
||||
// A route can only enter GraphHopper when both endpoints share real OSM nodes.
|
||||
// Keep an explicit status instead of silently publishing an isolated line.
|
||||
const [connected] = await this.dataSource.query(`
|
||||
WITH road AS (
|
||||
SELECT id, geometry FROM approved_roads WHERE candidate_id = $1::uuid
|
||||
),
|
||||
endpoints AS (
|
||||
SELECT ST_StartPoint(geometry) AS start_pt, ST_EndPoint(geometry) AS end_pt FROM road
|
||||
),
|
||||
start_node AS (
|
||||
SELECT n.id, ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326) AS point
|
||||
FROM endpoints e
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(ST_Transform(e.start_pt, 3857), 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS node_id
|
||||
JOIN planet_osm_nodes n ON n.id = node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326)::geography, e.start_pt::geography) <= 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326)::geography, e.start_pt::geography) LIMIT 1
|
||||
),
|
||||
end_node AS (
|
||||
SELECT n.id, ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326) AS point
|
||||
FROM endpoints e
|
||||
JOIN planet_osm_line l ON l.highway IS NOT NULL AND l.way && ST_Expand(ST_Transform(e.end_pt, 3857), 60)
|
||||
JOIN planet_osm_ways w ON w.id = l.osm_id
|
||||
CROSS JOIN LATERAL unnest(w.nodes) AS node_id
|
||||
JOIN planet_osm_nodes n ON n.id = node_id
|
||||
WHERE ST_Distance(ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326)::geography, e.end_pt::geography) <= 30
|
||||
ORDER BY ST_Distance(ST_SetSRID(ST_MakePoint(n.lon / 1e7, n.lat / 1e7), 4326)::geography, e.end_pt::geography) LIMIT 1
|
||||
)
|
||||
UPDATE approved_roads a
|
||||
SET geometry = ST_SetPoint(ST_SetPoint(a.geometry, 0, s.point), ST_NPoints(a.geometry) - 1, e.point),
|
||||
start_node = s.id, end_node = e.id,
|
||||
routing_status = 'queued', routing_error = NULL, routing_requested_at = NOW()
|
||||
FROM start_node s CROSS JOIN end_node e
|
||||
WHERE a.candidate_id = $1::uuid
|
||||
RETURNING a.id
|
||||
`, [id]);
|
||||
|
||||
if (!connected) {
|
||||
await this.dataSource.query(`UPDATE approved_roads
|
||||
SET routing_status = 'needs_endpoint_connection',
|
||||
routing_error = 'Both road endpoints must be within 30 metres of existing road nodes.'
|
||||
WHERE candidate_id = $1::uuid`, [id]);
|
||||
return { success: true, id, status: 'approved', routingStatus: 'needs_endpoint_connection' };
|
||||
}
|
||||
|
||||
await this.redisService.set('routing_sync_requested', '1');
|
||||
this.logger.log(`✅ Road candidate ${id} queued for routing rebuild.`);
|
||||
return { success: true, id, status: 'approved', routingStatus: 'queued' };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to approve candidate road: ${e.message}`);
|
||||
throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
Reference in New Issue
Block a user