vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (¶ms.journey_mode, ¶ms.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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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((
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue