Good changes
This commit is contained in:
parent
80a5a2a774
commit
791bc6976b
24 changed files with 890 additions and 312 deletions
|
|
@ -8,11 +8,13 @@ use polars::lazy::frame::LazyFrame;
|
|||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use tracing::info;
|
||||
|
||||
/// Per-postcode travel time data: median and optional best-case (transit only).
|
||||
#[derive(Clone, Copy)]
|
||||
/// Per-postcode travel time data: median, optional best-case (transit only),
|
||||
/// and optional journey instructions (JSON leg array, transit only with --paths).
|
||||
#[derive(Clone)]
|
||||
pub struct TravelDataRow {
|
||||
pub minutes: i16,
|
||||
pub best_minutes: Option<i16>,
|
||||
pub journey: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Cached postcode → travel time data for a single destination file.
|
||||
|
|
@ -198,17 +200,26 @@ impl TravelTimeStore {
|
|||
.column("best_minutes")
|
||||
.ok()
|
||||
.map(|col| col.i16().expect("'best_minutes' is not i16"));
|
||||
let journeys = df
|
||||
.column("journey")
|
||||
.ok()
|
||||
.map(|col| col.str().expect("'journey' is not string"));
|
||||
|
||||
let mut map = FxHashMap::default();
|
||||
map.reserve(df.height());
|
||||
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
|
||||
if let (Some(pc), Some(min)) = (pc, min) {
|
||||
let best_min = best.as_ref().and_then(|b| b.get(i));
|
||||
let journey = journeys
|
||||
.as_ref()
|
||||
.and_then(|j| j.get(i))
|
||||
.map(Arc::from);
|
||||
map.insert(
|
||||
pc.to_string(),
|
||||
TravelDataRow {
|
||||
minutes: min,
|
||||
best_minutes: best_min,
|
||||
journey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ pub struct HexagonStatsParams {
|
|||
/// Comma-separated feature names to include in stats response.
|
||||
/// Only listed features are computed; if absent or empty, no features are returned.
|
||||
pub fields: Option<String>,
|
||||
/// When set (with journey_slug), pick central_postcode as the postcode with the
|
||||
/// shortest travel time for this mode+slug (so it has journey data).
|
||||
pub journey_mode: Option<String>,
|
||||
pub journey_slug: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_hexagon_stats(
|
||||
|
|
@ -107,6 +111,17 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
// Load travel time data for central_postcode selection (if requested)
|
||||
let journey_travel_data = match (¶ms.journey_mode, ¶ms.journey_slug) {
|
||||
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
|
||||
state
|
||||
.travel_time_store
|
||||
.get(mode, slug)
|
||||
.ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let start_time = std::time::Instant::now();
|
||||
let precomputed = &state.h3_cells;
|
||||
|
|
@ -138,27 +153,58 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
let total_count = matching_rows.len();
|
||||
|
||||
// Find the postcode of the property closest to the hexagon center
|
||||
// Pick central_postcode: prefer the postcode with the shortest travel time
|
||||
// for the requested journey destination (so it has journey data). Fall back
|
||||
// to geographic proximity to the hexagon center.
|
||||
let central_postcode = if !matching_rows.is_empty() {
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
let closest_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da_lat = state.data.lat[a] - center_lat;
|
||||
let da_lon = state.data.lon[a] - center_lon;
|
||||
let db_lat = state.data.lat[b] - center_lat;
|
||||
let db_lon = state.data.lon[b] - center_lon;
|
||||
let dist_a = da_lat * da_lat + da_lon * da_lon;
|
||||
let dist_b = db_lat * db_lat + db_lon * db_lon;
|
||||
dist_a
|
||||
.partial_cmp(&dist_b)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty");
|
||||
Some(state.data.postcode(closest_row).to_string())
|
||||
if let Some(ref travel_data) = journey_travel_data {
|
||||
// Find the row with the shortest travel time in the travel data
|
||||
let best_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|row| {
|
||||
let pc = state.data.postcode(row);
|
||||
travel_data.get(pc).map(|td| (row, td.minutes))
|
||||
})
|
||||
.min_by_key(|&(_, mins)| mins)
|
||||
.map(|(row, _)| row);
|
||||
|
||||
// Fall back to geographic center if no row has travel data
|
||||
let row = best_row.unwrap_or_else(|| {
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
||||
+ (state.data.lon[a] - center_lon).powi(2);
|
||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
||||
+ (state.data.lon[b] - center_lon).powi(2);
|
||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty")
|
||||
});
|
||||
Some(state.data.postcode(row).to_string())
|
||||
} else {
|
||||
// No journey destination requested — use geographic center
|
||||
let center: h3o::LatLng = cell.into();
|
||||
let center_lat = center.lat() as f32;
|
||||
let center_lon = center.lng() as f32;
|
||||
let closest_row = matching_rows
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|&a, &b| {
|
||||
let da = (state.data.lat[a] - center_lat).powi(2)
|
||||
+ (state.data.lon[a] - center_lon).powi(2);
|
||||
let db = (state.data.lat[b] - center_lat).powi(2)
|
||||
+ (state.data.lon[b] - center_lon).powi(2);
|
||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.expect("matching_rows is non-empty");
|
||||
Some(state.data.postcode(closest_row).to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ struct InviteValidation {
|
|||
used: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateInviteRequest {
|
||||
/// Admins can explicitly choose "admin" or "referral". Ignored for non-admins.
|
||||
invite_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RedeemRequest {
|
||||
code: String,
|
||||
|
|
@ -66,12 +72,12 @@ fn generate_invite_code() -> String {
|
|||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license).
|
||||
/// Licensed non-admin users create "referral" invites (30% off).
|
||||
/// Create an invite. Admins create "admin" invites (free license) by default,
|
||||
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
_body: Json<serde_json::Value>,
|
||||
Json(body): Json<CreateInviteRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
|
|
@ -79,7 +85,10 @@ pub async fn post_invites(
|
|||
};
|
||||
|
||||
let invite_type = if user.is_admin {
|
||||
"admin"
|
||||
match body.invite_type.as_deref() {
|
||||
Some("referral") => "referral",
|
||||
_ => "admin",
|
||||
}
|
||||
} else if user.subscription == "licensed" {
|
||||
"referral"
|
||||
} else {
|
||||
|
|
|
|||
58
server-rs/src/routes/journey.rs
Normal file
58
server-rs/src/routes/journey.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JourneyQuery {
|
||||
postcode: String,
|
||||
mode: String,
|
||||
slug: 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>,
|
||||
}
|
||||
|
||||
pub async fn get_journey(
|
||||
state: Arc<AppState>,
|
||||
query: axum::extract::Query<JourneyQuery>,
|
||||
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
|
||||
let store = &state.travel_time_store;
|
||||
|
||||
if !store.has_destination(&query.mode, &query.slug) {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No travel data for mode={} slug={}", query.mode, query.slug),
|
||||
));
|
||||
}
|
||||
|
||||
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to load travel data: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let row = travel_data.get(&query.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);
|
||||
|
||||
Ok(Json(JourneyResponse {
|
||||
journey,
|
||||
minutes,
|
||||
best_minutes,
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeaheadParams {
|
||||
pub postcode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TypeaheadResponse {
|
||||
pub location_identifier: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveMatch {
|
||||
#[serde(rename = "type")]
|
||||
match_type: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: String,
|
||||
id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RightmoveTypeaheadResponse {
|
||||
matches: Vec<RightmoveMatch>,
|
||||
}
|
||||
|
||||
pub async fn get_rightmove_typeahead(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<TypeaheadParams>,
|
||||
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
|
||||
let postcode = params.postcode.trim().to_uppercase();
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
.get(TYPEAHEAD_URL)
|
||||
.query(&[("query", &postcode), ("limit", &"10".to_string())])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "Rightmove typeahead request failed");
|
||||
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
|
||||
})?;
|
||||
|
||||
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Rightmove typeahead response");
|
||||
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
|
||||
})?;
|
||||
|
||||
// Look for POSTCODE match first, then OUTCODE
|
||||
for match_type in &["POSTCODE", "OUTCODE"] {
|
||||
for m in &data.matches {
|
||||
if m.match_type == *match_type
|
||||
&& m.display_name.to_uppercase().replace(' ', "")
|
||||
== postcode.replace(' ', "")
|
||||
{
|
||||
let id = match &m.id {
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
return Ok(Json(TypeaheadResponse {
|
||||
location_identifier: format!("{}^{}", match_type, id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No Rightmove location found for: {}", postcode),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSubscriptionRequest {
|
||||
subscription: String,
|
||||
}
|
||||
|
||||
pub async fn patch_subscription(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<UpdateSubscriptionRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if !user.is_admin {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid subscription: {}", req.subscription),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to authenticate as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": req.subscription }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user update failed ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
server-rs/src/routes/travel_destinations.rs
Normal file
89
server-rs/src/routes/travel_destinations.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::data::slugify;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DestinationResult {
|
||||
name: String,
|
||||
slug: String,
|
||||
place_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
city: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DestinationsResponse {
|
||||
destinations: Vec<DestinationResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DestinationsParams {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
pub async fn get_travel_destinations(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<DestinationsParams>,
|
||||
) -> Result<Json<DestinationsResponse>, (StatusCode, String)> {
|
||||
let mode = params.mode;
|
||||
|
||||
let destinations = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let pd = &state.place_data;
|
||||
let tt_store = &state.travel_time_store;
|
||||
|
||||
let slug_set = match tt_store.destinations.get(&mode) {
|
||||
Some(slugs) => slugs,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
// Find places that have travel time data for this mode
|
||||
let mut matches: Vec<(usize, String, u8, u32, usize)> = pd
|
||||
.name
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
let slug = slugify(name);
|
||||
if slug_set.contains(&slug) {
|
||||
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: type rank asc, population desc, name length asc
|
||||
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
|
||||
|
||||
let results: Vec<DestinationResult> = matches
|
||||
.into_iter()
|
||||
.map(|(idx, slug, ..)| DestinationResult {
|
||||
name: pd.name[idx].clone(),
|
||||
slug,
|
||||
place_type: pd.place_type.get(idx).to_string(),
|
||||
city: pd.city[idx].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
mode = mode.as_str(),
|
||||
results = results.len(),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/travel-destinations"
|
||||
);
|
||||
|
||||
results
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
||||
|
||||
Ok(Json(DestinationsResponse { destinations }))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue