All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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('|') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue