Files
hermes/backend/src/app_state.rs
T
2026-04-09 15:17:08 +02:00

338 lines
10 KiB
Rust

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<PgPool>,
pub redis_client: Option<RedisClient>,
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<PgPool>,
redis_client: Option<RedisClient>,
) -> 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<SessionSnapshot> {
self.sessions.current_session().await
}
fn build_bet_intent_record(
&self,
request: &BetIntentRequest,
user_id: Option<Uuid>,
accepted: bool,
acceptance_code: &str,
accepted_odds_version_id: Option<Uuid>,
) -> 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<BetIntentResponse> {
self.bets.get(bet_intent_id).await
}
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
self.sessions
.end_session()
.await
.map_err(|_| AppError::not_found("No active session"))
}
pub async fn next_event(&self) -> Option<EventSnapshot> {
self.events.next_event().await
}
pub async fn event(&self, event_id: Uuid) -> Option<EventSnapshot> {
self.events.get_event(event_id).await
}
pub async fn event_manifest(&self, event_id: Uuid) -> Option<EventManifestSnapshot> {
self.events.get_manifest(event_id).await
}
pub async fn settlement_for_event(&self, event_id: Uuid) -> Option<SettlementSnapshot> {
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<LocalizationBundleSnapshot> {
self.localization.bundle(locale_code)
}
pub async fn ingest_analytics_batch(
&self,
request: AnalyticsBatchRequest,
) -> Result<usize, AppError> {
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<Vec<MarketSnapshot>> {
self.markets.markets_for_event(event_id).await
}
pub async fn current_odds(&self, market_id: Uuid) -> Option<OddsVersionSnapshot> {
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()
}
}