All changes

This commit is contained in:
Andras Schmelczer 2026-03-14 21:36:00 +00:00
parent 593f380581
commit 49f7ec2f5a
60 changed files with 1783 additions and 679 deletions

View file

@ -8,7 +8,7 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_POIS_PER_REQUEST: usize = 10000;
pub const MAX_CELLS_PER_REQUEST: usize = 5000;
pub const MAX_CELLS_PER_REQUEST: usize = 50000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PROPERTIES_LIMIT: usize = 500;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;

View file

@ -47,40 +47,6 @@ pub struct Histogram {
pub counts: Vec<u64>,
}
impl Histogram {
/// Width of each middle bin (between p1 and p99).
#[allow(dead_code)]
pub fn middle_bin_width(&self) -> f32 {
let num_bins = self.counts.len();
if num_bins <= 2 {
return self.p99 - self.p1;
}
(self.p99 - self.p1) / (num_bins - 2) as f32
}
/// Get the bin index for a value.
#[allow(dead_code)]
pub fn bin_for_value(&self, value: f32) -> usize {
let num_bins = self.counts.len();
if num_bins == 0 {
return 0;
}
if value < self.p1 {
return 0; // Low outlier bin
}
if value >= self.p99 {
return num_bins - 1; // High outlier bin
}
// Middle bins
let middle_width = self.middle_bin_width();
if middle_width <= 0.0 {
return num_bins / 2;
}
let middle_bin = ((value - self.p1) / middle_width) as usize;
// Bins 1 to n-2 are the middle bins
(1 + middle_bin).min(num_bins - 2)
}
}
pub struct FeatureStats {
pub slider_min: f32,

View file

@ -218,14 +218,14 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
linked: "",
},
FeatureConfig {
name: "Construction age",
name: "Construction year",
bounds: Bounds::Fixed {
min: 0.0,
max: 2026.0,
},
step: 1.0,
description: "Estimated year of construction from the EPC",
detail: "The approximate year of construction as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
detail: "The approximate construction year as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
source: "epc",
prefix: "",
suffix: "",

View file

@ -8,11 +8,11 @@ use crate::consts::FREE_ZONE_BOUNDS;
/// Check whether the user is allowed to query data at the given bounds.
/// Licensed users and admins bypass the check entirely.
/// Free/anonymous users get 403 if bounds exceed the free zone.
#[allow(clippy::result_large_err)]
pub fn check_license_bounds(
user: &Option<PocketBaseUser>,
bounds: (f64, f64, f64, f64),
) -> Result<(), (StatusCode, axum::response::Response)> {
// Licensed users and admins can query anywhere
) -> Result<(), axum::response::Response> {
if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" {
return Ok(());
@ -22,7 +22,6 @@ pub fn check_license_bounds(
let (south, west, north, east) = bounds;
let (fz_south, fz_west, fz_north, fz_east) = FREE_ZONE_BOUNDS;
// Check if requested bounds are fully within the free zone
if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east {
return Ok(());
}
@ -38,18 +37,15 @@ pub fn check_license_bounds(
}
});
Err((
StatusCode::FORBIDDEN,
(StatusCode::FORBIDDEN, axum::Json(body)).into_response(),
))
Err((StatusCode::FORBIDDEN, axum::Json(body)).into_response())
}
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
/// Used for endpoints that operate on a single location (e.g. postcode stats).
#[allow(clippy::result_large_err)]
pub fn check_license_point(
user: &Option<PocketBaseUser>,
lat: f64,
lon: f64,
) -> Result<(), (StatusCode, axum::response::Response)> {
) -> Result<(), axum::response::Response> {
check_license_bounds(user, (lat, lon, lat, lon))
}

View file

