perfect-postcode/server-rs/src/routes/journey.rs
2026-05-17 10:16:30 +01:00

111 lines
3.6 KiB
Rust

use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Json;
use axum::Extension;
use serde::{Deserialize, Serialize};
use crate::auth::OptionalUser;
use crate::data::{slugify, PlaceData};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
#[derive(Deserialize)]
pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
share: Option<String>,
}
#[derive(Serialize)]
pub struct JourneyResponse {
/// Raw JSON array of journey legs, or null if no journey data available.
journey: Option<serde_json::Value>,
/// Median (50th percentile) total travel time in minutes.
minutes: Option<i16>,
/// Best-case (5th percentile) total travel time in minutes (transit only).
best_minutes: Option<i16>,
/// Destination coordinates from PlaceData, used by clients for unambiguous external links.
#[serde(skip_serializing_if = "Option::is_none")]
destination_lat: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
destination_lon: Option<f32>,
}
fn destination_coordinates(place_data: &PlaceData, slug: &str) -> Option<(f32, f32)> {
let best_idx = place_data
.name
.iter()
.enumerate()
.filter_map(|(idx, name)| {
(place_data.travel_destination[idx] && slugify(name) == slug).then_some(idx)
})
.min_by(|a, b| {
place_data.type_rank[*a]
.cmp(&place_data.type_rank[*b])
.then(place_data.population[*b].cmp(&place_data.population[*a]))
.then(place_data.name[*a].len().cmp(&place_data.name[*b].len()))
})?;
Some((place_data.lat[best_idx], place_data.lon[best_idx]))
}
pub async fn get_journey(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, axum::response::Response> {
let state = shared.load_state();
let store = &state.travel_time_store;
let postcode = normalize_postcode(&query.postcode);
let pc_idx = state
.postcode_data
.postcode_to_idx
.get(&postcode)
.copied()
.ok_or_else(|| (StatusCode::NOT_FOUND, "Postcode not found").into_response())?;
let (lat, lon) = state.postcode_data.centroids[pc_idx];
let share_bounds = resolve_share_code(&state, query.share.as_deref()).await;
check_license_point(&user.0, lat as f64, lon as f64, share_bounds)?;
if !store.has_destination(&query.mode, &query.slug) {
return Err((
StatusCode::NOT_FOUND,
format!("No travel data for mode={} slug={}", query.mode, query.slug),
)
.into_response());
}
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load travel data: {e}"),
)
.into_response()
})?;
let row = travel_data.get(&postcode);
let journey = row
.and_then(|r| r.journey.as_ref())
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
let minutes = row.map(|r| r.minutes);
let best_minutes = row.and_then(|r| r.best_minutes);
let (destination_lat, destination_lon) =
destination_coordinates(&state.place_data, &query.slug)
.map(|(lat, lon)| (Some(lat), Some(lon)))
.unwrap_or((None, None));
Ok(Json(JourneyResponse {
journey,
minutes,
best_minutes,
destination_lat,
destination_lon,
}))
}