diff --git a/README.md b/README.md index 3aaff43..41d502e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index ab9eb22..0afb043 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -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, pub redis_client: Option, - current_user_id: Uuid, - session: Arc>>, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct SessionStartRequest { - pub locale_code: Option, - pub device_platform: Option, - pub device_model: Option, - pub os_version: Option, - pub app_version: Option, - pub experiment_variant: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SessionSnapshot { - pub session_id: Uuid, - pub user_id: Uuid, - pub started_at: DateTime, - pub ended_at: Option>, - pub experiment_variant: String, - pub app_version: String, - pub device_model: Option, - pub os_version: Option, - 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 { - self.session.read().await.clone() + self.sessions.current_session().await } pub async fn end_session(&self) -> Result { - 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 { + self.events.next_event().await + } + + pub async fn event(&self, event_id: Uuid) -> Option { + self.events.get_event(event_id).await + } + + pub async fn event_manifest(&self, event_id: Uuid) -> Option { + self.events.get_manifest(event_id).await } pub fn database_ready(&self) -> bool { diff --git a/backend/src/events/mod.rs b/backend/src/events/mod.rs new file mode 100644 index 0000000..760514f --- /dev/null +++ b/backend/src/events/mod.rs @@ -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, + pub settle_at: DateTime, +} + +#[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, + 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, + pub settlement_rule_key: String, + pub outcomes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EventManifestSnapshot { + pub event: EventSnapshot, + pub media: Vec, + pub markets: Vec, +} + +#[derive(Clone, Default)] +pub struct EventsStore { + inner: Arc>, +} + +#[derive(Default)] +struct EventsState { + feed_order: Vec, + manifests_by_event_id: HashMap, +} + +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 { + 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 { + 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 { + let state = self.inner.read().await; + let Some(manifest) = state.manifests_by_event_id.get(&event_id) else { + return None; + }; + Some(manifest.clone()) + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index fda2e55..1699c08 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -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; diff --git a/backend/src/routes/events.rs b/backend/src/routes/events.rs new file mode 100644 index 0000000..cbef3eb --- /dev/null +++ b/backend/src/routes/events.rs @@ -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) -> Result, 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, + Extension(state): Extension, +) -> Result, 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, + Extension(state): Extension, +) -> Result, AppError> { + let Some(manifest) = state.event_manifest(event_id).await else { + return Err(AppError::not_found("Event manifest not found")); + }; + Ok(Json(manifest)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index f14de35..1213595 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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)) diff --git a/backend/src/routes/session.rs b/backend/src/routes/session.rs index 51b43de..d5f001e 100644 --- a/backend/src/routes/session.rs +++ b/backend/src/routes/session.rs @@ -16,9 +16,8 @@ pub async fn end(Extension(state): Extension) -> Result) -> Result, 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)) } diff --git a/backend/src/sessions/mod.rs b/backend/src/sessions/mod.rs new file mode 100644 index 0000000..2867cf1 --- /dev/null +++ b/backend/src/sessions/mod.rs @@ -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, + pub device_platform: Option, + pub device_model: Option, + pub os_version: Option, + pub app_version: Option, + pub experiment_variant: Option, + pub external_ref: Option, +} + +#[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, + pub ended_at: Option>, + pub experiment_variant: String, + pub app_version: String, + pub device_model: Option, + pub os_version: Option, + pub locale_code: String, + pub device_platform: String, +} + +#[derive(Clone, Default)] +pub struct SessionsStore { + inner: Arc>, +} + +#[derive(Default)] +struct SessionsState { + sessions_by_id: HashMap, + current_session_id: Option, +} + +#[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 { + 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 { + 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()) + } +} diff --git a/backend/src/users/mod.rs b/backend/src/users/mod.rs new file mode 100644 index 0000000..c57f15c --- /dev/null +++ b/backend/src/users/mod.rs @@ -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, + 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, + pub preferred_language: String, + pub device_platform: String, +} + +#[derive(Clone, Default)] +pub struct UsersStore { + inner: Arc>, +} + +#[derive(Default)] +struct UsersState { + users_by_id: HashMap, + user_ids_by_external_ref: HashMap, +} + +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 { + self.inner.read().await.users_by_id.get(&user_id).cloned() + } +} diff --git a/backend/tests/api_smoke.rs b/backend/tests/api_smoke.rs index 87e7cba..2646989 100644 --- a/backend/tests/api_smoke.rs +++ b/backend/tests/api_smoke.rs @@ -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); +} diff --git a/contracts/openapi/openapi.yaml b/contracts/openapi/openapi.yaml index 90b9074..dd3c11b 100644 --- a/contracts/openapi/openapi.yaml +++ b/contracts/openapi/openapi.yaml @@ -278,6 +278,8 @@ components: SessionStartRequest: type: object properties: + external_ref: + type: string locale_code: type: string device_platform: diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..d6d1ef1 --- /dev/null +++ b/docs/conventions.md @@ -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.