chainage compute and map view
This commit is contained in:
@@ -223,6 +223,26 @@ fn route_quality_warnings(route: &Route) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Min perpendicular distance (m) from a point to a polyline track — used by the
|
||||
/// contamination detector to test a calibration point against OTHER groups' videos.
|
||||
fn min_dist_to_track(track: &[Pt], lat: f64, lon: f64) -> f64 {
|
||||
let (m_lat, m_lon) = wgs84_m_per_deg(lat);
|
||||
let (px, py) = (lon * m_lon, lat * m_lat);
|
||||
let mut best = f64::INFINITY;
|
||||
for w in track.windows(2) {
|
||||
let (ax, ay) = (w[0][1] * m_lon, w[0][0] * m_lat);
|
||||
let (bx, by) = (w[1][1] * m_lon, w[1][0] * m_lat);
|
||||
let (dx, dy) = (bx - ax, by - ay);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let t = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(((px - (ax + t * dx)).powi(2) + (py - (ay + t * dy)).powi(2)).sqrt());
|
||||
}
|
||||
if track.len() == 1 {
|
||||
best = best.min(dist_m(lat, lon, track[0][0], track[0][1]));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Project (lat, lon) onto the nearest route segment → (s along the route,
|
||||
/// perpendicular distance in meters). Local flat projection using the WGS84
|
||||
/// per-degree factors at the point's latitude — same meter as `dist_m`.
|
||||
@@ -443,6 +463,9 @@ pub struct ChainagePoint {
|
||||
pub chainage_m: f64,
|
||||
pub note: String,
|
||||
pub road_type: String,
|
||||
/// '' = manually entered; 'quick' = generated by the two-point quick assign
|
||||
/// (replaced wholesale on the next quick run — identity independent of the note).
|
||||
pub origin: String,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -493,6 +516,10 @@ pub struct VideoChainageRow {
|
||||
pub file_name: String,
|
||||
pub seq: i32,
|
||||
pub flipped: bool,
|
||||
/// Runs opposite to the MAJORITY of the route's videos — the only direction flag
|
||||
/// worth surfacing (when the whole road was filmed toward decreasing chainage,
|
||||
/// "flipped" is the norm, not an anomaly).
|
||||
pub against_flow: bool,
|
||||
pub sr_start_m: f64,
|
||||
pub sr_end_m: f64,
|
||||
pub chainage_start_m: f64,
|
||||
@@ -503,6 +530,9 @@ pub struct VideoChainageRow {
|
||||
pub struct CalibrationSummary {
|
||||
pub route_len_m: f64,
|
||||
pub reversed: bool,
|
||||
/// Most of the footage runs toward DECREASING chainage — that's this route's
|
||||
/// normal pattern, so per-video direction flags are relative to it.
|
||||
pub majority_flipped: bool,
|
||||
pub points_used: usize,
|
||||
pub points_excluded: usize,
|
||||
pub spans: Vec<SpanRow>,
|
||||
@@ -523,7 +553,7 @@ pub async fn list_points(
|
||||
) -> ApiResult<Vec<ChainagePoint>> {
|
||||
crate::require_project_access(&s, &user, id).await?;
|
||||
let rows = sqlx::query_as::<_, ChainagePoint>(
|
||||
r#"SELECT id, lat, lon, chainage_m, note, road_type, created_by, created_at
|
||||
r#"SELECT id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at
|
||||
FROM chainage_points WHERE project_id=$1 ORDER BY road_type, chainage_m"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -547,7 +577,7 @@ pub async fn add_point(
|
||||
let row = sqlx::query_as::<_, ChainagePoint>(
|
||||
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING id, lat, lon, chainage_m, note, road_type, created_by, created_at"#,
|
||||
RETURNING id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(req.lat)
|
||||
@@ -612,6 +642,64 @@ pub async fn add_points_bulk(
|
||||
Ok(Json(json!({ "added": req.points.len() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PointNoteRequest {
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// `POST /api/chainage/points/:id` — edit a calibration point's note (admin).
|
||||
/// Lat/lon/chainage stay immutable — delete + re-add to change the measurement.
|
||||
pub async fn update_point_note(
|
||||
State(s): State<AppState>,
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
Json(req): Json<PointNoteRequest>,
|
||||
) -> ApiResult<Value> {
|
||||
user.require_admin()?;
|
||||
let updated: Option<i32> =
|
||||
sqlx::query_scalar("UPDATE chainage_points SET note=$2 WHERE id=$1 RETURNING id")
|
||||
.bind(id)
|
||||
.bind(req.note.trim())
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if updated.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "point not found".into()));
|
||||
}
|
||||
Ok(Json(json!({ "updated": true })))
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct CalibrationRow {
|
||||
pub road_type: String,
|
||||
pub route_len_m: f64,
|
||||
pub anchor_count: i32,
|
||||
pub computed_by: String,
|
||||
pub computed_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// `GET /api/projects/:id/chainage/calibrations` — the saved calibrations (one per
|
||||
/// group), so anyone who opens the project sees what's already computed and by whom
|
||||
/// (member or admin). The per-video chainage itself lives on `videos` and is in every
|
||||
/// list/map response regardless.
|
||||
pub async fn list_calibrations(
|
||||
State(s): State<AppState>,
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
) -> ApiResult<Vec<CalibrationRow>> {
|
||||
crate::require_project_access(&s, &user, id).await?;
|
||||
let rows = sqlx::query_as::<_, CalibrationRow>(
|
||||
r#"SELECT road_type, route_len_m, jsonb_array_length(anchors)::int AS anchor_count,
|
||||
computed_by, computed_at
|
||||
FROM chainage_calibrations WHERE project_id=$1 ORDER BY road_type"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// `DELETE /api/chainage/points/:id` — remove a calibration point (admin).
|
||||
pub async fn delete_point(
|
||||
State(s): State<AppState>,
|
||||
@@ -665,18 +753,22 @@ pub async fn quick_assign(
|
||||
let last = route.pts.last().copied().unwrap();
|
||||
|
||||
// Replace any previous quick points for this group, then add the fresh pair.
|
||||
// Identity = origin='quick' (a real column), NOT the note text — notes are freely
|
||||
// editable, so matching on them would leave stale generated anchors behind.
|
||||
// "chain start/end" = the geometric ends of the ordered route — WHICH physical
|
||||
// end is which is the admin's call (the swap button in the UI flips the values).
|
||||
sqlx::query(
|
||||
"DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND note IN ('quick: route start','quick: route end')",
|
||||
"DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND origin='quick'",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&road_type)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
for (pt, chainage, note) in [(first, req.start_m, "quick: route start"), (last, req.end_m, "quick: route end")] {
|
||||
for (pt, chainage, note) in [(first, req.start_m, "quick: chain start"), (last, req.end_m, "quick: chain end")] {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)"#,
|
||||
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by, origin)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'quick')"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(pt.0)
|
||||
@@ -746,8 +838,8 @@ async fn run_recompute(
|
||||
warnings.extend(route_quality_warnings(&route));
|
||||
|
||||
// Control points for this calibration group (tagged with it, or untagged).
|
||||
let points: Vec<(i32, f64, f64, f64)> = sqlx::query_as(
|
||||
r#"SELECT id, lat, lon, chainage_m FROM chainage_points
|
||||
let points: Vec<(i32, f64, f64, f64, String)> = sqlx::query_as(
|
||||
r#"SELECT id, lat, lon, chainage_m, road_type FROM chainage_points
|
||||
WHERE project_id=$1 AND (road_type=$2 OR road_type='')
|
||||
ORDER BY chainage_m"#,
|
||||
)
|
||||
@@ -766,7 +858,8 @@ async fn run_recompute(
|
||||
// Snap each point to the route; drop the ones that don't belong to it.
|
||||
let mut anchors: Vec<(f64, f64)> = Vec::new(); // (s, chainage)
|
||||
let mut excluded = 0usize;
|
||||
for (pid, lat, lon, chainage_m) in &points {
|
||||
let mut used_untagged: Vec<(i32, f64, f64, f64)> = Vec::new();
|
||||
for (pid, lat, lon, chainage_m, ptag) in &points {
|
||||
let (sv, d) = snap(&route, *lat, *lon);
|
||||
if d > SNAP_MAX_M {
|
||||
excluded += 1;
|
||||
@@ -776,6 +869,44 @@ async fn run_recompute(
|
||||
));
|
||||
} else {
|
||||
anchors.push((sv, *chainage_m));
|
||||
if ptag.is_empty() {
|
||||
used_untagged.push((*pid, *lat, *lon, *chainage_m));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contamination detector: an UNTAGGED point is shared by every group's
|
||||
// calibration — if it also sits within snap distance of a DIFFERENT road-type
|
||||
// group's videos (parallel carriageway, ramp, service road), it's ambiguous
|
||||
// which road it belongs to and can silently distort both calibrations.
|
||||
if !used_untagged.is_empty() {
|
||||
let others: Vec<(String, Value)> = sqlx::query_as(
|
||||
r#"SELECT road_type, COALESCE(gps_track_corrected, gps_track)
|
||||
FROM videos
|
||||
WHERE project_id=$1 AND road_type <> '' AND road_type <> $2
|
||||
AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL"#,
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(road_type)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let others: Vec<(String, Vec<Pt>)> =
|
||||
others.into_iter().map(|(rt, tr)| (rt, parse_track(&tr))).collect();
|
||||
for (pid, lat, lon, chainage_m) in &used_untagged {
|
||||
let mut near: Vec<&str> = others
|
||||
.iter()
|
||||
.filter(|(_, tr)| tr.len() >= 2 && min_dist_to_track(tr, *lat, *lon) <= SNAP_MAX_M)
|
||||
.map(|(rt, _)| rt.as_str())
|
||||
.collect();
|
||||
near.sort_unstable();
|
||||
near.dedup();
|
||||
if !near.is_empty() {
|
||||
warnings.push(format!(
|
||||
"point #{pid} ({:.4} km) is untagged and ambiguous — it also lies within {SNAP_MAX_M:.0} m of group(s) {}; tag it to the road it belongs to",
|
||||
chainage_m / 1000.0, near.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if anchors.len() < 2 {
|
||||
@@ -837,6 +968,17 @@ async fn run_recompute(
|
||||
|
||||
let calib = Calib { anchors };
|
||||
|
||||
// Direction pattern: when most videos were filmed toward decreasing chainage,
|
||||
// "flipped" is the route's norm — only the exceptions are worth flagging.
|
||||
let majority_flipped = route.videos.iter().filter(|v| v.flipped).count() * 2 > route.videos.len();
|
||||
let against = route.videos.iter().filter(|v| v.flipped != majority_flipped).count();
|
||||
if route.videos.len() >= 4 && against * 4 >= route.videos.len() {
|
||||
warnings.push(format!(
|
||||
"mixed directions: {against} of {} videos run against the dominant direction — both carriageways may be mixed in one group (split LHS/RHS by road type)",
|
||||
route.videos.len()
|
||||
));
|
||||
}
|
||||
|
||||
// Persist: calibrated videos get chainage + sr + seq; GPS-less selections are
|
||||
// cleared (they're not on the route).
|
||||
let mut out_videos = Vec::new();
|
||||
@@ -862,6 +1004,7 @@ async fn run_recompute(
|
||||
file_name: v.file_name.clone(),
|
||||
seq: v.seq,
|
||||
flipped: v.flipped,
|
||||
against_flow: v.flipped != majority_flipped,
|
||||
sr_start_m: v.sr_start_m,
|
||||
sr_end_m: v.sr_end_m,
|
||||
chainage_start_m: ch_start,
|
||||
@@ -905,6 +1048,7 @@ async fn run_recompute(
|
||||
Ok(CalibrationSummary {
|
||||
route_len_m: route.len_m(),
|
||||
reversed,
|
||||
majority_flipped,
|
||||
points_used: calib.anchors.len(),
|
||||
points_excluded: excluded,
|
||||
spans,
|
||||
@@ -980,6 +1124,21 @@ mod tests {
|
||||
assert!((beyond - (2200.0 + 100.0 * last_scale)).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_dist_flags_nearby_foreign_tracks() {
|
||||
// A parallel carriageway 0.0002° (~22 m) north: a point on our route is
|
||||
// "near" it (ambiguous); a point 0.01° (~1.1 km) away is not.
|
||||
let other = seg(9, 0.0, 0.01, 50);
|
||||
let mut par = other.track.clone();
|
||||
for p in par.iter_mut() {
|
||||
p[0] = 0.0002;
|
||||
}
|
||||
let d_near = min_dist_to_track(&par, 0.0, 0.005);
|
||||
assert!((d_near - 22.0).abs() < 3.0, "d_near {d_near}");
|
||||
let d_far = min_dist_to_track(&par, 0.01, 0.005);
|
||||
assert!(d_far > 1000.0, "d_far {d_far}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_measures_perpendicular_distance() {
|
||||
let tracks = vec![seg(1, 0.0, 0.01, 100)];
|
||||
|
||||
@@ -227,22 +227,47 @@ fn collect_samples(obj: &serde_json::Map<String, Value>) -> (Vec<Pt>, Option<f64
|
||||
match field {
|
||||
"GPSLatitude" => entry.lat = v.as_f64(),
|
||||
"GPSLongitude" => entry.lon = v.as_f64(),
|
||||
"GPSDateTime" => entry.t = v.as_str().and_then(parse_gps_datetime),
|
||||
// Plausibility gate: cameras write garbage dates (1970/zero) on some
|
||||
// fixes — treat anything outside [2000, ~2096] as unstamped.
|
||||
"GPSDateTime" => {
|
||||
entry.t = v
|
||||
.as_str()
|
||||
.and_then(parse_gps_datetime)
|
||||
.filter(|&t| (946_684_800.0..4_100_000_000.0).contains(&t))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples: Vec<Pt> = Vec::with_capacity(by_doc.len());
|
||||
let mut t0: Option<f64> = None;
|
||||
let mut t0_abs: Option<f64> = None;
|
||||
for (idx, p) in by_doc {
|
||||
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { continue };
|
||||
if t0.is_none() {
|
||||
t0_abs = p.t; // real epoch only when the camera stamped it
|
||||
}
|
||||
let t_abs = p.t.unwrap_or(idx as f64);
|
||||
let t_rel = t_abs - *t0.get_or_insert(t_abs);
|
||||
samples.push([lat, lon, t_rel]);
|
||||
let raw: Vec<(u32, f64, f64, Option<f64>)> = by_doc
|
||||
.into_iter()
|
||||
.filter_map(|(idx, p)| Some((idx, p.lat?, p.lon?, p.t)))
|
||||
.collect();
|
||||
let t0_abs = raw.first().and_then(|r| r.3); // real epoch only when stamped
|
||||
|
||||
// ONE timebase for the whole track. Mixing them (epoch for stamped samples,
|
||||
// doc index for unstamped ones) once produced billion-second "dropouts" when a
|
||||
// camera skipped GPSDateTime on some fixes — so unless EVERY sample is stamped,
|
||||
// fall back to index-based 1 Hz for all of them.
|
||||
let index_based = |raw: &[(u32, f64, f64, Option<f64>)]| -> Vec<Pt> {
|
||||
let idx0 = raw.first().map(|r| r.0).unwrap_or(0);
|
||||
raw.iter().map(|&(idx, lat, lon, _)| [lat, lon, (idx - idx0) as f64]).collect()
|
||||
};
|
||||
let all_stamped = !raw.is_empty() && raw.iter().all(|r| r.3.is_some());
|
||||
let mut samples: Vec<Pt> = if all_stamped {
|
||||
let t0 = raw[0].3.unwrap();
|
||||
raw.iter().map(|&(_, lat, lon, t)| [lat, lon, t.unwrap() - t0]).collect()
|
||||
} else {
|
||||
index_based(&raw)
|
||||
};
|
||||
// Clock-glitch guard: a negative or >1 h step between consecutive fixes means
|
||||
// the stamps themselves are unusable (camera clock jumped) — rebuild the whole
|
||||
// track on the index timebase rather than trust any of them.
|
||||
if samples
|
||||
.windows(2)
|
||||
.any(|w| !(0.0..=3600.0).contains(&(w[1][2] - w[0][2])))
|
||||
{
|
||||
samples = index_based(&raw);
|
||||
}
|
||||
(samples, t0_abs)
|
||||
}
|
||||
@@ -846,6 +871,61 @@ mod tests {
|
||||
assert_eq!(fixed, t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_gps_datetime_never_mixes_timebases() {
|
||||
// Doc1 has NO GPSDateTime, Doc2/Doc3 do (real epoch ~1.69e9). The old code
|
||||
// seeded t0 from the index and then subtracted it from the epoch — a
|
||||
// 1,693,958,401-second "dropout". Every sample must share one timebase.
|
||||
let mut obj = serde_json::Map::new();
|
||||
let ins = |o: &mut serde_json::Map<String, Value>, d: u32, lat: f64, t: Option<&str>| {
|
||||
o.insert(format!("Doc{d}:GPSLatitude"), serde_json::json!(lat));
|
||||
o.insert(format!("Doc{d}:GPSLongitude"), serde_json::json!(78.0));
|
||||
if let Some(t) = t {
|
||||
o.insert(format!("Doc{d}:GPSDateTime"), serde_json::json!(t));
|
||||
}
|
||||
};
|
||||
ins(&mut obj, 1, 21.0, None);
|
||||
ins(&mut obj, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
|
||||
ins(&mut obj, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
|
||||
let (samples, t0_abs) = collect_samples(&obj);
|
||||
assert_eq!(samples.len(), 3);
|
||||
let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||
assert!(max_gap <= 1.0 + 1e-9, "timebases mixed: max gap {max_gap}");
|
||||
assert_eq!(samples[0][2], 0.0);
|
||||
assert!(t0_abs.is_none(), "first fix is unstamped — no reliable epoch");
|
||||
|
||||
// Fully-stamped tracks still use real GPS time (t0_abs = first fix's epoch).
|
||||
let mut obj2 = serde_json::Map::new();
|
||||
ins(&mut obj2, 1, 21.0, Some("2023:09:06 00:00:00Z"));
|
||||
ins(&mut obj2, 2, 21.0001, Some("2023:09:06 00:00:03Z"));
|
||||
let (s2, t0b) = collect_samples(&obj2);
|
||||
assert_eq!(s2[1][2], 3.0);
|
||||
assert!(t0b.is_some());
|
||||
|
||||
// The live-data case: a GARBAGE first stamp (1970) followed by real ones —
|
||||
// "all stamped", but the epoch jump would be the whole epoch. The
|
||||
// plausibility gate (>2000) turns the 1970 stamp into "unstamped" → index
|
||||
// timebase for the whole track, and no epoch is trusted for stitching.
|
||||
let mut obj3 = serde_json::Map::new();
|
||||
ins(&mut obj3, 1, 21.0, Some("1970:01:01 00:00:00Z"));
|
||||
ins(&mut obj3, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
|
||||
ins(&mut obj3, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
|
||||
let (s3, t0c) = collect_samples(&obj3);
|
||||
let g3 = s3.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||
assert!(g3 <= 1.0 + 1e-9, "garbage stamp leaked: max gap {g3}");
|
||||
assert!(t0c.is_none());
|
||||
|
||||
// Clock glitch WITHIN plausible dates (2023 → 2026 jump): the >1 h step
|
||||
// guard rebuilds on the index timebase too.
|
||||
let mut obj4 = serde_json::Map::new();
|
||||
ins(&mut obj4, 1, 21.0, Some("2023:09:06 00:00:00Z"));
|
||||
ins(&mut obj4, 2, 21.0001, Some("2026:05:28 12:00:00Z"));
|
||||
ins(&mut obj4, 3, 21.0002, Some("2026:05:28 12:00:01Z"));
|
||||
let (s4, _) = collect_samples(&obj4);
|
||||
let g4 = s4.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||
assert!(g4 <= 1.0 + 1e-9, "clock glitch leaked: max gap {g4}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_removes_a_burst() {
|
||||
let mut t = straight(80);
|
||||
|
||||
@@ -207,9 +207,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/api/videos/:id/gps/correct", post(gps::correct))
|
||||
.route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point))
|
||||
.route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk))
|
||||
.route("/api/chainage/points/:id", delete(chainage::delete_point))
|
||||
.route("/api/chainage/points/:id", delete(chainage::delete_point).post(chainage::update_point_note))
|
||||
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
|
||||
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign))
|
||||
.route("/api/projects/:id/chainage/calibrations", get(chainage::list_calibrations))
|
||||
// Phase 3 admin (token-gated).
|
||||
.route("/api/admin/storage", get(storage_info))
|
||||
.route("/api/users", get(admin::list_users).post(admin::create_user))
|
||||
|
||||
Reference in New Issue
Block a user