//! `GET /api/crime-records` — the individual police.uk crimes (last 7 years) //! behind a selected hexagon or postcode, paginated. Display-only and //! independent of the property filters, like the population figure: the records //! are an attribute of the area, not of the filter-matching subset. use std::str::FromStr; use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json}; use axum::Extension; use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::auth::OptionalUser; use crate::licensing::{check_license_bounds, check_license_point, resolve_share_code}; use crate::parsing::{cell_for_row_cached, h3_cell_bounds, needs_parent, validate_h3_resolution}; use crate::state::SharedState; use crate::utils::normalize_postcode; /// Default and hard-cap page sizes for the records list. const DEFAULT_LIMIT: usize = 200; const MAX_LIMIT: usize = 500; #[derive(Deserialize)] pub struct CrimeRecordsParams { /// Hexagon selection: H3 cell + resolution. Mutually exclusive with `postcode`. pub h3: Option, pub resolution: Option, /// Postcode selection. pub postcode: Option, pub offset: Option, pub limit: Option, /// Lower bound on `month_index` (`year*12 + month0`) to restrict to a recent /// window; omitted = all stored records (last 7 years). pub since: Option, /// Share-link code; grants scoped access for unlicensed users. pub share: Option, } #[derive(Serialize)] pub struct CrimeRecord { /// `"YYYY-MM"`. pub month: String, #[serde(rename = "type")] pub crime_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, #[serde(skip_serializing_if = "Option::is_none")] pub outcome: Option, pub lat: f32, pub lon: f32, } #[derive(Serialize)] pub struct CrimeRecordsResponse { pub records: Vec, pub total: usize, pub offset: usize, pub truncated: bool, } fn format_month(month_index: u32) -> String { let year = month_index / 12; let month = month_index % 12 + 1; format!("{year:04}-{month:02}") } pub async fn get_crime_records( State(shared): State>, Extension(user): Extension, Extension(geo): Extension, Query(params): Query, ) -> Result, axum::response::Response> { let state = shared.load_state(); let share_bounds = resolve_share_code(&state, params.share.as_deref()).await; let offset = params.offset.unwrap_or(0); let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); let since = params.since; // Resolve the selection to a set of postcodes, after a license check scoped // to the selection's geometry (bounds for a hexagon, point for a postcode). enum Selection { Hexagon { cell: u64, resolution: u8 }, Postcode(String), } let selection = if let Some(h3) = params.h3.clone() { let cell = h3o::CellIndex::from_str(&h3).map_err(|error| { warn!(h3 = %h3, error = %error, "Invalid H3 cell index"); (StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {error}")).into_response() })?; let resolution = params.resolution.ok_or_else(|| { (StatusCode::BAD_REQUEST, "resolution is required with h3").into_response() })?; validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?; let bounds = h3_cell_bounds(cell, 0.0); check_license_bounds(&user.0, bounds, geo.free_zone, share_bounds)?; Selection::Hexagon { cell: cell.into(), resolution, } } else if let Some(postcode) = params.postcode.clone() { let normalized = normalize_postcode(&postcode); let &pc_idx = state .postcode_data .postcode_to_idx .get(&normalized) .ok_or_else(|| { (StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response() })?; let (lat, lon) = state.postcode_data.centroids[pc_idx]; check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?; Selection::Postcode(normalized) } else { return Err((StatusCode::BAD_REQUEST, "h3 or postcode is required").into_response()); }; let result = tokio::task::spawn_blocking(move || -> Result { // Distinct postcodes covered by the selection. let postcodes: Vec = match selection { Selection::Postcode(pc) => vec![pc], Selection::Hexagon { cell, resolution } => { let h3_res = h3o::Resolution::try_from(resolution) .map_err(|err| format!("Invalid H3 resolution {resolution}: {err}"))?; let need_parent = needs_parent(resolution); let h3o_cell = h3o::CellIndex::try_from(cell) .map_err(|err| format!("Invalid H3 cell: {err}"))?; let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(h3o_cell, 0.001); let mut h3_cache: FxHashMap = FxHashMap::default(); let mut seen: FxHashSet<&str> = FxHashSet::default(); let mut out: 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 cell_for_row_cached( row, &state.h3_cells, h3_res, need_parent, &mut h3_cache, ) == cell { let pc = state.data.postcode(row); if seen.insert(pc) { out.push(pc.to_string()); } } }, ); out } }; let pc_refs: Vec<&str> = postcodes.iter().map(String::as_str).collect(); let indices = state.crime_records.gather(&pc_refs, since); let total = indices.len(); let records: Vec = indices .iter() .skip(offset) .take(limit) .map(|&idx| { let v = state.crime_records.view(idx); CrimeRecord { month: format_month(v.month_index), crime_type: v.crime_type.to_string(), location: v.location.map(str::to_string), outcome: v.outcome.map(str::to_string), lat: v.lat, lon: v.lon, } }) .collect(); let truncated = offset + records.len() < total; Ok(CrimeRecordsResponse { records, total, offset, truncated, }) }) .await .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())? .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?; info!(total = result.total, returned = result.records.len(), "GET /api/crime-records"); Ok(Json(result)) }