This commit is contained in:
2026-04-09 15:04:59 +02:00
parent 4a85efc270
commit 4cc22447c7
12 changed files with 589 additions and 68 deletions
+6
View File
@@ -17,11 +17,17 @@ authoritative for timing, odds, settlement, and analytics.
- `backend/` Rust API and migrations
- `contracts/` OpenAPI and localization contracts
- `docs/` architecture, schema, and state-machine docs
- `docs/conventions.md` Rust style notes
- `infra/` local environment and deployment assets
- `mobile/` native app scaffolds
- `fixtures/` sample media and test data
- `scripts/` helper automation
## Coding Conventions
- JSON-heavy Rust files should import `serde_json as json`
- Prefer happy-path functions and early `let Some(...) = ... else { ... }` exits
## Status
The repository has been initialized with the first planning and backend
+60 -63
View File
@@ -1,13 +1,19 @@
use std::{sync::Arc, time::Instant};
use std::time::Instant;
use chrono::{DateTime, Utc};
use redis::Client as RedisClient;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::{config::AppConfig, error::AppError};
pub use crate::events::{EventManifestSnapshot, EventSnapshot};
pub use crate::sessions::{SessionSnapshot, SessionStartRequest};
use crate::{
config::AppConfig,
error::AppError,
events::{EventsStore},
sessions::{SessionDefaults, SessionsStore},
users::{UserCreateRequest, UsersStore},
};
#[derive(Clone)]
pub struct AppState {
@@ -15,32 +21,9 @@ pub struct AppState {
pub started_at: Instant,
pub database_pool: Option<PgPool>,
pub redis_client: Option<RedisClient>,
current_user_id: Uuid,
session: Arc<RwLock<Option<SessionSnapshot>>>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SessionStartRequest {
pub locale_code: Option<String>,
pub device_platform: Option<String>,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub app_version: Option<String>,
pub experiment_variant: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session_id: Uuid,
pub user_id: Uuid,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub experiment_variant: String,
pub app_version: String,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub locale_code: String,
pub device_platform: String,
events: EventsStore,
users: UsersStore,
sessions: SessionsStore,
}
impl AppState {
@@ -54,49 +37,63 @@ impl AppState {
started_at: Instant::now(),
database_pool,
redis_client,
current_user_id: Uuid::new_v4(),
session: Arc::new(RwLock::new(None)),
events: EventsStore::new(),
users: UsersStore::new(),
sessions: SessionsStore::new(),
}
}
pub async fn start_session(&self, request: SessionStartRequest) -> SessionSnapshot {
let session = SessionSnapshot {
session_id: Uuid::new_v4(),
user_id: self.current_user_id,
started_at: Utc::now(),
ended_at: None,
experiment_variant: request
.experiment_variant
.unwrap_or_else(|| self.config.default_experiment_variant.clone()),
app_version: request
.app_version
.unwrap_or_else(|| self.config.app_version.clone()),
device_model: request.device_model,
os_version: request.os_version,
locale_code: request
.locale_code
.unwrap_or_else(|| self.config.default_locale.clone()),
device_platform: request
.device_platform
.unwrap_or_else(|| self.config.default_device_platform.clone()),
};
let user = self
.users
.upsert(UserCreateRequest {
external_ref: request.external_ref.clone(),
preferred_language: request
.locale_code
.clone()
.unwrap_or_else(|| self.config.default_locale.clone()),
device_platform: request
.device_platform
.clone()
.unwrap_or_else(|| self.config.default_device_platform.clone()),
})
.await;
*self.session.write().await = Some(session.clone());
session
self.sessions
.start_session(
user.id,
request,
SessionDefaults {
default_locale: self.config.default_locale.clone(),
default_device_platform: self.config.default_device_platform.clone(),
default_app_version: self.config.app_version.clone(),
default_experiment_variant: self.config.default_experiment_variant.clone(),
},
)
.await
}
pub async fn current_session(&self) -> Option<SessionSnapshot> {
self.session.read().await.clone()
self.sessions.current_session().await
}
pub async fn end_session(&self) -> Result<SessionSnapshot, AppError> {
let mut guard = self.session.write().await;
let mut session = guard
.clone()
.ok_or_else(|| AppError::not_found("No active session"))?;
session.ended_at = Some(Utc::now());
*guard = Some(session.clone());
Ok(session)
self.sessions
.end_session()
.await
.map_err(|_| AppError::not_found("No active session"))
}
pub async fn next_event(&self) -> Option<EventSnapshot> {
self.events.next_event().await
}
pub async fn event(&self, event_id: Uuid) -> Option<EventSnapshot> {
self.events.get_event(event_id).await
}
pub async fn event_manifest(&self, event_id: Uuid) -> Option<EventManifestSnapshot> {
self.events.get_manifest(event_id).await
}
pub fn database_ready(&self) -> bool {
+189
View File
@@ -0,0 +1,189 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventSnapshot {
pub id: Uuid,
pub sport_type: String,
pub source_ref: String,
pub title_en: String,
pub title_sv: String,
pub status: String,
pub preview_start_ms: i64,
pub preview_end_ms: i64,
pub reveal_start_ms: i64,
pub reveal_end_ms: i64,
pub lock_at: DateTime<Utc>,
pub settle_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventMediaSnapshot {
pub id: Uuid,
pub event_id: Uuid,
pub media_type: String,
pub hls_master_url: String,
pub poster_url: Option<String>,
pub duration_ms: i64,
pub preview_start_ms: i64,
pub preview_end_ms: i64,
pub reveal_start_ms: i64,
pub reveal_end_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OutcomeSnapshot {
pub id: Uuid,
pub market_id: Uuid,
pub outcome_code: String,
pub label_key: String,
pub sort_order: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MarketSnapshot {
pub id: Uuid,
pub event_id: Uuid,
pub question_key: String,
pub market_type: String,
pub status: String,
pub lock_at: DateTime<Utc>,
pub settlement_rule_key: String,
pub outcomes: Vec<OutcomeSnapshot>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventManifestSnapshot {
pub event: EventSnapshot,
pub media: Vec<EventMediaSnapshot>,
pub markets: Vec<MarketSnapshot>,
}
#[derive(Clone, Default)]
pub struct EventsStore {
inner: Arc<RwLock<EventsState>>,
}
#[derive(Default)]
struct EventsState {
feed_order: Vec<Uuid>,
manifests_by_event_id: HashMap<Uuid, EventManifestSnapshot>,
}
impl EventsStore {
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 preview_start = 0;
let preview_end = 45_000;
let reveal_start = 50_000;
let reveal_end = 90_000;
let lock_at = Utc::now() + ChronoDuration::minutes(10);
let settle_at = Utc::now() + ChronoDuration::minutes(25);
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: preview_start,
preview_end_ms: preview_end,
reveal_start_ms: reveal_start,
reveal_end_ms: reveal_end,
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: preview_start,
preview_end_ms: preview_end,
reveal_start_ms: reveal_start,
reveal_end_ms: reveal_end,
};
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: vec![
OutcomeSnapshot {
id: Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"),
market_id,
outcome_code: "home".to_string(),
label_key: "outcome.home".to_string(),
sort_order: 1,
},
OutcomeSnapshot {
id: Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid"),
market_id,
outcome_code: "away".to_string(),
label_key: "outcome.away".to_string(),
sort_order: 2,
},
],
};
let manifest = EventManifestSnapshot {
event,
media: vec![media],
markets: vec![market],
};
let mut manifests_by_event_id = HashMap::new();
manifests_by_event_id.insert(event_id, manifest);
Self {
inner: Arc::new(RwLock::new(EventsState {
feed_order: vec![event_id],
manifests_by_event_id,
})),
}
}
pub async fn next_event(&self) -> Option<EventSnapshot> {
let state = self.inner.read().await;
let Some(event_id) = state.feed_order.first() else {
return None;
};
let Some(manifest) = state.manifests_by_event_id.get(event_id) else {
return None;
};
Some(manifest.event.clone())
}
pub async fn get_event(&self, event_id: Uuid) -> Option<EventSnapshot> {
let state = self.inner.read().await;
let Some(manifest) = state.manifests_by_event_id.get(&event_id) else {
return None;
};
Some(manifest.event.clone())
}
pub async fn get_manifest(&self, event_id: Uuid) -> Option<EventManifestSnapshot> {
let state = self.inner.read().await;
let Some(manifest) = state.manifests_by_event_id.get(&event_id) else {
return None;
};
Some(manifest.clone())
}
}
+3
View File
@@ -3,8 +3,11 @@
pub mod app_state;
pub mod config;
pub mod db;
pub mod events;
pub mod error;
pub mod sessions;
pub mod routes;
pub mod users;
pub mod telemetry;
use axum::Router;
+31
View File
@@ -0,0 +1,31 @@
use axum::{extract::{Extension, Path}, Json};
use uuid::Uuid;
use crate::{app_state::{AppState, EventManifestSnapshot, EventSnapshot}, error::AppError};
pub async fn next(Extension(state): Extension<AppState>) -> Result<Json<EventSnapshot>, AppError> {
let Some(event) = state.next_event().await else {
return Err(AppError::not_found("No event available"));
};
Ok(Json(event))
}
pub async fn show(
Path(event_id): Path<Uuid>,
Extension(state): Extension<AppState>,
) -> Result<Json<EventSnapshot>, AppError> {
let Some(event) = state.event(event_id).await else {
return Err(AppError::not_found("Event not found"));
};
Ok(Json(event))
}
pub async fn manifest(
Path(event_id): Path<Uuid>,
Extension(state): Extension<AppState>,
) -> Result<Json<EventManifestSnapshot>, AppError> {
let Some(manifest) = state.event_manifest(event_id).await else {
return Err(AppError::not_found("Event manifest not found"));
};
Ok(Json(manifest))
}
+4
View File
@@ -1,4 +1,5 @@
pub mod health;
pub mod events;
pub mod session;
use axum::{routing::{get, post}, Router};
@@ -6,6 +7,9 @@ use axum::{routing::{get, post}, Router};
pub fn router() -> Router {
Router::new()
.route("/health", get(health::handler))
.route("/api/v1/feed/next", get(events::next))
.route("/api/v1/events/{event_id}", get(events::show))
.route("/api/v1/events/{event_id}/manifest", get(events::manifest))
.route("/api/v1/session/start", post(session::start))
.route("/api/v1/session/end", post(session::end))
.route("/api/v1/session/me", get(session::me))
+3 -4
View File
@@ -16,9 +16,8 @@ pub async fn end(Extension(state): Extension<AppState>) -> Result<Json<SessionSn
}
pub async fn me(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
let session = state
.current_session()
.await
.ok_or_else(|| AppError::not_found("No active session"))?;
let Some(session) = state.current_session().await else {
return Err(AppError::not_found("No active session"));
};
Ok(Json(session))
}
+112
View File
@@ -0,0 +1,112 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize)]
pub struct SessionStartRequest {
pub locale_code: Option<String>,
pub device_platform: Option<String>,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub app_version: Option<String>,
pub experiment_variant: Option<String>,
pub external_ref: Option<String>,
}
#[derive(Clone, Debug)]
pub struct SessionDefaults {
pub default_locale: String,
pub default_device_platform: String,
pub default_app_version: String,
pub default_experiment_variant: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session_id: Uuid,
pub user_id: Uuid,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub experiment_variant: String,
pub app_version: String,
pub device_model: Option<String>,
pub os_version: Option<String>,
pub locale_code: String,
pub device_platform: String,
}
#[derive(Clone, Default)]
pub struct SessionsStore {
inner: Arc<RwLock<SessionsState>>,
}
#[derive(Default)]
struct SessionsState {
sessions_by_id: HashMap<Uuid, SessionSnapshot>,
current_session_id: Option<Uuid>,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionStoreError {
#[error("no active session")]
NoActiveSession,
}
impl SessionsStore {
pub fn new() -> Self {
Self::default()
}
pub async fn start_session(
&self,
user_id: Uuid,
request: SessionStartRequest,
defaults: SessionDefaults,
) -> SessionSnapshot {
let snapshot = SessionSnapshot {
session_id: Uuid::new_v4(),
user_id,
started_at: Utc::now(),
ended_at: None,
experiment_variant: request
.experiment_variant
.unwrap_or(defaults.default_experiment_variant),
app_version: request.app_version.unwrap_or(defaults.default_app_version),
device_model: request.device_model,
os_version: request.os_version,
locale_code: request.locale_code.unwrap_or(defaults.default_locale),
device_platform: request
.device_platform
.unwrap_or(defaults.default_device_platform),
};
let mut state = self.inner.write().await;
state.current_session_id = Some(snapshot.session_id);
state.sessions_by_id.insert(snapshot.session_id, snapshot.clone());
snapshot
}
pub async fn current_session(&self) -> Option<SessionSnapshot> {
let state = self.inner.read().await;
let Some(session_id) = state.current_session_id else {
return None;
};
state.sessions_by_id.get(&session_id).cloned()
}
pub async fn end_session(&self) -> Result<SessionSnapshot, SessionStoreError> {
let mut state = self.inner.write().await;
let Some(session_id) = state.current_session_id else {
return Err(SessionStoreError::NoActiveSession);
};
let Some(snapshot) = state.sessions_by_id.get_mut(&session_id) else {
return Err(SessionStoreError::NoActiveSession);
};
snapshot.ended_at = Some(Utc::now());
Ok(snapshot.clone())
}
}
+85
View File
@@ -0,0 +1,85 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize)]
pub struct UserCreateRequest {
pub external_ref: Option<String>,
pub preferred_language: String,
pub device_platform: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserSnapshot {
pub id: Uuid,
pub external_ref: String,
pub created_at: DateTime<Utc>,
pub preferred_language: String,
pub device_platform: String,
}
#[derive(Clone, Default)]
pub struct UsersStore {
inner: Arc<RwLock<UsersState>>,
}
#[derive(Default)]
struct UsersState {
users_by_id: HashMap<Uuid, UserSnapshot>,
user_ids_by_external_ref: HashMap<String, Uuid>,
}
impl UsersStore {
pub fn new() -> Self {
Self::default()
}
fn insert_user(
state: &mut UsersState,
external_ref: String,
preferred_language: String,
device_platform: String,
) -> UserSnapshot {
let snapshot = UserSnapshot {
id: Uuid::new_v4(),
external_ref: external_ref.clone(),
created_at: Utc::now(),
preferred_language,
device_platform,
};
state.user_ids_by_external_ref.insert(external_ref, snapshot.id);
state.users_by_id.insert(snapshot.id, snapshot.clone());
snapshot
}
pub async fn upsert(&self, request: UserCreateRequest) -> UserSnapshot {
let UserCreateRequest {
external_ref,
preferred_language,
device_platform,
} = request;
let external_ref = external_ref.unwrap_or_else(|| format!("anon_{}", Uuid::new_v4()));
let mut state = self.inner.write().await;
let Some(user_id) = state.user_ids_by_external_ref.get(&external_ref).copied() else {
return Self::insert_user(&mut state, external_ref, preferred_language, device_platform);
};
let Some(existing) = state.users_by_id.get_mut(&user_id) else {
return Self::insert_user(&mut state, external_ref, preferred_language, device_platform);
};
existing.preferred_language = preferred_language;
existing.device_platform = device_platform;
existing.clone()
}
pub async fn get(&self, user_id: Uuid) -> Option<UserSnapshot> {
self.inner.read().await.users_by_id.get(&user_id).cloned()
}
}
+86 -1
View File
@@ -1,5 +1,6 @@
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
use serde_json as json;
use tower::ServiceExt;
#[tokio::test]
@@ -34,7 +35,7 @@ async fn session_start_and_me_work() {
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let json: json::Value = json::from_slice(&body).unwrap();
assert_eq!(json["locale_code"], "en");
let response = app
@@ -44,3 +45,87 @@ async fn session_start_and_me_work() {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn participant_ref_reuses_the_same_user() {
let app = build_router(AppState::new(AppConfig::default(), None, None));
let body = json::json!({
"external_ref": "participant-001",
"locale_code": "sv"
})
.to_string();
let first = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/session/start")
.header("content-type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
let first_body = to_bytes(first.into_body(), usize::MAX).await.unwrap();
let first_json: json::Value = json::from_slice(&first_body).unwrap();
let second = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/session/start")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let second_body = to_bytes(second.into_body(), usize::MAX).await.unwrap();
let second_json: json::Value = json::from_slice(&second_body).unwrap();
assert_eq!(first_json["user_id"], second_json["user_id"]);
assert_eq!(first_json["locale_code"], "sv");
}
#[tokio::test]
async fn feed_next_returns_a_manifestable_event() {
let app = build_router(AppState::new(AppConfig::default(), None, None));
let response = app
.clone()
.oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let event: json::Value = json::from_slice(&body).unwrap();
let event_id = event["id"].as_str().unwrap().to_string();
assert_eq!(event["status"], "prefetch_ready");
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/events/{event_id}/manifest"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let manifest: json::Value = json::from_slice(&body).unwrap();
assert_eq!(manifest["event"]["id"], event_id);
assert!(manifest["media"].as_array().unwrap().len() >= 1);
assert!(manifest["markets"].as_array().unwrap().len() >= 1);
}
+2
View File
@@ -278,6 +278,8 @@ components:
SessionStartRequest:
type: object
properties:
external_ref:
type: string
locale_code:
type: string
device_platform:
+8
View File
@@ -0,0 +1,8 @@
# Conventions
## Rust Style
- Prefer `use serde_json as json;` whenever a file works with JSON values or macros.
- Keep functions on the happy path.
- Use early exits for optional values with `let Some(x) = maybe else { return ...; }`.
- Avoid deep nesting when an early return makes the flow clearer.