338 lines
10 KiB
Rust
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()
|
|
}
|
|
}
|