use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; use axum::Extension; use serde::Deserialize; use tracing::{info, warn}; use crate::auth::OptionalUser; use crate::consts::{DEFAULT_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET}; use crate::licensing::{check_license_point, resolve_share_code}; use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters}; use crate::state::SharedState; use crate::utils::normalize_postcode; use super::properties::{HexagonPropertiesResponse, Property}; use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters}; #[derive(Deserialize)] pub struct PostcodePropertiesParams { pub postcode: String, pub filters: Option, /// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`. /// Optional min:max applies as a filter (exclude properties outside range). pub travel: Option, pub limit: Option, pub offset: Option, /// Exact address to rank first when opening properties from address search. pub focus_address: Option, /// Share-link code; grants bbox-scoped access for unlicensed users. pub share: Option, } pub async fn get_postcode_properties( State(shared): State>, Extension(user): Extension, Query(params): Query, ) -> Result, axum::response::Response> { let state = shared.load_state(); let normalized = normalize_postcode(¶ms.postcode); let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) { Some(&idx) => idx, None => { warn!(postcode = %normalized, "Postcode not found"); return Err(( StatusCode::NOT_FOUND, format!("Postcode not found: {}", normalized), ) .into_response()); } }; let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx]; let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; check_license_point( &user.0, centroid_lat as f64, centroid_lon as f64, 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| (StatusCode::BAD_REQUEST, err).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len() + parsed_poi_filters.len(); let filters_str = params.filters; let has_poi_filters = !parsed_poi_filters.is_empty(); let travel_entries = parse_optional_travel(params.travel.as_deref()) .map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?; let postcode_str = normalized; let focus_address = params .focus_address .as_deref() .map(str::trim) .filter(|address| !address.is_empty()) .map(str::to_ascii_lowercase); let result = tokio::task::spawn_blocking(move || { let t0 = std::time::Instant::now(); let num_features = state.data.num_features; let feature_data = &state.data.feature_data; let feature_names = &state.data.feature_names; let feature_name_to_index = &state.feature_name_to_index; let enum_values = &state.data.enum_values; let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?; let has_travel = !travel_entries.is_empty(); let offset_deg: f64 = POSTCODE_SEARCH_OFFSET; let min_lat = centroid_lat as f64 - offset_deg; let max_lat = centroid_lat as f64 + offset_deg; let min_lon = centroid_lon as f64 - offset_deg; let max_lon = centroid_lon as f64 + offset_deg; let mut matching_rows: Vec = Vec::new(); state .grid .for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| { let row = row_idx as usize; if state.data.postcode(row) == postcode_str && row_passes_filters( row, &parsed_filters, &parsed_enum_filters, feature_data, num_features, ) && (!has_poi_filters || row_passes_poi_filters( row, &parsed_poi_filters, &state.data.poi_metrics, )) { if has_travel && !row_passes_travel_filters( state.data.postcode(row), &travel_entries, &travel_data, ) { return; } matching_rows.push(row); } }); matching_rows.sort_unstable_by(|&left, &right| { let left_address = state.data.address(left).trim(); let right_address = state.data.address(right).trim(); let left_focused = focus_address .as_ref() .is_some_and(|address| left_address.eq_ignore_ascii_case(address)); let right_focused = focus_address .as_ref() .is_some_and(|address| right_address.eq_ignore_ascii_case(address)); right_focused .cmp(&left_focused) .then(left_address.is_empty().cmp(&right_address.is_empty())) }); let total = matching_rows.len(); let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT); let page_offset = params.offset.unwrap_or(0); let truncated = total > page_offset + limit; let properties: Vec = matching_rows .iter() .skip(page_offset) .take(limit) .map(|&row| { super::properties::build_property( row, &state, feature_names, feature_name_to_index, enum_values, ) }) .collect(); let elapsed = t0.elapsed(); info!( postcode = %postcode_str, total, returned = properties.len(), offset = page_offset, filters = num_filters, filters_raw = filters_str.as_deref().unwrap_or("-"), travel_entries = travel_entries.len(), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/postcode-properties" ); Ok(HexagonPropertiesResponse { properties, total, limit, offset: page_offset, truncated, }) }) .await .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)) }