perfect-postcode/server-rs/src/routes/postcode_stats.rs

146 lines
4.8 KiB
Rust

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 super::hexagon_stats::HexagonStatsResponse;
use super::stats;
#[derive(Deserialize)]
pub struct PostcodeStatsParams {
pub postcode: String,
pub filters: Option<String>,
/// 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<String>,
}
pub async fn get_postcode_stats(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
// Normalize postcode: uppercase, collapse whitespace
let normalized = params
.postcode
.to_uppercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
// 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<usize> = 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))
}