This commit is contained in:
2026-04-09 14:55:37 +02:00
parent 8be455ba98
commit 4a85efc270
30 changed files with 4895 additions and 1 deletions
+3014
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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);
+113
View File
@@ -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()
}
}
+82
View File
@@ -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)
}
}
+29
View File
@@ -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),
}
}
+69
View File
@@ -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()
}
}
+43
View File
@@ -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(())
}
+6
View File
@@ -0,0 +1,6 @@
#![forbid(unsafe_code)]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
hermes_backend::run().await
}
+27
View File
@@ -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(),
})
}
+12
View File
@@ -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))
}
+24
View File
@@ -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))
}
+15
View File
@@ -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");
}
+46
View File
@@ -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);
}