@ -423,6 +423,7 @@ async fn main() -> anyhow::Result<()> {
let state_checkout = state.clone();
let state_stripe_webhook = state.clone();
let state_pricing = state.clone();
let state_invites_list = state.clone();
let state_invites_create = state.clone();
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
@ -533,7 +534,8 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/invites",
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
get(move |ext| routes::get_invites(state_invites_list.clone(), ext))
.post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
)
.route(
"/api/invite/{code}",

View file

@ -46,22 +46,49 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
};
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
let og_image_url = if query_string.is_empty() {
let is_invite = path.starts_with("/invite/");
let og_image_url = if is_invite {
// Include path= so the screenshot service navigates to /invite/CODE
if query_string.is_empty() {
format!(
"{}/api/screenshot?og=1&path={}",
state.public_url, path
)
} else {
format!(
"{}/api/screenshot?og=1&path={}&{}",
state.public_url, path, query_string
)
}
} else if query_string.is_empty() {
format!("{}/api/screenshot?og=1", state.public_url)
} else {
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
};
let (og_title, og_description) = if is_invite {
(
"You\u{2019}re invited to Perfect Postcode",
"Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
)
} else {
(
"Perfect Postcode \u{2014} Every neighbourhood in England",
"Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.",
)
};
let og_tags = format!(
r#"<meta property="og:title" content="Perfect Postcode Every neighbourhood in England" />
<meta property="og:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
r#"<meta property="og:title" content="{og_title}" />
<meta property="og:description" content="{og_description}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="{og_image_url}" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Perfect Postcode — Every neighbourhood in England" />
<meta name="twitter:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />"#
<meta name="twitter:title" content="{og_title}" />
<meta name="twitter:description" content="{og_description}" />"#
);
let html = index_html.replace(OG_PLACEHOLDER, &og_tags);

View file

@ -50,6 +50,10 @@ struct Field {
max_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
mime_types: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
on_create: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
on_update: Option<bool>,
}
impl Field {
@ -62,6 +66,8 @@ impl Field {
collection_id: None,
max_size: None,
mime_types: None,
on_create: None,
on_update: None,
}
}
@ -74,6 +80,8 @@ impl Field {
collection_id: None,
max_size: Some(10 * 1024 * 1024), // 10 MB
mime_types: Some(mime_types.into_iter().map(String::from).collect()),
on_create: None,
on_update: None,
}
}
@ -86,6 +94,22 @@ impl Field {
collection_id: Some(collection_id.to_string()),
max_size: None,
mime_types: None,
on_create: None,
on_update: None,
}
}
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
Self {
name: name.to_string(),
r#type: "autodate".to_string(),
required: None,
max_select: None,
collection_id: None,
max_size: None,
mime_types: None,
on_create: Some(on_create),
on_update: Some(on_update),
}
}
}
@ -146,7 +170,7 @@ async fn create_collection(
) -> anyhow::Result<()> {
let name = collection.name.clone();
let resp = client
.post(&format!("{base_url}/api/collections"))
.post(format!("{base_url}/api/collections"))
.header("Authorization", format!("Bearer {token}"))
.json(&collection)
.send()
@ -262,13 +286,14 @@ async fn ensure_user_fields(
Ok(())
}
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
async fn ensure_saved_searches_rules(
/// Ensure a collection has API rules allowing users to manage their own records.
async fn ensure_user_owned_rules(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/saved_searches");
let url = format!("{base_url}/api/collections/{collection_name}");
let user_only = "user = @request.auth.id";
let resp = client
.patch(&url)
@ -286,10 +311,89 @@ async fn ensure_saved_searches_rules(
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to update saved_searches API rules ({status}): {text}");
anyhow::bail!("Failed to update {collection_name} API rules ({status}): {text}");
}
info!("PocketBase collection 'saved_searches' API rules updated");
info!("PocketBase collection '{collection_name}' API rules updated");
Ok(())
}
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
async fn ensure_saved_searches_rules(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
ensure_user_owned_rules(client, base_url, token, "saved_searches").await
}
/// Ensure a collection has `created` and `updated` autodate fields.
/// PocketBase 0.23+ no longer adds these automatically — they must be explicit.
async fn ensure_autodate_fields(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/{collection_name}");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?;
let has_created = fields.iter().any(|f| f["name"] == "created");
let has_updated = fields.iter().any(|f| f["name"] == "updated");
if has_created && has_updated {
return Ok(());
}
let mut new_fields = fields.clone();
if !has_created {
new_fields.push(serde_json::json!({
"name": "created",
"type": "autodate",
"onCreate": true,
"onUpdate": false,
}));
}
if !has_updated {
new_fields.push(serde_json::json!({
"name": "updated",
"type": "autodate",
"onCreate": true,
"onUpdate": true,
}));
}
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to add autodate fields to {collection_name} ({status}): {text}");
}
info!("Added created/updated autodate fields to PocketBase collection '{collection_name}'");
Ok(())
}
@ -324,6 +428,8 @@ pub async fn ensure_collections(
Field::text("name", true),
Field::text("params", true),
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: user_only.clone(),
view_rule: user_only.clone(),
@ -335,6 +441,38 @@ pub async fn ensure_collections(
.await?;
} else {
ensure_saved_searches_rules(client, base_url, &token).await?;
ensure_autodate_fields(client, base_url, &token, "saved_searches").await?;
}
if !existing.iter().any(|n| n == "saved_properties") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
let user_only = Some("user = @request.auth.id".to_string());
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "saved_properties".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::relation("user", &users_id),
Field::text("address", true),
Field::text("postcode", true),
Field::text("data", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: user_only.clone(),
view_rule: user_only.clone(),
create_rule: user_only.clone(),
update_rule: user_only.clone(),
delete_rule: user_only,
},
)
.await?;
} else {
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
}
if !existing.iter().any(|n| n == "invites") {
@ -351,6 +489,8 @@ pub async fn ensure_collections(
Field::text("invite_type", true),
Field::text("used_by_id", false),
Field::text("used_at", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: None,
view_rule: None,
@ -361,7 +501,7 @@ pub async fn ensure_collections(
)
.await?;
} else {
info!("PocketBase collection 'invites' already exists");
ensure_autodate_fields(client, base_url, &token, "invites").await?;
}
if !existing.iter().any(|n| n == "short_urls") {
@ -375,6 +515,8 @@ pub async fn ensure_collections(
fields: vec![
Field::text("code", true),
Field::text("params", true),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: None,
view_rule: None,
@ -385,7 +527,7 @@ pub async fn ensure_collections(
)
.await?;
} else {
info!("PocketBase collection 'short_urls' already exists");
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
}
Ok(())

View file

@ -43,7 +43,7 @@ pub use properties::get_hexagon_properties;
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
pub use shorten::{get_short_url, post_shorten};
pub use streetview::get_streetview;
pub use invites::{get_invite, post_invites, post_redeem_invite};
pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite};
pub use journey::get_journey;
pub use newsletter::patch_newsletter;
pub use pricing::get_pricing;

View file

@ -129,17 +129,16 @@ pub async fn get_export(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let filters_str = params.filters.clone();
let fields_str = params.fields.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let filters_str = params.filters;
let fields_str = params.fields;
let public_url = state.public_url.clone();

View file

@ -97,10 +97,9 @@ pub async fn get_hexagon_stats(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
let h3_str = params.h3;
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -108,6 +107,7 @@ pub async fn get_hexagon_stats(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());

View file

@ -18,7 +18,7 @@ use crate::parsing::{
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
};
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::AppState;
#[derive(Serialize)]
@ -141,11 +141,9 @@ pub async fn get_hexagons(
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
if !is_demo_view {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
}
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -153,19 +151,13 @@ pub async fn get_hexagons(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now();

View file

@ -18,11 +18,26 @@ struct InviteResponse {
invite_type: String,
}
#[derive(Serialize)]
struct InviteListItem {
code: String,
url: String,
invite_type: String,
used: bool,
created: String,
}
#[derive(Serialize)]
struct InviteListResponse {
invites: Vec<InviteListItem>,
}
#[derive(Serialize)]
struct InviteValidation {
valid: bool,
invite_type: String,
used: bool,
invited_by: Option<String>,
}
#[derive(Deserialize)]
@ -147,6 +162,10 @@ pub async fn post_invites(
}
}
/// Dev-only fake invite code (12 alphanumeric chars, passes validation).
/// Only recognized when `--dist` is not set (i.e., dev mode).
const DEV_INVITE_CODE: &str = "devdevdevdev";
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite(
@ -158,6 +177,17 @@ pub async fn get_invite(
return (StatusCode::BAD_REQUEST, msg).into_response();
}
// Dev-only: return a fake valid admin invite without hitting PocketBase
if state.index_html.is_none() && code == DEV_INVITE_CODE {
return Json(InviteValidation {
valid: true,
invite_type: "admin".to_string(),
used: false,
invited_by: Some("Developer".to_string()),
})
.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
@ -201,10 +231,38 @@ pub async fn get_invite(
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
let used_by = invite["used_by_id"].as_str().unwrap_or("");
let used = !used_by.is_empty();
let created_by = invite["created_by"].as_str().unwrap_or("");
// Look up inviter's name (email local part)
let invited_by = if !created_by.is_empty() {
let user_url =
format!("{pb_url}/api/collections/users/records/{created_by}");
match state
.http_client
.get(&user_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value =
resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.map(String::from)
}
_ => None,
}
} else {
None
};
Json(InviteValidation {
valid: true,
invite_type,
used,
invited_by,
})
.into_response()
}
@ -212,6 +270,7 @@ pub async fn get_invite(
valid: false,
invite_type: String::new(),
used: false,
invited_by: None,
})
.into_response(),
}
@ -234,6 +293,16 @@ pub async fn post_redeem_invite(
return (StatusCode::BAD_REQUEST, msg).into_response();
}
// Dev-only: fake redeem — just return "licensed" without touching PocketBase
if state.index_html.is_none() && req.code == DEV_INVITE_CODE {
info!(user_id = %user.id, "Dev invite redeemed (no-op)");
return Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.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
@ -290,7 +359,7 @@ pub async fn post_redeem_invite(
};
let _ = state
.http_client
.patch(&format!(
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
@ -391,3 +460,97 @@ pub async fn post_redeem_invite(
}
}
}
/// List invites. Admins see all invites; licensed users see only their own.
pub async fn get_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.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 auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let filter = if user.is_admin {
String::new()
} else {
format!("created_by=\"{}\"", user.id)
};
let mut url = format!(
"{pb_url}/api/collections/invites/records?sort=-created&perPage=200"
);
if !filter.is_empty() {
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
}
let res = match state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to list invites: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
warn!("PocketBase list invites failed ({status}): {text}");
return StatusCode::BAD_GATEWAY.into_response();
}
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse invites response: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let public_url = &state.public_url;
let invites: Vec<InviteListItem> = body["items"]
.as_array()
.map(|arr| {
arr.iter()
.map(|inv| {
let code = inv["code"].as_str().unwrap_or("").to_string();
let invite_type = inv["invite_type"].as_str().unwrap_or("").to_string();
let used_by = inv["used_by_id"].as_str().unwrap_or("");
let created = inv["created"].as_str().unwrap_or("").to_string();
InviteListItem {
url: format!("{public_url}/invite/{code}"),
code,
invite_type,
used: !used_by.is_empty(),
created,
}
})
.collect()
})
.unwrap_or_default();
Json(InviteListResponse { invites }).into_response()
}

View file

@ -41,7 +41,6 @@ pub async fn get_pois(
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
let categories_str = params.categories.clone();
let category_filter: Option<rustc_hash::FxHashSet<String>> = params
.categories
.as_deref()
@ -51,6 +50,7 @@ pub async fn get_pois(
.map(|part| part.trim().to_string())
.collect()
});
let categories_raw = params.categories;
let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);
@ -100,7 +100,7 @@ pub async fn get_pois(
results = pois.len(),
candidates = row_indices.len(),
categories = num_categories,
categories_raw = categories_str.as_deref().unwrap_or("-"),
categories_raw = categories_raw.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/pois"
);

View file

@ -44,10 +44,8 @@ pub async fn get_postcode_properties(
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
.map_err(|(_, resp)| resp)?;
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -55,8 +53,9 @@ pub async fn get_postcode_properties(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let postcode_str = normalized.clone();
let postcode_str = normalized;
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();

View file

@ -48,10 +48,8 @@ pub async fn get_postcode_stats(
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
.map_err(|(_, resp)| resp)?;
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -59,10 +57,11 @@ pub async fn get_postcode_stats(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let postcode_str = normalized.clone();
let postcode_str = normalized;
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();

View file

@ -17,7 +17,7 @@ use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
};
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::AppState;
use crate::utils::normalize_postcode;
@ -74,10 +74,8 @@ pub async fn get_postcodes(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, (south, west, north, east))?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -85,19 +83,13 @@ pub async fn get_postcodes(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
// Parse travel entries
let travel_entries = params
.travel
.as_deref()
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
let postcode_data = &state.postcode_data;

View file

@ -172,10 +172,9 @@ pub async fn get_hexagon_properties(
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
let h3_str = params.h3;
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
@ -183,6 +182,7 @@ pub async fn get_hexagon_properties(
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let filters_str = params.filters;
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();

View file

@ -1,3 +1,11 @@
/// Parse the optional `travel` query param, returning an empty Vec when absent or empty.
pub fn parse_optional_travel(travel: Option<&str>) -> Result<Vec<TravelEntry>, String> {
match travel.filter(|val| !val.is_empty()) {
Some(s) => parse_travel_entries(s),
None => Ok(Vec::new()),
}
}
/// A parsed travel time entry from the `travel` query parameter.
pub struct TravelEntry {
pub mode: String,
@ -9,7 +17,7 @@ pub struct TravelEntry {
/// Parse `travel` param into a list of travel entries.
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
pub fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_keys = Vec::new();
for segment in travel_str.split('|') {