From a1dcebaec143d715f73c6492e5de5230d3884337 Mon Sep 17 00:00:00 2001 From: Love Billenius Date: Thu, 9 Apr 2026 15:17:08 +0200 Subject: [PATCH] add study flow endpoints --- backend/src/analytics/mod.rs | 109 +++++++++++ backend/src/app_state.rs | 214 ++++++++++++++++++++++ backend/src/bets/mod.rs | 116 ++++++++++++ backend/src/experiments/mod.rs | 40 +++++ backend/src/lib.rs | 5 + backend/src/localization/mod.rs | 51 ++++++ backend/src/markets/mod.rs | 49 +++-- backend/src/routes/analytics.rs | 11 ++ backend/src/routes/bets.rs | 23 +++ backend/src/routes/experiments.rs | 7 + backend/src/routes/localization.rs | 14 ++ backend/src/routes/mod.rs | 11 ++ backend/src/routes/results.rs | 15 ++ backend/src/settlement/mod.rs | 63 +++++++ backend/tests/api_smoke.rs | 280 +++++++++++++++++++++++++++++ 15 files changed, 992 insertions(+), 16 deletions(-) create mode 100644 backend/src/analytics/mod.rs create mode 100644 backend/src/bets/mod.rs create mode 100644 backend/src/experiments/mod.rs create mode 100644 backend/src/localization/mod.rs create mode 100644 backend/src/routes/analytics.rs create mode 100644 backend/src/routes/bets.rs create mode 100644 backend/src/routes/experiments.rs create mode 100644 backend/src/routes/localization.rs create mode 100644 backend/src/routes/results.rs create mode 100644 backend/src/settlement/mod.rs diff --git a/backend/src/analytics/mod.rs b/backend/src/analytics/mod.rs new file mode 100644 index 0000000..37fbf27 --- /dev/null +++ b/backend/src/analytics/mod.rs @@ -0,0 +1,109 @@ +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::sessions::SessionSnapshot; + +#[derive(Clone, Debug, Deserialize)] +pub struct AnalyticsBatchRequest { + pub events: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AnalyticsEventInput { + pub event_name: String, + pub occurred_at: DateTime, + pub attributes: Option>, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AttributeInput { + pub key: String, + pub value: String, +} + +#[derive(Clone)] +pub struct AnalyticsStore { + inner: Arc>, +} + +#[derive(Default)] +struct AnalyticsState { + event_types_by_name: HashMap, + events: Vec, + attributes: Vec, +} + +#[derive(Clone)] +#[allow(dead_code)] +struct AnalyticsEventSnapshot { + id: Uuid, + analytics_event_type_id: Uuid, + session_id: Uuid, + user_id: Uuid, + occurred_at: DateTime, +} + +#[derive(Clone)] +#[allow(dead_code)] +struct AnalyticsEventAttributeSnapshot { + id: Uuid, + analytics_event_id: Uuid, + attribute_key: String, + attribute_value: String, +} + +impl AnalyticsStore { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(AnalyticsState::default())), + } + } + + pub async fn ingest(&self, session: &SessionSnapshot, request: AnalyticsBatchRequest) -> usize { + let mut state = self.inner.write().await; + let mut inserted = 0usize; + + for event in request.events { + let analytics_event_type_id = *state + .event_types_by_name + .entry(event.event_name.clone()) + .or_insert_with(Uuid::new_v4); + + let analytics_event_id = Uuid::new_v4(); + state.events.push(AnalyticsEventSnapshot { + id: analytics_event_id, + analytics_event_type_id, + session_id: session.session_id, + user_id: session.user_id, + occurred_at: event.occurred_at, + }); + + let Some(attributes) = event.attributes else { + inserted += 1; + continue; + }; + + for attribute in attributes { + state.attributes.push(AnalyticsEventAttributeSnapshot { + id: Uuid::new_v4(), + analytics_event_id, + attribute_key: attribute.key, + attribute_value: attribute.value, + }); + } + + inserted += 1; + } + + inserted + } + + pub async fn counts(&self) -> (usize, usize) { + let state = self.inner.read().await; + (state.events.len(), state.attributes.len()) + } +} diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 85adf43..4e95bf5 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -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, pub redis_client: Option, 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, + 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() @@ -101,6 +269,52 @@ impl AppState { 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 } diff --git a/backend/src/bets/mod.rs b/backend/src/bets/mod.rs new file mode 100644 index 0000000..9a721e7 --- /dev/null +++ b/backend/src/bets/mod.rs @@ -0,0 +1,116 @@ +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use uuid::Uuid; + +pub const ACCEPTANCE_ACCEPTED: &str = "accepted"; +pub const ACCEPTANCE_REJECTED_TOO_LATE: &str = "rejected_too_late"; +pub const ACCEPTANCE_REJECTED_INVALID_MARKET: &str = "rejected_invalid_market"; +pub const ACCEPTANCE_REJECTED_INVALID_SESSION: &str = "rejected_invalid_session"; +pub const ACCEPTANCE_REJECTED_DUPLICATE: &str = "rejected_duplicate"; + +#[derive(Clone, Debug, Deserialize)] +pub struct BetIntentRequest { + pub session_id: Uuid, + pub event_id: Uuid, + pub market_id: Uuid, + pub outcome_id: Uuid, + pub idempotency_key: String, + pub client_sent_at: DateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BetIntentResponse { + pub id: Uuid, + pub accepted: bool, + pub acceptance_code: String, + pub accepted_odds_version_id: Option, + pub server_received_at: DateTime, +} + +#[derive(Clone, Debug)] +pub struct BetIntentRecord { + pub id: Uuid, + pub session_id: Uuid, + pub user_id: Option, + pub event_id: Uuid, + pub market_id: Uuid, + pub outcome_id: Uuid, + pub idempotency_key: String, + pub client_sent_at: DateTime, + pub server_received_at: DateTime, + pub accepted: bool, + pub acceptance_code: String, + pub accepted_odds_version_id: Option, +} + +impl BetIntentRecord { + pub fn response(&self) -> BetIntentResponse { + BetIntentResponse { + id: self.id, + accepted: self.accepted, + acceptance_code: self.acceptance_code.clone(), + accepted_odds_version_id: self.accepted_odds_version_id, + server_received_at: self.server_received_at, + } + } +} + +#[derive(Clone, Default)] +pub struct BetsStore { + inner: Arc>, +} + +#[derive(Default)] +struct BetsState { + intents_by_id: HashMap, + intents_by_idempotency_key: HashMap, +} + +impl BetsStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn get(&self, bet_intent_id: Uuid) -> Option { + let state = self.inner.read().await; + let Some(record) = state.intents_by_id.get(&bet_intent_id) else { + return None; + }; + Some(record.response()) + } + + pub async fn get_by_idempotency_key(&self, idempotency_key: &str) -> Option { + let state = self.inner.read().await; + let Some(bet_intent_id) = state.intents_by_idempotency_key.get(idempotency_key).copied() else { + return None; + }; + let Some(record) = state.intents_by_id.get(&bet_intent_id) else { + return None; + }; + Some(record.response()) + } + + pub async fn record(&self, record: BetIntentRecord) -> BetIntentResponse { + let mut state = self.inner.write().await; + + if let Some(existing_id) = state.intents_by_idempotency_key.get(&record.idempotency_key).copied() { + if let Some(existing) = state.intents_by_id.get(&existing_id) { + return existing.response(); + } + } + + let response = record.response(); + state + .intents_by_idempotency_key + .insert(record.idempotency_key.clone(), record.id); + state.intents_by_id.insert(record.id, record); + response + } + + pub async fn counts(&self) -> usize { + self.inner.read().await.intents_by_id.len() + } +} diff --git a/backend/src/experiments/mod.rs b/backend/src/experiments/mod.rs new file mode 100644 index 0000000..053c6df --- /dev/null +++ b/backend/src/experiments/mod.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExperimentConfigSnapshot { + pub variant: String, + pub feature_flags: HashMap, +} + +#[derive(Clone, Default)] +pub struct ExperimentsStore; + +impl ExperimentsStore { + pub fn new() -> Self { + Self + } + + pub fn config_for_variant(&self, variant: &str) -> ExperimentConfigSnapshot { + ExperimentConfigSnapshot { + variant: variant.to_string(), + feature_flags: feature_flags_for_variant(variant), + } + } +} + +fn feature_flags_for_variant(variant: &str) -> HashMap { + let modern = variant == "modern"; + let mut flags = HashMap::new(); + flags.insert("control_mode".to_string(), !modern); + flags.insert("modern_mode".to_string(), modern); + flags.insert("animation_intensity_high".to_string(), modern); + flags.insert("haptics_enabled".to_string(), true); + flags.insert("autoplay_next_enabled".to_string(), true); + flags.insert("countdown_style_compact".to_string(), modern); + flags.insert("odds_update_visualization_enabled".to_string(), modern); + flags.insert("result_card_style_modern".to_string(), modern); + flags.insert("localization_source_contracts".to_string(), true); + flags +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a157cfb..253b861 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,11 +1,16 @@ #![forbid(unsafe_code)] +pub mod analytics; +pub mod bets; pub mod app_state; pub mod config; pub mod db; pub mod events; pub mod error; +pub mod experiments; +pub mod localization; pub mod markets; +pub mod settlement; pub mod sessions; pub mod routes; pub mod users; diff --git a/backend/src/localization/mod.rs b/backend/src/localization/mod.rs new file mode 100644 index 0000000..b19ca31 --- /dev/null +++ b/backend/src/localization/mod.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json as json; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LocalizationBundleSnapshot { + pub locale_code: String, + pub values: HashMap, +} + +#[derive(Clone)] +pub struct LocalizationStore { + bundles: HashMap, +} + +impl LocalizationStore { + pub fn new() -> Self { + let mut bundles = HashMap::new(); + bundles.insert( + "en".to_string(), + load_bundle( + "en", + include_str!("../../../contracts/localization/en.json"), + ), + ); + bundles.insert( + "sv".to_string(), + load_bundle( + "sv", + include_str!("../../../contracts/localization/sv.json"), + ), + ); + Self { bundles } + } + + pub fn bundle(&self, locale_code: &str) -> Option { + let Some(bundle) = self.bundles.get(locale_code) else { + return None; + }; + Some(bundle.clone()) + } +} + +fn load_bundle(locale_code: &str, raw: &str) -> LocalizationBundleSnapshot { + let values: HashMap = json::from_str(raw).expect("valid localization bundle"); + LocalizationBundleSnapshot { + locale_code: locale_code.to_string(), + values, + } +} diff --git a/backend/src/markets/mod.rs b/backend/src/markets/mod.rs index c1794bd..c6a0883 100644 --- a/backend/src/markets/mod.rs +++ b/backend/src/markets/mod.rs @@ -36,6 +36,7 @@ pub struct MarketsStore { struct MarketsState { markets_by_event_id: HashMap>, markets_by_id: HashMap, + outcomes_by_id: HashMap, current_odds_by_market_id: HashMap, } @@ -54,6 +55,23 @@ impl MarketsStore { let home_outcome_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"); let away_outcome_id = Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid"); + let outcomes = vec![ + OutcomeSnapshot { + id: home_outcome_id, + market_id, + outcome_code: "home".to_string(), + label_key: "outcome.home".to_string(), + sort_order: 1, + }, + OutcomeSnapshot { + id: away_outcome_id, + market_id, + outcome_code: "away".to_string(), + label_key: "outcome.away".to_string(), + sort_order: 2, + }, + ]; + let market = MarketSnapshot { id: market_id, event_id, @@ -62,22 +80,7 @@ impl MarketsStore { status: "open".to_string(), lock_at: preview_lock_at, settlement_rule_key: "settle_on_match_winner".to_string(), - outcomes: vec![ - OutcomeSnapshot { - id: home_outcome_id, - market_id, - outcome_code: "home".to_string(), - label_key: "outcome.home".to_string(), - sort_order: 1, - }, - OutcomeSnapshot { - id: away_outcome_id, - market_id, - outcome_code: "away".to_string(), - label_key: "outcome.away".to_string(), - sort_order: 2, - }, - ], + outcomes: outcomes.clone(), }; let odds_version = OddsVersionSnapshot { @@ -112,6 +115,11 @@ impl MarketsStore { let mut markets_by_id = HashMap::new(); markets_by_id.insert(market_id, market); + let mut outcomes_by_id = HashMap::new(); + for outcome in outcomes { + outcomes_by_id.insert(outcome.id, outcome); + } + let mut current_odds_by_market_id = HashMap::new(); current_odds_by_market_id.insert(market_id, odds_version); @@ -119,6 +127,7 @@ impl MarketsStore { inner: Arc::new(RwLock::new(MarketsState { markets_by_event_id, markets_by_id, + outcomes_by_id, current_odds_by_market_id, })), } @@ -140,6 +149,14 @@ impl MarketsStore { Some(market.clone()) } + pub async fn outcome(&self, outcome_id: Uuid) -> Option { + let state = self.inner.read().await; + let Some(outcome) = state.outcomes_by_id.get(&outcome_id) else { + return None; + }; + Some(outcome.clone()) + } + pub async fn current_odds(&self, market_id: Uuid) -> Option { let state = self.inner.read().await; let Some(odds) = state.current_odds_by_market_id.get(&market_id) else { diff --git a/backend/src/routes/analytics.rs b/backend/src/routes/analytics.rs new file mode 100644 index 0000000..bf1ba56 --- /dev/null +++ b/backend/src/routes/analytics.rs @@ -0,0 +1,11 @@ +use axum::{extract::Extension, http::StatusCode, Json}; + +use crate::{app_state::{AnalyticsBatchRequest, AppState}, error::AppError}; + +pub async fn batch( + Extension(state): Extension, + Json(payload): Json, +) -> Result { + state.ingest_analytics_batch(payload).await?; + Ok(StatusCode::ACCEPTED) +} diff --git a/backend/src/routes/bets.rs b/backend/src/routes/bets.rs new file mode 100644 index 0000000..0657f4f --- /dev/null +++ b/backend/src/routes/bets.rs @@ -0,0 +1,23 @@ +use axum::{extract::{Extension, Path}, http::StatusCode, Json}; +use uuid::Uuid; + +use crate::{app_state::{AppState, BetIntentRequest, BetIntentResponse}, error::AppError}; + +pub async fn submit( + Extension(state): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json), AppError> { + let response = state.submit_bet_intent(payload).await; + Ok((StatusCode::CREATED, Json(response))) +} + +pub async fn show( + Path(bet_intent_id): Path, + Extension(state): Extension, +) -> Result, AppError> { + let Some(intent) = state.bet_intent(bet_intent_id).await else { + return Err(AppError::not_found("Bet intent not found")); + }; + + Ok(Json(intent)) +} diff --git a/backend/src/routes/experiments.rs b/backend/src/routes/experiments.rs new file mode 100644 index 0000000..1e4ee40 --- /dev/null +++ b/backend/src/routes/experiments.rs @@ -0,0 +1,7 @@ +use axum::{extract::Extension, Json}; + +use crate::{app_state::{AppState, ExperimentConfigSnapshot}, error::AppError}; + +pub async fn config(Extension(state): Extension) -> Result, AppError> { + Ok(Json(state.experiment_config().await)) +} diff --git a/backend/src/routes/localization.rs b/backend/src/routes/localization.rs new file mode 100644 index 0000000..983a048 --- /dev/null +++ b/backend/src/routes/localization.rs @@ -0,0 +1,14 @@ +use axum::{extract::{Extension, Path}, Json}; + +use crate::{app_state::{AppState, LocalizationBundleSnapshot}, error::AppError}; + +pub async fn bundle( + Path(locale_code): Path, + Extension(state): Extension, +) -> Result, AppError> { + let Some(bundle) = state.localization_bundle(&locale_code).await else { + return Err(AppError::not_found("Localization bundle not found")); + }; + + Ok(Json(bundle)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index c499e69..13642ea 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,6 +1,11 @@ pub mod health; +pub mod analytics; +pub mod bets; pub mod events; +pub mod experiments; +pub mod localization; pub mod markets; +pub mod results; pub mod session; use axum::{routing::{get, post}, Router}; @@ -12,7 +17,13 @@ pub fn router() -> Router { .route("/api/v1/events/{event_id}", get(events::show)) .route("/api/v1/events/{event_id}/manifest", get(events::manifest)) .route("/api/v1/events/{event_id}/markets", get(markets::list_for_event)) + .route("/api/v1/events/{event_id}/result", get(results::show)) .route("/api/v1/markets/{market_id}/odds/current", get(markets::current_odds)) + .route("/api/v1/bets/intent", post(bets::submit)) + .route("/api/v1/bets/{bet_intent_id}", get(bets::show)) + .route("/api/v1/analytics/batch", post(analytics::batch)) + .route("/api/v1/experiments/config", get(experiments::config)) + .route("/api/v1/localization/{locale_code}", get(localization::bundle)) .route("/api/v1/session/start", post(session::start)) .route("/api/v1/session/end", post(session::end)) .route("/api/v1/session/me", get(session::me)) diff --git a/backend/src/routes/results.rs b/backend/src/routes/results.rs new file mode 100644 index 0000000..7c727fe --- /dev/null +++ b/backend/src/routes/results.rs @@ -0,0 +1,15 @@ +use axum::{extract::{Extension, Path}, Json}; +use uuid::Uuid; + +use crate::{app_state::{AppState, SettlementSnapshot}, error::AppError}; + +pub async fn show( + Path(event_id): Path, + Extension(state): Extension, +) -> Result, AppError> { + let Some(settlement) = state.settlement_for_event(event_id).await else { + return Err(AppError::not_found("Settlement not found")); + }; + + Ok(Json(settlement)) +} diff --git a/backend/src/settlement/mod.rs b/backend/src/settlement/mod.rs new file mode 100644 index 0000000..bf953f7 --- /dev/null +++ b/backend/src/settlement/mod.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SettlementSnapshot { + pub id: Uuid, + pub market_id: Uuid, + pub settled_at: DateTime, + pub winning_outcome_id: Uuid, +} + +#[derive(Clone, Default)] +pub struct SettlementsStore { + settlements_by_event_id: HashMap, + settlements_by_market_id: HashMap, +} + +impl SettlementsStore { + pub fn new() -> Self { + Self::with_sample_data() + } + + pub fn with_sample_data() -> Self { + let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); + let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); + let winning_outcome_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"); + + let settlement = SettlementSnapshot { + id: Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("valid uuid"), + market_id, + settled_at: Utc::now() - ChronoDuration::minutes(5), + winning_outcome_id, + }; + + let mut settlements_by_event_id = HashMap::new(); + settlements_by_event_id.insert(event_id, settlement.clone()); + + let mut settlements_by_market_id = HashMap::new(); + settlements_by_market_id.insert(market_id, settlement.clone()); + + Self { + settlements_by_event_id, + settlements_by_market_id, + } + } + + pub async fn settlement_for_event(&self, event_id: Uuid) -> Option { + let Some(settlement) = self.settlements_by_event_id.get(&event_id) else { + return None; + }; + Some(settlement.clone()) + } + + pub async fn settlement_for_market(&self, market_id: Uuid) -> Option { + let Some(settlement) = self.settlements_by_market_id.get(&market_id) else { + return None; + }; + Some(settlement.clone()) + } +} diff --git a/backend/tests/api_smoke.rs b/backend/tests/api_smoke.rs index eef8d07..086f196 100644 --- a/backend/tests/api_smoke.rs +++ b/backend/tests/api_smoke.rs @@ -1,4 +1,5 @@ use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}}; +use chrono::Utc; use hermes_backend::{app_state::AppState, build_router, config::AppConfig}; use serde_json as json; use tower::ServiceExt; @@ -130,6 +131,285 @@ async fn feed_next_returns_a_manifestable_event() { assert!(manifest["markets"].as_array().unwrap().len() >= 1); } +#[tokio::test] +async fn bet_intent_accepts_and_is_idempotent() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + let session_body = to_bytes(session_response.into_body(), usize::MAX).await.unwrap(); + let session_json: json::Value = json::from_slice(&session_body).unwrap(); + let session_id = session_json["session_id"].as_str().unwrap().to_string(); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event_json: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event_json["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets_json: json::Value = json::from_slice(&markets_body).unwrap(); + let market_id = markets_json[0]["id"].as_str().unwrap().to_string(); + let outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string(); + + let request = json::json!({ + "session_id": session_id, + "event_id": event_id, + "market_id": market_id, + "outcome_id": outcome_id, + "idempotency_key": "bet-001", + "client_sent_at": Utc::now().to_rfc3339(), + }) + .to_string(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/bets/intent") + .header("content-type", "application/json") + .body(Body::from(request.clone())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let first_json: json::Value = json::from_slice(&body).unwrap(); + let bet_id = first_json["id"].as_str().unwrap().to_string(); + assert_eq!(first_json["accepted"], true); + assert_eq!(first_json["acceptance_code"], "accepted"); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/bets/{bet_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let lookup_json: json::Value = json::from_slice(&body).unwrap(); + assert_eq!(lookup_json["id"], bet_id); + assert_eq!(lookup_json["accepted"], true); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/bets/intent") + .header("content-type", "application/json") + .body(Body::from(request)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let duplicate_json: json::Value = json::from_slice(&body).unwrap(); + assert_eq!(duplicate_json["id"], bet_id); + assert_eq!(duplicate_json["accepted"], true); +} + +#[tokio::test] +async fn event_result_returns_settlement() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event_json: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event_json["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets_json: json::Value = json::from_slice(&markets_body).unwrap(); + let market_id = markets_json[0]["id"].as_str().unwrap().to_string(); + let winning_outcome_id = markets_json[0]["outcomes"][0]["id"].as_str().unwrap().to_string(); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/result")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let result_json: json::Value = json::from_slice(&body).unwrap(); + + assert_eq!(result_json["market_id"], market_id); + assert_eq!(result_json["winning_outcome_id"], winning_outcome_id); +} + +#[tokio::test] +async fn experiments_and_localization_work() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "experiment_variant": "modern" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(session_response.status(), StatusCode::CREATED); + + let config_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/experiments/config").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(config_response.status(), StatusCode::OK); + + let config_body = to_bytes(config_response.into_body(), usize::MAX).await.unwrap(); + let config_json: json::Value = json::from_slice(&config_body).unwrap(); + assert_eq!(config_json["variant"], "modern"); + assert_eq!(config_json["feature_flags"]["modern_mode"], true); + + let localization_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/localization/sv").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(localization_response.status(), StatusCode::OK); + + let localization_body = to_bytes(localization_response.into_body(), usize::MAX).await.unwrap(); + let localization_json: json::Value = json::from_slice(&localization_body).unwrap(); + assert_eq!(localization_json["locale_code"], "sv"); + assert_eq!(localization_json["values"]["common.continue"], "Fortsätt"); + + let localization_en_response = app + .oneshot(Request::builder().uri("/api/v1/localization/en").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(localization_en_response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn analytics_batch_is_recorded() { + let state = AppState::new(AppConfig::default(), None, None); + let app = build_router(state.clone()); + + let session_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/session/start") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(session_response.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/analytics/batch") + .header("content-type", "application/json") + .body(Body::from( + json::json!({ + "events": [ + { + "event_name": "screen_viewed", + "occurred_at": Utc::now().to_rfc3339(), + "attributes": [ + {"key": "screen_name", "value": "feed"} + ] + }, + { + "event_name": "cta_pressed", + "occurred_at": Utc::now().to_rfc3339() + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let (event_count, attribute_count) = state.analytics_counts().await; + assert_eq!(event_count, 2); + assert_eq!(attribute_count, 1); +} + #[tokio::test] async fn event_markets_and_current_odds_work() { let app = build_router(AppState::new(AppConfig::default(), None, None));