use std::sync::Arc; use axum::extract::{Query, State}; use axum::response::{IntoResponse, Json, Response}; use axum::Extension; use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; use tracing::info; use crate::api_error::ApiError; use crate::auth::OptionalUser; use crate::consts::NAN_U16; use crate::data::ActualListing; use crate::licensing::{check_license_bounds, resolve_share_code}; use crate::parsing::{ parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter, }; use crate::state::{AppState, SharedState}; use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters}; #[derive(Deserialize)] pub struct ActualListingsParams { bounds: Option, /// `;;`-separated filters: `name:min:max;;...` filters: Option, /// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max` travel: Option, /// Number of results to skip. Defaults to 0. offset: Option, /// Share-link code; grants bbox-scoped access for unlicensed users. share: Option, } #[derive(Serialize)] pub struct ActualListingsResponse { pub listings: Vec, pub total: usize, pub offset: usize, pub truncated: bool, } const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &[ "Total floor area (sqm)", "Leasehold/Freehold", "Number of bedrooms & living rooms", "Property type", ]; const LISTING_BOUNDS_EPSILON_DEGREES: f64 = 0.00001; pub async fn get_actual_listings( State(shared): State>, Extension(user): Extension, Query(params): Query, ) -> 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 actual_listings = state.actual_listings.clone(); let (south, west, north, east) = require_bounds(params.bounds).map_err(IntoResponse::into_response)?; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; check_license_bounds(&user.0, (south, west, north, east), share_bounds)?; let quant = state.data.quant_ref(); let poi_quant = state.data.poi_metrics.quant_ref(); let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi( params.filters.as_deref(), &state.feature_name_to_index, &state.data.enum_values, &quant, &state.data.poi_metrics.name_to_index, &poi_quant, ) .map_err(|err| ApiError::BadRequest(err).into_response())?; let travel_entries = parse_optional_travel(params.travel.as_deref()) .map_err(|err| ApiError::BadRequest(err).into_response())?; let keep_unknown_listing_filter_idxs = keep_unknown_listing_filter_feature_idxs(&state); let listing_filters = parsed_filters; let listing_enum_filters = parsed_enum_filters; let has_listing_filters = !listing_filters.is_empty() || !listing_enum_filters.is_empty(); let state_clone = state.clone(); let response = tokio::task::spawn_blocking(move || -> Result { let t0 = std::time::Instant::now(); let has_poi_filters = !parsed_poi_filters.is_empty(); let has_travel_filters = !travel_entries.is_empty(); let poi_num_features = state_clone.data.poi_metrics.num_features(); let travel_data = if has_travel_filters { load_travel_data(&state_clone.travel_time_store, &travel_entries)? } else { Vec::new() }; let row_indices = actual_listings.grid.query(south, west, north, east); let total_grid_candidates = row_indices.len(); let candidate_rows: Vec = row_indices .iter() .filter_map(|&row_idx| { let row = row_idx as usize; row_is_within_bounds( actual_listings.lat[row], actual_listings.lon[row], south, west, north, east, ) .then_some(row) }) .collect(); let total_in_bounds = candidate_rows.len(); // Build (row, sort_key) pairs so we can sort by index without // materializing the full ActualListing for every matching row. let mut matching_rows: Vec = candidate_rows .into_iter() .filter(|&row| { if has_listing_filters && !row_passes_listing_filters( row, &listing_filters, &listing_enum_filters, &actual_listings.filter_feature_data, state_clone.data.num_features, &keep_unknown_listing_filter_idxs, ) { return false; } if has_poi_filters && !row_passes_listing_poi_filters( row, &parsed_poi_filters, &actual_listings.poi_filter_feature_data, poi_num_features, ) { return false; } if has_travel_filters && !row_passes_travel_filters( actual_listings.postcode[row].as_str(), &travel_entries, &travel_data, ) { return false; } true }) .collect(); let total_matching = matching_rows.len(); matching_rows.sort_by(|&left, &right| { actual_listings.listing_date_iso[right] .cmp(&actual_listings.listing_date_iso[left]) .then_with(|| { actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left]) }) }); let listings: Vec = matching_rows .iter() .skip(offset) .map(|&row| actual_listings.listing_at(row)) .collect(); let elapsed = t0.elapsed(); info!( results = listings.len(), total = total_matching, total_in_bounds, total_grid_candidates, offset, listing_filtered = has_listing_filters, poi_filtered = has_poi_filters, travel_filtered = has_travel_filters, ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/actual-listings" ); Ok(ActualListingsResponse { listings, total: total_matching, offset, truncated: false, }) }) .await .map_err(|error| ApiError::Internal(error.to_string()).into_response())? .map_err(|err| ApiError::Internal(err).into_response())?; Ok(Json(response)) } fn keep_unknown_listing_filter_feature_idxs(state: &AppState) -> FxHashSet { feature_idxs(state, KEEP_UNKNOWN_LISTING_FILTER_FEATURES) } fn feature_idxs(state: &AppState, names: &[&str]) -> FxHashSet { names .iter() .filter_map(|name| state.feature_name_to_index.get(*name).copied()) .collect() } fn row_passes_listing_filters( row: usize, filters: &[ParsedFilter], enum_filters: &[ParsedEnumFilter], feature_data: &[u16], num_features: usize, keep_unknown_filter_idxs: &FxHashSet, ) -> bool { let base = row * num_features; filters.iter().all(|filter| { let raw = feature_data[base + filter.feat_idx]; if raw == NAN_U16 { keep_unknown_filter_idxs.contains(&filter.feat_idx) } else { raw >= filter.min_u16 && raw <= filter.max_u16 } }) && enum_filters.iter().all(|filter| { let raw = feature_data[base + filter.feat_idx]; if raw == NAN_U16 { keep_unknown_filter_idxs.contains(&filter.feat_idx) } else { filter.allowed.contains(&raw) } }) } fn row_is_within_bounds(lat: f32, lon: f32, south: f64, west: f64, north: f64, east: f64) -> bool { let lat = lat as f64; let lon = lon as f64; lat >= south - LISTING_BOUNDS_EPSILON_DEGREES && lat <= north + LISTING_BOUNDS_EPSILON_DEGREES && lon >= west - LISTING_BOUNDS_EPSILON_DEGREES && lon <= east + LISTING_BOUNDS_EPSILON_DEGREES } fn row_passes_listing_poi_filters( row: usize, filters: &[ParsedPoiFilter], feature_data: &[u16], num_features: usize, ) -> bool { if filters.is_empty() { return true; } if num_features == 0 || feature_data.is_empty() { return false; } let base = row * num_features; filters.iter().all(|filter| { let raw = feature_data .get(base + filter.metric_idx) .copied() .unwrap_or(NAN_U16); raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16 }) } #[cfg(test)] mod tests { use super::*; #[test] fn listing_bounds_check_keeps_only_exact_viewport_rows() { assert!(row_is_within_bounds(51.5, -0.1, 51.4, -0.2, 51.6, 0.0)); // Bounds are inclusive so edge points are retained. assert!(row_is_within_bounds(51.4, -0.2, 51.4, -0.2, 51.6, 0.0)); assert!(row_is_within_bounds(51.6, 0.0, 51.4, -0.2, 51.6, 0.0)); assert!(!row_is_within_bounds(51.399, -0.1, 51.4, -0.2, 51.6, 0.0)); assert!(!row_is_within_bounds(51.601, -0.1, 51.4, -0.2, 51.6, 0.0)); assert!(!row_is_within_bounds(51.5, -0.201, 51.4, -0.2, 51.6, 0.0)); assert!(!row_is_within_bounds(51.5, 0.001, 51.4, -0.2, 51.6, 0.0)); } #[test] fn listing_floor_area_filter_keeps_unknown_values() { let floor_area_filter = ParsedFilter { feat_idx: 0, min_u16: 10, max_u16: 20, }; let keep_unknown_filter_idxs: FxHashSet = [0usize].into_iter().collect(); assert!(row_passes_listing_filters( 0, &[floor_area_filter], &[], &[NAN_U16], 1, &keep_unknown_filter_idxs )); assert!(!row_passes_listing_filters( 0, &[ParsedFilter { feat_idx: 0, min_u16: 10, max_u16: 20, }], &[], &[9], 1, &keep_unknown_filter_idxs )); assert!(row_passes_listing_filters( 0, &[ParsedFilter { feat_idx: 0, min_u16: 10, max_u16: 20, }], &[], &[15], 1, &keep_unknown_filter_idxs )); } #[test] fn listing_enum_filter_keeps_allowlisted_unknown_values() { let enum_filter = ParsedEnumFilter { feat_idx: 0, allowed: [1u16].into_iter().collect(), }; let keep_unknown_filter_idxs: FxHashSet = [0usize].into_iter().collect(); assert!(row_passes_listing_filters( 0, &[], &[enum_filter], &[NAN_U16], 1, &keep_unknown_filter_idxs )); assert!(!row_passes_listing_filters( 0, &[], &[ParsedEnumFilter { feat_idx: 0, allowed: [1u16].into_iter().collect(), }], &[2], 1, &keep_unknown_filter_idxs )); assert!(row_passes_listing_filters( 0, &[], &[ParsedEnumFilter { feat_idx: 0, allowed: [1u16].into_iter().collect(), }], &[1], 1, &keep_unknown_filter_idxs )); } #[test] fn listing_poi_filter_uses_listing_metric_matrix() { let filter = ParsedPoiFilter { metric_idx: 1, min_u16: 10, max_u16: 20, }; assert!(row_passes_listing_poi_filters( 0, &[filter], &[NAN_U16, 15], 2 )); assert!(!row_passes_listing_poi_filters( 0, &[ParsedPoiFilter { metric_idx: 1, min_u16: 10, max_u16: 20, }], &[NAN_U16, NAN_U16], 2 )); } }