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, /// O(1) lookup: feature name → index in feature_names/feature_data pub feature_name_to_index: FxHashMap, /// Precomputed JSON key names: "min_{feature_name}" for each feature pub min_keys: Vec, /// Precomputed JSON key names: "max_{feature_name}" for each feature pub max_keys: Vec, /// Precomputed JSON key names: "avg_{feature_name}" for each feature pub avg_keys: Vec, /// 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, pub poi_grid: Arc, pub place_data: Arc, /// Postcode boundary data for high-zoom rendering pub postcode_data: Arc, /// Precomputed outcode centroids for search pub outcode_data: Arc, /// Precomputed POI category groups (sorted) pub poi_category_groups: Arc>, /// Precomputed travel time data store pub travel_time_store: Arc, /// Real-world listings (e.g. Rightmove / Zoopla data) loaded from ACTUAL_LISTINGS_PATH. pub actual_listings: Arc, /// Per-LSOA per-year crime counts used by the right pane to plot trends. pub crime_by_year: Arc, /// Token validation cache (60s TTL) pub token_cache: Arc, /// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting pub superuser_token_cache: Arc, /// Cached share-link bbox lookups (5min TTL); used to grant unlicensed /// users access to the area their share link references. pub share_cache: Arc, // --- 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, /// Per-key rate limiter for unlicensed ("demo") API traffic. pub demo_rate_limiter: Arc, } #[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>, } 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 { self.current.read().clone() } }