Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions crates/api/src/modules/admin/routes.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
use axum::Router;
use axum::extract::State;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
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<AppState> {
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(
Expand Down Expand Up @@ -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 <admin-jwt>.",
)]
pub async fn admin_get_leaderboard(
_admin: AdminUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
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 <admin-jwt>.",
)]
pub async fn admin_update_score(
_admin: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(req): axum::Json<UpdateScoreRequest>,
) -> Result<impl IntoResponse, ApiError> {
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 <admin-jwt>."
)]
pub async fn admin_delete_score(
_admin: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<impl IntoResponse, ApiError> {
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 <admin-jwt>."
)]
pub async fn admin_clear_leaderboard(
_admin: AdminUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let count = scores_service::clear_leaderboard(&state.db_pool).await?;
Ok((
StatusCode::OK,
axum::Json(serde_json::json!({ "deleted": count })),
))
}
9 changes: 9 additions & 0 deletions crates/api/src/modules/scores/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
pub score: Option<i64>,
pub boss_reached: Option<i64>,
}
60 changes: 59 additions & 1 deletion crates/api/src/modules/scores/service.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Option<ScoreEntry>, 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<bool, sqlx::Error> {
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<u64, sqlx::Error> {
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 {
Expand Down
Loading