This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

80
server-rs/Cargo.lock generated
View file

@ -302,6 +302,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "7.0.0"
@ -564,6 +573,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -645,6 +663,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "debug_unsafe"
version = "0.1.3"
@ -662,6 +690,17 @@ dependencies = [
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -942,6 +981,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -1062,6 +1111,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "705f81e042b11734af35c701c7f6b65f8a968a430621fa2c95e72e27f9f8be5c"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
@ -2368,6 +2426,8 @@ dependencies = [
"axum",
"clap",
"h3o",
"hex",
"hmac",
"lasso",
"metrics",
"metrics-exporter-prometheus",
@ -2381,7 +2441,9 @@ dependencies = [
"rustc-hash",
"serde",
"serde_json",
"sha2",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
@ -2925,6 +2987,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -3347,6 +3420,7 @@ dependencies = [
"pin-project-lite",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@ -3461,6 +3535,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"

View file

@ -26,6 +26,10 @@ urlencoding = "2"
rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
rand = "0.9"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
tower = { version = "0.5", features = ["limit"] }
[lints.clippy]
min_ident_chars = "warn"

View file

@ -19,6 +19,12 @@ pub struct PocketBaseUser {
pub email: String,
#[serde(default)]
pub verified: bool,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub subscription: String,
#[serde(default)]
pub newsletter: bool,
}
#[derive(Clone)]
@ -58,6 +64,12 @@ impl TokenCache {
}
map.insert(token, (user, Instant::now()));
}
/// Remove all cached tokens for a given user ID so the next request re-validates.
pub fn invalidate_by_user_id(&self, user_id: &str) {
let mut map = self.entries.write();
map.retain(|_, (user, _)| user.id != user_id);
}
}
#[derive(Deserialize)]

View file

@ -20,3 +20,7 @@ pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
/// Inner London free zone bounds (south, west, north, east) — roughly zones 12.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.48, -0.18, 51.54, -0.02);

View file

@ -2,8 +2,10 @@ mod places;
mod poi;
mod postcodes;
mod property;
pub mod travel_time;
pub use places::PlaceData;
pub use poi::{POICategoryGroup, POIData};
pub use postcodes::PostcodeData;
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent};
pub use travel_time::{slugify, TravelTimeStore};

View file

@ -0,0 +1,168 @@
use std::path::Path;
use anyhow::Context;
use polars::frame::DataFrame;
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use tracing::info;
use crate::utils::InternedColumn;
pub struct PlaceData {
pub name: Vec<String>,
pub name_lower: Vec<String>,
pub place_type: InternedColumn,
pub type_rank: Vec<u8>,
pub population: Vec<u32>,
pub lat: Vec<f32>,
pub lon: Vec<f32>,
pub city: Vec<Option<String>>,
}
fn type_rank(place_type: &str) -> u8 {
match place_type {
"city" => 0,
"borough" => 1,
"town" => 2,
"suburb" => 3,
"quarter" => 4,
"neighbourhood" => 5,
"village" => 6,
"station" => 7,
"island" => 8,
"hamlet" => 9,
"locality" => 10,
"isolated_dwelling" => 11,
_ => 12,
}
}
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in places data"))?;
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(string_column
.into_iter()
.map(|value| value.unwrap_or("").to_string())
.collect())
}
fn extract_f32_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<f32>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in places data"))?;
let cast = column
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
let float_column = cast
.f32()
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
Ok(float_column
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect())
}
impl PlaceData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
info!("Loading place data from {:?}...", parquet_path);
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan places parquet")?
.collect()
.context("Failed to read places parquet")?;
let row_count = df.height();
info!("Loaded {} places", row_count);
let name = extract_str_col(&df, "name")?;
let place_type_raw = extract_str_col(&df, "place_type")?;
let lat = extract_f32_col(&df, "lat")?;
let lon = extract_f32_col(&df, "lon")?;
let population: Vec<u32> = if df.column("population").is_ok() {
let pop_f32 = extract_f32_col(&df, "population")?;
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
} else {
vec![0; row_count]
};
let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect();
let type_rank_vec: Vec<u8> = place_type_raw.iter().map(|pt| type_rank(pt)).collect();
let place_type = InternedColumn::build(&place_type_raw);
// Precompute nearest city for each non-city place
let city_indices: Vec<usize> = type_rank_vec
.iter()
.enumerate()
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
.collect();
let city: Vec<Option<String>> = (0..row_count)
.map(|idx| {
if type_rank_vec[idx] == 0 {
return None; // Cities don't need a city label
}
let plat = lat[idx];
let plon = lon[idx];
let cos_lat = (plat.to_radians()).cos();
let mut best_dist_sq = f32::MAX;
let mut best_city: Option<&str> = None;
for &ci in &city_indices {
let dlat = lat[ci] - plat;
let dlon = (lon[ci] - plon) * cos_lat;
let dist_sq = dlat * dlat + dlon * dlon;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_city = Some(&name[ci]);
}
}
// ~100km threshold: 1° ≈ 111km, so 0.9° ≈ 100km → 0.81 squared
if best_dist_sq < 0.81 {
best_city.map(|s| s.to_string())
} else {
None
}
})
.collect();
let with_pop = population.iter().filter(|&&pop| pop > 0).count();
let with_city = city.iter().filter(|c| c.is_some()).count();
info!(
places = row_count,
types = place_type.values.len(),
with_population = with_pop,
with_city = with_city,
"Place data loaded"
);
Ok(PlaceData {
name,
name_lower,
place_type,
type_rank: type_rank_vec,
population,
lat,
lon,
city,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_rank_ordering() {
assert!(type_rank("city") < type_rank("town"));
assert!(type_rank("town") < type_rank("suburb"));
assert!(type_rank("suburb") < type_rank("village"));
assert!(type_rank("village") < type_rank("hamlet"));
assert!(type_rank("hamlet") < type_rank("isolated_dwelling"));
}
}

149
server-rs/src/data/poi.rs Normal file
View file

@ -0,0 +1,149 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{bail, Context};
use polars::frame::DataFrame;
use polars::lazy::frame::LazyFrame;
use polars::prelude::*;
use serde::Serialize;
use tracing::info;
use crate::features::POI_GROUP_ORDER;
use crate::utils::{generate_priorities, InternedColumn};
#[derive(Serialize, Clone)]
pub struct POICategoryGroup {
pub name: String,
pub categories: Vec<String>,
}
pub struct POIData {
pub id: Vec<String>,
pub group: InternedColumn,
pub category: InternedColumn,
pub name: Vec<String>,
pub lat: Vec<f32>,
pub lng: Vec<f32>,
pub emoji: InternedColumn,
/// Deterministic pseudo-random priority per row, used to select a spatially
/// uniform subset when the POI count exceeds the per-request limit.
/// Computed once at load time so the same POIs are always chosen for a given viewport.
pub priority: Vec<u32>,
}
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in POI data"))?;
let string_column = column
.str()
.with_context(|| format!("Column '{name}' is not a string column"))?;
Ok(string_column
.into_iter()
.map(|value| value.unwrap_or("").to_string())
.collect())
}
fn extract_f32_col(df: &DataFrame, name: &str, default: f32) -> anyhow::Result<Vec<f32>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}' in POI data"))?;
let cast = column
.cast(&DataType::Float32)
.with_context(|| format!("Failed to cast column '{name}' to Float32"))?;
let float_column = cast
.f32()
.with_context(|| format!("Column '{name}' is not a float32 column"))?;
Ok(float_column
.into_iter()
.map(|value| value.unwrap_or(default))
.collect())
}
impl POIData {
pub fn load(parquet_path: &Path) -> anyhow::Result<Self> {
info!("Loading POI data from {:?}...", parquet_path);
let df = LazyFrame::scan_parquet(parquet_path, Default::default())
.context("Failed to scan POI parquet")?
.collect()
.context("Failed to read POI parquet")?;
let row_count = df.height();
info!("Loaded {} POIs", row_count);
let id: Vec<String> = extract_str_col(&df, "id")?;
let name = extract_str_col(&df, "name")?;
let category_raw = extract_str_col(&df, "category")?;
let group_raw = extract_str_col(&df, "group")?;
let lat = extract_f32_col(&df, "lat", 0.0)?;
let lng = extract_f32_col(&df, "lng", 0.0)?;
let emoji_raw = extract_str_col(&df, "emoji")?;
let category = InternedColumn::build(&category_raw);
let group = InternedColumn::build(&group_raw);
let emoji = InternedColumn::build(&emoji_raw);
info!(
category_unique = category.values.len(),
group_unique = group.values.len(),
emoji_unique = emoji.values.len(),
"POI string columns interned"
);
// Assign a deterministic pseudo-random priority to each row.
// This ensures the same POIs are selected across requests,
// preventing visual "shuffling" when panning the map.
let priority = generate_priorities(row_count);
info!("POI data loading complete.");
Ok(POIData {
id,
name,
category,
group,
lat,
lng,
emoji,
priority,
})
}
/// Build category groups from the loaded POI data, validated against POI_GROUP_ORDER.
pub fn category_groups(&self) -> anyhow::Result<Vec<POICategoryGroup>> {
let mut group_cats: HashMap<String, HashSet<String>> = HashMap::new();
let num_pois = self.category.indices.len();
for row in 0..num_pois {
let category = self.category.get(row).to_string();
let group = self.group.get(row).to_string();
group_cats.entry(group).or_default().insert(category);
}
// Validate that data groups match the hardcoded order exactly
let expected: HashSet<&str> = POI_GROUP_ORDER.iter().copied().collect();
let actual: HashSet<&str> = group_cats.keys().map(|key| key.as_str()).collect();
let missing_from_data: Vec<&&str> = expected.difference(&actual).collect();
let missing_from_order: Vec<&&str> = actual.difference(&expected).collect();
if !missing_from_data.is_empty() || !missing_from_order.is_empty() {
bail!(
"POI group mismatch!\n In POI_GROUP_ORDER but not in data: {:?}\n In data but not in POI_GROUP_ORDER: {:?}",
missing_from_data, missing_from_order
);
}
POI_GROUP_ORDER
.iter()
.map(|group_name| {
let name = group_name.to_string();
let mut categories: Vec<String> = group_cats
.remove(&name)
.context("POI group validated but missing from map")?
.into_iter()
.collect();
categories.sort();
Ok(POICategoryGroup { name, categories })
})
.collect()
}
}

