All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
|
|
@ -129,17 +129,16 @@ pub async fn get_export(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let fields_str = params.fields.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 filters_str = params.filters;
|
||||
let fields_str = params.fields;
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
|
|
|
|||
|
|
@ -97,10 +97,9 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
let h3_str = params.h3;
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -108,6 +107,7 @@ pub async fn get_hexagon_stats(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use crate::parsing::{
|
|||
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
|
||||
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
||||
};
|
||||
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -141,11 +141,9 @@ pub async fn get_hexagons(
|
|||
|
||||
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
|
||||
if !is_demo_view {
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
}
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -153,19 +151,13 @@ pub async fn get_hexagons(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.filter(|val| !val.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
.unwrap_or_default();
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -18,11 +18,26 @@ struct InviteResponse {
|
|||
invite_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteListItem {
|
||||
code: String,
|
||||
url: String,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
created: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteListResponse {
|
||||
invites: Vec<InviteListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteValidation {
|
||||
valid: bool,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
invited_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -147,6 +162,10 @@ pub async fn post_invites(
|
|||
}
|
||||
}
|
||||
|
||||
/// Dev-only fake invite code (12 alphanumeric chars, passes validation).
|
||||
/// Only recognized when `--dist` is not set (i.e., dev mode).
|
||||
const DEV_INVITE_CODE: &str = "devdevdevdev";
|
||||
|
||||
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
|
||||
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
|
||||
pub async fn get_invite(
|
||||
|
|
@ -158,6 +177,17 @@ pub async fn get_invite(
|
|||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
// Dev-only: return a fake valid admin invite without hitting PocketBase
|
||||
if state.index_html.is_none() && code == DEV_INVITE_CODE {
|
||||
return Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type: "admin".to_string(),
|
||||
used: false,
|
||||
invited_by: Some("Developer".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -201,10 +231,38 @@ pub async fn get_invite(
|
|||
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = invite["used_by_id"].as_str().unwrap_or("");
|
||||
let used = !used_by.is_empty();
|
||||
let created_by = invite["created_by"].as_str().unwrap_or("");
|
||||
|
||||
// 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}");
|
||||
match state
|
||||
.http_client
|
||||
.get(&user_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let user_body: serde_json::Value =
|
||||
resp.json().await.unwrap_or_default();
|
||||
user_body["email"]
|
||||
.as_str()
|
||||
.and_then(|e| e.split('@').next())
|
||||
.map(String::from)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type,
|
||||
used,
|
||||
invited_by,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -212,6 +270,7 @@ pub async fn get_invite(
|
|||
valid: false,
|
||||
invite_type: String::new(),
|
||||
used: false,
|
||||
invited_by: None,
|
||||
})
|
||||
.into_response(),
|
||||
}
|
||||
|
|
@ -234,6 +293,16 @@ pub async fn post_redeem_invite(
|
|||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
// Dev-only: fake redeem — just return "licensed" without touching PocketBase
|
||||
if state.index_html.is_none() && req.code == DEV_INVITE_CODE {
|
||||
info!(user_id = %user.id, "Dev invite redeemed (no-op)");
|
||||
return Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -290,7 +359,7 @@ pub async fn post_redeem_invite(
|
|||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(&format!(
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
|
|
@ -391,3 +460,97 @@ pub async fn post_redeem_invite(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List invites. Admins see all invites; licensed users see only their own.
|
||||
pub async fn get_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let filter = if user.is_admin {
|
||||
String::new()
|
||||
} else {
|
||||
format!("created_by=\"{}\"", user.id)
|
||||
};
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to list invites: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
warn!("PocketBase list invites failed ({status}): {text}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse invites response: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let public_url = &state.public_url;
|
||||
let invites: Vec<InviteListItem> = body["items"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|inv| {
|
||||
let code = inv["code"].as_str().unwrap_or("").to_string();
|
||||
let invite_type = inv["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = inv["used_by_id"].as_str().unwrap_or("");
|
||||
let created = inv["created"].as_str().unwrap_or("").to_string();
|
||||
InviteListItem {
|
||||
url: format!("{public_url}/invite/{code}"),
|
||||
code,
|
||||
invite_type,
|
||||
used: !used_by.is_empty(),
|
||||
created,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(InviteListResponse { invites }).into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ pub async fn get_pois(
|
|||
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
|
||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
||||
|
||||
let categories_str = params.categories.clone();
|
||||
let category_filter: Option<rustc_hash::FxHashSet<String>> = params
|
||||
.categories
|
||||
.as_deref()
|
||||
|
|
@ -51,6 +50,7 @@ pub async fn get_pois(
|
|||
.map(|part| part.trim().to_string())
|
||||
.collect()
|
||||
});
|
||||
let categories_raw = params.categories;
|
||||
|
||||
let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ pub async fn get_pois(
|
|||
results = pois.len(),
|
||||
candidates = row_indices.len(),
|
||||
categories = num_categories,
|
||||
categories_raw = categories_str.as_deref().unwrap_or("-"),
|
||||
categories_raw = categories_raw.as_deref().unwrap_or("-"),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/pois"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,10 +44,8 @@ pub async fn get_postcode_properties(
|
|||
};
|
||||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -55,8 +53,9 @@ pub async fn get_postcode_properties(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let postcode_str = normalized.clone();
|
||||
let postcode_str = normalized;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -48,10 +48,8 @@ pub async fn get_postcode_stats(
|
|||
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)?;
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -59,10 +57,11 @@ pub async fn get_postcode_stats(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
let postcode_str = normalized.clone();
|
||||
let postcode_str = normalized;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::licensing::check_license_bounds;
|
|||
use crate::parsing::{
|
||||
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
|
||||
};
|
||||
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
|
|
@ -74,10 +74,8 @@ pub async fn get_postcodes(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -85,19 +83,13 @@ pub async fn get_postcodes(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.filter(|val| !val.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
.unwrap_or_default();
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
|
|
|||
|
|
@ -172,10 +172,9 @@ pub async fn get_hexagon_properties(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
let h3_str = params.h3;
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -183,6 +182,7 @@ pub async fn get_hexagon_properties(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
/// Parse the optional `travel` query param, returning an empty Vec when absent or empty.
|
||||
pub fn parse_optional_travel(travel: Option<&str>) -> Result<Vec<TravelEntry>, String> {
|
||||
match travel.filter(|val| !val.is_empty()) {
|
||||
Some(s) => parse_travel_entries(s),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed travel time entry from the `travel` query parameter.
|
||||
pub struct TravelEntry {
|
||||
pub mode: String,
|
||||
|
|
@ -9,7 +17,7 @@ pub struct TravelEntry {
|
|||
|
||||
/// Parse `travel` param into a list of travel entries.
|
||||
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
|
||||
pub fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen_keys = Vec::new();
|
||||
for segment in travel_str.split('|') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue