This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -125,7 +125,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
let slug_set = match tt_store.destinations.get(mode) {
Some(slugs) => slugs,
None => return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) }),
None => {
return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) })
}
};
// Find places matching the query that have travel time data.
@ -154,7 +156,11 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
matches.truncate(10);
if matches.is_empty() {
info!(query = query, mode = mode, "Destination search returned no results");
info!(
query = query,
mode = mode,
"Destination search returned no results"
);
return json!({
"results": [],
"message": format!("No travel time data available for '{}' by {}. This destination cannot be used as a travel time filter.", query, mode)
@ -597,10 +603,7 @@ pub async fn post_ai_filters(
.and_then(|c| c.get("content"))
.ok_or_else(|| {
warn!("Malformed Gemini response: missing candidates[0].content");
(
StatusCode::BAD_GATEWAY,
"Malformed Gemini response".into(),
)
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
})?;
let parts = candidate
@ -608,10 +611,7 @@ pub async fn post_ai_filters(
.and_then(|p| p.as_array())
.ok_or_else(|| {
warn!("Malformed Gemini response: missing parts array");
(
StatusCode::BAD_GATEWAY,
"Malformed Gemini response".into(),
)
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
})?;
// Check if the model made a function call.
@ -621,17 +621,10 @@ pub async fn post_ai_filters(
let fn_name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("");
let fn_args = fc.get("args").cloned().unwrap_or(json!({}));
info!(
function = fn_name,
round = round,
"AI called tool"
);
info!(function = fn_name, round = round, "AI called tool");
let fn_result = if fn_name == "search_destinations" {
let query = fn_args
.get("query")
.and_then(|q| q.as_str())
.unwrap_or("");
let query = fn_args.get("query").and_then(|q| q.as_str()).unwrap_or("");
let mode = fn_args
.get("mode")
.and_then(|m| m.as_str())
@ -710,7 +703,10 @@ pub async fn post_ai_filters(
}
// Exhausted tool rounds without getting a final text response
warn!("AI exhausted {} tool-calling rounds without final response", MAX_TOOL_ROUNDS);
warn!(
"AI exhausted {} tool-calling rounds without final response",
MAX_TOOL_ROUNDS
);
Err((
StatusCode::BAD_GATEWAY,
"AI could not complete the request".into(),
@ -746,7 +742,11 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
// Verify this destination actually exists
if !tt_store.has_destination(mode, slug) {
warn!(mode = mode, slug = slug, "AI suggested non-existent destination");
warn!(
mode = mode,
slug = slug,
"AI suggested non-existent destination"
);
continue;
}

View file

@ -81,7 +81,10 @@ pub async fn post_checkout(
// If a referral code is provided and valid, look it up and apply the coupon
if let Some(ref code) = req.referral_code {
if validate_referral_invite(&state, code).await {
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
form_params.push((
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
));
info!(code = %code, "Applying referral coupon to checkout");
}
}
@ -127,7 +130,13 @@ pub async fn post_checkout(
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
@ -151,10 +160,7 @@ async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
/// Check if a referral invite code exists and is unused.
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
// Only allow alphanumeric codes to prevent PocketBase filter injection
if code.is_empty()
|| code.len() > 20
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
{
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return false;
}

View file

@ -12,6 +12,8 @@ use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::QuantRef;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
@ -50,18 +52,20 @@ impl PostcodeExportAgg {
#[inline]
fn add_row(
&mut self,
feature_data: &[f32],
feature_data: &[u16],
row: usize,
num_features: usize,
enum_indices: &FxHashMap<usize, ()>,
quant: &QuantRef,
) {
self.count += 1;
let base = row * num_features;
let row_slice = &feature_data[base..base + num_features];
for (feat_idx, &value) in row_slice.iter().enumerate() {
if !value.is_finite() {
for (feat_idx, &raw) in row_slice.iter().enumerate() {
if raw == NAN_U16 {
continue;
}
let value = quant.decode(feat_idx, raw);
if enum_indices.contains_key(&feat_idx) {
*self
.enum_freqs
@ -131,10 +135,12 @@ pub async fn get_export(
check_license_bounds(&user.0, (south, west, north, east))?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let filters_str = params.filters;
@ -188,6 +194,7 @@ pub async fn get_export(
let t0 = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let quant = state.data.quant_ref();
let feature_names = &state.data.feature_names;
let enum_values = &state.data.enum_values;
let postcode_data = &state.postcode_data;
@ -222,7 +229,7 @@ pub async fn get_export(
for (pc_idx, rows) in postcode_rows {
let mut agg = PostcodeExportAgg::new(num_features);
for &row in &rows {
agg.add_row(feature_data, row, num_features, &enum_indices);
agg.add_row(feature_data, row, num_features, &enum_indices, &quant);
}
if agg.count > 0 {
postcode_aggs.push((pc_idx, agg));
@ -470,11 +477,9 @@ pub async fn get_export(
let mode_idx = mode_f32 as usize;
if let Some(values) = enum_values.get(&feat_idx) {
if mode_idx < values.len() {
sheet
.write_string(row, col, &values[mode_idx])
.map_err(|e| {
format!("Failed to write enum value: {e}")
})?;
sheet.write_string(row, col, &values[mode_idx]).map_err(
|e| format!("Failed to write enum value: {e}"),
)?;
}
}
}
@ -486,13 +491,11 @@ pub async fn get_export(
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)
.map_err(|e| {
format!("Failed to write numeric value: {e}")
})?;
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
} else {
sheet.write_number(row, col, mean).map_err(|e| {
format!("Failed to write numeric value: {e}")
})?;
sheet
.write_number(row, col, mean)
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
}
}
}

View file

@ -100,10 +100,12 @@ pub async fn get_hexagon_stats(
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -114,10 +116,7 @@ pub async fn get_hexagon_stats(
// Load travel time data for central_postcode selection (if requested)
let journey_travel_data = match (&params.journey_mode, &params.journey_slug) {
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
state
.travel_time_store
.get(mode, slug)
.ok()
state.travel_time_store.get(mode, slug).ok()
}
_ => None,
};
@ -209,18 +208,13 @@ pub async fn get_hexagon_stats(
None
};
let price_history = stats::extract_price_history(
&matching_rows,
feature_data,
num_features,
&state.feature_name_to_index,
);
let price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
let (numeric_features, enum_features_out) = stats::compute_feature_stats(
&matching_rows,
feature_data,
&state.data,
&state.data.feature_names,
num_features,
&state.data.enum_values,
&state.data.feature_stats,
fields_specified,

View file

@ -40,7 +40,6 @@ pub struct HexagonParams {
travel: Option<String>,
}
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
#[allow(clippy::too_many_arguments)]
fn build_feature_maps(
@ -144,10 +143,12 @@ pub async fn get_hexagons(
check_license_bounds(&user.0, (south, west, north, east))?;
}
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -185,6 +186,7 @@ pub async fn get_hexagons(
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let quant = state.data.quant_ref();
let (pc_interner, pc_keys) = state.data.postcode_parts();
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
@ -196,8 +198,9 @@ pub async fn get_hexagons(
let need_parent = needs_parent(resolution);
let mut groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> =
(0..travel_entries.len()).map(|_| FxHashMap::default()).collect();
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> = (0..travel_entries.len())
.map(|_| FxHashMap::default())
.collect();
// Main aggregation loop
let aggregate_row =
@ -246,9 +249,15 @@ pub async fn get_hexagons(
.entry(cell_id)
.or_insert_with(|| Aggregator::new(num_features));
if let Some(sel_indices) = field_indices.as_deref() {
aggregation.add_row_selective(feature_data, row, num_features, sel_indices);
aggregation.add_row_selective(
feature_data,
row,
num_features,
sel_indices,
&quant,
);
} else {
aggregation.add_row(feature_data, row, num_features);
aggregation.add_row(feature_data, row, num_features, &quant);
}
// Aggregate travel time

View file

@ -107,13 +107,23 @@ pub async fn post_invites(
} else if user.subscription == "licensed" {
"referral"
} else {
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
return (
StatusCode::FORBIDDEN,
"Only licensed users can create invites",
)
.into_response();
};
let code = generate_invite_code();
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
@ -190,7 +200,13 @@ pub async fn get_invite(
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
@ -205,9 +221,12 @@ pub async fn get_invite(
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&url)
let res = match state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send().await
.send()
.await
{
Ok(r) => r,
Err(err) => {
@ -235,8 +254,7 @@ pub async fn get_invite(
// Look up inviter's name (email local part)
let invited_by = if !created_by.is_empty() {
let user_url =
format!("{pb_url}/api/collections/users/records/{created_by}");
let user_url = format!("{pb_url}/api/collections/users/records/{created_by}");
match state
.http_client
.get(&user_url)
@ -245,8 +263,7 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value =
resp.json().await.unwrap_or_default();
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
@ -305,7 +322,13 @@ pub async fn post_redeem_invite(
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
@ -315,18 +338,18 @@ pub async fn post_redeem_invite(
};
// Look up invite
let filter = format!(
"code=\"{}\" && used_by_id=\"\"",
req.code
);
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&lookup_url)
let res = match state
.http_client
.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send().await
.send()
.await
{
Ok(r) => r,
Err(err) => {
@ -428,7 +451,10 @@ pub async fn post_redeem_invite(
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
(
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
),
];
let stripe_res = state
@ -442,10 +468,7 @@ pub async fn post_redeem_invite(
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
let checkout_url = stripe_body["url"]
.as_str()
.unwrap_or_default()
.to_string();
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
@ -494,9 +517,7 @@ pub async fn get_invites(
format!("created_by=\"{}\"", user.id)
};
let mut url = format!(
"{pb_url}/api/collections/invites/records?sort=-created&perPage=200"
);
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
if !filter.is_empty() {
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
}

View file

@ -27,7 +27,13 @@ pub async fn patch_newsletter(
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {

View file

@ -85,7 +85,7 @@ pub async fn get_pois(
let pois: Vec<POI> = matching_rows
.iter()
.map(|&row| POI {
id: state.poi_data.id[row].clone(),
id: state.poi_data.id(row).to_string(),
name: state.poi_data.name[row].clone(),
category: state.poi_data.category.get(row).to_string(),
group: state.poi_data.group.get(row).to_string(),

View file

@ -46,10 +46,12 @@ pub async fn get_postcode_properties(
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -105,8 +107,11 @@ pub async fn get_postcode_properties(
.take(limit)
.map(|&row| {
super::properties::build_property(
row, &state, feature_names, feature_name_to_index, feature_data,
num_features, enum_values,
row,
&state,
feature_names,
feature_name_to_index,
enum_values,
)
})
.collect();

View file

@ -50,10 +50,12 @@ pub async fn get_postcode_stats(
// License check using postcode centroid
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -96,18 +98,13 @@ pub async fn get_postcode_stats(
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 price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
let (numeric_features, enum_features_out) = stats::compute_feature_stats(
&matching_rows,
feature_data,
&state.data,
&state.data.feature_names,
num_features,
&state.data.enum_values,
&state.data.feature_stats,
fields_specified,

View file

@ -76,10 +76,12 @@ pub async fn get_postcodes(
check_license_bounds(&user.0, (south, west, north, east))?;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -118,6 +120,7 @@ pub async fn get_postcodes(
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let quant = state.data.quant_ref();
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
let avg_keys = &state.avg_keys;
@ -185,18 +188,20 @@ pub async fn get_postcodes(
.or_insert_with(|| Aggregator::new(num_features));
for &row in rows {
if has_selective {
agg.add_row_selective(feature_data, row, num_features, sel_indices);
agg.add_row_selective(feature_data, row, num_features, sel_indices, &quant);
} else {
agg.add_row(feature_data, row, num_features);
agg.add_row(feature_data, row, num_features, &quant);
}
}
// Aggregate travel times for this postcode
if has_travel {
let postcode = &postcode_data.postcodes[pc_idx];
let tt_aggs = travel_aggs
.entry(pc_idx)
.or_insert_with(|| (0..travel_entries.len()).map(|_| TravelTimeAgg::new()).collect());
let tt_aggs = travel_aggs.entry(pc_idx).or_insert_with(|| {
(0..travel_entries.len())
.map(|_| TravelTimeAgg::new())
.collect()
});
for (ti, entry) in travel_entries.iter().enumerate() {
if let Some(row_data) = travel_data[ti].get(postcode.as_str()) {
let minutes = if entry.use_best {

View file

@ -12,9 +12,9 @@ use crate::state::AppState;
/// Pricing tiers: (cumulative user cap, price in pence).
const TIERS: &[(u64, u64)] = &[
(1, 0), // First 10 users: free
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50
(20, 1000), // Next 10: £10
(45, 2500), // Next 25: £25
(95, 5000), // Next 50: £50
];
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
@ -45,7 +45,13 @@ pub fn price_for_count(count: u64) -> u64 {
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let filter = "subscription=\"licensed\"";
let url = format!(

View file

@ -11,12 +11,12 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
use crate::data::RenovationEvent;
use crate::licensing::check_license_bounds;
use crate::parsing::{
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
validate_h3_resolution,
};
use crate::data::RenovationEvent;
use crate::state::AppState;
#[derive(Deserialize)]
@ -78,18 +78,17 @@ fn non_empty_string(text: &str) -> Option<String> {
}
/// Look up an enum feature value by column name.
/// Uses the unified feature model: enum values stored as f32 indices in feature_data.
/// Uses the unified feature model: enum values stored as u16 indices in feature_data.
fn lookup_enum_value(
feature_name_to_index: &FxHashMap<String, usize>,
feature_data: &[f32],
num_features: usize,
data: &crate::data::PropertyData,
enum_values: &FxHashMap<usize, Vec<String>>,
row: usize,
name: &str,
) -> Option<String> {
let &feat_idx = feature_name_to_index.get(name)?;
let values = enum_values.get(&feat_idx)?;
let value = feature_data[row * num_features + feat_idx];
let value = data.get_feature(row, feat_idx);
if value.is_finite() {
let idx = value as usize;
values.get(idx).cloned()
@ -103,17 +102,14 @@ pub fn build_property(
state: &AppState,
feature_names: &[String],
feature_name_to_index: &FxHashMap<String, usize>,
feature_data: &[f32],
num_features: usize,
enum_values: &FxHashMap<usize, Vec<String>>,
) -> Property {
let mut features = FxHashMap::default();
let base = row * num_features;
for (feat_idx, feat_name) in feature_names.iter().enumerate() {
if enum_values.contains_key(&feat_idx) {
continue;
}
let value = feature_data[base + feat_idx];
let value = state.data.get_feature(row, feat_idx);
if value.is_finite() {
features.insert(feat_name.clone(), value);
}
@ -124,26 +120,50 @@ pub fn build_property(
postcode: non_empty_string(state.data.postcode(row)),
is_construction_date_approximate: Some(state.data.is_approx_build_date(row)),
property_type: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Property type",
feature_name_to_index,
&state.data,
enum_values,
row,
"Property type",
),
built_form: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Property type/built form",
feature_name_to_index,
&state.data,
enum_values,
row,
"Property type/built form",
),
duration: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Leasehold/Freehold",
feature_name_to_index,
&state.data,
enum_values,
row,
"Leasehold/Freehold",
),
current_energy_rating: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Current energy rating",
feature_name_to_index,
&state.data,
enum_values,
row,
"Current energy rating",
),
potential_energy_rating: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Potential energy rating",
feature_name_to_index,
&state.data,
enum_values,
row,
"Potential energy rating",
),
lat: state.data.lat[row],
lon: state.data.lon[row],
renovation_history: state.data.renovation_history(row).to_vec(),
listing_features: state.data.listing_features(row).to_vec(),
listing_status: lookup_enum_value(
feature_name_to_index, feature_data, num_features, enum_values, row, "Listing status",
feature_name_to_index,
&state.data,
enum_values,
row,
"Listing status",
),
listing_url: state.data.listing_url(row).map(String::from),
property_sub_type: state.data.property_sub_type(row).map(String::from),
@ -175,10 +195,12 @@ pub async fn get_hexagon_properties(
check_license_bounds(&user.0, h3_bounds)?;
let h3_str = params.h3;
let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
@ -233,8 +255,11 @@ pub async fn get_hexagon_properties(
.take(limit)
.map(|&row| {
build_property(
row, &state, feature_names, feature_name_to_index, feature_data,
num_features, enum_values,
row,
&state,
feature_names,
feature_name_to_index,
enum_values,
)
})
.collect();

View file

@ -7,7 +7,7 @@ use tracing::{info, warn};
use crate::state::AppState;
/// Fetch a PNG screenshot from the screenshot service.
/// Fetch a JPEG screenshot from the screenshot service.
/// Used by both the `/api/screenshot` proxy and the xlsx export.
pub async fn fetch_screenshot_bytes(
state: &AppState,
@ -31,9 +31,7 @@ pub async fn fetch_screenshot_bytes(
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(format!(
"Screenshot service returned {status}: {body}"
))
Err(format!("Screenshot service returned {status}: {body}"))
}
Err(err) => Err(format!("Failed to reach screenshot service: {err}")),
}
@ -51,7 +49,7 @@ pub async fn get_screenshot(
Ok(bytes) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/png"),
(header::CONTENT_TYPE, "image/jpeg"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
bytes,

View file

@ -65,9 +65,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
let res = state
.http_client
.post(format!(
"{pb_url}/api/collections/short_urls/records"
))
.post(format!("{pb_url}/api/collections/short_urls/records"))
.header("Authorization", format!("Bearer {token}"))
.json(&record)
.send()
@ -95,10 +93,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
}
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> Response {
if code.is_empty()
|| code.len() > 20
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
{
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return StatusCode::BAD_REQUEST.into_response();
}

View file

@ -4,15 +4,14 @@ use rustc_hash::FxHashMap;
use tracing::warn;
use crate::consts::MAX_PRICE_HISTORY_POINTS;
use crate::data::FeatureStats;
use crate::data::{FeatureStats, PropertyData};
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
pub fn extract_price_history(
matching_rows: &[usize],
feature_data: &[f32],
num_features: usize,
data: &PropertyData,
feature_name_to_index: &FxHashMap<String, usize>,
) -> Vec<PricePoint> {
let year_idx = feature_name_to_index
@ -24,8 +23,8 @@ pub fn extract_price_history(
let mut points: Vec<PricePoint> = matching_rows
.iter()
.filter_map(|&row| {
let year = feature_data[row * num_features + yi];
let price = feature_data[row * num_features + pi];
let year = data.get_feature(row, yi);
let price = data.get_feature(row, pi);
if year.is_finite() && price.is_finite() {
Some(PricePoint { year, price })
} else {
@ -55,9 +54,8 @@ pub fn extract_price_history(
#[allow(clippy::too_many_arguments)]
pub fn compute_feature_stats(
matching_rows: &[usize],
feature_data: &[f32],
data: &PropertyData,
feature_names: &[String],
num_features: usize,
enum_values: &FxHashMap<usize, Vec<String>>,
feature_stats_data: &[FeatureStats],
fields_specified: bool,
@ -74,7 +72,7 @@ pub fn compute_feature_stats(
if let Some(ev) = enum_values.get(&feature_index) {
let mut value_counts = vec![0u64; ev.len()];
for &row in matching_rows {
let value = feature_data[row * num_features + feature_index];
let value = data.get_feature(row, feature_index);
if value.is_finite() {
let idx = value as usize;
if idx < value_counts.len() {
@ -123,7 +121,7 @@ pub fn compute_feature_stats(
};
for &row in matching_rows {
let value = feature_data[row * num_features + feature_index];
let value = data.get_feature(row, feature_index);
if value.is_finite() {
count += 1;
if value < min_value {

View file

@ -55,7 +55,10 @@ pub async fn post_stripe_webhook(
) -> Response {
let webhook_secret = &state.stripe_webhook_secret;
let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) {
let sig_header = match headers
.get("stripe-signature")
.and_then(|h| h.to_str().ok())
{
Some(s) => s,
None => {
warn!("Missing Stripe-Signature header");
@ -90,8 +93,13 @@ pub async fn post_stripe_webhook(
// Update user subscription to "licensed" via PocketBase superuser auth
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password)
.await
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(t) => t,
Err(err) => {
@ -112,12 +120,18 @@ pub async fn post_stripe_webhook(
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(user_id);
info!(user_id, "User subscription updated to licensed via Stripe webhook");
info!(
user_id,
"User subscription updated to licensed via Stripe webhook"
);
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(user_id, "Failed to update user subscription ({status}): {text}");
warn!(
user_id,
"Failed to update user subscription ({status}): {text}"
);
}
Err(err) => {
warn!(user_id, "PocketBase request error in webhook: {err}");

View file

@ -71,7 +71,10 @@ pub async fn get_style(
.unwrap_or_default();
// Build absolute tile URL using the configured public URL (not the Host header)
let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/'));
let tile_url = format!(
"{}/api/tiles/{{z}}/{{x}}/{{y}}",
public_url.trim_end_matches('/')
);
let style = build_style(is_dark, &layers, &tile_url);
Ok((