From d98819b5695279c4627c6548c70b5d6766055067 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 13:19:26 +0100 Subject: [PATCH] server --- server-rs/src/auth.rs | 2 + server-rs/src/data/crime_by_year.rs | 41 ++++++------ server-rs/src/data/property.rs | 53 ---------------- server-rs/src/features.rs | 62 +++++------------- server-rs/src/main.rs | 83 +++++++++++++++++++------ server-rs/src/pocketbase.rs | 21 +++++-- server-rs/src/routes/actual_listings.rs | 10 +++ server-rs/src/routes/ai_filters.rs | 10 +-- server-rs/src/routes/overlays.rs | 8 +-- server-rs/src/routes/places.rs | 1 + server-rs/src/routes/properties.rs | 10 ++- server-rs/src/routes/stats.rs | 17 ++--- 12 files changed, 157 insertions(+), 161 deletions(-) diff --git a/server-rs/src/auth.rs b/server-rs/src/auth.rs index b760093..eea4264 100644 --- a/server-rs/src/auth.rs +++ b/server-rs/src/auth.rs @@ -23,6 +23,8 @@ pub struct PocketBaseUser { pub subscription: String, #[serde(default)] pub newsletter: bool, + #[serde(default)] + pub can_see_listings: bool, } #[derive(Clone)] diff --git a/server-rs/src/data/crime_by_year.rs b/server-rs/src/data/crime_by_year.rs index bc0d5f0..2c99c1e 100644 --- a/server-rs/src/data/crime_by_year.rs +++ b/server-rs/src/data/crime_by_year.rs @@ -1,5 +1,5 @@ -//! Per-LSOA per-crime-type per-year crime counts, loaded from a side parquet -//! and used by the right pane to plot crime-over-time. Filtering is not +//! Per-postcode per-crime-type per-year crime counts, loaded from a side +//! parquet and used by the right pane to plot crime-over-time. Filtering is not //! supported — this data is display-only. use std::path::Path; @@ -23,8 +23,8 @@ pub struct YearPoint { pub count: f32, } -/// One per crime type: ordered list of (year, count) for a single LSOA. -pub struct LsoaCrimeSeries { +/// One per crime type: ordered list of (year, count) for a single postcode. +pub struct PostcodeCrimeSeries { /// Index into `crime_types`. pub type_idx: u16, pub points: Vec, @@ -35,8 +35,8 @@ pub struct CrimeByYearData { pub crime_types: Vec, /// All years available for each crime type, same order as `crime_types`. pub years_by_type: Vec>, - /// LSOA code → all available per-type series for that LSOA. - pub series_by_lsoa: FxHashMap>, + /// Postcode → all available per-type series for that postcode. + pub series_by_postcode: FxHashMap>, } impl CrimeByYearData { @@ -44,7 +44,7 @@ impl CrimeByYearData { Self { crime_types: Vec::new(), years_by_type: Vec::new(), - series_by_lsoa: FxHashMap::default(), + series_by_postcode: FxHashMap::default(), } } @@ -67,20 +67,20 @@ impl CrimeByYearData { format!("Failed to read crime-by-year parquet at {}", path.display()) })?; - let lsoa_col = df - .column("LSOA code") - .context("crime-by-year parquet missing 'LSOA code' column")? + let postcode_col = df + .column("postcode") + .context("crime-by-year parquet missing 'postcode' column")? .str() - .context("'LSOA code' column is not a string")?; - let lsoa_values: Vec = lsoa_col + .context("'postcode' column is not a string")?; + let postcode_values: Vec = postcode_col .into_iter() .enumerate() .map(|(row, value)| { let value = - value.with_context(|| format!("crime-by-year row {row} has null LSOA code"))?; + value.with_context(|| format!("crime-by-year row {row} has null postcode"))?; let trimmed = value.trim(); if trimmed.is_empty() { - bail!("crime-by-year row {row} has blank LSOA code"); + bail!("crime-by-year row {row} has blank postcode"); } Ok(trimmed.to_string()) }) @@ -106,7 +106,8 @@ impl CrimeByYearData { let crime_types: Vec = crime_type_cols.iter().map(|(t, _)| t.clone()).collect(); - let mut series_by_lsoa: FxHashMap> = FxHashMap::default(); + let mut series_by_postcode: FxHashMap> = + FxHashMap::default(); let mut years_by_type: Vec> = Vec::with_capacity(crime_type_cols.len()); let row_count = df.height(); @@ -161,10 +162,10 @@ impl CrimeByYearData { } points.sort_by_key(|p| p.year); - series_by_lsoa - .entry(lsoa_values[row].clone()) + series_by_postcode + .entry(postcode_values[row].clone()) .or_default() - .push(LsoaCrimeSeries { + .push(PostcodeCrimeSeries { type_idx: type_idx as u16, points, }); @@ -173,7 +174,7 @@ impl CrimeByYearData { } info!( - lsoas = series_by_lsoa.len(), + postcodes = series_by_postcode.len(), crime_types = crime_types.len(), "Crime-by-year data loaded" ); @@ -181,7 +182,7 @@ impl CrimeByYearData { Ok(Self { crime_types, years_by_type, - series_by_lsoa, + series_by_postcode, }) } } diff --git a/server-rs/src/data/property.rs b/server-rs/src/data/property.rs index 4612ab9..141661b 100644 --- a/server-rs/src/data/property.rs +++ b/server-rs/src/data/property.rs @@ -831,10 +831,6 @@ pub struct PropertyData { /// Interned postcodes: reader is thread-safe, keys index into it. postcode_interner: lasso::RodeoReader, postcode_keys: Vec, - /// Interned LSOA (2021) codes per row. - /// Used to look up per-LSOA side tables (e.g. crime time series). - lsoa_interner: lasso::RodeoReader, - lsoa_keys: Vec, /// Rows for each postcode, keyed by the interned postcode key. postcode_row_index: FxHashMap>, /// Inverted index from address tokens to property rows. @@ -881,11 +877,6 @@ impl PropertyData { self.postcode_interner.resolve(&self.postcode_keys[row]) } - /// Get the LSOA (2021) code for a given row. - pub fn lsoa(&self, row: usize) -> &str { - self.lsoa_interner.resolve(&self.lsoa_keys[row]) - } - /// Get postcode components for field-level borrowing (avoids conflicting borrows with feature_data). pub fn postcode_parts(&self) -> (&lasso::RodeoReader, &[lasso::Spur]) { (&self.postcode_interner, &self.postcode_keys) @@ -1541,15 +1532,6 @@ impl PropertyData { } } - // LSOA (2021) code per row, brought in via the postcode join. Used as a - // lookup key into per-LSOA side tables (e.g. crime time series). - match schema.get("lsoa21") { - Some(dtype) if matches!(dtype, DataType::String) || dtype.is_categorical() => {} - Some(dtype) => bail!("Column 'lsoa21' has unexpected type {:?}", dtype), - None => bail!("Required column 'lsoa21' not found in joined property data"), - } - select_exprs.push(col("lsoa21").cast(DataType::String)); - // Enum features as String for &name in &enum_names { select_exprs.push(col(name).cast(DataType::String)); @@ -1704,33 +1686,8 @@ impl PropertyData { Ok(vec![None; row_count]) } }; - let extract_required_trimmed_string_col = - |df: &DataFrame, name: &str| -> anyhow::Result> { - let column = df - .column(name) - .with_context(|| format!("Required column '{name}' not found in parquet"))?; - let string_column = column - .str() - .with_context(|| format!("Column '{name}' is not a string column"))?; - string_column - .into_iter() - .enumerate() - .map(|(row, value)| { - let value = value.with_context(|| { - format!("Required column '{name}' has null at row {row}") - })?; - let trimmed = value.trim(); - if trimmed.is_empty() { - bail!("Required column '{name}' has blank value at row {row}"); - } - Ok(trimmed.to_string()) - }) - .collect() - }; - let property_sub_type_raw = extract_optional_string_col(&df, "Property sub-type")?; let price_qualifier_raw = extract_optional_string_col(&df, "Price qualifier")?; - let lsoa_raw = extract_required_trimmed_string_col(&df, "lsoa21")?; tracing::info!("Building enum features"); // enum_col_major: Vec<(values_list, encoded_as_f32)> @@ -2041,14 +1998,6 @@ impl PropertyData { } let postcode_interner = postcode_rodeo.into_reader(); - // Intern LSOA codes (permuted). - let mut lsoa_rodeo = lasso::Rodeo::default(); - let mut lsoa_keys: Vec = Vec::with_capacity(row_count); - for &perm_index in perm.iter() { - lsoa_keys.push(lsoa_rodeo.get_or_intern(&lsoa_raw[perm_index as usize])); - } - let lsoa_interner = lsoa_rodeo.into_reader(); - let row_to_poi_metric_idx: Vec = if poi_metrics.is_empty() { vec![NO_POI_METRIC_ROW; row_count] } else { @@ -2220,8 +2169,6 @@ impl PropertyData { address_lengths, postcode_interner, postcode_keys, - lsoa_interner, - lsoa_keys, postcode_row_index, address_token_index, address_prefix_index, diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index 64bdf5e..25bd6cb 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -526,36 +526,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ FeatureGroup { name: "Crime", features: &[ - Feature::Numeric(FeatureConfig { - name: "Serious crime per 1k residents (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 0.1, - description: "Serious crime rate per 1,000 residents per year", - detail: "Violence, robbery, burglary, and weapons possession per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - }), - Feature::Numeric(FeatureConfig { - name: "Minor crime per 1k residents (avg/yr)", - bounds: Bounds::Percentile { - low: 2.0, - high: 98.0, - }, - step: 0.1, - description: "Minor crime rate per 1,000 residents per year", - detail: "Anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.", - source: "crime", - prefix: "", - suffix: "/yr", - raw: false, - absolute: false, - }), Feature::Numeric(FeatureConfig { name: "Serious crime (avg/yr)", bounds: Bounds::Percentile { @@ -564,7 +534,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, 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. Provides a single serious crime metric.", + detail: "Sum of violence, robbery, burglary, and weapons possession per year within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). Provides a single serious crime metric.", source: "crime", prefix: "", suffix: "/yr", @@ -579,7 +549,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, 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. Provides a single minor crime metric.", + detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). Provides a single minor crime metric.", source: "crime", prefix: "", suffix: "/yr", @@ -594,7 +564,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly violent and sexual offences in the area", - detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data. Includes assault, harassment, and sexual offences.", + detail: "Average number of violence and sexual offences per year within 50m of the postcode, from police.uk street-level crime data. Includes assault, harassment, and sexual offences.", source: "crime", prefix: "", suffix: "/yr", @@ -609,7 +579,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly burglary offences in the area", - detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data. Includes residential and commercial burglary.", + detail: "Average number of burglary offences per year within 50m of the postcode, from police.uk street-level crime data. Includes residential and commercial burglary.", source: "crime", prefix: "", suffix: "/yr", @@ -624,7 +594,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly robbery offences in the area", - detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data. Robbery involves theft with force or threat of force.", + detail: "Average number of robbery offences per year within 50m of the postcode, from police.uk street-level crime data. Robbery involves theft with force or threat of force.", source: "crime", prefix: "", suffix: "/yr", @@ -639,7 +609,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly vehicle crime in the area", - detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data. Includes theft of and from vehicles.", + detail: "Average number of vehicle crime incidents per year within 50m of the postcode, from police.uk street-level crime data. Includes theft of and from vehicles.", source: "crime", prefix: "", suffix: "/yr", @@ -654,7 +624,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly anti-social behaviour incidents in the area", - detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data. Includes nuisance, environmental, and personal anti-social behaviour.", + detail: "Average number of anti-social behaviour incidents per year within 50m of the postcode, from police.uk street-level crime data. Includes nuisance, environmental, and personal anti-social behaviour.", source: "crime", prefix: "", suffix: "/yr", @@ -669,7 +639,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly criminal damage and arson in the area", - detail: "Average number of criminal damage and arson incidents per year in the LSOA, from police.uk street-level crime data.", + detail: "Average number of criminal damage and arson incidents per year within 50m of the postcode, from police.uk street-level crime data.", source: "crime", prefix: "", suffix: "/yr", @@ -684,7 +654,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly other theft offences in the area", - detail: "Average number of 'other theft' offences per year in the LSOA, from police.uk street-level crime data. Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.", + detail: "Average number of 'other theft' offences per year within 50m of the postcode, from police.uk street-level crime data. Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.", source: "crime", prefix: "", suffix: "/yr", @@ -699,7 +669,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly theft from the person in the area", - detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data. Includes pickpocketing and bag snatching without force.", + detail: "Average number of theft from the person offences per year within 50m of the postcode, from police.uk street-level crime data. Includes pickpocketing and bag snatching without force.", source: "crime", prefix: "", suffix: "/yr", @@ -714,7 +684,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly shoplifting offences in the area", - detail: "Average number of shoplifting offences per year in the LSOA, from police.uk street-level crime data.", + detail: "Average number of shoplifting offences per year within 50m of the postcode, from police.uk street-level crime data.", source: "crime", prefix: "", suffix: "/yr", @@ -729,7 +699,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly bicycle theft in the area", - detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data.", + detail: "Average number of bicycle theft offences per year within 50m of the postcode, from police.uk street-level crime data.", source: "crime", prefix: "", suffix: "/yr", @@ -744,7 +714,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly drug offences in the area", - detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data. Includes possession and trafficking offences.", + detail: "Average number of drug offences per year within 50m of the postcode, from police.uk street-level crime data. Includes possession and trafficking offences.", source: "crime", prefix: "", suffix: "/yr", @@ -759,7 +729,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly weapons possession offences in the area", - detail: "Average number of possession of weapons offences per year in the LSOA, from police.uk street-level crime data.", + detail: "Average number of possession of weapons offences per year within 50m of the postcode, from police.uk street-level crime data.", source: "crime", prefix: "", suffix: "/yr", @@ -774,7 +744,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly public order offences in the area", - detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data. Includes causing fear, alarm, or distress.", + detail: "Average number of public order offences per year within 50m of the postcode, from police.uk street-level crime data. Includes causing fear, alarm, or distress.", source: "crime", prefix: "", suffix: "/yr", @@ -789,7 +759,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Average yearly other crime in the area", - detail: "Average number of other crime offences per year in the LSOA, from police.uk street-level crime data. A catch-all category for offences not classified elsewhere.", + detail: "Average number of other crime offences per year within 50m of the postcode, from police.uk street-level crime data. A catch-all category for offences not classified elsewhere.", source: "crime", prefix: "", suffix: "/yr", diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 80bf040..b1293fc 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -171,6 +171,10 @@ struct Cli { #[arg(long, env = "SATELLITE_TILES")] satellite_tiles: Option, + /// Optional PMTiles raster overlay for high-resolution EA aerial photography. + #[arg(long, env = "SATELLITE_HIGHRES_TILES")] + satellite_highres_tiles: Option, + /// Optional PMTiles raster overlay for high-resolution strategic noise. #[arg(long, env = "NOISE_OVERLAY_TILES")] noise_overlay_tiles: Option, @@ -183,6 +187,10 @@ struct Cli { #[arg(long, env = "TREE_OVERLAY_TILES")] tree_overlay_tiles: Option, + /// Optional PMTiles vector overlay for INSPIRE property-border polygons. + #[arg(long, env = "PROPERTY_BORDER_TILES")] + property_border_tiles: Option, + /// Path to the frontend dist directory (optional; disables static serving and OG injection when omitted) #[arg(long)] dist: Option, @@ -300,34 +308,31 @@ async fn capture_server_error_responses( response } -async fn init_optional_tile_reader( +async fn init_required_tile_reader( label: &'static str, - path: Option<&PathBuf>, -) -> anyhow::Result>> { - let Some(path) = path else { - info!("{label} overlay tiles not configured"); - return Ok(None); - }; - + path: &Path, +) -> anyhow::Result> { if !path.exists() { - bail!("{label} overlay PMTiles not found: {}", path.display()); + bail!("{label} PMTiles not found: {}", path.display()); } - info!("Loading {label} overlay PMTiles from {}", path.display()); - Ok(Some(Arc::new(routes::init_tile_reader(path).await?))) + info!("Loading {label} PMTiles from {}", path.display()); + Ok(Arc::new(routes::init_tile_reader(path).await?)) } fn configured_or_default_overlay_path( configured: &Option, tiles_path: &Path, file_name: &str, -) -> Option { +) -> PathBuf { if let Some(path) = configured { - return Some(path.clone()); + return path.clone(); } - let default_path = tiles_path.parent()?.join(file_name); - default_path.exists().then_some(default_path) + tiles_path + .parent() + .map(|parent| parent.join(file_name)) + .unwrap_or_else(|| PathBuf::from(file_name)) } #[tokio::main] @@ -481,6 +486,11 @@ async fn main() -> anyhow::Result<()> { ); let satellite_tiles = configured_or_default_overlay_path(&cli.satellite_tiles, tiles_path, "satellite.pmtiles"); + let satellite_highres_tiles = configured_or_default_overlay_path( + &cli.satellite_highres_tiles, + tiles_path, + "satellite_highres.pmtiles", + ); let crime_hotspot_tiles = configured_or_default_overlay_path( &cli.crime_hotspot_tiles, tiles_path, @@ -491,14 +501,23 @@ async fn main() -> anyhow::Result<()> { tiles_path, "trees_outside_woodlands.pmtiles", ); + let property_border_tiles = configured_or_default_overlay_path( + &cli.property_border_tiles, + tiles_path, + "property_borders.pmtiles", + ); let noise_overlay_reader = - init_optional_tile_reader("Noise", noise_overlay_tiles.as_ref()).await?; - let satellite_reader = init_optional_tile_reader("Satellite", satellite_tiles.as_ref()).await?; + init_required_tile_reader("Noise", &noise_overlay_tiles).await?; + let satellite_reader = init_required_tile_reader("Satellite", &satellite_tiles).await?; + let satellite_highres_reader = + init_required_tile_reader("Satellite high-res", &satellite_highres_tiles).await?; let crime_hotspot_reader = - init_optional_tile_reader("Crime hotspots", crime_hotspot_tiles.as_ref()).await?; + init_required_tile_reader("Crime hotspots", &crime_hotspot_tiles).await?; let tree_overlay_reader = - init_optional_tile_reader("Trees outside woodland", tree_overlay_tiles.as_ref()).await?; + init_required_tile_reader("Trees outside woodland", &tree_overlay_tiles).await?; + let property_border_reader = + init_required_tile_reader("Property borders", &property_border_tiles).await?; let feature_name_to_index: rustc_hash::FxHashMap = property_data .feature_names @@ -700,9 +719,11 @@ async fn main() -> anyhow::Result<()> { let reader_tile = tile_reader.clone(); let reader_style = tile_reader.clone(); let reader_satellite = satellite_reader.clone(); + let reader_satellite_highres = satellite_highres_reader.clone(); let reader_noise_overlay = noise_overlay_reader.clone(); let reader_crime_hotspot = crime_hotspot_reader.clone(); let reader_tree_overlay = tree_overlay_reader.clone(); + let reader_property_border = property_border_reader.clone(); let public_url_tiles = initial_state.public_url.clone(); let api = Router::new() @@ -878,6 +899,18 @@ async fn main() -> anyhow::Result<()> { }) .layer(ConcurrencyLimitLayer::new(30)), ) + .route( + "/api/tiles/satellite-highres/{z}/{x}/{y}", + get(move |path| { + routes::get_overlay_tile( + reader_satellite_highres.clone(), + routes::OverlayTileFormat::RasterWebp, + "satellite-highres", + path, + ) + }) + .layer(ConcurrencyLimitLayer::new(30)), + ) .route( "/api/overlays/noise/{z}/{x}/{y}", get(move |path| { @@ -914,6 +947,18 @@ async fn main() -> anyhow::Result<()> { }) .layer(ConcurrencyLimitLayer::new(30)), ) + .route( + "/api/overlays/property-borders/{z}/{x}/{y}", + get(move |path| { + routes::get_overlay_tile( + reader_property_border.clone(), + routes::OverlayTileFormat::VectorMvtGzip, + "property-borders", + path, + ) + }) + .layer(ConcurrencyLimitLayer::new(30)), + ) .route("/health", get(|| async { "ok" })) .route( "/metrics", diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index d419b19..56dade1 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -283,7 +283,9 @@ async fn find_users_collection_id( Ok(id.to_string()) } -/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection. +/// Ensure custom fields (`is_admin`, `subscription`, `newsletter`, `ai_tokens_*`, +/// `can_see_listings`) exist on the `users` collection. Booleans default to false, +/// so new fields are off for everyone until a superuser write flips them. /// 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"); @@ -309,12 +311,14 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter"); let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used"); let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week"); + let has_can_see_listings = fields.iter().any(|f| f["name"] == "can_see_listings"); let has_all_required_fields = has_is_admin && has_subscription && has_newsletter && has_ai_tokens_used - && has_ai_tokens_week; + && has_ai_tokens_week + && has_can_see_listings; if has_all_required_fields { info!("PocketBase users collection already has all required fields"); @@ -358,6 +362,13 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any })); } + if !has_can_see_listings { + new_fields.push(serde_json::json!({ + "name": "can_see_listings", + "type": "bool", + })); + } + let patch_resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) @@ -388,13 +399,15 @@ async fn ensure_user_auth_rules( "@request.body.subscription:isset = false", " && @request.body.is_admin:isset = false", " && @request.body.ai_tokens_used:isset = false", - " && @request.body.ai_tokens_week:isset = false" + " && @request.body.ai_tokens_week:isset = false", + " && @request.body.can_see_listings:isset = false" ); let protected_fields_unchanged = concat!( "@request.body.subscription:changed = false", " && @request.body.is_admin:changed = false", " && @request.body.ai_tokens_used:changed = false", - " && @request.body.ai_tokens_week:changed = false" + " && @request.body.ai_tokens_week:changed = false", + " && @request.body.can_see_listings:changed = false" ); let update_rule = format!("{self_only} && {protected_fields_unchanged}"); diff --git a/server-rs/src/routes/actual_listings.rs b/server-rs/src/routes/actual_listings.rs index 8a75e27..9723cec 100644 --- a/server-rs/src/routes/actual_listings.rs +++ b/server-rs/src/routes/actual_listings.rs @@ -55,6 +55,16 @@ pub async fn get_actual_listings( ) -> Result, Response> { let state = shared.load_state(); let offset = params.offset.unwrap_or(0); + + // Gate the entire feature behind the per-user `can_see_listings` flag. The + // flag is off by default for everyone, so listings are invisible unless a + // superuser has explicitly granted access to this account. + if !user.0.as_ref().is_some_and(|u| u.can_see_listings) { + return Err( + ApiError::Forbidden("You do not have access to listings".to_string()).into_response(), + ); + } + let Some(actual_listings) = state.actual_listings.clone() else { return Ok(Json(ActualListingsResponse { listings: Vec::new(), diff --git a/server-rs/src/routes/ai_filters.rs b/server-rs/src/routes/ai_filters.rs index 88e2bbb..c8c3cc5 100644 --- a/server-rs/src/routes/ai_filters.rs +++ b/server-rs/src/routes/ai_filters.rs @@ -359,9 +359,9 @@ pub fn build_system_prompt( or \"max\" (at most this value). Never set two filters on the same feature.\n\ - Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\ - \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\ - - \"low crime\" / \"safe\" = low values on the Serious crime and Minor crime features. \ - Prefer the per-1k resident crime features for broad area safety; use specific crime \ - features only when the user names a crime type.\n\ + - \"low crime\" / \"safe\" = low values on the Serious crime (avg/yr) and Minor crime (avg/yr) \ + features (incidents counted within 50m of the postcode). Prefer these aggregates for broad \ + area safety; use specific crime features only when the user names a crime type.\n\ - \"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of amenities (Park) within 2km \ or low Distance to nearest park (km), depending on wording.\n\ - \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\ @@ -505,8 +505,8 @@ pub fn build_system_prompt( parts.push( "\nUser: \"safe quiet area with good schools and parks\"\n\ Output: {\"numeric_filters\": [\ - {\"name\": \"Serious crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \ - {\"name\": \"Minor crime per 1k residents (avg/yr)\", \"bound\": \"max\", \"value\": 50}, \ + {\"name\": \"Serious crime (avg/yr)\", \"bound\": \"max\", \"value\": 5}, \ + {\"name\": \"Minor crime (avg/yr)\", \"bound\": \"max\", \"value\": 20}, \ {\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \ {\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}, \ {\"name\": \"Good+ secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \ diff --git a/server-rs/src/routes/overlays.rs b/server-rs/src/routes/overlays.rs index 0e7a369..a4ad1e0 100644 --- a/server-rs/src/routes/overlays.rs +++ b/server-rs/src/routes/overlays.rs @@ -13,6 +13,7 @@ pub enum OverlayTileFormat { VectorMvtGzip, RasterPng, RasterJpeg, + RasterWebp, } impl OverlayTileFormat { @@ -21,6 +22,7 @@ impl OverlayTileFormat { Self::VectorMvtGzip => "application/x-protobuf", Self::RasterPng => "image/png", Self::RasterJpeg => "image/jpeg", + Self::RasterWebp => "image/webp", } } @@ -30,15 +32,11 @@ impl OverlayTileFormat { } pub async fn get_overlay_tile( - reader: Option>, + reader: Arc, format: OverlayTileFormat, overlay_name: &'static str, Path((zoom, col, row)): Path<(u8, u32, u32)>, ) -> Response { - let Some(reader) = reader else { - return StatusCode::NOT_FOUND.into_response(); - }; - let tile_coord = match TileCoord::new(zoom, col, row) { Ok(tile_coord) => tile_coord, Err(err) => { diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs index fef10fb..430517e 100644 --- a/server-rs/src/routes/places.rs +++ b/server-rs/src/routes/places.rs @@ -227,6 +227,7 @@ pub async fn get_places( .postcodes .iter() .filter(|postcode| postcode_starts_with_compact(postcode, &compact_query)) + .filter(|postcode| !property_data.rows_for_postcode(postcode).is_empty()) .take(limit) .cloned() .collect() diff --git a/server-rs/src/routes/properties.rs b/server-rs/src/routes/properties.rs index dfbb7a7..00de13d 100644 --- a/server-rs/src/routes/properties.rs +++ b/server-rs/src/routes/properties.rs @@ -123,7 +123,15 @@ fn insert_feature_value( return; } - let value = state.data.get_feature(row, feat_idx); + // `get_feature` decodes the lossy u16-quantized value, which turns round + // sale prices into noise (e.g. £428,000 → £427,984). For the last sale we + // keep an unquantized copy, so serve that instead to match the exact + // `historical_prices` entries and the price-history chart. + let value = if feature_names[feat_idx] == "Last known price" { + state.data.last_known_price_raw(row) + } else { + state.data.get_feature(row, feat_idx) + }; if value.is_finite() { features.insert(feature_names[feat_idx].clone(), value); } diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs index 12d3850..cd9eb75 100644 --- a/server-rs/src/routes/stats.rs +++ b/server-rs/src/routes/stats.rs @@ -257,10 +257,11 @@ pub fn compute_feature_stats( /// Compute property-weighted per-year crime means across the selection. /// -/// Each matching property contributes its LSOA's per-year counts; this is the -/// same property-weighted-LSOA-average shape used elsewhere in the right pane. -/// LSOAs with no series for a given crime type contribute 0 for that type -/// (matching how the existing `(avg/yr)` columns treat missing crime types). +/// Each matching property contributes its postcode's per-year counts (incidents +/// within 50m of that postcode); this is the same property-weighted-average +/// shape used elsewhere in the right pane. Postcodes with no series for a given +/// crime type contribute 0 for that type (matching how the `(avg/yr)` columns +/// treat missing crime types). pub fn compute_crime_by_year( matching_rows: &[usize], data: &PropertyData, @@ -273,19 +274,19 @@ pub fn compute_crime_by_year( } // For each crime type, accumulate per-year sums and the count of rows whose - // LSOA exists in the crime side table. + // postcode exists in the crime side table. let num_types = crime_by_year.crime_types.len(); let mut per_type_year_sums: Vec> = (0..num_types).map(|_| FxHashMap::default()).collect(); let mut per_type_row_counts: Vec = vec![0; num_types]; for &row in matching_rows { - let lsoa = data.lsoa(row); - let Some(series_list) = crime_by_year.series_by_lsoa.get(lsoa) else { + let postcode = data.postcode(row); + let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) else { continue; }; - // For every type the LSOA reports, add its per-year counts. + // For every type the postcode reports, add its per-year counts. // For types it doesn't report, treat the row as contributing 0 — so we // bump the row count for *every* known type below. for series in series_list {