use std::time::Instant; use chrono::Utc; use redis::Client as RedisClient; use sqlx::PgPool; use uuid::Uuid; pub use crate::analytics::{AnalyticsBatchRequest, AnalyticsEventInput, AttributeInput}; pub use crate::bets::{BetIntentRecord, BetIntentRequest, BetIntentResponse}; pub use crate::events::{EventManifestSnapshot, EventSnapshot}; pub use crate::events::{MarketSnapshot, OutcomeSnapshot}; pub use crate::experiments::ExperimentConfigSnapshot; pub use crate::localization::LocalizationBundleSnapshot; pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot}; pub use crate::settlement::SettlementSnapshot; pub use crate::sessions::{SessionSnapshot, SessionStartRequest}; use crate::{ analytics::AnalyticsStore, bets::{ BetsStore, ACCEPTANCE_ACCEPTED, ACCEPTANCE_REJECTED_INVALID_MARKET, ACCEPTANCE_REJECTED_INVALID_SESSION, ACCEPTANCE_REJECTED_TOO_LATE, }, config::AppConfig, error::AppError, events::{EventsStore}, experiments::ExperimentsStore, localization::LocalizationStore, markets::MarketsStore, settlement::SettlementsStore, sessions::{SessionDefaults, SessionsStore}, users::{UserCreateRequest, UsersStore}, }; #[derive(Clone)] pub struct AppState { pub config: AppConfig, pub started_at: Instant, pub database_pool: Option, pub redis_client: Option, events: EventsStore, bets: BetsStore, settlements: SettlementsStore, experiments: ExperimentsStore, localization: LocalizationStore, analytics: AnalyticsStore, markets: MarketsStore, users: UsersStore, sessions: SessionsStore, } impl AppState { pub fn new( config: AppConfig, database_pool: Option, redis_client: Option, ) -> Self { Self { config, started_at: Instant::now(), database_pool, redis_client, events: EventsStore::new(), bets: BetsStore::new(), settlements: SettlementsStore::new(), experiments: ExperimentsStore::new(), localization: LocalizationStore::new(), analytics: AnalyticsStore::new(), markets: MarketsStore::new(), users: UsersStore::new(), sessions: SessionsStore::new(), } } pub async fn start_session(&self, request: SessionStartRequest) -> SessionSnapshot { let user = self .users .upsert(UserCreateRequest { external_ref: request.external_ref.clone(), preferred_language: request .locale_code .clone() .unwrap_or_else(|| self.config.default_locale.clone()), device_platform: request .device_platform .clone() .unwrap_or_else(|| self.config.default_device_platform.clone()), }) .await; self.sessions .start_session( user.id, request, SessionDefaults { default_locale: self.config.default_locale.clone(), default_device_platform: self.config.default_device_platform.clone(), default_app_version: self.config.app_version.clone(), default_experiment_variant: self.config.default_experiment_variant.clone(), }, ) .await } pub async fn current_session(&self) -> Option { self.sessions.current_session().await } fn build_bet_intent_record( &self, request: &BetIntentRequest, user_id: Option, accepted: bool, acceptance_code: &str, accepted_odds_version_id: Option, ) -> BetIntentRecord { BetIntentRecord { id: Uuid::new_v4(), session_id: request.session_id, user_id, event_id: request.event_id, market_id: request.market_id, outcome_id: request.outcome_id, idempotency_key: request.idempotency_key.clone(), client_sent_at: request.client_sent_at, server_received_at: Utc::now(), accepted, acceptance_code: acceptance_code.to_string(), accepted_odds_version_id, } } pub async fn submit_bet_intent(&self, request: BetIntentRequest) -> BetIntentResponse { let Some(existing) = self .bets .get_by_idempotency_key(&request.idempotency_key) .await else { return self.submit_bet_intent_fresh(request).await; }; existing } async fn submit_bet_intent_fresh(&self, request: BetIntentRequest) -> BetIntentResponse { let Some(session) = self.current_session().await else { return self .bets .record(self.build_bet_intent_record( &request, None, false, ACCEPTANCE_REJECTED_INVALID_SESSION, None, )) .await; }; if session.ended_at.is_some() || session.session_id != request.session_id { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_INVALID_SESSION, None, )) .await; } let Some(market) = self.markets.market(request.market_id).await else { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_INVALID_MARKET, None, )) .await; }; if market.event_id != request.event_id { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_INVALID_MARKET, None, )) .await; } let Some(outcome) = self.markets.outcome(request.outcome_id).await else { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_INVALID_MARKET, None, )) .await; }; if outcome.market_id != market.id || request.event_id != market.event_id { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_INVALID_MARKET, None, )) .await; } if Utc::now() >= market.lock_at { return self .bets .record(self.build_bet_intent_record( &request, Some(session.user_id), false, ACCEPTANCE_REJECTED_TOO_LATE, None, )) .await; } let accepted_odds_version_id = self.current_odds(market.id).await.map(|odds| odds.id); self.bets .record(self.build_bet_intent_record( &request, Some(session.user_id), true, ACCEPTANCE_ACCEPTED, accepted_odds_version_id, )) .await } pub async fn bet_intent(&self, bet_intent_id: Uuid) -> Option { self.bets.get(bet_intent_id).await } pub async fn end_session(&self) -> Result { self.sessions .end_session() .await .map_err(|_| AppError::not_found("No active session")) } pub async fn next_event(&self) -> Option { self.events.next_event().await } pub async fn event(&self, event_id: Uuid) -> Option { self.events.get_event(event_id).await } pub async fn event_manifest(&self, event_id: Uuid) -> Option { self.events.get_manifest(event_id).await } pub async fn settlement_for_event(&self, event_id: Uuid) -> Option { self.settlements.settlement_for_event(event_id).await } pub async fn experiment_config(&self) -> ExperimentConfigSnapshot { let Some(session) = self.current_session().await else { return self .experiments .config_for_variant(&self.config.default_experiment_variant); }; if session.ended_at.is_some() { return self .experiments .config_for_variant(&self.config.default_experiment_variant); } self.experiments.config_for_variant(&session.experiment_variant) } pub async fn localization_bundle( &self, locale_code: &str, ) -> Option { self.localization.bundle(locale_code) } pub async fn ingest_analytics_batch( &self, request: AnalyticsBatchRequest, ) -> Result { let Some(session) = self.current_session().await else { return Err(AppError::not_found("No active session")); }; if session.ended_at.is_some() { return Err(AppError::not_found("No active session")); } Ok(self.analytics.ingest(&session, request).await) } pub async fn analytics_counts(&self) -> (usize, usize) { self.analytics.counts().await } pub async fn markets_for_event(&self, event_id: Uuid) -> Option> { self.markets.markets_for_event(event_id).await } pub async fn current_odds(&self, market_id: Uuid) -> Option { self.markets.current_odds(market_id).await } pub fn database_ready(&self) -> bool { self.database_pool.is_some() } pub fn redis_ready(&self) -> bool { self.redis_client.is_some() } pub fn uptime_ms(&self) -> u128 { self.started_at.elapsed().as_millis() } }