add fixture-backed admin and ios scaffold
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
pub use crate::events::{
|
||||||
|
EventManifestSnapshot, EventMediaSnapshot, EventSnapshot, MarketSnapshot, OutcomeSnapshot,
|
||||||
|
};
|
||||||
|
pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot};
|
||||||
|
pub use crate::settlement::SettlementSnapshot;
|
||||||
@@ -9,10 +9,9 @@ pub use crate::analytics::{AnalyticsBatchRequest, AnalyticsEventInput, Attribute
|
|||||||
pub use crate::bets::{BetIntentRecord, BetIntentRequest, BetIntentResponse};
|
pub use crate::bets::{BetIntentRecord, BetIntentRequest, BetIntentResponse};
|
||||||
pub use crate::events::{EventManifestSnapshot, EventSnapshot};
|
pub use crate::events::{EventManifestSnapshot, EventSnapshot};
|
||||||
pub use crate::events::{MarketSnapshot, OutcomeSnapshot};
|
pub use crate::events::{MarketSnapshot, OutcomeSnapshot};
|
||||||
|
pub use crate::admin::{EventMediaSnapshot, OddsVersionSnapshot, OutcomeOddsSnapshot, SettlementSnapshot};
|
||||||
pub use crate::experiments::ExperimentConfigSnapshot;
|
pub use crate::experiments::ExperimentConfigSnapshot;
|
||||||
pub use crate::localization::LocalizationBundleSnapshot;
|
pub use crate::localization::LocalizationBundleSnapshot;
|
||||||
pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot};
|
|
||||||
pub use crate::settlement::SettlementSnapshot;
|
|
||||||
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -257,6 +256,54 @@ impl AppState {
|
|||||||
.map_err(|_| AppError::not_found("No active session"))
|
.map_err(|_| AppError::not_found("No active session"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn admin_create_event_manifest(
|
||||||
|
&self,
|
||||||
|
manifest: EventManifestSnapshot,
|
||||||
|
) -> Result<EventManifestSnapshot, AppError> {
|
||||||
|
let created = self.events.insert_manifest(manifest.clone()).await;
|
||||||
|
|
||||||
|
for market in &created.markets {
|
||||||
|
self.markets.insert_market(market.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_create_market(
|
||||||
|
&self,
|
||||||
|
market: MarketSnapshot,
|
||||||
|
) -> Result<MarketSnapshot, AppError> {
|
||||||
|
let Some(_) = self.event(market.event_id).await else {
|
||||||
|
return Err(AppError::not_found("Event not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
self.markets.insert_market(market.clone()).await;
|
||||||
|
let _ = self.events.insert_market(market.event_id, market.clone()).await;
|
||||||
|
Ok(market)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_publish_odds(
|
||||||
|
&self,
|
||||||
|
odds: OddsVersionSnapshot,
|
||||||
|
) -> Result<OddsVersionSnapshot, AppError> {
|
||||||
|
let Some(_) = self.markets.market(odds.market_id).await else {
|
||||||
|
return Err(AppError::not_found("Market not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(self.markets.publish_odds(odds).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_publish_settlement(
|
||||||
|
&self,
|
||||||
|
settlement: SettlementSnapshot,
|
||||||
|
) -> Result<SettlementSnapshot, AppError> {
|
||||||
|
let Some(market) = self.markets.market(settlement.market_id).await else {
|
||||||
|
return Err(AppError::not_found("Market not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(self.settlements.upsert(market.event_id, settlement).await)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
||||||
self.events.next_event().await
|
self.events.next_event().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use serde_json as json;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let fixture = hermes_backend::fixtures::build_sample_study_fixture();
|
||||||
|
let output = json::to_string_pretty(&fixture).expect("valid test event fixture");
|
||||||
|
println!("{output}");
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::fixtures::SampleStudyFixture;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct EventSnapshot {
|
pub struct EventSnapshot {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -76,9 +78,22 @@ struct EventsState {
|
|||||||
|
|
||||||
impl EventsStore {
|
impl EventsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::with_sample_data()
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_fixture(fixture: &SampleStudyFixture) -> Self {
|
||||||
|
let mut manifests_by_event_id = HashMap::new();
|
||||||
|
manifests_by_event_id.insert(fixture.manifest.event.id, fixture.manifest.clone());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(EventsState {
|
||||||
|
feed_order: vec![fixture.manifest.event.id],
|
||||||
|
manifests_by_event_id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code, unused_variables)]
|
||||||
pub fn with_sample_data() -> Self {
|
pub fn with_sample_data() -> Self {
|
||||||
let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid");
|
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 market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid");
|
||||||
@@ -152,12 +167,32 @@ impl EventsStore {
|
|||||||
let mut manifests_by_event_id = HashMap::new();
|
let mut manifests_by_event_id = HashMap::new();
|
||||||
manifests_by_event_id.insert(event_id, manifest);
|
manifests_by_event_id.insert(event_id, manifest);
|
||||||
|
|
||||||
Self {
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
inner: Arc::new(RwLock::new(EventsState {
|
}
|
||||||
feed_order: vec![event_id],
|
|
||||||
manifests_by_event_id,
|
pub async fn insert_manifest(&self, manifest: EventManifestSnapshot) -> EventManifestSnapshot {
|
||||||
})),
|
let mut state = self.inner.write().await;
|
||||||
|
let event_id = manifest.event.id;
|
||||||
|
if !state.manifests_by_event_id.contains_key(&event_id) {
|
||||||
|
state.feed_order.push(event_id);
|
||||||
}
|
}
|
||||||
|
state.manifests_by_event_id.insert(event_id, manifest.clone());
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_market(&self, event_id: Uuid, market: MarketSnapshot) -> bool {
|
||||||
|
let mut state = self.inner.write().await;
|
||||||
|
let Some(manifest) = state.manifests_by_event_id.get_mut(&event_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(existing) = manifest.markets.iter_mut().find(|existing| existing.id == market.id) else {
|
||||||
|
manifest.markets.push(market);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
*existing = market;
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
pub async fn next_event(&self) -> Option<EventSnapshot> {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json as json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
events::{
|
||||||
|
EventManifestSnapshot, EventMediaSnapshot, EventSnapshot, MarketSnapshot, OutcomeSnapshot,
|
||||||
|
},
|
||||||
|
markets::{OddsVersionSnapshot, OutcomeOddsSnapshot},
|
||||||
|
settlement::SettlementSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SettlementFixture {
|
||||||
|
pub event_id: Uuid,
|
||||||
|
pub settlement: SettlementSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SampleStudyFixture {
|
||||||
|
pub manifest: EventManifestSnapshot,
|
||||||
|
pub odds_versions: Vec<OddsVersionSnapshot>,
|
||||||
|
pub settlement: SettlementFixture,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_sample_study_fixture() -> SampleStudyFixture {
|
||||||
|
let raw = include_str!("../../../fixtures/manifests/sample_event.json");
|
||||||
|
json::from_str(raw).expect("valid sample study fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_sample_study_fixture() -> SampleStudyFixture {
|
||||||
|
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 odds_version_id =
|
||||||
|
Uuid::parse_str("66666666-6666-6666-6666-666666666666").expect("valid uuid");
|
||||||
|
let lock_at = parse_ts("2099-01-01T00:10:00Z");
|
||||||
|
let settle_at = parse_ts("2099-01-01T00:25:00Z");
|
||||||
|
let created_at = parse_ts("2099-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
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 event = EventSnapshot {
|
||||||
|
id: event_id,
|
||||||
|
sport_type: "football".to_string(),
|
||||||
|
source_ref: "sample-event-001".to_string(),
|
||||||
|
title_en: "Late winner chance".to_string(),
|
||||||
|
title_sv: "Möjlighet till segermål".to_string(),
|
||||||
|
status: "prefetch_ready".to_string(),
|
||||||
|
preview_start_ms: 0,
|
||||||
|
preview_end_ms: 45_000,
|
||||||
|
reveal_start_ms: 50_000,
|
||||||
|
reveal_end_ms: 90_000,
|
||||||
|
lock_at,
|
||||||
|
settle_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
let media = EventMediaSnapshot {
|
||||||
|
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").expect("valid uuid"),
|
||||||
|
event_id,
|
||||||
|
media_type: "hls_main".to_string(),
|
||||||
|
hls_master_url: "https://cdn.example.com/hermes/sample-event/master.m3u8".to_string(),
|
||||||
|
poster_url: Some("https://cdn.example.com/hermes/sample-event/poster.jpg".to_string()),
|
||||||
|
duration_ms: 90_000,
|
||||||
|
preview_start_ms: 0,
|
||||||
|
preview_end_ms: 45_000,
|
||||||
|
reveal_start_ms: 50_000,
|
||||||
|
reveal_end_ms: 90_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
question_key: "market.sample.winner".to_string(),
|
||||||
|
market_type: "winner".to_string(),
|
||||||
|
status: "open".to_string(),
|
||||||
|
lock_at,
|
||||||
|
settlement_rule_key: "settle_on_match_winner".to_string(),
|
||||||
|
outcomes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let odds_version = OddsVersionSnapshot {
|
||||||
|
id: odds_version_id,
|
||||||
|
market_id,
|
||||||
|
version_no: 1,
|
||||||
|
created_at,
|
||||||
|
is_current: true,
|
||||||
|
odds: vec![
|
||||||
|
OutcomeOddsSnapshot {
|
||||||
|
id: Uuid::parse_str("77777777-7777-7777-7777-777777777777").expect("valid uuid"),
|
||||||
|
odds_version_id,
|
||||||
|
outcome_id: home_outcome_id,
|
||||||
|
decimal_odds: 1.85,
|
||||||
|
fractional_num: 17,
|
||||||
|
fractional_den: 20,
|
||||||
|
},
|
||||||
|
OutcomeOddsSnapshot {
|
||||||
|
id: Uuid::parse_str("88888888-8888-8888-8888-888888888888").expect("valid uuid"),
|
||||||
|
odds_version_id,
|
||||||
|
outcome_id: away_outcome_id,
|
||||||
|
decimal_odds: 2.05,
|
||||||
|
fractional_num: 21,
|
||||||
|
fractional_den: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let settlement = SettlementFixture {
|
||||||
|
event_id,
|
||||||
|
settlement: SettlementSnapshot {
|
||||||
|
id: Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("valid uuid"),
|
||||||
|
market_id,
|
||||||
|
settled_at: settle_at,
|
||||||
|
winning_outcome_id: home_outcome_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
SampleStudyFixture {
|
||||||
|
manifest: EventManifestSnapshot {
|
||||||
|
event,
|
||||||
|
media: vec![media],
|
||||||
|
markets: vec![market],
|
||||||
|
},
|
||||||
|
odds_versions: vec![odds_version],
|
||||||
|
settlement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ts(value: &str) -> DateTime<Utc> {
|
||||||
|
DateTime::parse_from_rfc3339(value)
|
||||||
|
.expect("valid timestamp")
|
||||||
|
.with_timezone(&Utc)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
|
pub mod admin;
|
||||||
pub mod bets;
|
pub mod bets;
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -8,6 +9,7 @@ pub mod db;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod experiments;
|
pub mod experiments;
|
||||||
|
pub mod fixtures;
|
||||||
pub mod localization;
|
pub mod localization;
|
||||||
pub mod markets;
|
pub mod markets;
|
||||||
pub mod settlement;
|
pub mod settlement;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::fixtures::SampleStudyFixture;
|
||||||
use crate::events::{MarketSnapshot, OutcomeSnapshot};
|
use crate::events::{MarketSnapshot, OutcomeSnapshot};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -42,9 +43,46 @@ struct MarketsState {
|
|||||||
|
|
||||||
impl MarketsStore {
|
impl MarketsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::with_sample_data()
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_fixture(fixture: &SampleStudyFixture) -> Self {
|
||||||
|
let mut markets_by_event_id: HashMap<Uuid, Vec<MarketSnapshot>> = HashMap::new();
|
||||||
|
let mut markets_by_id = HashMap::new();
|
||||||
|
let mut outcomes_by_id = HashMap::new();
|
||||||
|
let mut current_odds_by_market_id = HashMap::new();
|
||||||
|
|
||||||
|
for market_fixture in &fixture.manifest.markets {
|
||||||
|
let market = market_fixture.clone();
|
||||||
|
markets_by_event_id.entry(market.event_id).or_default().push(market.clone());
|
||||||
|
markets_by_id.insert(market.id, market.clone());
|
||||||
|
|
||||||
|
for outcome in &market.outcomes {
|
||||||
|
outcomes_by_id.insert(outcome.id, outcome.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(odds_version) = fixture
|
||||||
|
.odds_versions
|
||||||
|
.iter()
|
||||||
|
.find(|odds_version| odds_version.market_id == market.id)
|
||||||
|
.cloned() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
current_odds_by_market_id.insert(market.id, odds_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(MarketsState {
|
||||||
|
markets_by_event_id,
|
||||||
|
markets_by_id,
|
||||||
|
outcomes_by_id,
|
||||||
|
current_odds_by_market_id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code, unused_variables)]
|
||||||
pub fn with_sample_data() -> Self {
|
pub fn with_sample_data() -> Self {
|
||||||
let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid");
|
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 market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid");
|
||||||
@@ -123,14 +161,35 @@ impl MarketsStore {
|
|||||||
let mut current_odds_by_market_id = HashMap::new();
|
let mut current_odds_by_market_id = HashMap::new();
|
||||||
current_odds_by_market_id.insert(market_id, odds_version);
|
current_odds_by_market_id.insert(market_id, odds_version);
|
||||||
|
|
||||||
Self {
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
inner: Arc::new(RwLock::new(MarketsState {
|
}
|
||||||
markets_by_event_id,
|
|
||||||
markets_by_id,
|
pub async fn insert_market(&self, market: MarketSnapshot) -> MarketSnapshot {
|
||||||
outcomes_by_id,
|
let mut state = self.inner.write().await;
|
||||||
current_odds_by_market_id,
|
|
||||||
})),
|
state
|
||||||
|
.markets_by_event_id
|
||||||
|
.entry(market.event_id)
|
||||||
|
.or_default()
|
||||||
|
.retain(|existing| existing.id != market.id);
|
||||||
|
state
|
||||||
|
.markets_by_event_id
|
||||||
|
.entry(market.event_id)
|
||||||
|
.or_default()
|
||||||
|
.push(market.clone());
|
||||||
|
state.markets_by_id.insert(market.id, market.clone());
|
||||||
|
|
||||||
|
for outcome in &market.outcomes {
|
||||||
|
state.outcomes_by_id.insert(outcome.id, outcome.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
market
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publish_odds(&self, odds: OddsVersionSnapshot) -> OddsVersionSnapshot {
|
||||||
|
let mut state = self.inner.write().await;
|
||||||
|
state.current_odds_by_market_id.insert(odds.market_id, odds.clone());
|
||||||
|
odds
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
|
pub async fn markets_for_event(&self, event_id: Uuid) -> Option<Vec<MarketSnapshot>> {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use axum::{extract::{Extension, Json}, http::StatusCode};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app_state::{AppState, EventManifestSnapshot, MarketSnapshot, OddsVersionSnapshot, SettlementSnapshot},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn create_event(
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Json(payload): Json<EventManifestSnapshot>,
|
||||||
|
) -> Result<(StatusCode, Json<EventManifestSnapshot>), AppError> {
|
||||||
|
let created = state.admin_create_event_manifest(payload).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_market(
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Json(payload): Json<MarketSnapshot>,
|
||||||
|
) -> Result<(StatusCode, Json<MarketSnapshot>), AppError> {
|
||||||
|
let created = state.admin_create_market(payload).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_odds(
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Json(payload): Json<OddsVersionSnapshot>,
|
||||||
|
) -> Result<(StatusCode, Json<OddsVersionSnapshot>), AppError> {
|
||||||
|
let created = state.admin_publish_odds(payload).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_settlement(
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Json(payload): Json<SettlementSnapshot>,
|
||||||
|
) -> Result<(StatusCode, Json<SettlementSnapshot>), AppError> {
|
||||||
|
let created = state.admin_publish_settlement(payload).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod admin;
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod bets;
|
pub mod bets;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
@@ -19,6 +20,10 @@ pub fn router() -> Router {
|
|||||||
.route("/api/v1/events/{event_id}/markets", get(markets::list_for_event))
|
.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/events/{event_id}/result", get(results::show))
|
||||||
.route("/api/v1/markets/{market_id}/odds/current", get(markets::current_odds))
|
.route("/api/v1/markets/{market_id}/odds/current", get(markets::current_odds))
|
||||||
|
.route("/api/v1/admin/events", post(admin::create_event))
|
||||||
|
.route("/api/v1/admin/markets", post(admin::create_market))
|
||||||
|
.route("/api/v1/admin/odds", post(admin::create_odds))
|
||||||
|
.route("/api/v1/admin/settlements", post(admin::create_settlement))
|
||||||
.route("/api/v1/bets/intent", post(bets::submit))
|
.route("/api/v1/bets/intent", post(bets::submit))
|
||||||
.route("/api/v1/bets/{bet_intent_id}", get(bets::show))
|
.route("/api/v1/bets/{bet_intent_id}", get(bets::show))
|
||||||
.route("/api/v1/analytics/batch", post(analytics::batch))
|
.route("/api/v1/analytics/batch", post(analytics::batch))
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::fixtures::SampleStudyFixture;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct SettlementSnapshot {
|
pub struct SettlementSnapshot {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -14,15 +17,39 @@ pub struct SettlementSnapshot {
|
|||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct SettlementsStore {
|
pub struct SettlementsStore {
|
||||||
|
inner: Arc<RwLock<SettlementsState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SettlementsState {
|
||||||
settlements_by_event_id: HashMap<Uuid, SettlementSnapshot>,
|
settlements_by_event_id: HashMap<Uuid, SettlementSnapshot>,
|
||||||
settlements_by_market_id: HashMap<Uuid, SettlementSnapshot>,
|
settlements_by_market_id: HashMap<Uuid, SettlementSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettlementsStore {
|
impl SettlementsStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::with_sample_data()
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_fixture(fixture: &SampleStudyFixture) -> Self {
|
||||||
|
let mut settlements_by_event_id = HashMap::new();
|
||||||
|
settlements_by_event_id.insert(fixture.settlement.event_id, fixture.settlement.settlement.clone());
|
||||||
|
|
||||||
|
let mut settlements_by_market_id = HashMap::new();
|
||||||
|
settlements_by_market_id.insert(
|
||||||
|
fixture.settlement.settlement.market_id,
|
||||||
|
fixture.settlement.settlement.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(SettlementsState {
|
||||||
|
settlements_by_event_id,
|
||||||
|
settlements_by_market_id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code, unused_variables)]
|
||||||
pub fn with_sample_data() -> Self {
|
pub fn with_sample_data() -> Self {
|
||||||
let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid");
|
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 market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid");
|
||||||
@@ -41,23 +68,31 @@ impl SettlementsStore {
|
|||||||
let mut settlements_by_market_id = HashMap::new();
|
let mut settlements_by_market_id = HashMap::new();
|
||||||
settlements_by_market_id.insert(market_id, settlement.clone());
|
settlements_by_market_id.insert(market_id, settlement.clone());
|
||||||
|
|
||||||
Self {
|
Self::from_fixture(&crate::fixtures::load_sample_study_fixture())
|
||||||
settlements_by_event_id,
|
|
||||||
settlements_by_market_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn settlement_for_event(&self, event_id: Uuid) -> Option<SettlementSnapshot> {
|
pub async fn settlement_for_event(&self, event_id: Uuid) -> Option<SettlementSnapshot> {
|
||||||
let Some(settlement) = self.settlements_by_event_id.get(&event_id) else {
|
let state = self.inner.read().await;
|
||||||
|
let Some(settlement) = state.settlements_by_event_id.get(&event_id) else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some(settlement.clone())
|
Some(settlement.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn settlement_for_market(&self, market_id: Uuid) -> Option<SettlementSnapshot> {
|
pub async fn settlement_for_market(&self, market_id: Uuid) -> Option<SettlementSnapshot> {
|
||||||
let Some(settlement) = self.settlements_by_market_id.get(&market_id) else {
|
let state = self.inner.read().await;
|
||||||
|
let Some(settlement) = state.settlements_by_market_id.get(&market_id) else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some(settlement.clone())
|
Some(settlement.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upsert(&self, event_id: Uuid, settlement: SettlementSnapshot) -> SettlementSnapshot {
|
||||||
|
let mut state = self.inner.write().await;
|
||||||
|
state.settlements_by_event_id.insert(event_id, settlement.clone());
|
||||||
|
state
|
||||||
|
.settlements_by_market_id
|
||||||
|
.insert(settlement.market_id, settlement.clone());
|
||||||
|
settlement
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
||||||
use serde_json as json;
|
use serde_json as json;
|
||||||
|
use uuid::Uuid;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -410,6 +411,223 @@ async fn analytics_batch_is_recorded() {
|
|||||||
assert_eq!(attribute_count, 1);
|
assert_eq!(attribute_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_endpoints_publish_round_data() {
|
||||||
|
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||||
|
|
||||||
|
let event_id = Uuid::new_v4();
|
||||||
|
let market_id = Uuid::new_v4();
|
||||||
|
let home_outcome_id = Uuid::new_v4();
|
||||||
|
let away_outcome_id = Uuid::new_v4();
|
||||||
|
let odds_version_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let event_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/admin/events")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json::json!({
|
||||||
|
"event": {
|
||||||
|
"id": event_id,
|
||||||
|
"sport_type": "football",
|
||||||
|
"source_ref": "admin-event-001",
|
||||||
|
"title_en": "Admin created event",
|
||||||
|
"title_sv": "Adminskapad händelse",
|
||||||
|
"status": "scheduled",
|
||||||
|
"preview_start_ms": 0,
|
||||||
|
"preview_end_ms": 1000,
|
||||||
|
"reveal_start_ms": 2000,
|
||||||
|
"reveal_end_ms": 3000,
|
||||||
|
"lock_at": "2099-01-01T01:10:00Z",
|
||||||
|
"settle_at": "2099-01-01T01:25:00Z"
|
||||||
|
},
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"id": Uuid::new_v4(),
|
||||||
|
"event_id": event_id,
|
||||||
|
"media_type": "hls_main",
|
||||||
|
"hls_master_url": "https://cdn.example.com/admin/master.m3u8",
|
||||||
|
"poster_url": "https://cdn.example.com/admin/poster.jpg",
|
||||||
|
"duration_ms": 3000,
|
||||||
|
"preview_start_ms": 0,
|
||||||
|
"preview_end_ms": 1000,
|
||||||
|
"reveal_start_ms": 2000,
|
||||||
|
"reveal_end_ms": 3000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markets": []
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(event_response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let market_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/admin/markets")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json::json!({
|
||||||
|
"id": market_id,
|
||||||
|
"event_id": event_id,
|
||||||
|
"question_key": "market.admin.winner",
|
||||||
|
"market_type": "winner",
|
||||||
|
"status": "open",
|
||||||
|
"lock_at": "2099-01-01T01:10:00Z",
|
||||||
|
"settlement_rule_key": "settle_on_match_winner",
|
||||||
|
"outcomes": [
|
||||||
|
{
|
||||||
|
"id": home_outcome_id,
|
||||||
|
"market_id": market_id,
|
||||||
|
"outcome_code": "home",
|
||||||
|
"label_key": "outcome.home",
|
||||||
|
"sort_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": away_outcome_id,
|
||||||
|
"market_id": market_id,
|
||||||
|
"outcome_code": "away",
|
||||||
|
"label_key": "outcome.away",
|
||||||
|
"sort_order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(market_response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let odds_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/admin/odds")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json::json!({
|
||||||
|
"id": odds_version_id,
|
||||||
|
"market_id": market_id,
|
||||||
|
"version_no": 1,
|
||||||
|
"created_at": "2099-01-01T01:00:00Z",
|
||||||
|
"is_current": true,
|
||||||
|
"odds": [
|
||||||
|
{
|
||||||
|
"id": Uuid::new_v4(),
|
||||||
|
"odds_version_id": odds_version_id,
|
||||||
|
"outcome_id": home_outcome_id,
|
||||||
|
"decimal_odds": 1.9,
|
||||||
|
"fractional_num": 9,
|
||||||
|
"fractional_den": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": Uuid::new_v4(),
|
||||||
|
"odds_version_id": odds_version_id,
|
||||||
|
"outcome_id": away_outcome_id,
|
||||||
|
"decimal_odds": 2.1,
|
||||||
|
"fractional_num": 11,
|
||||||
|
"fractional_den": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(odds_response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let settlement_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/admin/settlements")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json::json!({
|
||||||
|
"id": Uuid::new_v4(),
|
||||||
|
"market_id": market_id,
|
||||||
|
"settled_at": "2099-01-01T01:25:00Z",
|
||||||
|
"winning_outcome_id": home_outcome_id
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(settlement_response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let manifest_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/v1/events/{event_id}/manifest"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let manifest_body = to_bytes(manifest_response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let manifest_json: json::Value = json::from_slice(&manifest_body).unwrap();
|
||||||
|
assert_eq!(manifest_json["markets"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let odds_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/v1/markets/{market_id}/odds/current"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(odds_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let odds_body = to_bytes(odds_response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let odds_json: json::Value = json::from_slice(&odds_body).unwrap();
|
||||||
|
assert_eq!(odds_json["id"], odds_version_id.to_string());
|
||||||
|
|
||||||
|
let result_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/v1/events/{event_id}/result"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let result_body = to_bytes(result_response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let result_json: json::Value = json::from_slice(&result_body).unwrap();
|
||||||
|
assert_eq!(result_json["market_id"], market_id.to_string());
|
||||||
|
assert_eq!(result_json["winning_outcome_id"], home_outcome_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn event_markets_and_current_odds_work() {
|
async fn event_markets_and_current_odds_work() {
|
||||||
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"event": {
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"sport_type": "football",
|
||||||
|
"source_ref": "sample-event-001",
|
||||||
|
"title_en": "Late winner chance",
|
||||||
|
"title_sv": "Möjlighet till segermål",
|
||||||
|
"status": "prefetch_ready",
|
||||||
|
"preview_start_ms": 0,
|
||||||
|
"preview_end_ms": 45000,
|
||||||
|
"reveal_start_ms": 50000,
|
||||||
|
"reveal_end_ms": 90000,
|
||||||
|
"lock_at": "2099-01-01T00:10:00Z",
|
||||||
|
"settle_at": "2099-01-01T00:25:00Z"
|
||||||
|
},
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"id": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"event_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"media_type": "hls_main",
|
||||||
|
"hls_master_url": "https://cdn.example.com/hermes/sample-event/master.m3u8",
|
||||||
|
"poster_url": "https://cdn.example.com/hermes/sample-event/poster.jpg",
|
||||||
|
"duration_ms": 90000,
|
||||||
|
"preview_start_ms": 0,
|
||||||
|
"preview_end_ms": 45000,
|
||||||
|
"reveal_start_ms": 50000,
|
||||||
|
"reveal_end_ms": 90000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markets": [
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"event_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"question_key": "market.sample.winner",
|
||||||
|
"market_type": "winner",
|
||||||
|
"status": "open",
|
||||||
|
"lock_at": "2099-01-01T00:10:00Z",
|
||||||
|
"settlement_rule_key": "settle_on_match_winner",
|
||||||
|
"outcomes": [
|
||||||
|
{
|
||||||
|
"id": "44444444-4444-4444-4444-444444444444",
|
||||||
|
"market_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"outcome_code": "home",
|
||||||
|
"label_key": "outcome.home",
|
||||||
|
"sort_order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "55555555-5555-5555-5555-555555555555",
|
||||||
|
"market_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"outcome_code": "away",
|
||||||
|
"label_key": "outcome.away",
|
||||||
|
"sort_order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"odds_versions": [
|
||||||
|
{
|
||||||
|
"id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"market_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"version_no": 1,
|
||||||
|
"created_at": "2099-01-01T00:00:00Z",
|
||||||
|
"is_current": true,
|
||||||
|
"odds": [
|
||||||
|
{
|
||||||
|
"id": "77777777-7777-7777-7777-777777777777",
|
||||||
|
"odds_version_id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"outcome_id": "44444444-4444-4444-4444-444444444444",
|
||||||
|
"decimal_odds": 1.85,
|
||||||
|
"fractional_num": 17,
|
||||||
|
"fractional_den": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "88888888-8888-8888-8888-888888888888",
|
||||||
|
"odds_version_id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"outcome_id": "55555555-5555-5555-5555-555555555555",
|
||||||
|
"decimal_odds": 2.05,
|
||||||
|
"fractional_num": 21,
|
||||||
|
"fractional_den": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settlement": {
|
||||||
|
"event_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"settlement": {
|
||||||
|
"id": "99999999-9999-9999-9999-999999999999",
|
||||||
|
"market_id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"settled_at": "2099-01-01T00:25:00Z",
|
||||||
|
"winning_outcome_id": "44444444-4444-4444-4444-444444444444"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HermesApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Hermes")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
Text("Native study app scaffold")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
OnboardingView()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Hermes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AnalyticsTracking {
|
||||||
|
func track(_ event: String, attributes: [String: String])
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HermesAnalyticsClient: AnalyticsTracking {
|
||||||
|
func track(_ event: String, attributes: [String: String]) {
|
||||||
|
// Scaffold implementation.
|
||||||
|
_ = event
|
||||||
|
_ = attributes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum HermesTheme {
|
||||||
|
static let background = Color.black
|
||||||
|
static let surface = Color(red: 0.12, green: 0.12, blue: 0.14)
|
||||||
|
static let accent = Color(red: 0.87, green: 0.74, blue: 0.34)
|
||||||
|
static let textPrimary = Color.white
|
||||||
|
static let textSecondary = Color.white.opacity(0.72)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GestureHandlers {
|
||||||
|
func handleTap() {}
|
||||||
|
func handleSwipeDown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class HapticsController {
|
||||||
|
func selectionAccepted() {}
|
||||||
|
func marketLocked() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class LocalizationStore {
|
||||||
|
private let bundle: Bundle
|
||||||
|
|
||||||
|
init(bundle: Bundle = .main) {
|
||||||
|
self.bundle = bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func string(for key: String, locale: String) -> String {
|
||||||
|
guard let path = bundle.path(forResource: locale, ofType: "lproj"),
|
||||||
|
let localizedBundle = Bundle(path: path) else {
|
||||||
|
return bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localizedBundle.localizedString(forKey: key, value: nil, table: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class PlayerCoordinator: ObservableObject {
|
||||||
|
@Published var isPlaying = false
|
||||||
|
@Published var playbackPositionMs: Int = 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct APIEnvironment {
|
||||||
|
let baseURL: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HermesAPIClient {
|
||||||
|
let environment: APIEnvironment
|
||||||
|
let session: URLSession
|
||||||
|
|
||||||
|
init(environment: APIEnvironment, session: URLSession = .shared) {
|
||||||
|
self.environment = environment
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(path: String) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let url = environment.baseURL.appendingPathComponent(path)
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
return (data, httpResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FeedView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Next round")
|
||||||
|
.font(.headline)
|
||||||
|
Text("A new clip is ready for review.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(HermesTheme.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnboardingView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Study intro")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Watch the clip, make your choice before lock, then see the reveal.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Start session") {}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(HermesTheme.surface)
|
||||||
|
.foregroundStyle(HermesTheme.textPrimary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ResultView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Result scaffold")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RevealView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Reveal scaffold")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RoundView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Round scaffold")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SelectionView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Selection scaffold")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SessionView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Session scaffold")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Settings scaffold")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"app.name" = "Hermes";
|
||||||
|
"onboarding.title" = "Study intro";
|
||||||
|
"onboarding.start_session" = "Start session";
|
||||||
|
"feed.next_round_title" = "Next round";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"app.name" = "Hermes";
|
||||||
|
"onboarding.title" = "Studieintro";
|
||||||
|
"onboarding.start_session" = "Starta session";
|
||||||
|
"feed.next_round_title" = "Nästa runda";
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cargo run --quiet --manifest-path "backend/Cargo.toml" --bin generate_test_event
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
./scripts/generate_test_event.sh > fixtures/manifests/sample_event.json
|
||||||
Reference in New Issue
Block a user