209 lines
9 KiB
Rust
209 lines
9 KiB
Rust
use std::sync::Arc;
|
|
|
|
use parking_lot::RwLock;
|
|
use rustc_hash::FxHashMap;
|
|
|
|
use crate::auth::TokenCache;
|
|
use crate::bugsink::FrontendConfig as BugsinkFrontendConfig;
|
|
use crate::data::{
|
|
ActualListingData, CrimeByYearData, OutcodeData, POICategoryGroup, POIData, PlaceData,
|
|
PostcodeData, PropertyData, TravelTimeStore,
|
|
};
|
|
use crate::licensing::ShareBoundsCache;
|
|
use crate::pocketbase::SuperuserTokenCache;
|
|
use crate::routes::FeaturesResponse;
|
|
use crate::utils::GridIndex;
|
|
|
|
pub struct AppState {
|
|
pub data: PropertyData,
|
|
pub grid: GridIndex,
|
|
/// h3_cells[row_idx] = precomputed H3 cell ID at max resolution (9).
|
|
/// Parent cells for lower resolutions derived via CellIndex::parent().
|
|
pub h3_cells: Vec<u64>,
|
|
/// O(1) lookup: feature name → index in feature_names/feature_data
|
|
pub feature_name_to_index: FxHashMap<String, usize>,
|
|
/// Precomputed JSON key names: "min_{feature_name}" for each feature
|
|
pub min_keys: Vec<String>,
|
|
/// Precomputed JSON key names: "max_{feature_name}" for each feature
|
|
pub max_keys: Vec<String>,
|
|
/// Precomputed JSON key names: "avg_{feature_name}" for each feature
|
|
pub avg_keys: Vec<String>,
|
|
/// Precomputed features response for /api/features endpoint
|
|
pub features_response: FeaturesResponse,
|
|
/// Complete system prompt for AI filters (features + examples + instructions)
|
|
pub ai_filters_system_prompt: String,
|
|
|
|
pub poi_data: Arc<POIData>,
|
|
pub poi_grid: Arc<GridIndex>,
|
|
pub place_data: Arc<PlaceData>,
|
|
/// Postcode boundary data for high-zoom rendering
|
|
pub postcode_data: Arc<PostcodeData>,
|
|
/// Precomputed outcode centroids for search
|
|
pub outcode_data: Arc<OutcodeData>,
|
|
/// Precomputed POI category groups (sorted)
|
|
pub poi_category_groups: Arc<Vec<POICategoryGroup>>,
|
|
/// Precomputed travel time data store
|
|
pub travel_time_store: Arc<TravelTimeStore>,
|
|
/// Real-world listings (e.g. Rightmove / Zoopla data) loaded from ACTUAL_LISTINGS_PATH.
|
|
pub actual_listings: Arc<ActualListingData>,
|
|
/// Per-LSOA per-year crime counts used by the right pane to plot trends.
|
|
pub crime_by_year: Arc<CrimeByYearData>,
|
|
/// Token validation cache (60s TTL)
|
|
pub token_cache: Arc<TokenCache>,
|
|
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
|
pub superuser_token_cache: Arc<SuperuserTokenCache>,
|
|
/// Cached share-link bbox lookups (5min TTL); used to grant unlicensed
|
|
/// users access to the area their share link references.
|
|
pub share_cache: Arc<ShareBoundsCache>,
|
|
|
|
// --- Config (cheap to clone) ---
|
|
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
|
pub screenshot_url: String,
|
|
/// Public-facing URL for absolute og:image URLs (e.g. https://perfectpostcodes.dev)
|
|
pub public_url: String,
|
|
/// True when --dist is not provided (no static serving, relaxed auth checks)
|
|
pub is_dev: bool,
|
|
/// Shared HTTP client for proxying to the screenshot service and PocketBase
|
|
pub http_client: reqwest::Client,
|
|
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
|
pub pocketbase_url: String,
|
|
/// PocketBase superuser email (needed for admin-only operations like subscription updates)
|
|
pub pocketbase_admin_email: String,
|
|
/// PocketBase superuser password
|
|
pub pocketbase_admin_password: String,
|
|
/// Gemini API key for AI filters
|
|
pub gemini_api_key: String,
|
|
/// Gemini model name (e.g. gemini-2.0-flash)
|
|
pub gemini_model: String,
|
|
/// Google Maps API key for Street View metadata lookups
|
|
pub google_maps_api_key: String,
|
|
/// Stripe secret key for creating checkout sessions
|
|
pub stripe_secret_key: String,
|
|
/// Stripe webhook signing secret
|
|
pub stripe_webhook_secret: String,
|
|
/// Stripe Coupon ID for referral discounts
|
|
pub stripe_referral_coupon_id: String,
|
|
/// Bugsink/Sentry-compatible browser error reporting config injected into served HTML.
|
|
pub bugsink_frontend_config: Option<BugsinkFrontendConfig>,
|
|
/// Per-key rate limiter for unlicensed ("demo") API traffic.
|
|
pub demo_rate_limiter: Arc<crate::ratelimit::DemoRateLimiter>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl AppState {
|
|
/// Minimal AppState for integration tests of the PocketBase/Stripe money
|
|
/// paths (checkout, webhook, licensing, invites). All map/property data is
|
|
/// empty; only the HTTP-facing config (PocketBase URL, Stripe secrets,
|
|
/// caches) carries meaningful values.
|
|
pub(crate) fn for_tests(pocketbase_url: String) -> Self {
|
|
use std::time::Duration;
|
|
|
|
use crate::data::{
|
|
ActualListingData, CrimeByYearData, OutcodeData, POIData, PlaceData, PostcodeData,
|
|
PropertyData, TravelTimeStore,
|
|
};
|
|
use crate::utils::InternedColumn;
|
|
|
|
let http_client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.connect_timeout(Duration::from_secs(2))
|
|
.build()
|
|
.expect("test HTTP client should build");
|
|
|
|
AppState {
|
|
data: PropertyData::empty_for_tests(),
|
|
grid: GridIndex::build(&[], &[], 0.01),
|
|
h3_cells: Vec::new(),
|
|
feature_name_to_index: FxHashMap::default(),
|
|
min_keys: Vec::new(),
|
|
max_keys: Vec::new(),
|
|
avg_keys: Vec::new(),
|
|
features_response: FeaturesResponse { groups: Vec::new() },
|
|
ai_filters_system_prompt: String::new(),
|
|
poi_data: Arc::new(POIData::empty_for_tests()),
|
|
poi_grid: Arc::new(GridIndex::build(&[], &[], 0.01)),
|
|
place_data: Arc::new(PlaceData::empty_for_tests()),
|
|
postcode_data: Arc::new(PostcodeData {
|
|
postcodes: Vec::new(),
|
|
centroids: Vec::new(),
|
|
aabbs: Vec::new(),
|
|
polygons: Vec::new(),
|
|
postcode_to_idx: FxHashMap::default(),
|
|
}),
|
|
outcode_data: Arc::new(OutcodeData {
|
|
names: Vec::new(),
|
|
name_lower: Vec::new(),
|
|
centroids: Vec::new(),
|
|
cities: Vec::new(),
|
|
}),
|
|
poi_category_groups: Arc::new(Vec::new()),
|
|
travel_time_store: Arc::new(TravelTimeStore::empty_for_tests()),
|
|
actual_listings: Arc::new(ActualListingData {
|
|
lat: Vec::new(),
|
|
lon: Vec::new(),
|
|
postcode: Vec::new(),
|
|
address: Vec::new(),
|
|
property_type: InternedColumn::build(&[]),
|
|
property_sub_type: InternedColumn::build(&[]),
|
|
leasehold_freehold: InternedColumn::build(&[]),
|
|
price_qualifier: InternedColumn::build(&[]),
|
|
bedrooms: Vec::new(),
|
|
bathrooms: Vec::new(),
|
|
rooms_total: Vec::new(),
|
|
floor_area_sqm: Vec::new(),
|
|
asking_price: Vec::new(),
|
|
asking_price_per_sqm: Vec::new(),
|
|
listing_url: Vec::new(),
|
|
listing_status: InternedColumn::build(&[]),
|
|
listing_date_iso: Vec::new(),
|
|
features: Vec::new(),
|
|
filter_feature_data: Vec::new(),
|
|
poi_filter_feature_data: Vec::new(),
|
|
grid: GridIndex::build(&[], &[], 0.01),
|
|
}),
|
|
crime_by_year: Arc::new(CrimeByYearData {
|
|
crime_types: Vec::new(),
|
|
years_by_type: Vec::new(),
|
|
series_by_postcode: FxHashMap::default(),
|
|
covered_years_by_postcode: FxHashMap::default(),
|
|
}),
|
|
token_cache: Arc::new(TokenCache::new()),
|
|
superuser_token_cache: Arc::new(SuperuserTokenCache::new()),
|
|
share_cache: Arc::new(ShareBoundsCache::new()),
|
|
screenshot_url: "http://127.0.0.1:1/screenshot".to_string(),
|
|
public_url: "https://test.example".to_string(),
|
|
is_dev: false,
|
|
http_client,
|
|
pocketbase_url,
|
|
pocketbase_admin_email: "admin@test.example".to_string(),
|
|
pocketbase_admin_password: "test-admin-password".to_string(),
|
|
gemini_api_key: "test-gemini-key".to_string(),
|
|
gemini_model: "test-model".to_string(),
|
|
google_maps_api_key: "test-maps-key".to_string(),
|
|
stripe_secret_key: "sk_test_dummy".to_string(),
|
|
stripe_webhook_secret: "whsec_test_secret".to_string(),
|
|
stripe_referral_coupon_id: "couponTest30".to_string(),
|
|
bugsink_frontend_config: None,
|
|
demo_rate_limiter: Arc::new(crate::ratelimit::DemoRateLimiter::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wraps AppState for shared access across route handlers.
|
|
/// Route handlers call `load_state()` to get the current snapshot.
|
|
pub struct SharedState {
|
|
current: RwLock<Arc<AppState>>,
|
|
}
|
|
|
|
impl SharedState {
|
|
pub fn new(state: AppState) -> Self {
|
|
Self {
|
|
current: RwLock::new(Arc::new(state)),
|
|
}
|
|
}
|
|
|
|
/// Get the current AppState snapshot. Cheap (Arc clone under a brief read lock).
|
|
pub fn load_state(&self) -> Arc<AppState> {
|
|
self.current.read().clone()
|
|
}
|
|
}
|