View file

@ -0,0 +1,179 @@
use anyhow::Context;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use std::fs;
use std::path::Path;
use tracing::{debug, info};
/// GeoJSON structures for parsing postcode boundary files
#[derive(Deserialize)]
struct FeatureCollection {
features: Vec<Feature>,
}
#[derive(Deserialize)]
struct Feature {
geometry: Geometry,
properties: Properties,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Geometry {
Polygon {
coordinates: Vec<Vec<[f64; 2]>>,
},
MultiPolygon {
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
},
}
#[derive(Deserialize)]
struct Properties {
postcodes: String,
}
/// Postcode boundary data: polygon vertices and spatial index for fast queries.
pub struct PostcodeData {
/// Postcode strings
pub postcodes: Vec<String>,
/// All polygon parts per postcode: polygons[i] = list of outer rings
/// Single Polygon → 1 ring, MultiPolygon → N rings
pub polygons: Vec<Vec<Vec<[f32; 2]>>>,
/// Centroid (lat, lon) for lookups
pub centroids: Vec<(f32, f32)>,
/// Lookup from postcode string to index
pub postcode_to_idx: FxHashMap<String, usize>,
}
impl PostcodeData {
/// Load postcode boundaries from a directory of GeoJSON files.
/// Expects the directory to have a `units/` subdirectory containing .geojson files.
pub fn load(dir_path: &Path) -> anyhow::Result<Self> {
info!("Loading postcode boundaries from {:?}", dir_path);
let units_dir = dir_path.join("units");
if !units_dir.exists() {
anyhow::bail!(
"Expected 'units' subdirectory in postcode boundaries path: {:?}",
dir_path
);
}
let mut postcodes: Vec<String> = Vec::new();
let mut polygons: Vec<Vec<Vec<[f32; 2]>>> = Vec::new();
let mut centroids: Vec<(f32, f32)> = Vec::new();
// Read all .geojson files in the units directory
let mut entries: Vec<_> = fs::read_dir(&units_dir)
.with_context(|| format!("Failed to read directory: {:?}", units_dir))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "geojson")
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|entry| entry.path());
info!(files = entries.len(), "Found GeoJSON files to process");
// Parse files in parallel
let file_results: Vec<_> = entries
.into_par_iter()
.map(|entry| {
let file_path = entry.path();
let content = fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read file: {:?}", file_path))?;
let collection: FeatureCollection = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse GeoJSON: {:?}", file_path))?;
let mut local_postcodes = Vec::new();
let mut local_polygons = Vec::new();
let mut local_centroids = Vec::new();
for feature in collection.features {
let postcode = feature.properties.postcodes;
// Extract all outer rings from the geometry
let rings: Vec<Vec<[f32; 2]>> = match feature.geometry {
Geometry::Polygon { coordinates } => coordinates
.first()
.map(|ring| {
vec![ring
.iter()
.map(|[lon, lat]| [*lon as f32, *lat as f32])
.collect()]
})
.unwrap_or_default(),
Geometry::MultiPolygon { coordinates } => coordinates
.iter()
.filter_map(|poly| {
poly.first().map(|ring| {
ring.iter()
.map(|[lon, lat]| [*lon as f32, *lat as f32])
.collect()
})
})
.collect(),
};
// Compute centroid across all vertices from all rings
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
let centroid = if total_vertices == 0 {
(0.0, 0.0)
} else {
let mut sum_lat: f32 = 0.0;
let mut sum_lon: f32 = 0.0;
for ring in &rings {
for &[lon, lat] in ring {
sum_lat += lat;
sum_lon += lon;
}
}
let count = total_vertices as f32;
(sum_lat / count, sum_lon / count)
};
local_postcodes.push(postcode);
local_polygons.push(rings);
local_centroids.push(centroid);
}
Ok::<_, anyhow::Error>((local_postcodes, local_polygons, local_centroids))
})
.collect::<Result<Vec<_>, _>>()?;
// Flatten results
for (local_postcodes, local_polygons, local_centroids) in file_results {
postcodes.extend(local_postcodes);
polygons.extend(local_polygons);
centroids.extend(local_centroids);
}
debug!(
postcodes = postcodes.len(),
"Extracted postcodes from GeoJSON"
);
// Build postcode -> index lookup
let mut postcode_to_idx: FxHashMap<String, usize> = FxHashMap::default();
for (idx, postcode) in postcodes.iter().enumerate() {
postcode_to_idx.insert(postcode.clone(), idx);
}
info!(postcodes = postcodes.len(), "Postcode boundary data ready");
Ok(PostcodeData {
postcodes,
polygons,
centroids,
postcode_to_idx,
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,232 @@
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context};
use parking_lot::Mutex;
use polars::lazy::frame::LazyFrame;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::info;
/// Cached postcode → travel_minutes mapping for a single destination file.
pub type TravelData = Arc<FxHashMap<String, i16>>;
/// Simple LRU cache for travel time data, limited to `capacity` entries.
struct LruCache {
map: FxHashMap<(String, String), TravelData>,
order: VecDeque<(String, String)>,
capacity: usize,
}
impl LruCache {
fn new(capacity: usize) -> Self {
Self {
map: FxHashMap::default(),
order: VecDeque::with_capacity(capacity),
capacity,
}
}
fn get(&mut self, key: &(String, String)) -> Option<TravelData> {
if let Some(data) = self.map.get(key) {
// Move to front (most recently used)
if let Some(pos) = self.order.iter().position(|k| k == key) {
self.order.remove(pos);
self.order.push_front(key.clone());
}
Some(data.clone())
} else {
None
}
}
fn insert(&mut self, key: (String, String), data: TravelData) {
if self.map.contains_key(&key) {
self.map.insert(key.clone(), data);
if let Some(pos) = self.order.iter().position(|k| k == &key) {
self.order.remove(pos);
}
self.order.push_front(key);
} else {
while self.map.len() >= self.capacity {
if let Some(old_key) = self.order.pop_back() {
self.map.remove(&old_key);
}
}
self.map.insert(key.clone(), data);
self.order.push_front(key);
}
}
}
/// Manages on-demand loading and caching of precomputed travel time parquet files.
///
/// Directory structure: `{base_dir}/{mode}/{slug}.parquet`
/// Each parquet file has columns: `pcds` (String), `travel_minutes` (Int16).
pub struct TravelTimeStore {
base_dir: PathBuf,
/// Available transport modes (subdirectory names, e.g., "bicycle")
pub available_modes: Vec<String>,
/// mode → set of destination slugs (filenames without .parquet)
pub destinations: FxHashMap<String, FxHashSet<String>>,
cache: Mutex<LruCache>,
}
impl TravelTimeStore {
/// Scan the travel-times directory to discover available modes and destinations.
pub fn load(base_dir: &Path, cache_capacity: usize) -> anyhow::Result<Self> {
let mut available_modes = Vec::new();
let mut destinations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
for entry in std::fs::read_dir(base_dir)
.with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let mode = entry.file_name().to_string_lossy().to_string();
let mut slugs = FxHashSet::default();
for file_entry in std::fs::read_dir(&path)
.with_context(|| format!("Failed to read mode dir: {}", path.display()))?
{
let file_entry = file_entry?;
let file_name = file_entry.file_name();
let file_name = file_name.to_string_lossy();
if file_name.ends_with(".parquet") {
let slug = file_name.trim_end_matches(".parquet").to_string();
slugs.insert(slug);
}
}
if !slugs.is_empty() {
info!(
mode = mode.as_str(),
destinations = slugs.len(),
"Travel time mode discovered"
);
available_modes.push(mode.clone());
destinations.insert(mode, slugs);
}
}
available_modes.sort();
Ok(Self {
base_dir: base_dir.to_path_buf(),
available_modes,
destinations,
cache: Mutex::new(LruCache::new(cache_capacity)),
})
}
/// Load travel time data for a given mode and destination slug.
/// Returns a cached or freshly-loaded postcode → travel_minutes mapping.
pub fn get(&self, mode: &str, slug: &str) -> anyhow::Result<TravelData> {
let key = (mode.to_string(), slug.to_string());
// Check cache first
{
let mut cache = self.cache.lock();
if let Some(data) = cache.get(&key) {
return Ok(data);
}
}
// Load from file (no lock held — harmless if two threads load the same file)
let path = self
.base_dir
.join(mode)
.join(format!("{}.parquet", slug));
if !path.exists() {
bail!("Travel time file not found: {}", path.display());
}
let df = LazyFrame::scan_parquet(&path, Default::default())
.with_context(|| format!("Failed to scan: {}", path.display()))?
.collect()
.with_context(|| format!("Failed to read: {}", path.display()))?;
let postcodes = df
.column("pcds")
.context("Missing 'pcds' column")?
.str()
.context("'pcds' is not string")?;
let minutes = df
.column("travel_minutes")
.context("Missing 'travel_minutes' column")?
.i16()
.context("'travel_minutes' is not i16")?;
let mut map = FxHashMap::default();
map.reserve(df.height());
for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) {
if let (Some(pc), Some(min)) = (pc, min) {
map.insert(pc.to_string(), min);
}
}
let data: TravelData = Arc::new(map);
// Insert into cache
{
let mut cache = self.cache.lock();
cache.insert(key, data.clone());
}
Ok(data)
}
/// Check if a mode + slug combination is available.
pub fn has_destination(&self, mode: &str, slug: &str) -> bool {
self.destinations
.get(mode)
.map(|slugs| slugs.contains(slug))
.unwrap_or(false)
}
}
/// Slugify a place name to match travel time file naming convention.
/// "Abbey Hey" → "abbey-hey", "A'Bhuaile Ghlas" → "a-bhuaile-ghlas"
pub fn slugify(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut last_was_hyphen = true; // Start true to skip leading hyphens
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
result.push(ch.to_ascii_lowercase());
last_was_hyphen = false;
} else if !last_was_hyphen {
result.push('-');
last_was_hyphen = true;
}
}
if result.ends_with('-') {
result.pop();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_basic() {
assert_eq!(slugify("Abbey Hey"), "abbey-hey");
assert_eq!(slugify("Abbots Bickington"), "abbots-bickington");
assert_eq!(slugify("London"), "london");
}
#[test]
fn slugify_special_chars() {
assert_eq!(slugify("A'Bhuaile Ghlas"), "a-bhuaile-ghlas");
}
#[test]
fn slugify_edges() {
assert_eq!(slugify(" Hello "), "hello");
assert_eq!(slugify("Abbey"), "abbey");
}
}

View file

@ -32,7 +32,8 @@ pub struct FeatureConfig {
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
/// p1/p99 are snapped to integer boundaries before binning.
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
pub const INTEGER_BIN_FEATURES: &[&str] =
&["Number of bedrooms & living rooms", "Bedrooms", "Bathrooms"];
pub struct FeatureGroup {
pub name: &'static str,
@ -68,6 +69,9 @@ pub const IGNORED_COLUMNS: &[&str] = &[
"Is construction date approximate",
"Current energy rating",
"Potential energy rating",
"Property sub-type",
"Listing URL",
"Price qualifier",
];
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
@ -221,6 +225,81 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: true,
absolute: false,
},
FeatureConfig {
name: "Asking price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
},
step: 10000.0,
description: "Listed asking price for properties currently for sale",
detail: "The advertised asking price for properties currently listed for sale on online property portals. Only populated for 'For sale' listings; null for historical sales and rentals.",
source: "online-listings",
prefix: "£",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Asking rent (monthly)",
bounds: Bounds::Fixed {
min: 0.0,
max: 10_000.0,
},
step: 50.0,
description: "Listed monthly rent for properties currently for rent",
detail: "The advertised rental price normalized to monthly for properties currently listed for rent on online property portals. Weekly rents are converted (×52/12), yearly (/12), daily (×365.25/12), and quarterly (/3). Only populated for 'For rent' listings.",
source: "online-listings",
prefix: "£",
suffix: "/mo",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Bedrooms",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Number of bedrooms from online listing",
detail: "Number of bedrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
source: "online-listings",
prefix: "",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Bathrooms",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Number of bathrooms from online listing",
detail: "Number of bathrooms as advertised in the online property listing. Only populated for online listings (for sale and for rent); null for historical sales.",
source: "online-listings",
prefix: "",
suffix: "",
raw: false,
absolute: true,
},
FeatureConfig {
name: "Listing date",
bounds: Bounds::Fixed {
min: 2006.0,
max: 2026.0,
},
step: 1.0,
description: "Date the property was first listed online",
detail: "The date when the property listing first appeared on the online property portal. Stored as a datetime; converted to fractional year for filtering. Only populated for online listings.",
source: "online-listings",
prefix: "",
suffix: "",
raw: true,
absolute: false,
},
],
},
FeatureGroup {
@ -442,7 +521,43 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
],
},
FeatureGroup {
FeatureGroup {
name: "Crime summary",
features: &[
FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of serious crime categories per year",
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of minor crime categories per year",
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
],
},
FeatureGroup {
name: "Crime",
features: &[
FeatureConfig {
@ -655,36 +770,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
},
FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of serious crime categories per year",
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of minor crime categories per year",
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
source: "crime",
prefix: "",
suffix: "/yr",
raw: false,
absolute: false,
},
],
},
FeatureGroup {
@ -858,6 +943,13 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
EnumFeatureGroup {
name: "Property",
features: &[
EnumFeatureConfig {
name: "Listing status",
order: Some(&["Historical sale", "For sale", "For rent"]),
description: "Whether the property is from historical sales, currently for sale, or for rent",
detail: "Indicates the source of the property record: 'Historical sale' from HM Land Registry Price Paid data, 'For sale' from current online buy listings, or 'For rent' from current online rental listings.",
source: "online-listings",
},
EnumFeatureConfig {
name: "Leashold/Freehold",
order: Some(&["Freehold", "Leasehold"]),

View file

@ -0,0 +1,55 @@
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use crate::auth::PocketBaseUser;
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.
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
if let Some(u) = user {
if u.is_admin || u.subscription == "licensed" {
return Ok(());
}
}
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(());
}
let body = json!({
"error": "license_required",
"message": "A license is required to view data outside inner London",
"free_zone": {
"south": fz_south,
"west": fz_west,
"north": fz_north,
"east": fz_east,
}
});
Err((
StatusCode::FORBIDDEN,
(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).
pub fn check_license_point(
user: &Option<PocketBaseUser>,
lat: f64,
lon: f64,
) -> Result<(), (StatusCode, axum::response::Response)> {
check_license_bounds(user, (lat, lon, lat, lon))
}

View file

@ -3,6 +3,7 @@ mod auth;
mod consts;
mod data;
mod features;
mod licensing;
mod metrics;
mod og_middleware;
pub mod parsing;
@ -13,15 +14,17 @@ pub mod utils;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context};
use axum::middleware;
use axum::routing::{any, get, post};
use axum::routing::{any, get, patch, post};
use axum::Router;
use clap::Parser;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::info;
@ -52,7 +55,7 @@ struct Cli {
#[arg(long)]
tiles: PathBuf,
/// Path to the frontend dist directory
/// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted)
#[arg(long)]
dist: Option<PathBuf>,
@ -70,11 +73,11 @@ struct Cli {
/// PocketBase superuser email (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
pocketbase_admin_email: Option<String>,
pocketbase_admin_email: String,
/// PocketBase superuser password (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
pocketbase_admin_password: Option<String>,
pocketbase_admin_password: String,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
#[arg(long, env = "OLLAMA_URL")]
@ -84,13 +87,41 @@ struct Cli {
#[arg(long, env = "OLLAMA_MODEL")]
ollama_model: String,
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL")]
r5_url: Option<String>,
/// Path to precomputed travel times directory (contains mode subdirs with parquet files)
#[arg(long, env = "TRAVEL_TIMES")]
travel_times: PathBuf,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
/// Stripe secret key for checkout sessions
#[arg(long, env = "STRIPE_SECRET_KEY")]
stripe_secret_key: String,
/// Stripe webhook signing secret for verifying webhook signatures
#[arg(long, env = "STRIPE_WEBHOOK_SECRET")]
stripe_webhook_secret: String,
/// Stripe Coupon ID applied when a referral code is used
#[arg(long, env = "STRIPE_REFERRAL_COUPON_ID")]
stripe_referral_coupon_id: String,
/// Google OAuth client ID for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_ID")]
google_oauth_client_id: String,
/// Google OAuth client secret for PocketBase SSO
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
google_oauth_client_secret: String,
/// Apple OAuth client ID for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_ID")]
apple_oauth_client_id: String,
/// Apple OAuth client secret for PocketBase SSO
#[arg(long, env = "APPLE_OAUTH_CLIENT_SECRET")]
apple_oauth_client_secret: String,
}
#[tokio::main]
@ -212,19 +243,23 @@ async fn main() -> anyhow::Result<()> {
let poi_category_groups = poi_data.category_groups()?;
// Read index.html at startup for crawler OG injection
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
// Read index.html at startup for crawler OG injection (only when --dist is provided)
let index_html = if let Some(ref dist) = cli.dist {
let index_path = dist.join("index.html");
let html = std::fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
info!("Loaded index.html for OG injection");
(Some(dist), Some(html))
Some(html)
} else {
info!("No --dist provided, static serving and OG injection disabled");
(None, None)
info!("No --dist provided; static serving and OG injection disabled");
None
};
let http_client = reqwest::Client::new();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.context("Failed to build HTTP client")?;
info!("Screenshot service configured: {}", cli.screenshot_url);
@ -247,23 +282,46 @@ async fn main() -> anyhow::Result<()> {
info!("PocketBase configured: {}", cli.pocketbase_url);
if let (Some(ref email), Some(ref password)) =
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
{
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
} else {
info!("PocketBase admin credentials not set — skipping collection auto-creation");
}
pocketbase::ensure_collections(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
)
.await?;
pocketbase::ensure_oauth_providers(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
&cli.public_url,
&cli.google_oauth_client_id,
&cli.google_oauth_client_secret,
&cli.apple_oauth_client_id,
&cli.apple_oauth_client_secret,
)
.await?;
info!(
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
if let Some(ref url) = cli.r5_url {
info!("R5 routing service configured: {}", url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
let tt_path = &cli.travel_times;
if !tt_path.exists() {
bail!(
"Travel times directory not found: {}",
tt_path.display()
);
}
info!("Loading travel time data from {}", tt_path.display());
let travel_time_store = {
let store = data::TravelTimeStore::load(tt_path, 50)?;
info!(
modes = store.available_modes.len(),
"Travel time store loaded"
);
Arc::new(store)
};
let token_cache = Arc::new(auth::TokenCache::new());
@ -286,19 +344,30 @@ async fn main() -> anyhow::Result<()> {
index_html,
http_client,
pocketbase_url: cli.pocketbase_url,
pocketbase_admin_email: cli.pocketbase_admin_email,
pocketbase_admin_password: cli.pocketbase_admin_password,
ollama_url: cli.ollama_url,
ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
travel_time_store,
token_cache,
ai_filters_schema,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
stripe_secret_key: cli.stripe_secret_key,
stripe_webhook_secret: cli.stripe_webhook_secret,
stripe_referral_coupon_id: cli.stripe_referral_coupon_id,
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
.allow_origin(
state
.public_url
.parse::<axum::http::HeaderValue>()
.expect("public_url must be a valid header value"),
)
.allow_methods(AllowMethods::mirror_request())
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(true);
let state_features = state.clone();
let state_hexagons = state.clone();
@ -319,6 +388,15 @@ async fn main() -> anyhow::Result<()> {
let state_short_url = state.clone();
let state_ai_filters = state.clone();
let state_streetview = state.clone();
let state_subscription = state.clone();
let state_newsletter = state.clone();
let state_travel_modes = state.clone();
let state_checkout = state.clone();
let state_stripe_webhook = state.clone();
let state_pricing = state.clone();
let state_invites_create = state.clone();
let state_invite_get = state.clone();
let state_redeem_invite = state.clone();
let api = Router::new()
.route(
@ -327,11 +405,11 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/hexagons",
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
get(move |ext, query| routes::get_hexagons(state_hexagons.clone(), ext, query)),
)
.route(
"/api/postcodes",
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
get(move |ext, query| routes::get_postcodes(state_postcodes.clone(), ext, query)),
)
.route(
"/api/postcode/{postcode}",
@ -349,19 +427,23 @@ async fn main() -> anyhow::Result<()> {
"/api/places",
get(move |query| routes::get_places(state_places.clone(), query)),
)
.route(
"/api/travel-modes",
get(move || routes::get_travel_modes(state_travel_modes.clone())),
)
.route(
"/api/hexagon-properties",
get(move |query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), query)
get(move |ext, query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), ext, query)
}),
)
.route(
"/api/hexagon-stats",
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)),
)
.route(
"/api/postcode-stats",
get(move |query| routes::get_postcode_stats(state_postcode_stats.clone(), query)),
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
)
.route(
"/api/screenshot",
@ -369,12 +451,14 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/export",
get(move |query| routes::get_export(state_export.clone(), query)),
get(move |ext, query| routes::get_export(state_export.clone(), ext, query))
.layer(ConcurrencyLimitLayer::new(3)),
)
.route("/api/me", get(routes::get_me))
.route(
"/api/area-summary",
post(move |body| routes::post_area_summary(state_area_summary.clone(), body)),
post(move |body| routes::post_area_summary(state_area_summary.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/shorten",
@ -382,12 +466,54 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/ai-filters",
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body))
.layer(ConcurrencyLimitLayer::new(5)),
)
.route(
"/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
)
.route(
"/api/subscription",
patch(move |ext, body| {
routes::patch_subscription(state_subscription.clone(), ext, body)
}),
)
.route(
"/api/newsletter",
patch(move |ext, body| {
routes::patch_newsletter(state_newsletter.clone(), ext, body)
}),
)
.route(
"/api/pricing",
get(move || routes::get_pricing(state_pricing.clone())),
)
.route(
"/api/checkout",
post(move |ext, body| routes::post_checkout(state_checkout.clone(), ext, body))
.layer(ConcurrencyLimitLayer::new(10)),
)
.route(
"/api/stripe-webhook",
post(move |headers, body| {
routes::post_stripe_webhook(state_stripe_webhook.clone(), headers, body)
}),
)
.route(
"/api/invites",
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
)
.route(
"/api/invite/{code}",
get(move |ext, path| routes::get_invite(state_invite_get.clone(), ext, path)),
)
.route(
"/api/redeem-invite",
post(move |ext, body| {
routes::post_redeem_invite(state_redeem_invite.clone(), ext, body)
}),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
@ -396,6 +522,7 @@ async fn main() -> anyhow::Result<()> {
// Add tile routes
let reader_tile = tile_reader.clone();
let reader_style = tile_reader.clone();
let public_url_tiles = state.public_url.clone();
let api = api
.route(
"/api/tiles/{z}/{x}/{y}",
@ -403,8 +530,9 @@ async fn main() -> anyhow::Result<()> {
)
.route(
"/api/tiles/style.json",
get(move |headers, query| {
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
get(move |query| {
let pu = public_url_tiles.clone();
routes::get_style(axum::extract::State(reader_style.clone()), pu, query)
}),
)
.route("/health", get(|| async { "ok" }))
@ -417,15 +545,13 @@ async fn main() -> anyhow::Result<()> {
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
);
let app = if let Some(ref dist) = frontend_dist {
let app = if let Some(ref dist) = cli.dist {
api.fallback_service(
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
)
} else {
api
};
let app = app
}
.layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(auth::auth_middleware))
.layer(middleware::from_fn(

View file

@ -1,28 +1,38 @@
use std::collections::HashSet;
use axum::http::StatusCode;
use rustc_hash::FxHashMap;
/// Parse an optional `?fields=` query param into feature indices for selective aggregation.
/// Returns `None` if fields is `None` (all features included), or `Some(indices)` if specified.
/// Returns an error if any field name is unknown.
pub fn parse_field_indices(
fields: Option<&str>,
name_to_index: &FxHashMap<String, usize>,
) -> Option<Vec<usize>> {
fields.map(|fields_str| {
if fields_str.is_empty() {
return Vec::new();
) -> Result<Option<Vec<usize>>, (StatusCode, String)> {
let Some(fields_str) = fields else {
return Ok(None);
};
if fields_str.is_empty() {
return Ok(None);
}
let mut indices = Vec::new();
for name in fields_str.split(',') {
let name = name.trim();
if name.is_empty() {
continue;
}
fields_str
.split(',')
.filter_map(|name| {
let name = name.trim();
if name.is_empty() {
return None;
}
name_to_index.get(name).copied()
})
.collect()
})
match name_to_index.get(name) {
Some(&idx) => indices.push(idx),
None => {
return Err((
StatusCode::BAD_REQUEST,
format!("Unknown field: {}", name),
))
}
}
}
Ok(Some(indices))
}
/// Parse an optional `?fields=` query param into a HashSet for stats filtering.

View file

@ -1,6 +1,6 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing::{info, warn};
#[derive(Deserialize)]
struct AuthResponse {
@ -79,7 +79,7 @@ impl Field {
}
}
async fn auth_superuser(
pub async fn auth_superuser(
client: &Client,
base_url: &str,
email: &str,
@ -177,7 +177,82 @@ async fn find_users_collection_id(
Ok(id.to_string())
}
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase.
/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection.
/// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields.
async fn ensure_user_fields(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/users");
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 users collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("users collection has no fields array"))?;
let has_is_admin = fields.iter().any(|f| f["name"] == "is_admin");
let has_subscription = fields.iter().any(|f| f["name"] == "subscription");
let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter");
if has_is_admin && has_subscription && has_newsletter {
info!("PocketBase users collection already has is_admin, subscription, and newsletter fields");
return Ok(());
}
let mut new_fields = fields.clone();
if !has_is_admin {
new_fields.push(serde_json::json!({
"name": "is_admin",
"type": "bool",
}));
}
if !has_subscription {
new_fields.push(serde_json::json!({
"name": "subscription",
"type": "text",
}));
}
if !has_newsletter {
new_fields.push(serde_json::json!({
"name": "newsletter",
"type": "bool",
}));
}
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 patch users collection ({status}): {text}");
}
info!("Added missing fields to PocketBase users collection");
Ok(())
}
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase,
/// and that the `users` collection has `is_admin` and `subscription` fields.
/// Authenticates as superuser, checks existing collections, and creates any that are missing.
pub async fn ensure_collections(
client: &Client,
@ -190,6 +265,8 @@ pub async fn ensure_collections(
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
let existing = list_collections(client, base_url, &token).await?;
ensure_user_fields(client, base_url, &token).await?;
if !existing.iter().any(|n| n == "saved_searches") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
create_collection(
@ -212,6 +289,28 @@ pub async fn ensure_collections(
info!("PocketBase collection 'saved_searches' already exists");
}
if !existing.iter().any(|n| n == "invites") {
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "invites".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::text("code", true),
Field::text("created_by", true),
Field::text("invite_type", true),
Field::text("used_by_id", false),
Field::text("used_at", false),
],
},
)
.await?;
} else {
info!("PocketBase collection 'invites' already exists");
}
if !existing.iter().any(|n| n == "short_urls") {
create_collection(
client,
@ -233,3 +332,94 @@ pub async fn ensure_collections(
Ok(())
}
/// Configure Google and Apple OAuth2 providers in PocketBase settings.
/// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`.
pub async fn ensure_oauth_providers(
client: &Client,
base_url: &str,
admin_email: &str,
admin_password: &str,
public_url: &str,
google_client_id: &str,
google_client_secret: &str,
apple_client_id: &str,
apple_client_secret: &str,
) -> anyhow::Result<()> {
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
// GET current settings
let settings_url = format!("{base_url}/api/settings");
let resp = client
.get(&settings_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 PocketBase settings ({status}): {text}");
}
let mut settings: serde_json::Value = resp.json().await?;
// Set meta.appUrl for OAuth redirect
let app_url = format!("{}/pb", public_url.trim_end_matches('/'));
if let Some(meta) = settings.get_mut("meta") {
meta["appUrl"] = serde_json::json!(app_url);
} else {
settings["meta"] = serde_json::json!({ "appUrl": app_url });
}
// Update OAuth2 providers
let providers = settings
.pointer_mut("/oauth2/providers")
.and_then(|v| v.as_array_mut());
if let Some(providers) = providers {
for provider in providers.iter_mut() {
let name = provider
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("");
match name {
"google" => {
provider["clientId"] = serde_json::json!(google_client_id);
provider["clientSecret"] = serde_json::json!(google_client_secret);
provider["enabled"] = serde_json::json!(true);
info!("Configured Google OAuth provider");
}
"apple" => {
provider["clientId"] = serde_json::json!(apple_client_id);
provider["clientSecret"] = serde_json::json!(apple_client_secret);
provider["enabled"] = serde_json::json!(true);
info!("Configured Apple OAuth provider");
}
_ => {}
}
}
} else {
warn!("PocketBase settings missing oauth2.providers array — cannot configure OAuth");
return Ok(());
}
// PATCH settings back
let patch_resp = client
.patch(&settings_url)
.header("Authorization", format!("Bearer {token}"))
.json(&settings)
.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 update PocketBase settings ({status}): {text}");
}
info!("PocketBase OAuth settings updated (appUrl: {app_url})");
Ok(())
}

View file

@ -1,9 +1,11 @@
mod ai_filters;
mod area_summary;
mod checkout;
mod export;
mod features;
mod hexagon_stats;
pub(crate) mod hexagons;
mod invites;
mod me;
mod pb_proxy;
mod places;
@ -15,11 +17,17 @@ mod screenshot;
mod shorten;
mod stats;
mod streetview;
mod stripe_webhook;
mod newsletter;
pub(crate) mod pricing;
mod subscription;
mod tiles;
pub(crate) mod travel_time;
mod travel_modes;
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
pub use area_summary::post_area_summary;
pub use checkout::post_checkout;
pub use export::get_export;
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
pub use hexagon_stats::get_hexagon_stats;
@ -34,4 +42,10 @@ pub use properties::get_hexagon_properties;
pub use screenshot::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 newsletter::patch_newsletter;
pub use pricing::get_pricing;
pub use stripe_webhook::post_stripe_webhook;
pub use subscription::patch_subscription;
pub use tiles::{get_style, get_tile, init_tile_reader};
pub use travel_modes::get_travel_modes;

View file

@ -0,0 +1,178 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
use super::pricing::{count_licensed_users, price_for_count};
#[derive(Deserialize)]
pub struct CheckoutRequest {
referral_code: Option<String>,
}
#[derive(Serialize)]
struct CheckoutResponse {
url: String,
}
/// Create a Stripe Checkout session for the lifetime license (or grant for free if in free tier).
/// Requires authentication. Optionally accepts a referral code to apply a coupon.
pub async fn post_checkout(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<CheckoutRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users at checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let price_pence = price_for_count(count);
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
// Free tier — grant license directly without Stripe
if price_pence == 0 {
if let Err(err) = grant_license(&state, &user.id).await {
warn!(user_id = %user.id, "Failed to grant free license: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
info!(user_id = %user.id, "Granted free early-bird license");
return Json(CheckoutResponse { url: success_url }).into_response();
}
// Paid tier — create Stripe checkout with dynamic price
let secret_key = &state.stripe_secret_key;
let cancel_url = format!("{public_url}/pricing");
let mut form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
];
// If a referral code is provided and valid, look it up and apply the coupon
if let Some(ref code) = req.referral_code {
if validate_referral_invite(&state, code).await {
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
info!(code = %code, "Applying referral coupon to checkout");
}
}
let res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse Stripe response: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = body["url"].as_str().unwrap_or_default().to_string();
if url.is_empty() {
warn!("Stripe session missing URL");
return StatusCode::BAD_GATEWAY.into_response();
}
info!(user_id = %user.id, price_pence, "Created Stripe checkout session");
Json(CheckoutResponse { url }).into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Stripe checkout failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("Stripe request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("PocketBase update failed ({status}): {text}");
}
state.token_cache.invalidate_by_user_id(user_id);
Ok(())
}
/// Check if a referral invite code exists and is unused.
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
// Only allow alphanumeric codes to prevent PocketBase filter injection
if code.is_empty()
|| code.len() > 20
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
{
return false;
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!(
"code=\"{}\" && invite_type=\"referral\" && used_by_id=\"\"",
code
);
let url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
body["totalItems"].as_u64().unwrap_or(0) > 0
}
_ => false,
}
}

View file

@ -5,11 +5,14 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Extension;
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::FeatureInfo;
use crate::state::AppState;
@ -150,9 +153,14 @@ async fn fetch_screenshot(
pub async fn get_export(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
) -> Result<impl IntoResponse, axum::response::Response> {
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)?;
let filters_str = params.filters.clone();
let fields_str = params.fields.clone();
@ -161,7 +169,7 @@ pub async fn get_export(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let public_url = state.public_url.clone();
@ -269,7 +277,8 @@ pub async fn get_export(
let filter_feature_names = extract_filter_feature_names(filters_str.as_deref());
let field_indices =
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index);
parse_field_indices(fields_str.as_deref(), &state.feature_name_to_index)
.map_err(|err| err.1)?;
let all_feature_indices: Vec<usize> = if let Some(ref indices) = field_indices {
indices.clone()
@ -564,8 +573,8 @@ pub async fn get_export(
Ok(buf)
})
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err))?;
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
Ok((
[

View file

@ -4,10 +4,13 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_field_set, parse_filters, row_passes_filters,
validate_h3_resolution,
@ -70,19 +73,25 @@ pub struct HexagonStatsParams {
pub async fn get_hexagon_stats(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonStatsParams>,
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
.into_response()
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// 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)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
@ -91,7 +100,7 @@ pub async fn get_hexagon_stats(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
@ -164,8 +173,8 @@ pub async fn get_hexagon_stats(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -2,19 +2,23 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
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::fetch_travel_times;
use crate::routes::travel_time::TravelTimeAgg;
use crate::state::AppState;
#[derive(Serialize)]
@ -27,64 +31,69 @@ pub struct HexagonParams {
resolution: u8,
bounds: Option<String>,
/// Comma-separated filters: `name:min:max,...`
/// Rows must have non-NaN values within [min,max] for each filter.
filters: Option<String>,
/// Comma-separated feature names to include in min/max aggregation.
/// When present (even if empty), only listed features are aggregated and written.
/// When absent, all features are included (backward compatible).
fields: Option<String>,
/// Pipe-separated travel time entries: `lat,lon,mode|lat,lon,mode`
/// Each entry requests travel time from hex centroids to that destination via the given mode.
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
/// Each entry requests travel time aggregation for that mode+destination.
/// Optional min:max applies as a filter (exclude properties outside range).
travel: Option<String>,
}
struct TravelEntry {
lat: f64,
lon: f64,
mode: String,
slug: String,
filter_min: Option<f32>,
filter_max: Option<f32>,
}
const VALID_MODES: &[&str] = &["car", "bicycle", "walking", "transit"];
/// Parse `travel` param into a list of travel entries.
/// Format: `lat,lon,mode|lat,lon,mode`
fn parse_travel_entries(s: &str) -> Result<Vec<TravelEntry>, String> {
/// Format: `mode:slug` or `mode:slug:min:max`
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
let mut entries = Vec::new();
let mut seen_modes = Vec::new();
for segment in s.split('|') {
let parts: Vec<&str> = segment.split(',').collect();
if parts.len() != 3 {
let mut seen_keys = Vec::new();
for segment in travel_str.split('|') {
let parts: Vec<&str> = segment.split(':').collect();
if parts.len() < 2 {
return Err(format!(
"each travel entry must be 'lat,lon,mode', got '{}'",
"each travel entry must be 'mode:slug' or 'mode:slug:min:max', got '{}'",
segment
));
}
let lat: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid travel latitude in '{}'", segment))?;
let lon: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid travel longitude in '{}'", segment))?;
let mode = parts[2].trim().to_string();
if !VALID_MODES.contains(&mode.as_str()) {
return Err(format!(
"invalid travel mode '{}', must be one of: {}",
mode,
VALID_MODES.join(", ")
));
let mode = parts[0].trim().to_string();
let slug = parts[1].trim().to_string();
let (filter_min, filter_max) = if parts.len() >= 4 {
let min: f32 = parts[2]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter min in '{}'", segment))?;
let max: f32 = parts[3]
.trim()
.parse()
.map_err(|_| format!("invalid travel filter max in '{}'", segment))?;
(Some(min), Some(max))
} else {
(None, None)
};
let key = format!("{}:{}", mode, slug);
if seen_keys.contains(&key) {
return Err(format!("duplicate travel entry '{}'", key));
}
if seen_modes.contains(&mode) {
return Err(format!("duplicate travel mode '{}'", mode));
}
seen_modes.push(mode.clone());
entries.push(TravelEntry { lat, lon, mode });
seen_keys.push(key);
entries.push(TravelEntry {
mode,
slug,
filter_min,
filter_max,
});
}
Ok(entries)
}
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
#[allow(clippy::too_many_arguments)]
fn build_feature_maps(
groups: &FxHashMap<u64, Aggregator>,
min_keys: &[String],
@ -92,7 +101,9 @@ fn build_feature_maps(
avg_keys: &[String],
num_features: usize,
indices: Option<&[usize]>,
query_bounds: (f64, f64, f64, f64), // (south, west, north, east)
query_bounds: (f64, f64, f64, f64),
travel_aggs: &[FxHashMap<u64, TravelTimeAgg>],
travel_field_keys: &[String],
) -> Vec<Map<String, Value>> {
let mut features = Vec::with_capacity(groups.len());
let (q_south, q_west, q_north, q_east) = query_bounds;
@ -143,6 +154,25 @@ fn build_feature_maps(
}
}
// Add travel time aggregation fields
for (ti, agg_map) in travel_aggs.iter().enumerate() {
if let Some(agg) = agg_map.get(&cell_id) {
if agg.count > 0 {
let key = &travel_field_keys[ti];
let avg = agg.sum / agg.count as f64;
if let Some(nm) = serde_json::Number::from_f64(agg.min as f64) {
map.insert(format!("min_{key}"), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(agg.max as f64) {
map.insert(format!("max_{key}"), Value::Number(nm));
}
if let Some(nm) = serde_json::Number::from_f64(avg) {
map.insert(format!("avg_{key}"), Value::Number(nm));
}
}
}
}
features.push(map);
}
@ -151,12 +181,21 @@ fn build_feature_maps(
pub async fn get_hexagons(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonParams>,
) -> Result<Json<HexagonsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonsResponse>, axum::response::Response> {
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
// Skip license check at low resolutions (≤5) — data is too aggregated to be
// commercially useful, and the homepage demo needs country-wide access.
if resolution > 5 {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
}
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
@ -164,30 +203,49 @@ pub async fn get_hexagons(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
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(|s| !s.is_empty())
.filter(|val| !val.is_empty())
.map(parse_travel_entries)
.transpose()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
.unwrap_or_default();
// Capture what we need for the R5 calls before moving state into spawn_blocking
let r5_url = state.r5_url.clone();
let http_client = state.http_client.clone();
let mut response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
let t0 = std::time::Instant::now();
// Load travel time data from precomputed parquet files
let travel_data: Vec<TravelData> = if !travel_entries.is_empty() {
let store = &state.travel_time_store;
travel_entries
.iter()
.map(|entry| {
store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect::<Result<Vec<_>, _>>()?
} else {
Vec::new()
};
let has_travel = !travel_entries.is_empty();
let travel_field_keys: Vec<String> = travel_entries
.iter()
.map(|te| format!("tt_{}_{}", te.mode, te.slug))
.collect();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let (pc_interner, pc_keys) = state.data.postcode_parts();
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
let avg_keys = &state.avg_keys;
@ -198,49 +256,70 @@ pub async fn get_hexagons(
let need_parent = needs_parent(resolution);
let mut groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> =
(0..travel_entries.len()).map(|_| FxHashMap::default()).collect();
// Hoist has_selective branch outside the hot loop to avoid per-row branching
if let Some(sel_indices) = field_indices.as_deref() {
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
// Main aggregation loop
let aggregate_row =
|row: usize,
groups: &mut FxHashMap<u64, Aggregator>,
travel_aggs: &mut [FxHashMap<u64, TravelTimeAgg>]| {
// Regular filters
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
// Travel time filter: check each entry with a range
let mut travel_minutes: Vec<Option<i16>> = Vec::new();
if has_travel {
let postcode = pc_interner.resolve(&pc_keys[row]);
travel_minutes.reserve(travel_entries.len());
for (ti, entry) in travel_entries.iter().enumerate() {
let minutes = travel_data[ti].get(postcode).copied();
travel_minutes.push(minutes);
if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) {
match minutes {
Some(mins) if (mins as f32) >= fmin && (mins as f32) <= fmax => {}
_ => return, // Filtered out
}
}
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
// Aggregate regular features
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
if let Some(sel_indices) = field_indices.as_deref() {
aggregation.add_row_selective(feature_data, row, num_features, sel_indices);
});
} else {
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
let cell_id = cell_for_row(row, precomputed, h3_res, need_parent);
let aggregation = groups
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
} else {
aggregation.add_row(feature_data, row, num_features);
});
}
}
// Aggregate travel time
for (ti, minutes) in travel_minutes.iter().enumerate() {
if let Some(mins) = minutes {
let agg = travel_aggs[ti]
.entry(cell_id)
.or_insert_with(TravelTimeAgg::new);
agg.add(*mins as f32);
}
}
};
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
aggregate_row(row_idx as usize, &mut groups, &mut travel_aggs);
});
let t_agg = t0.elapsed();
@ -252,6 +331,8 @@ pub async fn get_hexagons(
num_features,
field_indices.as_deref(),
(south, west, north, east),
&travel_aggs,
&travel_field_keys,
);
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
@ -268,6 +349,7 @@ pub async fn get_hexagons(
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
"GET /api/hexagons"
@ -276,76 +358,8 @@ pub async fn get_hexagons(
Ok(HexagonsResponse { features })
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
// If travel entries were requested and R5 is configured, fetch travel times concurrently.
if !travel_entries.is_empty() {
let url = r5_url.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Travel time queries require routing service (R5_URL not configured)".into(),
))?;
// Collect hex centroids
let origins: Vec<[f64; 2]> = response
.features
.iter()
.map(|f| {
let lat = f
.get("lat")
.and_then(|v| v.as_f64())
.expect("lat must be present in feature map");
let lon = f
.get("lon")
.and_then(|v| v.as_f64())
.expect("lon must be present in feature map");
[lat, lon]
})
.collect();
// Fire concurrent R5 calls for each travel entry
let mut handles = Vec::with_capacity(travel_entries.len());
for entry in &travel_entries {
let client = http_client.clone();
let url = url.to_string();
let origins = origins.clone();
let dest = [entry.lat, entry.lon];
let mode = entry.mode.clone();
handles.push(tokio::spawn(async move {
fetch_travel_times(&client, &url, origins, dest, &mode).await
}));
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
results.push(handle.await);
}
for (entry, result) in travel_entries.iter().zip(results) {
let travel_times = result
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|err| (StatusCode::BAD_GATEWAY, err))?;
let field_name = format!("travel_time_{}", entry.mode);
for (feature, tt) in response.features.iter_mut().zip(&travel_times) {
match tt {
Some(minutes) => {
if let Some(num) = serde_json::Number::from_f64(*minutes) {
feature.insert(field_name.clone(), Value::Number(num));
}
}
None => {
feature.insert(field_name.clone(), Value::Null);
}
}
}
info!(
hexagons = response.features.len(),
destination = format_args!("{},{}", entry.lat, entry.lon),
mode = entry.mode,
"Travel times merged"
);
}
}
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -0,0 +1,374 @@
use std::sync::Arc;
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
#[derive(Serialize)]
struct InviteResponse {
code: String,
url: String,
invite_type: String,
}
#[derive(Serialize)]
struct InviteValidation {
valid: bool,
invite_type: String,
used: bool,
}
#[derive(Deserialize)]
pub struct RedeemRequest {
code: String,
}
#[derive(Serialize)]
struct RedeemResponse {
/// "licensed" if admin invite was redeemed directly, or a checkout URL for referral
result: String,
/// For referral invites: the Stripe checkout URL with coupon
checkout_url: Option<String>,
}
/// Validate that an invite code contains only safe characters (alphanumeric, lowercase).
/// Rejects any code that could be used for PocketBase filter injection.
fn validate_invite_code(code: &str) -> Result<(), &'static str> {
if code.is_empty() || code.len() > 20 {
return Err("Invalid invite code length");
}
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err("Invalid invite code characters");
}
Ok(())
}
fn generate_invite_code() -> String {
use rand::Rng;
let mut rng = rand::rng();
let chars: Vec<char> = (0..12)
.map(|_| {
let idx: u8 = rng.random_range(0..36);
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect();
chars.into_iter().collect()
}
/// Create an invite. Admins create "admin" invites (free license).
/// Licensed non-admin users create "referral" invites (30% off).
pub async fn post_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
_body: Json<serde_json::Value>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let invite_type = if user.is_admin {
"admin"
} else if user.subscription == "licensed" {
"referral"
} else {
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
};
let code = generate_invite_code();
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 create_url = format!("{pb_url}/api/collections/invites/records");
let res = state
.http_client
.post(&create_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"code": code,
"created_by": user.id,
"invite_type": invite_type,
"used_by_id": "",
"used_at": "",
}))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
let public_url = &state.public_url;
let url = format!("{public_url}/invite/{code}");
info!(code = %code, invite_type, user_id = %user.id, "Created invite");
Json(InviteResponse {
code,
url,
invite_type: invite_type.to_string(),
})
.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Failed to create invite ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
/// Validate an invite code. Requires authentication to prevent enumeration.
pub async fn get_invite(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Path(code): Path<String>,
) -> Response {
if user.0.is_none() {
return StatusCode::UNAUTHORIZED.into_response();
}
if let Err(msg) = validate_invite_code(&code) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = format!("code=\"{}\"", code);
let url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&url).send().await {
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
if !res.status().is_success() {
return StatusCode::BAD_GATEWAY.into_response();
}
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let items = body["items"].as_array();
match items.and_then(|arr| arr.first()) {
Some(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();
Json(InviteValidation {
valid: true,
invite_type,
used,
})
.into_response()
}
None => Json(InviteValidation {
valid: false,
invite_type: String::new(),
used: false,
})
.into_response(),
}
}
/// Redeem an invite code. Requires authentication.
/// Admin invite: sets subscription to "licensed" directly.
/// Referral invite: returns a discounted Stripe checkout URL.
pub async fn post_redeem_invite(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<RedeemRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if let Err(msg) = validate_invite_code(&req.code) {
return (StatusCode::BAD_REQUEST, msg).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();
}
};
// Look up invite
let filter = format!(
"code=\"{}\" && used_by_id=\"\"",
req.code
);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send().await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
Some(inv) => inv.clone(),
None => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
};
let invite_id = invite["id"].as_str().unwrap_or("");
let invite_type = invite["invite_type"].as_str().unwrap_or("");
// Mark invite as used
let now = {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
dur.as_secs().to_string()
};
let _ = state
.http_client
.patch(&format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user.id,
"used_at": now,
}))
.send()
.await;
if invite_type == "admin" {
// Grant license directly
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response()
}
_ => {
warn!("Failed to update user subscription for admin invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
} else {
// Referral invite — create discounted checkout with dynamic pricing
let count = match super::pricing::count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let price_pence = super::pricing::price_for_count(count);
let secret_key = &state.stripe_secret_key;
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
let checkout_url = stripe_body["url"]
.as_str()
.unwrap_or_default()
.to_string();
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
checkout_url: Some(checkout_url),
})
.into_response()
}
_ => {
warn!("Failed to create Stripe checkout for referral invite");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
}

View file

@ -0,0 +1,64 @@
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;
#[derive(Deserialize)]
pub struct UpdateNewsletterRequest {
newsletter: bool,
}
pub async fn patch_newsletter(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<UpdateNewsletterRequest>,
) -> 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 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!({ "newsletter": req.newsletter }))
.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()
}
}
}

View file

@ -23,9 +23,16 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
let method = req.method().clone();
let mut builder = state.http_client.request(method, &url);
// Forward headers except host
// Forward only safe headers (allowlist)
const ALLOWED_HEADERS: &[&str] = &[
"content-type",
"accept",
"authorization",
"cookie",
"accept-language",
];
for (name, value) in req.headers() {
if name != "host" {
if ALLOWED_HEADERS.contains(&name.as_str()) {
builder = builder.header(name.clone(), value.clone());
}
}

View file

@ -6,11 +6,13 @@ use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PlaceResult {
name: String,
slug: String,
place_type: String,
lat: f32,
lon: f32,
@ -28,6 +30,8 @@ pub struct PlacesResponse {
pub struct PlacesParams {
q: String,
limit: Option<usize>,
/// If set, only return places that have travel time data for this mode.
mode: Option<String>,
}
pub async fn get_places(
@ -41,33 +45,44 @@ pub async fn get_places(
};
let limit = params.limit.unwrap_or(7).min(20);
let mode_filter = params.mode;
let places = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let query_lower = query.to_lowercase();
let pd = &state.place_data;
let tt_store = &state.travel_time_store;
// Linear scan — ~50-100k rows, <1ms
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len)
let mut matches: Vec<(usize, bool, bool, u8, u32, usize)> = pd
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug)
let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd
.name_lower
.iter()
.enumerate()
.filter_map(|(idx, name)| {
if name.contains(&query_lower) {
let is_exact = name.len() == query_lower.len();
let is_prefix = name.starts_with(&query_lower);
Some((
idx,
is_exact,
is_prefix,
pd.type_rank[idx],
pd.population[idx],
pd.name[idx].len(),
))
} else {
None
if !name.contains(&query_lower) {
return None;
}
let slug = slugify(&pd.name[idx]);
// If mode filter is set, only include places with travel data
if let Some(ref mode) = mode_filter {
if !tt_store.has_destination(mode, &slug) {
return None;
}
}
let is_exact = name.len() == query_lower.len();
let is_prefix = name.starts_with(&query_lower);
Some((
idx,
is_exact,
is_prefix,
pd.type_rank[idx],
pd.population[idx],
pd.name[idx].len(),
slug,
))
})
.collect();
@ -85,12 +100,13 @@ pub async fn get_places(
let results: Vec<PlaceResult> = matches
.iter()
.map(|&(idx, ..)| PlaceResult {
name: pd.name[idx].clone(),
place_type: pd.place_type.get(idx).to_string(),
lat: pd.lat[idx],
lon: pd.lon[idx],
city: pd.city[idx].clone(),
.map(|(idx, .., slug)| PlaceResult {
name: pd.name[*idx].clone(),
slug: slug.clone(),
place_type: pd.place_type.get(*idx).to_string(),
lat: pd.lat[*idx],
lon: pd.lon[*idx],
city: pd.city[*idx].clone(),
})
.collect();
@ -99,6 +115,7 @@ pub async fn get_places(
query = query.as_str(),
results = results.len(),
scanned = pd.name_lower.len(),
mode = mode_filter.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/places"
);

View file

@ -2,11 +2,14 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point;
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
use crate::state::AppState;
@ -24,8 +27,9 @@ pub struct PostcodeStatsParams {
pub async fn get_postcode_stats(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
// Normalize postcode: uppercase, collapse whitespace
let normalized = params
.postcode
@ -42,18 +46,23 @@ pub async fn get_postcode_stats(
return Err((
StatusCode::NOT_FOUND,
format!("Postcode not found: {}", normalized),
));
)
.into_response());
}
};
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)?;
let filters_str = params.filters.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))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
@ -129,8 +138,8 @@ pub async fn get_postcode_stats(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -2,14 +2,17 @@ use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
};
@ -60,9 +63,14 @@ fn build_postcode_geometry(rings: &[Vec<[f32; 2]>]) -> Value {
pub async fn get_postcodes(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeParams>,
) -> Result<Json<PostcodesResponse>, (StatusCode, String)> {
let (south, west, north, east) = require_bounds(params.bounds)?;
) -> Result<Json<PostcodesResponse>, axum::response::Response> {
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)?;
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
@ -70,10 +78,11 @@ pub async fn get_postcodes(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
.map_err(|err| (err.0, err.1).into_response())?;
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
let postcode_data = &state.postcode_data;
@ -222,7 +231,7 @@ pub async fn get_postcodes(
}
}
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
postcodes_before_filter,
@ -242,8 +251,8 @@ pub async fn get_postcodes(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}

View file

@ -0,0 +1,105 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
(10, 0), // First 10 users: free
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50
];
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
#[derive(Serialize)]
pub struct Tier {
up_to: Option<u64>,
price_pence: u64,
slots: u64,
}
#[derive(Serialize)]
pub struct PricingResponse {
licensed_count: u64,
current_price_pence: u64,
tiers: Vec<Tier>,
}
/// Determine the price (in pence) for the next user given `count` existing licensed users.
pub fn price_for_count(count: u64) -> u64 {
for &(cap, price) in TIERS {
if count < cap {
return price;
}
}
FINAL_PRICE_PENCE
}
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",
urlencoding::encode(filter)
);
let resp = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("PocketBase returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await?;
let total = body["totalItems"].as_u64().unwrap_or(0);
Ok(total)
}
pub async fn get_pricing(state: Arc<AppState>) -> Response {
let count = match count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let current_price = price_for_count(count);
let mut tiers = Vec::new();
let mut prev_cap = 0u64;
for &(cap, price) in TIERS {
tiers.push(Tier {
up_to: Some(cap),
price_pence: price,
slots: cap - prev_cap,
});
prev_cap = cap;
}
tiers.push(Tier {
up_to: None,
price_pence: FINAL_PRICE_PENCE,
slots: 0,
});
Json(PricingResponse {
licensed_count: count,
current_price_pence: current_price,
tiers,
})
.into_response()
}

View file

@ -3,12 +3,15 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
@ -90,19 +93,25 @@ fn lookup_enum_value(
pub async fn get_hexagon_properties(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> {
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
.into_response()
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution;
validate_h3_resolution(resolution)?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// 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)?;
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
@ -111,7 +120,7 @@ pub async fn get_hexagon_properties(
&state.feature_name_to_index,
&state.data.enum_values,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err))?;
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let result = tokio::task::spawn_blocking(move || {
@ -249,8 +258,8 @@ pub async fn get_hexagon_properties(
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(result))
}

View file

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use rustc_hash::FxHashMap;
use tracing::warn;
use crate::consts::MAX_PRICE_HISTORY_POINTS;
use crate::data::FeatureStats;
@ -78,6 +79,13 @@ pub fn compute_feature_stats(
let idx = value as usize;
if idx < value_counts.len() {
value_counts[idx] += 1;
} else {
warn!(
feature = feature_name.as_str(),
idx,
max = value_counts.len(),
"Enum index out of bounds — possible data/schema mismatch"
);
}
}
}

View file

@ -0,0 +1,129 @@
use std::sync::Arc;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{info, warn};
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
type HmacSha256 = Hmac<Sha256>;
/// Verify Stripe webhook signature (v1 scheme).
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
let mut timestamp = None;
let mut signature = None;
for part in sig_header.split(',') {
if let Some(ts) = part.strip_prefix("t=") {
timestamp = Some(ts);
} else if let Some(sig) = part.strip_prefix("v1=") {
signature = Some(sig);
}
}
let (ts, sig_hex) = match (timestamp, signature) {
(Some(t), Some(s)) => (t, s),
_ => return false,
};
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(signed_payload.as_bytes());
// Decode the provided hex signature and verify with constant-time comparison
let sig_bytes = match hex::decode(sig_hex) {
Ok(bytes) => bytes,
Err(_) => return false,
};
mac.verify_slice(&sig_bytes).is_ok()
}
/// Handle Stripe webhook events.
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
pub async fn post_stripe_webhook(
state: Arc<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let webhook_secret = &state.stripe_webhook_secret;
let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) {
Some(s) => s,
None => {
warn!("Missing Stripe-Signature header");
return StatusCode::BAD_REQUEST.into_response();
}
};
if !verify_signature(&body, sig_header, webhook_secret) {
warn!("Invalid Stripe webhook signature");
return StatusCode::BAD_REQUEST.into_response();
}
let event: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse webhook body: {err}");
return StatusCode::BAD_REQUEST.into_response();
}
};
let event_type = event["type"].as_str().unwrap_or("");
info!(event_type, "Received Stripe webhook");
if event_type == "checkout.session.completed" {
let user_id = event["data"]["object"]["client_reference_id"]
.as_str()
.unwrap_or("");
if user_id.is_empty() {
warn!("checkout.session.completed missing client_reference_id");
return StatusCode::OK.into_response();
}
// Update user subscription to "licensed" via PocketBase superuser auth
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 in webhook: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.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": "licensed" }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(user_id);
info!(user_id, "User subscription updated to licensed via Stripe webhook");
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(user_id, "Failed to update user subscription ({status}): {text}");
}
Err(err) => {
warn!(user_id, "PocketBase request error in webhook: {err}");
}
}
}
StatusCode::OK.into_response()
}

View file

@ -0,0 +1,78 @@
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()
}
}
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use pmtiles::async_reader::AsyncPmTilesReader;
use pmtiles::MmapBackend;
@ -40,7 +40,7 @@ pub struct StyleParams {
pub async fn get_style(
State(reader): State<Arc<TileReader>>,
headers: HeaderMap,
public_url: String,
Query(params): Query<StyleParams>,
) -> Result<Response, (StatusCode, String)> {
let is_dark = params.theme.as_deref() == Some("dark");
@ -50,7 +50,7 @@ pub async fn get_style(
warn!(error = %err, "Failed to get PMTiles metadata");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get PMTiles metadata: {err}"),
"Failed to read tile metadata".to_string(),
)
})?;
@ -59,7 +59,7 @@ pub async fn get_style(
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse PMTiles metadata: {err}"),
"Failed to parse tile metadata".to_string(),
)
})?;
@ -70,15 +70,8 @@ pub async fn get_style(
.cloned()
.unwrap_or_default();
// Build absolute tile URL using the request host
let host = headers
.get(header::HOST)
.and_then(|hv| hv.to_str().ok())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing Host header".into(),
))?;
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
// Build absolute tile URL using the configured public URL (not the Host header)
let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/'));
let style = build_style(is_dark, &layers, &tile_url);
Ok((

View file

@ -0,0 +1,38 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub struct TravelModeInfo {
mode: String,
destinations: usize,
}
#[derive(Serialize)]
pub struct TravelModesResponse {
modes: Vec<TravelModeInfo>,
}
pub async fn get_travel_modes(
state: Arc<AppState>,
) -> Result<Json<TravelModesResponse>, (StatusCode, String)> {
let store = &state.travel_time_store;
let modes = store
.available_modes
.iter()
.map(|mode| TravelModeInfo {
mode: mode.clone(),
destinations: store
.destinations
.get(mode)
.map(|slugs| slugs.len())
.unwrap_or(0),
})
.collect();
Ok(Json(TravelModesResponse { modes }))
}

View file

@ -1,72 +1,30 @@
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Serialize)]
struct R5Request {
origin: [f64; 2],
destinations: Vec<[f64; 2]>,
mode: String,
/// Per-hex-cell travel time aggregation.
pub struct TravelTimeAgg {
pub min: f32,
pub max: f32,
pub sum: f64,
pub count: u32,
}
#[derive(Deserialize)]
struct R5Response {
travel_times: Vec<f64>,
}
/// Call the R5 Java service to compute one-to-many travel times.
///
/// `origins` are hex centroids as `[lat, lon]`.
/// `destination` is the user-chosen point as `[lat, lon]`.
/// `mode` is one of "car", "bicycle", "walking", "transit".
///
/// R5 computes from destination to all origins (one-to-many from the user's chosen point).
/// Returns a Vec of travel times in minutes (one per origin), with None for unreachable.
pub async fn fetch_travel_times(
client: &reqwest::Client,
r5_url: &str,
origins: Vec<[f64; 2]>,
destination: [f64; 2],
mode: &str,
) -> Result<Vec<Option<f64>>, String> {
if origins.is_empty() {
return Ok(vec![]);
impl TravelTimeAgg {
pub fn new() -> Self {
TravelTimeAgg {
min: f32::INFINITY,
max: f32::NEG_INFINITY,
sum: 0.0,
count: 0,
}
}
let body = R5Request {
origin: destination,
destinations: origins,
mode: mode.to_string(),
};
let resp = client
.post(format!("{}/travel-times", r5_url))
.json(&body)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| {
warn!("R5 request failed: {}", e);
format!("R5 routing error: {}", e)
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
warn!("R5 returned {}: {}", status, body);
return Err(format!("R5 returned {}: {}", status, body));
#[inline]
pub fn add(&mut self, value: f32) {
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
self.sum += value as f64;
self.count += 1;
}
let r5_resp: R5Response = resp.json().await.map_err(|e| {
warn!("Failed to parse R5 response: {}", e);
format!("Failed to parse R5 response: {}", e)
})?;
// R5 returns -1 for unreachable destinations
let travel_times: Vec<Option<f64>> = r5_resp
.travel_times
.into_iter()
.map(|t| if t < 0.0 { None } else { Some(t) })
.collect();
Ok(travel_times)
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use rustc_hash::FxHashMap;
use crate::auth::TokenCache;
use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData};
use crate::data::{POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore};
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
@ -34,18 +34,22 @@ pub struct AppState {
pub screenshot_url: String,
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.schmelczer.dev)
pub public_url: String,
/// Contents of index.html read at startup, used for crawler OG injection
/// Contents of index.html read at startup, used for crawler OG injection (None when --dist omitted)
pub index_html: Option<String>,
/// Shared HTTP client for proxying to the screenshot service and PocketBase
pub http_client: reqwest::Client,
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
pub pocketbase_url: String,
/// PocketBase superuser email (needed for admin-only operations like subscription updates)
pub pocketbase_admin_email: String,
/// PocketBase superuser password
pub pocketbase_admin_password: String,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
pub ollama_url: String,
/// Ollama model name for area summaries (e.g. gemma3:12b)
pub ollama_model: String,
/// R5 routing service URL for all travel times (None = disabled)
pub r5_url: Option<String>,
/// Precomputed travel time data store
pub travel_time_store: Arc<TravelTimeStore>,
/// Token validation cache (60s TTL)
pub token_cache: Arc<TokenCache>,
/// JSON schema for Ollama structured output in AI filters
@ -54,4 +58,10 @@ pub struct AppState {
pub ai_filters_system_prompt: String,
/// Google Maps API key for Street View metadata lookups
pub google_maps_api_key: String,
/// Stripe secret key for creating checkout sessions
pub stripe_secret_key: String,
/// Stripe webhook signing secret
pub stripe_webhook_secret: String,
/// Stripe Coupon ID for referral discounts
pub stripe_referral_coupon_id: String,
}