diff --git a/.sqlx/query-3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9.json b/.sqlx/query-3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9.json new file mode 100644 index 0000000..33a0259 --- /dev/null +++ b/.sqlx/query-3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n r.id AS \"id!: String\",\n u.username AS \"username!: String\",\n r.seed AS \"seed!: i64\",\n r.draw_mode AS \"draw_mode!: String\",\n r.mode AS \"mode!: String\",\n r.time_seconds AS \"time_seconds!: i64\",\n r.final_score AS \"final_score!: i64\",\n r.recorded_at AS \"recorded_at!: String\",\n r.received_at AS \"received_at!: String\"\n FROM replays r\n JOIN users u ON u.id = r.user_id\n ORDER BY r.received_at DESC\n LIMIT ?", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "username!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "seed!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "draw_mode!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "mode!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "time_seconds!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "final_score!: i64", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "recorded_at!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "received_at!: String", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3a9bd2e51b2389da5b7e85f26806fcffa896748e0b589d216cf60827fc3857a9" +} diff --git a/.sqlx/query-5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c.json b/.sqlx/query-5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c.json new file mode 100644 index 0000000..2492490 --- /dev/null +++ b/.sqlx/query-5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT replay_json FROM replays WHERE id = ?", + "describe": { + "columns": [ + { + "name": "replay_json", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "5bc1984044bc792c2e9577a159ca22789469df14cb25144451f37e8cdad8165c" +} diff --git a/.sqlx/query-6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f.json b/.sqlx/query-6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f.json new file mode 100644 index 0000000..41449fd --- /dev/null +++ b/.sqlx/query-6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO replays (\n id, user_id, seed, draw_mode, mode, time_seconds, final_score,\n recorded_at, received_at, replay_json\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "6a36a96faa9d9b423aae3b72b0c049a1489b67ca2361581b2300bb4ee0bc9e2f" +} diff --git a/solitaire_server/migrations/002_replays.sql b/solitaire_server/migrations/002_replays.sql new file mode 100644 index 0000000..c438202 --- /dev/null +++ b/solitaire_server/migrations/002_replays.sql @@ -0,0 +1,33 @@ +-- Migration 002: winning-replay storage +-- +-- One row per winning replay uploaded via POST /api/replays. The replay +-- itself is stored as the canonical JSON the desktop client wrote — it +-- already carries a schema_version field, so the server doesn't need to +-- shape-validate the payload beyond ensuring it parses as JSON. +-- +-- The handful of denormalised columns (final_score, time_seconds, +-- recorded_at) are projected out of the JSON at insert time so list +-- endpoints (e.g. recent / per-user / leaderboard-style sorts) can be +-- served via a covering query without touching every row's blob. + +CREATE TABLE IF NOT EXISTS replays ( + id TEXT PRIMARY KEY, -- UUID v4 minted server-side + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + seed INTEGER NOT NULL, -- replay's deal seed + draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree" + mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack" + time_seconds INTEGER NOT NULL, -- duration of the win + final_score INTEGER NOT NULL, -- final score at the win + recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD) + received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601) + replay_json TEXT NOT NULL -- full Replay serialisation +); + +-- Recent-replays list endpoint sorts by received_at DESC; the index +-- keeps that scan cheap on a populated table. +CREATE INDEX IF NOT EXISTS replays_received_at_idx + ON replays(received_at DESC); + +-- Lookups by user (e.g. "my replays" view) are common too. +CREATE INDEX IF NOT EXISTS replays_user_id_idx + ON replays(user_id); diff --git a/solitaire_server/src/error.rs b/solitaire_server/src/error.rs index 22aeb04..f048a27 100644 --- a/solitaire_server/src/error.rs +++ b/solitaire_server/src/error.rs @@ -31,6 +31,10 @@ pub enum AppError { #[error("bad request: {0}")] BadRequest(String), + /// The requested resource does not exist. + #[error("not found: {0}")] + NotFound(String), + /// A database error occurred. #[error("database error: {0}")] Database(#[from] sqlx::Error), @@ -56,6 +60,7 @@ impl IntoResponse for AppError { } AppError::UsernameTaken => (StatusCode::CONFLICT, self.to_string()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::Database(e) => { tracing::error!("database error: {e}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 1977a4e..b789a16 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -9,6 +9,7 @@ pub mod challenge; pub mod error; pub mod leaderboard; pub mod middleware; +pub mod replays; pub mod sync; use axum::{ @@ -64,6 +65,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { let protected = Router::new() .route("/api/sync/pull", get(sync::pull)) .route("/api/sync/push", post(sync::push)) + .route("/api/replays", post(replays::upload)) .route("/api/leaderboard", get(leaderboard::get_leaderboard)) .route("/api/leaderboard/opt-in", post(leaderboard::opt_in)) .route("/api/leaderboard/opt-in", delete(leaderboard::opt_out)) @@ -98,6 +100,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { // Public endpoints (no auth, no rate limit beyond defaults). let public = Router::new() .route("/api/daily-challenge", get(challenge::daily_challenge)) + .route("/api/replays/recent", get(replays::recent)) + .route("/api/replays/{id}", get(replays::get_by_id)) .route("/health", get(health)); Router::new() diff --git a/solitaire_server/src/replays.rs b/solitaire_server/src/replays.rs new file mode 100644 index 0000000..78bd3dd --- /dev/null +++ b/solitaire_server/src/replays.rs @@ -0,0 +1,191 @@ +//! Winning-replay storage and retrieval. +//! +//! `POST /api/replays` — upload a winning replay (auth required). +//! `GET /api/replays/recent` — list the N most-recent replays across users. +//! `GET /api/replays/:id` — fetch a single replay's full JSON. +//! +//! The replay payload itself is opaque to the server — the desktop client +//! generates a `solitaire_data::Replay` and the web playback re-executes +//! the same atomic input list against a fresh `GameState`. The server +//! just persists, indexes, and serves the JSON; it does not validate the +//! semantics of the move list. +//! +//! Three columns are projected out of the replay JSON at insert time +//! (`final_score`, `time_seconds`, `recorded_at`) so list endpoints can +//! be served without scanning every blob. + +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{error::AppError, middleware::AuthenticatedUser, AppState}; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +/// Subset of `Replay` fields the server needs to project out of the +/// uploaded JSON to populate the denormalised columns. Mirrors the +/// fields on `solitaire_data::Replay`; we don't depend on +/// `solitaire_data` here because the server crate must not pull in +/// the desktop client's transitive dependencies. +#[derive(Debug, Deserialize)] +struct ReplayHeader { + seed: i64, + draw_mode: String, + mode: String, + time_seconds: i64, + final_score: i64, + recorded_at: String, +} + +/// Successful upload acknowledgement. The server-minted `id` is what +/// the client / web UI uses to link to `/replays/`. +#[derive(Debug, Serialize)] +pub struct ReplayUploadResponse { + /// UUID v4 minted server-side at insert time. + pub id: String, +} + +/// One row in the recent-replays list. Just the projection columns — +/// the full move list lives behind `GET /api/replays/:id`. +#[derive(Debug, Serialize)] +pub struct ReplaySummary { + pub id: String, + pub username: String, + pub seed: i64, + pub draw_mode: String, + pub mode: String, + pub time_seconds: i64, + pub final_score: i64, + pub recorded_at: String, + pub received_at: String, +} + +/// `GET /api/replays/recent?limit=N` — bound the result set so a +/// long-tail history doesn't ship megabytes per request. +#[derive(Debug, Deserialize)] +pub struct RecentQuery { + pub limit: Option, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /api/replays` — accept a winning replay JSON, persist it, +/// return the server-minted `id`. Auth required (the upload is +/// attributed to the authenticated user). +pub async fn upload( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> Result, AppError> { + // Project the header fields the SQL columns need. The full payload + // is stored verbatim — schema_version sits inside it and the + // playback path is what enforces compatibility. + let header: ReplayHeader = serde_json::from_value(payload.clone()) + .map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?; + + let id = Uuid::new_v4().to_string(); + let received_at = Utc::now().to_rfc3339(); + let replay_json = serde_json::to_string(&payload)?; + + sqlx::query!( + r#"INSERT INTO replays ( + id, user_id, seed, draw_mode, mode, time_seconds, final_score, + recorded_at, received_at, replay_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + id, + user.user_id, + header.seed, + header.draw_mode, + header.mode, + header.time_seconds, + header.final_score, + header.recorded_at, + received_at, + replay_json, + ) + .execute(&state.pool) + .await?; + + Ok(Json(ReplayUploadResponse { id })) +} + +/// `GET /api/replays/recent` — list the N most-recent replays across +/// every user, newest first. Auth not required so the web UI can show +/// a public "latest wins" feed without a logged-in client. +pub async fn recent( + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + // 50 is a sane upper bound so a `?limit=999999` request can't make + // the server allocate megabytes. 20 is the default for a quick feed. + let limit = q.limit.unwrap_or(20).min(50) as i64; + + let rows = sqlx::query!( + r#"SELECT + r.id AS "id!: String", + u.username AS "username!: String", + r.seed AS "seed!: i64", + r.draw_mode AS "draw_mode!: String", + r.mode AS "mode!: String", + r.time_seconds AS "time_seconds!: i64", + r.final_score AS "final_score!: i64", + r.recorded_at AS "recorded_at!: String", + r.received_at AS "received_at!: String" + FROM replays r + JOIN users u ON u.id = r.user_id + ORDER BY r.received_at DESC + LIMIT ?"#, + limit, + ) + .fetch_all(&state.pool) + .await?; + + Ok(Json( + rows.into_iter() + .map(|r| ReplaySummary { + id: r.id, + username: r.username, + seed: r.seed, + draw_mode: r.draw_mode, + mode: r.mode, + time_seconds: r.time_seconds, + final_score: r.final_score, + recorded_at: r.recorded_at, + received_at: r.received_at, + }) + .collect(), + )) +} + +/// `GET /api/replays/:id` — return the full replay JSON the desktop +/// client uploaded. Public; the web UI fetches this directly. +/// +/// The server does not validate or transform the payload — what was +/// stored is what's returned. Schema-version compatibility is the +/// responsibility of the playback side (web UI), matching the +/// `schema_version` gate the desktop loader uses. +pub async fn get_by_id( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let row = sqlx::query!( + "SELECT replay_json FROM replays WHERE id = ?", + id, + ) + .fetch_optional(&state.pool) + .await?; + + let replay_json = row + .ok_or_else(|| AppError::NotFound("replay not found".into()))? + .replay_json; + let value: serde_json::Value = serde_json::from_str(&replay_json)?; + Ok(Json(value)) +}