first
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
build/
|
||||||
@@ -1,3 +1,29 @@
|
|||||||
# Hermes
|
# Hermes
|
||||||
|
|
||||||
Hermes is the internal name of this project
|
Hermes is a native iOS and Android prototype for a sports-betting research study.
|
||||||
|
The app is designed to feel premium, fast, and clear while keeping the server
|
||||||
|
authoritative for timing, odds, settlement, and analytics.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Native UI on iOS and Android
|
||||||
|
- English and Swedish from day one
|
||||||
|
- Research-first flow, not real-money betting
|
||||||
|
- Structured analytics and audit logging
|
||||||
|
- Stable backend and API contracts for iteration
|
||||||
|
|
||||||
|
## Repo Layout
|
||||||
|
|
||||||
|
- `backend/` Rust API and migrations
|
||||||
|
- `contracts/` OpenAPI and localization contracts
|
||||||
|
- `docs/` architecture, schema, and state-machine docs
|
||||||
|
- `infra/` local environment and deployment assets
|
||||||
|
- `mobile/` native app scaffolds
|
||||||
|
- `fixtures/` sample media and test data
|
||||||
|
- `scripts/` helper automation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The repository has been initialized with the first planning and backend
|
||||||
|
foundation artifacts. The remaining work is to fill out the domain modules and
|
||||||
|
native clients.
|
||||||
|
|||||||
Generated
+3014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "hermes-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", features = ["json"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
config = "0.14"
|
||||||
|
redis = "0.28"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "uuid", "chrono"] }
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
|
||||||
|
tower = { version = "0.5", features = ["make"] }
|
||||||
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
|
validator = { version = "0.19", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DROP TABLE IF EXISTS audit_log_attributes;
|
||||||
|
DROP TABLE IF EXISTS audit_logs;
|
||||||
|
DROP TABLE IF EXISTS analytics_event_attributes;
|
||||||
|
DROP TABLE IF EXISTS analytics_events;
|
||||||
|
DROP TABLE IF EXISTS analytics_event_types;
|
||||||
|
DROP TABLE IF EXISTS localization_values;
|
||||||
|
DROP TABLE IF EXISTS localization_keys;
|
||||||
|
DROP TABLE IF EXISTS experiment_assignments;
|
||||||
|
DROP TABLE IF EXISTS settlements;
|
||||||
|
DROP TABLE IF EXISTS bet_intents;
|
||||||
|
DROP TABLE IF EXISTS outcome_odds;
|
||||||
|
DROP TABLE IF EXISTS odds_versions;
|
||||||
|
DROP TABLE IF EXISTS outcomes;
|
||||||
|
DROP TABLE IF EXISTS markets;
|
||||||
|
DROP TABLE IF EXISTS event_media;
|
||||||
|
DROP TABLE IF EXISTS events;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_ref text NOT NULL UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
preferred_language text NOT NULL,
|
||||||
|
device_platform text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ended_at timestamptz,
|
||||||
|
experiment_variant text NOT NULL,
|
||||||
|
app_version text NOT NULL,
|
||||||
|
device_model text,
|
||||||
|
os_version text,
|
||||||
|
locale_code text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sport_type text NOT NULL,
|
||||||
|
source_ref text NOT NULL UNIQUE,
|
||||||
|
title_en text NOT NULL,
|
||||||
|
title_sv text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
preview_start_ms bigint NOT NULL,
|
||||||
|
preview_end_ms bigint NOT NULL,
|
||||||
|
reveal_start_ms bigint NOT NULL,
|
||||||
|
reveal_end_ms bigint NOT NULL,
|
||||||
|
lock_at timestamptz NOT NULL,
|
||||||
|
settle_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE event_media (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
media_type text NOT NULL,
|
||||||
|
hls_master_url text NOT NULL,
|
||||||
|
poster_url text,
|
||||||
|
duration_ms bigint NOT NULL,
|
||||||
|
preview_start_ms bigint NOT NULL,
|
||||||
|
preview_end_ms bigint NOT NULL,
|
||||||
|
reveal_start_ms bigint NOT NULL,
|
||||||
|
reveal_end_ms bigint NOT NULL,
|
||||||
|
UNIQUE (event_id, media_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE markets (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
question_key text NOT NULL,
|
||||||
|
market_type text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
lock_at timestamptz NOT NULL,
|
||||||
|
settlement_rule_key text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outcomes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
|
||||||
|
outcome_code text NOT NULL,
|
||||||
|
label_key text NOT NULL,
|
||||||
|
sort_order integer NOT NULL,
|
||||||
|
UNIQUE (market_id, outcome_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE odds_versions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
market_id uuid NOT NULL REFERENCES markets(id) ON DELETE CASCADE,
|
||||||
|
version_no integer NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
is_current boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (market_id, version_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX odds_versions_current_idx
|
||||||
|
ON odds_versions (market_id)
|
||||||
|
WHERE is_current;
|
||||||
|
|
||||||
|
CREATE TABLE outcome_odds (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
odds_version_id uuid NOT NULL REFERENCES odds_versions(id) ON DELETE CASCADE,
|
||||||
|
outcome_id uuid NOT NULL REFERENCES outcomes(id) ON DELETE CASCADE,
|
||||||
|
decimal_odds numeric(10,4) NOT NULL,
|
||||||
|
fractional_num integer NOT NULL,
|
||||||
|
fractional_den integer NOT NULL,
|
||||||
|
UNIQUE (odds_version_id, outcome_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bet_intents (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id),
|
||||||
|
session_id uuid NOT NULL REFERENCES sessions(id),
|
||||||
|
event_id uuid NOT NULL REFERENCES events(id),
|
||||||
|
market_id uuid NOT NULL REFERENCES markets(id),
|
||||||
|
outcome_id uuid NOT NULL REFERENCES outcomes(id),
|
||||||
|
idempotency_key text NOT NULL UNIQUE,
|
||||||
|
client_sent_at timestamptz NOT NULL,
|
||||||
|
server_received_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
accepted boolean NOT NULL,
|
||||||
|
acceptance_code text NOT NULL,
|
||||||
|
accepted_odds_version_id uuid REFERENCES odds_versions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settlements (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
market_id uuid NOT NULL UNIQUE REFERENCES markets(id) ON DELETE CASCADE,
|
||||||
|
settled_at timestamptz NOT NULL,
|
||||||
|
winning_outcome_id uuid NOT NULL REFERENCES outcomes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE experiment_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
variant text NOT NULL,
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (session_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE localization_keys (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key_name text NOT NULL UNIQUE,
|
||||||
|
description text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE localization_values (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
localization_key_id uuid NOT NULL REFERENCES localization_keys(id) ON DELETE CASCADE,
|
||||||
|
locale_code text NOT NULL,
|
||||||
|
text_value text NOT NULL,
|
||||||
|
UNIQUE (localization_key_id, locale_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE analytics_event_types (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_name text NOT NULL UNIQUE,
|
||||||
|
description text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE analytics_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analytics_event_type_id uuid NOT NULL REFERENCES analytics_event_types(id),
|
||||||
|
session_id uuid NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE analytics_event_attributes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
analytics_event_id uuid NOT NULL REFERENCES analytics_events(id) ON DELETE CASCADE,
|
||||||
|
attribute_key text NOT NULL,
|
||||||
|
attribute_value text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
actor_type text NOT NULL,
|
||||||
|
actor_id uuid,
|
||||||
|
action_name text NOT NULL,
|
||||||
|
target_type text NOT NULL,
|
||||||
|
target_id uuid,
|
||||||
|
trace_id text NOT NULL,
|
||||||
|
note text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_log_attributes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
audit_log_id uuid NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE,
|
||||||
|
attribute_key text NOT NULL,
|
||||||
|
attribute_value text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||||
|
CREATE INDEX sessions_started_at_idx ON sessions (started_at);
|
||||||
|
CREATE INDEX events_status_idx ON events (status);
|
||||||
|
CREATE INDEX markets_event_id_idx ON markets (event_id);
|
||||||
|
CREATE INDEX markets_lock_at_idx ON markets (lock_at);
|
||||||
|
CREATE INDEX bet_intents_session_id_idx ON bet_intents (session_id);
|
||||||
|
CREATE INDEX bet_intents_market_id_idx ON bet_intents (market_id);
|
||||||
|
CREATE INDEX bet_intents_idempotency_key_idx ON bet_intents (idempotency_key);
|
||||||
|
CREATE INDEX analytics_events_session_id_idx ON analytics_events (session_id);
|
||||||
|
CREATE INDEX analytics_events_user_id_idx ON analytics_events (user_id);
|
||||||
|
CREATE INDEX analytics_events_type_id_idx ON analytics_events (analytics_event_type_id);
|
||||||
|
CREATE INDEX audit_logs_created_at_idx ON audit_logs (created_at);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use std::{sync::Arc, 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};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: AppConfig,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(
|
||||||
|
config: AppConfig,
|
||||||
|
database_pool: Option<PgPool>,
|
||||||
|
redis_client: Option<RedisClient>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
database_pool,
|
||||||
|
redis_client,
|
||||||
|
current_user_id: Uuid::new_v4(),
|
||||||
|
session: Arc::new(RwLock::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
};
|
||||||
|
|
||||||
|
*self.session.write().await = Some(session.clone());
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn current_session(&self) -> Option<SessionSnapshot> {
|
||||||
|
self.session.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn database_ready(&self) -> bool {
|
||||||
|
self.database_pool.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redis_ready(&self) -> bool {
|
||||||
|
self.redis_client.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uptime_ms(&self) -> u128 {
|
||||||
|
self.started_at.elapsed().as_millis()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Validate)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub service_name: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub environment: String,
|
||||||
|
pub bind_addr: SocketAddr,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub app_version: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub default_locale: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub default_device_platform: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub default_experiment_variant: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub log_level: String,
|
||||||
|
pub stream_base_url: Option<String>,
|
||||||
|
pub cdn_base_url: Option<String>,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub analytics_mode: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub experiment_config: String,
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
pub localization_source: String,
|
||||||
|
pub database_url: Option<String>,
|
||||||
|
pub redis_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
service_name: "hermes-backend".to_string(),
|
||||||
|
environment: "local".to_string(),
|
||||||
|
bind_addr: "127.0.0.1:3000".parse().expect("valid bind address"),
|
||||||
|
app_version: "0.1.0-dev".to_string(),
|
||||||
|
default_locale: "en".to_string(),
|
||||||
|
default_device_platform: "ios".to_string(),
|
||||||
|
default_experiment_variant: "control".to_string(),
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
stream_base_url: None,
|
||||||
|
cdn_base_url: None,
|
||||||
|
analytics_mode: "relational".to_string(),
|
||||||
|
experiment_config: "study-default".to_string(),
|
||||||
|
localization_source: "contracts/localization".to_string(),
|
||||||
|
database_url: None,
|
||||||
|
redis_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.set_default("service_name", "hermes-backend")?
|
||||||
|
.set_default("environment", "local")?
|
||||||
|
.set_default("bind_addr", "127.0.0.1:3000")?
|
||||||
|
.set_default("app_version", "0.1.0-dev")?
|
||||||
|
.set_default("default_locale", "en")?
|
||||||
|
.set_default("default_device_platform", "ios")?
|
||||||
|
.set_default("default_experiment_variant", "control")?
|
||||||
|
.set_default("log_level", "info")?
|
||||||
|
.set_default("analytics_mode", "relational")?
|
||||||
|
.set_default("experiment_config", "study-default")?
|
||||||
|
.set_default("localization_source", "contracts/localization")?
|
||||||
|
.add_source(config::Environment::with_prefix("HERMES").separator("__"))
|
||||||
|
.build()
|
||||||
|
.context("failed to load Hermes configuration")?;
|
||||||
|
|
||||||
|
let config: Self = settings
|
||||||
|
.try_deserialize()
|
||||||
|
.context("failed to deserialize Hermes configuration")?;
|
||||||
|
config.validate().context("invalid Hermes configuration")?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use redis::Client as RedisClient;
|
||||||
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
|
|
||||||
|
pub async fn connect_postgres(database_url: Option<&str>) -> anyhow::Result<Option<PgPool>> {
|
||||||
|
match database_url {
|
||||||
|
Some(url) if !url.trim().is_empty() => {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
|
.connect(url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to connect to postgres at {url}"))?;
|
||||||
|
Ok(Some(pool))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_redis(redis_url: Option<&str>) -> anyhow::Result<Option<RedisClient>> {
|
||||||
|
match redis_url {
|
||||||
|
Some(url) if !url.trim().is_empty() => {
|
||||||
|
Ok(Some(RedisClient::open(url).context("failed to open redis client")?))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("{0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Conflict(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
ServiceUnavailable(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
code: &'static str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::BadRequest(message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::NotFound(message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
|
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
Self::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(_) => "bad_request",
|
||||||
|
Self::NotFound(_) => "not_found",
|
||||||
|
Self::Conflict(_) => "conflict",
|
||||||
|
Self::ServiceUnavailable(_) => "service_unavailable",
|
||||||
|
Self::Internal(_) => "internal_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = self.status_code();
|
||||||
|
let body = Json(ErrorResponse {
|
||||||
|
code: self.code(),
|
||||||
|
message: self.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod app_state;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod error;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod telemetry;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::Extension;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
use crate::{app_state::AppState, config::AppConfig};
|
||||||
|
|
||||||
|
pub fn build_router(state: AppState) -> Router {
|
||||||
|
routes::router()
|
||||||
|
.layer(Extension(state))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run() -> anyhow::Result<()> {
|
||||||
|
let config = AppConfig::load()?;
|
||||||
|
telemetry::init(&config.log_level);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
service = %config.service_name,
|
||||||
|
environment = %config.environment,
|
||||||
|
version = %config.app_version,
|
||||||
|
"starting Hermes backend"
|
||||||
|
);
|
||||||
|
|
||||||
|
let database_pool = db::connect_postgres(config.database_url.as_deref()).await?;
|
||||||
|
let redis_client = db::connect_redis(config.redis_url.as_deref())?;
|
||||||
|
|
||||||
|
let state = AppState::new(config.clone(), database_pool, redis_client);
|
||||||
|
let app = build_router(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|
||||||
|
tracing::info!(addr = %config.bind_addr, "listening");
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
hermes_backend::run().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use axum::{extract::Extension, Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::app_state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub service_name: String,
|
||||||
|
pub environment: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_ms: u128,
|
||||||
|
pub database_ready: bool,
|
||||||
|
pub redis_ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handler(Extension(state): Extension<AppState>) -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
service_name: state.config.service_name.clone(),
|
||||||
|
environment: state.config.environment.clone(),
|
||||||
|
version: state.config.app_version.clone(),
|
||||||
|
uptime_ms: state.uptime_ms(),
|
||||||
|
database_ready: state.database_ready(),
|
||||||
|
redis_ready: state.redis_ready(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod health;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
use axum::{routing::{get, post}, Router};
|
||||||
|
|
||||||
|
pub fn router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health::handler))
|
||||||
|
.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,24 @@
|
|||||||
|
use axum::{extract::Extension, http::StatusCode, Json};
|
||||||
|
|
||||||
|
use crate::{app_state::{AppState, SessionSnapshot, SessionStartRequest}, error::AppError};
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Json(payload): Json<SessionStartRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<SessionSnapshot>), AppError> {
|
||||||
|
let session = state.start_session(payload).await;
|
||||||
|
Ok((StatusCode::CREATED, Json(session)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn end(Extension(state): Extension<AppState>) -> Result<Json<SessionSnapshot>, AppError> {
|
||||||
|
let session = state.end_session().await?;
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))?;
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
pub fn init(filter: &str) {
|
||||||
|
let filter = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(true)
|
||||||
|
.with_thread_ids(true)
|
||||||
|
.with_thread_names(true)
|
||||||
|
.compact()
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
|
.expect("failed to install tracing subscriber");
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}};
|
||||||
|
use hermes_backend::{app_state::AppState, build_router, config::AppConfig};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_returns_ok() {
|
||||||
|
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_start_and_me_work() {
|
||||||
|
let app = build_router(AppState::new(AppConfig::default(), None, None));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/session/start")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from("{}"))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(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();
|
||||||
|
assert_eq!(json["locale_code"], "en");
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(Request::builder().uri("/api/v1/session/me").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"app.name": "Hermes",
|
||||||
|
"common.continue": "Continue",
|
||||||
|
"common.cancel": "Cancel",
|
||||||
|
"common.close": "Close",
|
||||||
|
"common.retry": "Retry",
|
||||||
|
"common.loading": "Loading",
|
||||||
|
"common.error_title": "Something went wrong",
|
||||||
|
"common.ok": "OK",
|
||||||
|
"onboarding.title": "Study intro",
|
||||||
|
"onboarding.subtitle": "Watch the clip, make your choice before lock, then see the reveal.",
|
||||||
|
"onboarding.consent_title": "Consent",
|
||||||
|
"onboarding.consent_body": "This prototype is for research and does not use real money.",
|
||||||
|
"onboarding.language_title": "Choose language",
|
||||||
|
"onboarding.start_session": "Start session",
|
||||||
|
"feed.next_round_title": "Next round",
|
||||||
|
"feed.next_round_body": "A new clip is ready for review.",
|
||||||
|
"feed.watch_preview": "Watch preview",
|
||||||
|
"feed.round_ready": "Round ready",
|
||||||
|
"round.countdown_label": "Lock in",
|
||||||
|
"round.locked_label": "Locked",
|
||||||
|
"round.selection_prompt": "Choose an outcome before lock.",
|
||||||
|
"round.selection_confirmed": "Selection accepted",
|
||||||
|
"round.selection_submitting": "Submitting selection",
|
||||||
|
"round.odds_label": "Odds",
|
||||||
|
"reveal.title": "Reveal",
|
||||||
|
"reveal.subtitle": "See what happened next.",
|
||||||
|
"result.title": "Result",
|
||||||
|
"result.user_selection": "Your selection",
|
||||||
|
"result.outcome": "Outcome",
|
||||||
|
"result.next_round": "Next round",
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.language": "Language",
|
||||||
|
"settings.haptics": "Haptics",
|
||||||
|
"settings.analytics": "Analytics",
|
||||||
|
"errors.generic": "Please try again.",
|
||||||
|
"errors.network": "Network error. Check your connection.",
|
||||||
|
"errors.playback": "Video playback failed.",
|
||||||
|
"errors.session_expired": "Session expired. Please start again."
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"app.name": "Hermes",
|
||||||
|
"common.continue": "Fortsätt",
|
||||||
|
"common.cancel": "Avbryt",
|
||||||
|
"common.close": "Stäng",
|
||||||
|
"common.retry": "Försök igen",
|
||||||
|
"common.loading": "Laddar",
|
||||||
|
"common.error_title": "Något gick fel",
|
||||||
|
"common.ok": "OK",
|
||||||
|
"onboarding.title": "Studieintro",
|
||||||
|
"onboarding.subtitle": "Titta på klippet, gör ditt val före låsning och se sedan avslöjandet.",
|
||||||
|
"onboarding.consent_title": "Samtycke",
|
||||||
|
"onboarding.consent_body": "Denna prototyp är för forskning och använder inte riktiga pengar.",
|
||||||
|
"onboarding.language_title": "Välj språk",
|
||||||
|
"onboarding.start_session": "Starta session",
|
||||||
|
"feed.next_round_title": "Nästa runda",
|
||||||
|
"feed.next_round_body": "Ett nytt klipp är klart för granskning.",
|
||||||
|
"feed.watch_preview": "Titta på förhandsklipp",
|
||||||
|
"feed.round_ready": "Rundan är klar",
|
||||||
|
"round.countdown_label": "Låsning om",
|
||||||
|
"round.locked_label": "Låst",
|
||||||
|
"round.selection_prompt": "Välj ett utfall före låsning.",
|
||||||
|
"round.selection_confirmed": "Valet accepterat",
|
||||||
|
"round.selection_submitting": "Skickar val",
|
||||||
|
"round.odds_label": "Odds",
|
||||||
|
"reveal.title": "Avslöjande",
|
||||||
|
"reveal.subtitle": "Se vad som hände sedan.",
|
||||||
|
"result.title": "Resultat",
|
||||||
|
"result.user_selection": "Ditt val",
|
||||||
|
"result.outcome": "Utfall",
|
||||||
|
"result.next_round": "Nästa runda",
|
||||||
|
"settings.title": "Inställningar",
|
||||||
|
"settings.language": "Språk",
|
||||||
|
"settings.haptics": "Haptik",
|
||||||
|
"settings.analytics": "Analys",
|
||||||
|
"errors.generic": "Försök igen.",
|
||||||
|
"errors.network": "Nätverksfel. Kontrollera anslutningen.",
|
||||||
|
"errors.playback": "Videouppspelningen misslyckades.",
|
||||||
|
"errors.session_expired": "Sessionen har gått ut. Starta igen."
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Hermes API
|
||||||
|
version: 0.1.0
|
||||||
|
description: Native betting study prototype API.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:3000
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
summary: Health check
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service health
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HealthResponse'
|
||||||
|
/api/v1/session/start:
|
||||||
|
post:
|
||||||
|
summary: Start a session
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionStartRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Session started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
/api/v1/session/end:
|
||||||
|
post:
|
||||||
|
summary: End the current session
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session ended
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
/api/v1/session/me:
|
||||||
|
get:
|
||||||
|
summary: Inspect the current session
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current session snapshot
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
/api/v1/feed/next:
|
||||||
|
get:
|
||||||
|
summary: Fetch the next round
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Next event payload
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Event'
|
||||||
|
/api/v1/events/{event_id}:
|
||||||
|
get:
|
||||||
|
summary: Fetch an event
|
||||||
|
parameters:
|
||||||
|
- name: event_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Event
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Event'
|
||||||
|
/api/v1/events/{event_id}/manifest:
|
||||||
|
get:
|
||||||
|
summary: Fetch the event manifest
|
||||||
|
parameters:
|
||||||
|
- name: event_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Manifest
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EventManifest'
|
||||||
|
/api/v1/events/{event_id}/markets:
|
||||||
|
get:
|
||||||
|
summary: List markets for an event
|
||||||
|
parameters:
|
||||||
|
- name: event_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Markets
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Market'
|
||||||
|
/api/v1/markets/{market_id}/odds/current:
|
||||||
|
get:
|
||||||
|
summary: Fetch current odds for a market
|
||||||
|
parameters:
|
||||||
|
- name: market_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current odds
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OddsVersion'
|
||||||
|
/api/v1/stream:
|
||||||
|
get:
|
||||||
|
summary: Live odds and state stream
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Server-sent events stream
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
/api/v1/bets/intent:
|
||||||
|
post:
|
||||||
|
summary: Submit a bet intent
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BetIntentRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Bet intent result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BetIntentResponse'
|
||||||
|
/api/v1/bets/{bet_intent_id}:
|
||||||
|
get:
|
||||||
|
summary: Fetch a bet intent
|
||||||
|
parameters:
|
||||||
|
- name: bet_intent_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Bet intent
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BetIntentResponse'
|
||||||
|
/api/v1/events/{event_id}/result:
|
||||||
|
get:
|
||||||
|
summary: Fetch event result
|
||||||
|
parameters:
|
||||||
|
- name: event_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Result payload
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Settlement'
|
||||||
|
/api/v1/analytics/batch:
|
||||||
|
post:
|
||||||
|
summary: Ingest analytics batch
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AnalyticsBatchRequest'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Accepted
|
||||||
|
/api/v1/experiments/config:
|
||||||
|
get:
|
||||||
|
summary: Fetch experiment config
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Experiment config
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ExperimentConfig'
|
||||||
|
/api/v1/localization/{locale_code}:
|
||||||
|
get:
|
||||||
|
summary: Fetch a localization bundle
|
||||||
|
parameters:
|
||||||
|
- name: locale_code
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [en, sv]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Localization bundle
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LocalizationBundle'
|
||||||
|
/api/v1/admin/events:
|
||||||
|
post:
|
||||||
|
summary: Create an event
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/admin/markets:
|
||||||
|
post:
|
||||||
|
summary: Create a market
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/admin/odds:
|
||||||
|
post:
|
||||||
|
summary: Publish odds
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/admin/settlements:
|
||||||
|
post:
|
||||||
|
summary: Publish a settlement
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
HealthResponse:
|
||||||
|
type: object
|
||||||
|
required: [status, service_name, environment, version, uptime_ms, database_ready, redis_ready]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
service_name:
|
||||||
|
type: string
|
||||||
|
environment:
|
||||||
|
type: string
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
uptime_ms:
|
||||||
|
type: integer
|
||||||
|
database_ready:
|
||||||
|
type: boolean
|
||||||
|
redis_ready:
|
||||||
|
type: boolean
|
||||||
|
SessionStartRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
locale_code:
|
||||||
|
type: string
|
||||||
|
device_platform:
|
||||||
|
type: string
|
||||||
|
device_model:
|
||||||
|
type: string
|
||||||
|
os_version:
|
||||||
|
type: string
|
||||||
|
app_version:
|
||||||
|
type: string
|
||||||
|
SessionResponse:
|
||||||
|
type: object
|
||||||
|
required: [session_id, user_id, started_at, experiment_variant, app_version, locale_code, device_platform]
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
user_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
started_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
ended_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
experiment_variant:
|
||||||
|
type: string
|
||||||
|
app_version:
|
||||||
|
type: string
|
||||||
|
device_model:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
os_version:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
locale_code:
|
||||||
|
type: string
|
||||||
|
device_platform:
|
||||||
|
type: string
|
||||||
|
Event:
|
||||||
|
type: object
|
||||||
|
required: [id, sport_type, source_ref, title_en, title_sv, status, lock_at, settle_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
sport_type:
|
||||||
|
type: string
|
||||||
|
source_ref:
|
||||||
|
type: string
|
||||||
|
title_en:
|
||||||
|
type: string
|
||||||
|
title_sv:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
preview_start_ms:
|
||||||
|
type: integer
|
||||||
|
preview_end_ms:
|
||||||
|
type: integer
|
||||||
|
reveal_start_ms:
|
||||||
|
type: integer
|
||||||
|
reveal_end_ms:
|
||||||
|
type: integer
|
||||||
|
lock_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
settle_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
EventManifest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
event:
|
||||||
|
$ref: '#/components/schemas/Event'
|
||||||
|
media:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EventMedia'
|
||||||
|
markets:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Market'
|
||||||
|
EventMedia:
|
||||||
|
type: object
|
||||||
|
required: [id, event_id, media_type, hls_master_url, duration_ms]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
media_type:
|
||||||
|
type: string
|
||||||
|
hls_master_url:
|
||||||
|
type: string
|
||||||
|
poster_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
duration_ms:
|
||||||
|
type: integer
|
||||||
|
preview_start_ms:
|
||||||
|
type: integer
|
||||||
|
preview_end_ms:
|
||||||
|
type: integer
|
||||||
|
reveal_start_ms:
|
||||||
|
type: integer
|
||||||
|
reveal_end_ms:
|
||||||
|
type: integer
|
||||||
|
Market:
|
||||||
|
type: object
|
||||||
|
required: [id, event_id, question_key, market_type, status, lock_at, settlement_rule_key]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
question_key:
|
||||||
|
type: string
|
||||||
|
market_type:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
lock_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
settlement_rule_key:
|
||||||
|
type: string
|
||||||
|
outcomes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Outcome'
|
||||||
|
Outcome:
|
||||||
|
type: object
|
||||||
|
required: [id, market_id, outcome_code, label_key, sort_order]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
market_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
outcome_code:
|
||||||
|
type: string
|
||||||
|
label_key:
|
||||||
|
type: string
|
||||||
|
sort_order:
|
||||||
|
type: integer
|
||||||
|
OddsVersion:
|
||||||
|
type: object
|
||||||
|
required: [id, market_id, version_no, created_at, is_current]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
market_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
version_no:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
is_current:
|
||||||
|
type: boolean
|
||||||
|
odds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OutcomeOdds'
|
||||||
|
OutcomeOdds:
|
||||||
|
type: object
|
||||||
|
required: [id, odds_version_id, outcome_id, decimal_odds, fractional_num, fractional_den]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
odds_version_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
outcome_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
decimal_odds:
|
||||||
|
type: number
|
||||||
|
fractional_num:
|
||||||
|
type: integer
|
||||||
|
fractional_den:
|
||||||
|
type: integer
|
||||||
|
BetIntentRequest:
|
||||||
|
type: object
|
||||||
|
required: [session_id, event_id, market_id, outcome_id, idempotency_key, client_sent_at]
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
market_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
outcome_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
idempotency_key:
|
||||||
|
type: string
|
||||||
|
client_sent_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
BetIntentResponse:
|
||||||
|
type: object
|
||||||
|
required: [id, accepted, acceptance_code, server_received_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
accepted:
|
||||||
|
type: boolean
|
||||||
|
acceptance_code:
|
||||||
|
type: string
|
||||||
|
accepted_odds_version_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
server_received_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
Settlement:
|
||||||
|
type: object
|
||||||
|
required: [id, market_id, settled_at, winning_outcome_id]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
market_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
settled_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
winning_outcome_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
AnalyticsBatchRequest:
|
||||||
|
type: object
|
||||||
|
required: [events]
|
||||||
|
properties:
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AnalyticsEventInput'
|
||||||
|
AnalyticsEventInput:
|
||||||
|
type: object
|
||||||
|
required: [event_name, occurred_at]
|
||||||
|
properties:
|
||||||
|
event_name:
|
||||||
|
type: string
|
||||||
|
occurred_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
attributes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AttributeInput'
|
||||||
|
AttributeInput:
|
||||||
|
type: object
|
||||||
|
required: [key, value]
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
ExperimentConfig:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
variant:
|
||||||
|
type: string
|
||||||
|
feature_flags:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: boolean
|
||||||
|
LocalizationBundle:
|
||||||
|
type: object
|
||||||
|
required: [locale_code, values]
|
||||||
|
properties:
|
||||||
|
locale_code:
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Analytics Taxonomy
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- All analytics events are structured
|
||||||
|
- No JSONB payloads
|
||||||
|
- Events live in `analytics_events`
|
||||||
|
- Attributes live in `analytics_event_attributes`
|
||||||
|
|
||||||
|
## Event Groups
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
- `app_opened`
|
||||||
|
- `app_backgrounded`
|
||||||
|
- `app_closed`
|
||||||
|
- `session_started`
|
||||||
|
- `session_ended`
|
||||||
|
|
||||||
|
### Onboarding
|
||||||
|
|
||||||
|
- `language_selected`
|
||||||
|
- `consent_viewed`
|
||||||
|
- `consent_accepted`
|
||||||
|
|
||||||
|
### Feed and Round Load
|
||||||
|
|
||||||
|
- `feed_viewed`
|
||||||
|
- `round_card_viewed`
|
||||||
|
- `round_loaded`
|
||||||
|
- `event_manifest_received`
|
||||||
|
- `preview_prefetch_started`
|
||||||
|
- `preview_prefetch_completed`
|
||||||
|
|
||||||
|
### Playback
|
||||||
|
|
||||||
|
- `preview_started`
|
||||||
|
- `preview_paused`
|
||||||
|
- `preview_resumed`
|
||||||
|
- `preview_completed`
|
||||||
|
- `reveal_started`
|
||||||
|
- `reveal_completed`
|
||||||
|
- `playback_error`
|
||||||
|
- `stream_reconnected`
|
||||||
|
|
||||||
|
### Timing and Odds
|
||||||
|
|
||||||
|
- `countdown_visible`
|
||||||
|
- `countdown_warning_threshold_hit`
|
||||||
|
- `odds_panel_viewed`
|
||||||
|
- `odds_version_received`
|
||||||
|
- `odds_changed`
|
||||||
|
|
||||||
|
### Selection and Settlement
|
||||||
|
|
||||||
|
- `outcome_focused`
|
||||||
|
- `outcome_selected`
|
||||||
|
- `selection_submitted`
|
||||||
|
- `selection_accepted`
|
||||||
|
- `selection_rejected`
|
||||||
|
- `duplicate_selection_attempt`
|
||||||
|
- `market_locked`
|
||||||
|
- `result_viewed`
|
||||||
|
- `next_round_requested`
|
||||||
|
|
||||||
|
### UI and Input
|
||||||
|
|
||||||
|
- `screen_viewed`
|
||||||
|
- `cta_pressed`
|
||||||
|
- `gesture_swipe`
|
||||||
|
- `gesture_tap`
|
||||||
|
- `gesture_cancelled`
|
||||||
|
- `haptic_triggered`
|
||||||
|
|
||||||
|
### Localization and Errors
|
||||||
|
|
||||||
|
- `localization_bundle_loaded`
|
||||||
|
- `locale_changed`
|
||||||
|
- `network_error`
|
||||||
|
|
||||||
|
## Common Attributes
|
||||||
|
|
||||||
|
- `screen_name`
|
||||||
|
- `event_id`
|
||||||
|
- `market_id`
|
||||||
|
- `outcome_id`
|
||||||
|
- `odds_version_id`
|
||||||
|
- `countdown_ms_remaining`
|
||||||
|
- `locale_code`
|
||||||
|
- `experiment_variant`
|
||||||
|
- `network_type`
|
||||||
|
- `device_orientation`
|
||||||
|
- `playback_position_ms`
|
||||||
|
- `latency_ms`
|
||||||
|
|
||||||
|
## Research Metrics
|
||||||
|
|
||||||
|
- Decision latency
|
||||||
|
- Round completion rate
|
||||||
|
- Missed rounds
|
||||||
|
- Post-lock selection attempts
|
||||||
|
- Replay desire
|
||||||
|
- Exit rate
|
||||||
|
- Control vs modern comparison
|
||||||
|
- Locale effects
|
||||||
|
- Device platform effects
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## System Shape
|
||||||
|
|
||||||
|
Hermes uses three layers:
|
||||||
|
|
||||||
|
- Native client apps for iOS and Android
|
||||||
|
- A Rust backend API that owns timing and state
|
||||||
|
- PostgreSQL plus Valkey for durable and short-lived data
|
||||||
|
|
||||||
|
## Client Responsibilities
|
||||||
|
|
||||||
|
- Render the study experience
|
||||||
|
- Play preview and reveal video
|
||||||
|
- Handle gestures and haptics
|
||||||
|
- Show odds and lock timing
|
||||||
|
- Keep local session state
|
||||||
|
- Prefetch media
|
||||||
|
- Sync clocks with the server
|
||||||
|
- Capture structured analytics
|
||||||
|
- Load English and Swedish strings from localization assets
|
||||||
|
|
||||||
|
## Backend Responsibilities
|
||||||
|
|
||||||
|
- Auth and session binding
|
||||||
|
- Event feed and manifests
|
||||||
|
- Markets and odds distribution
|
||||||
|
- Bet intent validation and acceptance
|
||||||
|
- Settlement and audit logging
|
||||||
|
- Experiment assignment
|
||||||
|
- Localization bundle serving
|
||||||
|
- Analytics ingestion
|
||||||
|
- Admin fixture publishing
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
- The server decides lock time and settlement
|
||||||
|
- Clients send intent, not fairness decisions
|
||||||
|
- Video playback must not block overlay updates
|
||||||
|
- Client and server state machines must match
|
||||||
|
- Localization is mandatory for every user-facing string
|
||||||
|
- Analytics and audit data stay relational
|
||||||
|
|
||||||
|
## Repo Order
|
||||||
|
|
||||||
|
1. Documents and contracts
|
||||||
|
2. Backend foundation
|
||||||
|
3. Domain modules and API coverage
|
||||||
|
4. Fixture and admin workflows
|
||||||
|
5. Native iOS and Android apps
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Localization Catalog
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Every user-facing string must be localizable
|
||||||
|
- Stable key names only
|
||||||
|
- English is the fallback locale
|
||||||
|
- Swedish must be present at launch
|
||||||
|
- Keys are mirrored in `contracts/localization/en.json` and `contracts/localization/sv.json`
|
||||||
|
|
||||||
|
## Current Keys
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `app.name` | App name |
|
||||||
|
| `common.continue` | Continue action |
|
||||||
|
| `common.cancel` | Cancel action |
|
||||||
|
| `common.close` | Close action |
|
||||||
|
| `common.retry` | Retry action |
|
||||||
|
| `common.loading` | Loading state |
|
||||||
|
| `common.error_title` | Error title |
|
||||||
|
| `common.ok` | OK action |
|
||||||
|
| `onboarding.title` | Study intro title |
|
||||||
|
| `onboarding.subtitle` | Study intro body |
|
||||||
|
| `onboarding.consent_title` | Consent title |
|
||||||
|
| `onboarding.consent_body` | Consent body |
|
||||||
|
| `onboarding.language_title` | Language picker title |
|
||||||
|
| `onboarding.start_session` | Start session CTA |
|
||||||
|
| `feed.next_round_title` | Next round title |
|
||||||
|
| `feed.next_round_body` | Next round body |
|
||||||
|
| `feed.watch_preview` | Watch preview CTA |
|
||||||
|
| `feed.round_ready` | Round ready label |
|
||||||
|
| `round.countdown_label` | Countdown label |
|
||||||
|
| `round.locked_label` | Locked label |
|
||||||
|
| `round.selection_prompt` | Selection prompt |
|
||||||
|
| `round.selection_confirmed` | Selection accepted |
|
||||||
|
| `round.selection_submitting` | Selection submitting |
|
||||||
|
| `round.odds_label` | Odds label |
|
||||||
|
| `reveal.title` | Reveal title |
|
||||||
|
| `reveal.subtitle` | Reveal subtitle |
|
||||||
|
| `result.title` | Result title |
|
||||||
|
| `result.user_selection` | User selection label |
|
||||||
|
| `result.outcome` | Outcome label |
|
||||||
|
| `result.next_round` | Next round CTA |
|
||||||
|
| `settings.title` | Settings title |
|
||||||
|
| `settings.language` | Language setting |
|
||||||
|
| `settings.haptics` | Haptics setting |
|
||||||
|
| `settings.analytics` | Analytics setting |
|
||||||
|
| `errors.generic` | Generic error copy |
|
||||||
|
| `errors.network` | Network error copy |
|
||||||
|
| `errors.playback` | Playback error copy |
|
||||||
|
| `errors.session_expired` | Session expired copy |
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Relational Schema
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- UUID primary keys
|
||||||
|
- `timestamptz` for all timestamps
|
||||||
|
- No JSONB columns
|
||||||
|
- Child tables for attributes and event payloads
|
||||||
|
- Foreign keys wherever possible
|
||||||
|
|
||||||
|
## Core Tables
|
||||||
|
|
||||||
|
### users
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `external_ref`
|
||||||
|
- `created_at`
|
||||||
|
- `preferred_language`
|
||||||
|
- `device_platform`
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id`
|
||||||
|
- `started_at`
|
||||||
|
- `ended_at`
|
||||||
|
- `experiment_variant`
|
||||||
|
- `app_version`
|
||||||
|
- `device_model`
|
||||||
|
- `os_version`
|
||||||
|
- `locale_code`
|
||||||
|
|
||||||
|
### events
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `sport_type`
|
||||||
|
- `source_ref`
|
||||||
|
- `title_en`
|
||||||
|
- `title_sv`
|
||||||
|
- `status`
|
||||||
|
- `preview_start_ms`
|
||||||
|
- `preview_end_ms`
|
||||||
|
- `reveal_start_ms`
|
||||||
|
- `reveal_end_ms`
|
||||||
|
- `lock_at`
|
||||||
|
- `settle_at`
|
||||||
|
|
||||||
|
### event_media
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `event_id`
|
||||||
|
- `media_type`
|
||||||
|
- `hls_master_url`
|
||||||
|
- `poster_url`
|
||||||
|
- `duration_ms`
|
||||||
|
- `preview_start_ms`
|
||||||
|
- `preview_end_ms`
|
||||||
|
- `reveal_start_ms`
|
||||||
|
- `reveal_end_ms`
|
||||||
|
|
||||||
|
### markets
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `event_id`
|
||||||
|
- `question_key`
|
||||||
|
- `market_type`
|
||||||
|
- `status`
|
||||||
|
- `lock_at`
|
||||||
|
- `settlement_rule_key`
|
||||||
|
|
||||||
|
### outcomes
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `market_id`
|
||||||
|
- `outcome_code`
|
||||||
|
- `label_key`
|
||||||
|
- `sort_order`
|
||||||
|
|
||||||
|
### odds_versions
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `market_id`
|
||||||
|
- `version_no`
|
||||||
|
- `created_at`
|
||||||
|
- `is_current`
|
||||||
|
|
||||||
|
### outcome_odds
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `odds_version_id`
|
||||||
|
- `outcome_id`
|
||||||
|
- `decimal_odds`
|
||||||
|
- `fractional_num`
|
||||||
|
- `fractional_den`
|
||||||
|
|
||||||
|
### bet_intents
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id`
|
||||||
|
- `session_id`
|
||||||
|
- `event_id`
|
||||||
|
- `market_id`
|
||||||
|
- `outcome_id`
|
||||||
|
- `idempotency_key`
|
||||||
|
- `client_sent_at`
|
||||||
|
- `server_received_at`
|
||||||
|
- `accepted`
|
||||||
|
- `acceptance_code`
|
||||||
|
- `accepted_odds_version_id`
|
||||||
|
|
||||||
|
### settlements
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `market_id`
|
||||||
|
- `settled_at`
|
||||||
|
- `winning_outcome_id`
|
||||||
|
|
||||||
|
### experiment_assignments
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id`
|
||||||
|
- `session_id`
|
||||||
|
- `variant`
|
||||||
|
- `assigned_at`
|
||||||
|
|
||||||
|
### localization_keys
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `key_name`
|
||||||
|
- `description`
|
||||||
|
|
||||||
|
### localization_values
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `localization_key_id`
|
||||||
|
- `locale_code`
|
||||||
|
- `text_value`
|
||||||
|
|
||||||
|
### analytics_event_types
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `event_name`
|
||||||
|
- `description`
|
||||||
|
|
||||||
|
### analytics_events
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `analytics_event_type_id`
|
||||||
|
- `session_id`
|
||||||
|
- `user_id`
|
||||||
|
- `occurred_at`
|
||||||
|
|
||||||
|
### analytics_event_attributes
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `analytics_event_id`
|
||||||
|
- `attribute_key`
|
||||||
|
- `attribute_value`
|
||||||
|
|
||||||
|
### audit_logs
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `created_at`
|
||||||
|
- `actor_type`
|
||||||
|
- `actor_id`
|
||||||
|
- `action_name`
|
||||||
|
- `target_type`
|
||||||
|
- `target_id`
|
||||||
|
- `trace_id`
|
||||||
|
- `note`
|
||||||
|
|
||||||
|
### audit_log_attributes
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `audit_log_id`
|
||||||
|
- `attribute_key`
|
||||||
|
- `attribute_value`
|
||||||
|
|
||||||
|
## Indexes
|
||||||
|
|
||||||
|
- `sessions.user_id`
|
||||||
|
- `sessions.started_at`
|
||||||
|
- `events.status`
|
||||||
|
- `markets.event_id`
|
||||||
|
- `markets.lock_at`
|
||||||
|
- `bet_intents.session_id`
|
||||||
|
- `bet_intents.market_id`
|
||||||
|
- `bet_intents.idempotency_key`
|
||||||
|
- `analytics_events.session_id`
|
||||||
|
- `analytics_events.user_id`
|
||||||
|
- `analytics_events.analytics_event_type_id`
|
||||||
|
- `audit_logs.created_at`
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# State Machines
|
||||||
|
|
||||||
|
## Event Lifecycle
|
||||||
|
|
||||||
|
`scheduled -> prefetch_ready -> preview_open -> locking -> locked -> reveal_open -> settled -> archived`
|
||||||
|
|
||||||
|
## Client Round State
|
||||||
|
|
||||||
|
`idle -> prefetching -> ready -> preview_playing -> selection_pending -> selection_submitting -> selection_accepted -> locked -> reveal_playing -> result_visible -> transitioning -> error`
|
||||||
|
|
||||||
|
## Bet Acceptance State
|
||||||
|
|
||||||
|
`received -> validated -> accepted`
|
||||||
|
|
||||||
|
Rejection states:
|
||||||
|
|
||||||
|
- `rejected_too_late`
|
||||||
|
- `rejected_invalid_market`
|
||||||
|
- `rejected_invalid_session`
|
||||||
|
- `rejected_duplicate`
|
||||||
|
|
||||||
|
Terminal state:
|
||||||
|
|
||||||
|
- `settled`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Locking is server authoritative
|
||||||
|
- Clients may display synced countdowns, but acceptance is decided by the server
|
||||||
|
- Selection confirmation must be visually unambiguous
|
||||||
|
- The locked state freezes the odds display for the user
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Fixtures
|
||||||
|
|
||||||
|
Reserved for sample videos, manifests, and test data.
|
||||||
|
|
||||||
|
Planned subdirectories:
|
||||||
|
|
||||||
|
- `videos/`
|
||||||
|
- `manifests/`
|
||||||
|
- `test-data/`
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: hermes
|
||||||
|
POSTGRES_USER: hermes
|
||||||
|
POSTGRES_PASSWORD: hermes
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Android App
|
||||||
|
|
||||||
|
Native Jetpack Compose client scaffold for the Hermes study app.
|
||||||
|
|
||||||
|
Planned structure:
|
||||||
|
|
||||||
|
- `app/`
|
||||||
|
- `core/`
|
||||||
|
- `data/`
|
||||||
|
- `domain/`
|
||||||
|
- `feature/`
|
||||||
|
- `benchmark/`
|
||||||
|
- `androidTest/`
|
||||||
|
|
||||||
|
This directory is reserved for the phase 6 native Android implementation.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# iOS App
|
||||||
|
|
||||||
|
Native SwiftUI client scaffold for the Hermes study app.
|
||||||
|
|
||||||
|
Planned structure:
|
||||||
|
|
||||||
|
- `App/`
|
||||||
|
- `Core/`
|
||||||
|
- `Features/`
|
||||||
|
- `Resources/`
|
||||||
|
- `Tests/`
|
||||||
|
|
||||||
|
This directory is reserved for the phase 5 native iOS implementation.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Scripts
|
||||||
|
|
||||||
|
Reserved for repo automation and data-loading helpers.
|
||||||
Reference in New Issue
Block a user