add study flow endpoints
This commit is contained in:
@@ -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<AnalyticsEventInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AnalyticsEventInput {
|
||||
pub event_name: String,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
pub attributes: Option<Vec<AttributeInput>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AttributeInput {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnalyticsStore {
|
||||
inner: Arc<RwLock<AnalyticsState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AnalyticsState {
|
||||
event_types_by_name: HashMap<String, Uuid>,
|
||||
events: Vec<AnalyticsEventSnapshot>,
|
||||
attributes: Vec<AnalyticsEventAttributeSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct AnalyticsEventSnapshot {
|
||||
id: Uuid,
|
||||
analytics_event_type_id: Uuid,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BetIntentResponse {
|
||||
pub id: Uuid,
|
||||
pub accepted: bool,
|
||||
pub acceptance_code: String,
|
||||
pub accepted_odds_version_id: Option<Uuid>,
|
||||
pub server_received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BetIntentRecord {
|
||||
pub id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub event_id: Uuid,
|
||||
pub market_id: Uuid,
|
||||
pub outcome_id: Uuid,
|
||||
pub idempotency_key: String,
|
||||
pub client_sent_at: DateTime<Utc>,
|
||||
pub server_received_at: DateTime<Utc>,
|
||||
pub accepted: bool,
|
||||
pub acceptance_code: String,
|
||||
pub accepted_odds_version_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
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<RwLock<BetsState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BetsState {
|
||||
intents_by_id: HashMap<Uuid, BetIntentRecord>,
|
||||
intents_by_idempotency_key: HashMap<String, Uuid>,
|
||||
}
|
||||
|
||||
impl BetsStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn get(&self, bet_intent_id: Uuid) -> Option<BetIntentResponse> {
|
||||
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<BetIntentResponse> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, bool>,
|
||||
}
|
||||
|
||||
#[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<String, bool> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalizationStore {
|
||||
bundles: HashMap<String, LocalizationBundleSnapshot>,
|
||||
}
|
||||
|
||||
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<LocalizationBundleSnapshot> {
|
||||
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<String, String> = json::from_str(raw).expect("valid localization bundle");
|
||||
LocalizationBundleSnapshot {
|
||||
locale_code: locale_code.to_string(),
|
||||
values,
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -36,6 +36,7 @@ pub struct MarketsStore {
|
||||
struct MarketsState {
|
||||
markets_by_event_id: HashMap<Uuid, Vec<MarketSnapshot>>,
|
||||
markets_by_id: HashMap<Uuid, MarketSnapshot>,
|
||||
outcomes_by_id: HashMap<Uuid, OutcomeSnapshot>,
|
||||
current_odds_by_market_id: HashMap<Uuid, OddsVersionSnapshot>,
|
||||
}
|
||||
|
||||
@@ -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<OutcomeSnapshot> {
|
||||
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<OddsVersionSnapshot> {
|
||||
let state = self.inner.read().await;
|
||||
let Some(odds) = state.current_odds_by_market_id.get(&market_id) else {
|
||||
|
||||
@@ -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<AppState>,
|
||||
Json(payload): Json<AnalyticsBatchRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
state.ingest_analytics_batch(payload).await?;
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
Json(payload): Json<BetIntentRequest>,
|
||||
) -> Result<(StatusCode, Json<BetIntentResponse>), AppError> {
|
||||
let response = state.submit_bet_intent(payload).await;
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
pub async fn show(
|
||||
Path(bet_intent_id): Path<Uuid>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Result<Json<BetIntentResponse>, AppError> {
|
||||
let Some(intent) = state.bet_intent(bet_intent_id).await else {
|
||||
return Err(AppError::not_found("Bet intent not found"));
|
||||
};
|
||||
|
||||
Ok(Json(intent))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
|
||||
use crate::{app_state::{AppState, ExperimentConfigSnapshot}, error::AppError};
|
||||
|
||||
pub async fn config(Extension(state): Extension<AppState>) -> Result<Json<ExperimentConfigSnapshot>, AppError> {
|
||||
Ok(Json(state.experiment_config().await))
|
||||
}
|
||||
@@ -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<String>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Result<Json<LocalizationBundleSnapshot>, AppError> {
|
||||
let Some(bundle) = state.localization_bundle(&locale_code).await else {
|
||||
return Err(AppError::not_found("Localization bundle not found"));
|
||||
};
|
||||
|
||||
Ok(Json(bundle))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Uuid>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Result<Json<SettlementSnapshot>, AppError> {
|
||||
let Some(settlement) = state.settlement_for_event(event_id).await else {
|
||||
return Err(AppError::not_found("Settlement not found"));
|
||||
};
|
||||
|
||||
Ok(Json(settlement))
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
pub winning_outcome_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SettlementsStore {
|
||||
settlements_by_event_id: HashMap<Uuid, SettlementSnapshot>,
|
||||
settlements_by_market_id: HashMap<Uuid, SettlementSnapshot>,
|
||||
}
|
||||
|
||||
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<SettlementSnapshot> {
|
||||
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<SettlementSnapshot> {
|
||||
let Some(settlement) = self.settlements_by_market_id.get(&market_id) else {
|
||||
return None;
|
||||
};
|
||||
Some(settlement.clone())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user