From 3d34c8d2acf98bb25832e834facaf57c653aca11 Mon Sep 17 00:00:00 2001 From: Jeck0v Date: Thu, 2 Jul 2026 17:06:30 +0200 Subject: [PATCH] feat: Add endpoint + config Lucyd for clearing Leaderboard --- crates/api/src/modules/admin/routes.rs | 84 +++++++++++++++++++++++- crates/api/src/modules/scores/dto.rs | 9 +++ crates/api/src/modules/scores/service.rs | 60 ++++++++++++++++- 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/crates/api/src/modules/admin/routes.rs b/crates/api/src/modules/admin/routes.rs index 4ce1fd3..1ee57c2 100644 --- a/crates/api/src/modules/admin/routes.rs +++ b/crates/api/src/modules/admin/routes.rs @@ -1,5 +1,5 @@ use axum::Router; -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use lucyd::lucy_http; @@ -7,17 +7,25 @@ use lucyd::lucy_http; use game_logic::engine::config::{self, GameConfig, GameConfigPatch}; use crate::errors::ApiError; +use crate::modules::scores::{ + dto::{LeaderboardResponse, UpdateScoreRequest}, + service as scores_service, +}; use crate::state::AppState; use super::auth::AdminUser; use super::service::AdminService; pub fn router() -> Router { - use axum::routing::{get, patch, put}; + use axum::routing::{delete, get, patch, put}; Router::new() .route("/api/v1/admin/config", get(get_config)) .route("/api/v1/admin/config", put(update_config)) .route("/api/v1/admin/config", patch(patch_config)) + .route("/api/v1/admin/leaderboard", get(admin_get_leaderboard)) + .route("/api/v1/admin/leaderboard", delete(admin_clear_leaderboard)) + .route("/api/v1/admin/leaderboard/{id}", put(admin_update_score)) + .route("/api/v1/admin/leaderboard/{id}", delete(admin_delete_score)) } #[lucy_http( @@ -69,3 +77,75 @@ pub async fn patch_config( AdminService::save_config(&state.db_pool, &updated).await?; Ok((StatusCode::OK, axum::Json(updated))) } + +#[lucy_http( + method = "GET", + path = "/api/v1/admin/leaderboard", + tags = "admin", + response = LeaderboardResponse, + description = "Returns all leaderboard entries (no limit) sorted by score descending. Requires Authorization: Bearer .", +)] +pub async fn admin_get_leaderboard( + _admin: AdminUser, + State(state): State, +) -> Result { + let scores = scores_service::get_leaderboard(&state.db_pool, -1).await?; + Ok((StatusCode::OK, axum::Json(LeaderboardResponse { scores }))) +} + +#[lucy_http( + method = "PUT", + path = "/api/v1/admin/leaderboard/{id}", + tags = "admin", + request = UpdateScoreRequest, + response = LeaderboardResponse, + description = "Update a specific leaderboard entry by id. Only provided fields are updated. Returns 404 when the entry does not exist. Requires Authorization: Bearer .", +)] +pub async fn admin_update_score( + _admin: AdminUser, + State(state): State, + Path(id): Path, + axum::Json(req): axum::Json, +) -> Result { + let entry = scores_service::update_score(&state.db_pool, id, req).await?; + match entry { + Some(score) => Ok((StatusCode::OK, axum::Json(score)).into_response()), + None => Err(ApiError::NotFound(format!("score entry {id} not found"))), + } +} + +#[lucy_http( + method = "DELETE", + path = "/api/v1/admin/leaderboard/{id}", + tags = "admin", + description = "Delete a specific leaderboard entry by id. Returns 204 on success, 404 when the entry does not exist. Requires Authorization: Bearer ." +)] +pub async fn admin_delete_score( + _admin: AdminUser, + State(state): State, + Path(id): Path, +) -> Result { + let deleted = scores_service::delete_score(&state.db_pool, id).await?; + if deleted { + Ok(StatusCode::NO_CONTENT.into_response()) + } else { + Err(ApiError::NotFound(format!("score entry {id} not found"))) + } +} + +#[lucy_http( + method = "DELETE", + path = "/api/v1/admin/leaderboard", + tags = "admin", + description = "Delete all leaderboard entries (hard reset). Returns 200 with the number of deleted rows. Requires Authorization: Bearer ." +)] +pub async fn admin_clear_leaderboard( + _admin: AdminUser, + State(state): State, +) -> Result { + let count = scores_service::clear_leaderboard(&state.db_pool).await?; + Ok(( + StatusCode::OK, + axum::Json(serde_json::json!({ "deleted": count })), + )) +} diff --git a/crates/api/src/modules/scores/dto.rs b/crates/api/src/modules/scores/dto.rs index fab9ed2..676b79f 100644 --- a/crates/api/src/modules/scores/dto.rs +++ b/crates/api/src/modules/scores/dto.rs @@ -28,3 +28,12 @@ pub struct SaveScoreRequest { pub score: u64, pub boss_reached: u8, } + +/// Body for `PUT /api/v1/admin/leaderboard/{id}` — all fields are optional +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct UpdateScoreRequest { + /// DB integer ID matching `game_logic::slug_to_db_id` (1=enforcer, 2=viper, 3=ghost, 4=oracle) + pub character_id: Option, + pub score: Option, + pub boss_reached: Option, +} diff --git a/crates/api/src/modules/scores/service.rs b/crates/api/src/modules/scores/service.rs index 77f5fae..2049ead 100644 --- a/crates/api/src/modules/scores/service.rs +++ b/crates/api/src/modules/scores/service.rs @@ -1,6 +1,6 @@ use sqlx::{Row, SqlitePool}; -use super::dto::{SaveScoreRequest, ScoreEntry}; +use super::dto::{SaveScoreRequest, ScoreEntry, UpdateScoreRequest}; // Hard cap on leaderboard size enforced at write time, never exceeded in the DB const LEADERBOARD_LIMIT: i64 = 10; @@ -65,6 +65,64 @@ pub async fn get_leaderboard( Ok(rows.into_iter().map(row_to_entry).collect()) } +/// Update an existing score entry by `id`; returns the updated row or `None` if not found. +/// +/// All field updates run inside a single transaction so partial failures leave the row unchanged. +pub async fn update_score( + pool: &SqlitePool, + id: i64, + req: UpdateScoreRequest, +) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; + + if let Some(character_id) = req.character_id { + sqlx::query("UPDATE scores SET character_id = ? WHERE id = ?") + .bind(character_id) + .bind(id) + .execute(&mut *tx) + .await?; + } + if let Some(score) = req.score { + sqlx::query("UPDATE scores SET score = ? WHERE id = ?") + .bind(score) + .bind(id) + .execute(&mut *tx) + .await?; + } + if let Some(boss_reached) = req.boss_reached { + sqlx::query("UPDATE scores SET boss_reached = ? WHERE id = ?") + .bind(boss_reached) + .bind(id) + .execute(&mut *tx) + .await?; + } + + let row = sqlx::query( + "SELECT id, character_id, score, boss_reached, created_at FROM scores WHERE id = ?", + ) + .bind(id) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + Ok(row.map(row_to_entry)) +} + +/// Delete a single score entry by `id`; returns `true` when a row was deleted +pub async fn delete_score(pool: &SqlitePool, id: i64) -> Result { + let result = sqlx::query("DELETE FROM scores WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Delete every entry in the leaderboard (hard reset) +pub async fn clear_leaderboard(pool: &SqlitePool) -> Result { + let result = sqlx::query("DELETE FROM scores").execute(pool).await?; + Ok(result.rows_affected()) +} + // Explicit field mapping avoids `FromRow` macro magic that breaks silently on column renames fn row_to_entry(row: sqlx::sqlite::SqliteRow) -> ScoreEntry { ScoreEntry {