use std::sync::Arc; use axum::extract::Query; 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::POSTCODE_SEARCH_OFFSET; use crate::licensing::check_license_point; use crate::parsing::{parse_field_set, parse_filters, row_passes_filters}; use crate::state::AppState; use crate::utils::normalize_postcode; use super::hexagon_stats::HexagonStatsResponse; use super::stats; #[derive(Deserialize)] pub struct PostcodeStatsParams { pub postcode: String, pub filters: Option, /// Comma-separated feature names to include in stats response. /// Only listed features are computed; if absent or empty, no features are returned. pub fields: Option, } pub async fn get_postcode_stats( state: Arc, Extension(user): Extension, Query(params): Query, ) -> Result, axum::response::Response> { let normalized = normalize_postcode(¶ms.postcode); // Look up postcode centroid for spatial search 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]; // 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).into_response())?; let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let (fields_specified, field_set) = parse_field_set(params.fields.as_deref()); let postcode_str = normalized.clone(); let response = tokio::task::spawn_blocking(move || { let start_time = std::time::Instant::now(); let num_features = state.data.num_features; let feature_data = &state.data.feature_data; // Search around centroid (generous for a postcode) let offset: f64 = POSTCODE_SEARCH_OFFSET; let min_lat = centroid_lat as f64 - offset; let max_lat = centroid_lat as f64 + offset; let min_lon = centroid_lon as f64 - offset; let max_lon = centroid_lon as f64 + offset; 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; let row_postcode = state.data.postcode(row); if row_postcode == postcode_str && row_passes_filters( row, &parsed_filters, &parsed_enum_filters, feature_data, num_features, ) { matching_rows.push(row); } }); let total_count = matching_rows.len(); let price_history = stats::extract_price_history( &matching_rows, feature_data, num_features, &state.feature_name_to_index, ); let (numeric_features, enum_features_out) = stats::compute_feature_stats( &matching_rows, feature_data, &state.data.feature_names, num_features, &state.data.enum_values, &state.data.feature_stats, fields_specified, &field_set, ); let elapsed = start_time.elapsed(); info!( postcode = %postcode_str, total_count, filters = num_filters, filters_raw = filters_str.as_deref().unwrap_or("-"), ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), "GET /api/postcode-stats" ); Ok(HexagonStatsResponse { count: total_count, numeric_features, enum_features: enum_features_out, price_history, central_postcode: None, }) }) .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(response)) }