add study flow endpoints

This commit is contained in:
2026-04-09 15:17:08 +02:00
parent d45202770e
commit a1dcebaec1
15 changed files with 992 additions and 16 deletions
+214
View File
@@ -1,19 +1,33 @@
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},
};
@@ -25,6 +39,11 @@ pub struct AppState {
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,
@@ -42,6 +61,11 @@ impl AppState {
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(),
@@ -82,6 +106,150 @@ impl AppState {
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()
@@ -101,6 +269,52 @@ impl AppState {
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
}