From 3fafca28602c673ae116faf69e4d6207ca9846ff Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Jan 2026 08:55:04 +0000 Subject: [PATCH] Add utoipa OpenAPI annotations to all handlers and fix score_confirmation_status column name Co-Authored-By: Warp --- migrations/20260119000001_initial_schema.sql | 8 - src/handlers/ladders.rs | 499 +++++-------- src/handlers/matches.rs | 341 +++++---- src/handlers/players.rs | 599 ++++++--------- src/handlers/teams.rs | 729 +++++++++---------- src/handlers/uploads.rs | 162 ++--- src/models/match_round.rs | 2 +- src/openapi.rs | 175 +++-- 8 files changed, 1132 insertions(+), 1383 deletions(-) diff --git a/migrations/20260119000001_initial_schema.sql b/migrations/20260119000001_initial_schema.sql index e67abe7..5f66196 100644 --- a/migrations/20260119000001_initial_schema.sql +++ b/migrations/20260119000001_initial_schema.sql @@ -41,8 +41,6 @@ CREATE TABLE IF NOT EXISTS team_players ( date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, player_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - game_name TEXT NOT NULL DEFAULT '', - game_mode TEXT NOT NULL DEFAULT '', position VARCHAR(20) NOT NULL DEFAULT 'member', status INTEGER NOT NULL DEFAULT 0, UNIQUE(team_id, player_id) @@ -72,12 +70,6 @@ CREATE TABLE IF NOT EXISTS ladder_teams ( date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE, team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - status VARCHAR(255) NOT NULL DEFAULT 'active', - seed INTEGER, - result_position INTEGER, - win_count INTEGER NOT NULL DEFAULT 0, - loss_count INTEGER NOT NULL DEFAULT 0, UNIQUE(ladder_id, team_id) ); diff --git a/src/handlers/ladders.rs b/src/handlers/ladders.rs index 497d9b4..2cfcc49 100644 --- a/src/handlers/ladders.rs +++ b/src/handlers/ladders.rs @@ -5,6 +5,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use validator::Validate; use crate::{ @@ -20,15 +21,15 @@ use super::auth::AppState; // Request/Response Types // ============================================================================ -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, ToSchema)] pub struct ListLaddersQuery { pub search: Option, - pub status: Option, // 0=open, 1=closed + pub status: Option, pub page: Option, pub per_page: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct LadderListResponse { pub ladders: Vec, pub total: i64, @@ -36,7 +37,7 @@ pub struct LadderListResponse { pub per_page: i64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct LadderWithTeamCount { pub id: i32, pub name: String, @@ -49,7 +50,7 @@ pub struct LadderWithTeamCount { pub team_count: Option, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateLadderRequest { #[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))] pub name: String, @@ -58,7 +59,7 @@ pub struct CreateLadderRequest { pub logo: Option, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateLadderRequest { #[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))] pub name: Option, @@ -68,7 +69,7 @@ pub struct UpdateLadderRequest { pub logo: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct LadderResponse { pub id: i32, pub name: String, @@ -95,7 +96,7 @@ impl From for LadderResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct LadderDetailResponse { pub ladder: LadderResponse, pub teams: Vec, @@ -103,7 +104,7 @@ pub struct LadderDetailResponse { pub user_team_id: Option, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct LadderTeamResponse { pub id: i32, pub team_id: i32, @@ -114,7 +115,7 @@ pub struct LadderTeamResponse { pub date_enrolled: chrono::DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct EnrollmentResponse { pub message: String, pub ladder_team_id: i32, @@ -124,8 +125,21 @@ pub struct EnrollmentResponse { // Handlers // ============================================================================ -/// GET /api/ladders /// List all ladders with pagination and search +#[utoipa::path( + get, + path = "/api/ladders", + tag = "ladders", + params( + ("search" = Option, Query, description = "Search by ladder name"), + ("status" = Option, Query, description = "Filter by status (0=open, 1=closed)"), + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page") + ), + responses( + (status = 200, description = "List of ladders", body = LadderListResponse) + ) +)] pub async fn list_ladders( Query(query): Query, State(state): State, @@ -136,312 +150,207 @@ pub async fn list_ladders( let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); - // Build dynamic query based on filters let (total, ladders): (i64, Vec) = match (&search_pattern, query.status) { (Some(pattern), Some(status)) => { - let total = sqlx::query_scalar( - "SELECT COUNT(*) FROM ladders WHERE name ILIKE $1 AND status = $2" - ) - .bind(pattern) - .bind(status) - .fetch_one(&state.pool) - .await?; - + let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE name ILIKE $1 AND status = $2") + .bind(pattern).bind(status).fetch_one(&state.pool).await?; let ladders = sqlx::query_as( - r#" - SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, - l.date_created, l.created_by_id, - COUNT(lt.id) as team_count - FROM ladders l - LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id - WHERE l.name ILIKE $1 AND l.status = $2 - GROUP BY l.id - ORDER BY l.date_created DESC - LIMIT $3 OFFSET $4 - "# - ) - .bind(pattern) - .bind(status) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, + l.date_created, l.created_by_id, COUNT(lt.id) as team_count + FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id + WHERE l.name ILIKE $1 AND l.status = $2 GROUP BY l.id + ORDER BY l.date_created DESC LIMIT $3 OFFSET $4"# + ).bind(pattern).bind(status).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, ladders) } (Some(pattern), None) => { - let total = sqlx::query_scalar( - "SELECT COUNT(*) FROM ladders WHERE name ILIKE $1" - ) - .bind(pattern) - .fetch_one(&state.pool) - .await?; - + let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE name ILIKE $1") + .bind(pattern).fetch_one(&state.pool).await?; let ladders = sqlx::query_as( - r#" - SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, - l.date_created, l.created_by_id, - COUNT(lt.id) as team_count - FROM ladders l - LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id - WHERE l.name ILIKE $1 - GROUP BY l.id - ORDER BY l.date_created DESC - LIMIT $2 OFFSET $3 - "# - ) - .bind(pattern) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, + l.date_created, l.created_by_id, COUNT(lt.id) as team_count + FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id + WHERE l.name ILIKE $1 GROUP BY l.id ORDER BY l.date_created DESC LIMIT $2 OFFSET $3"# + ).bind(pattern).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, ladders) } (None, Some(status)) => { - let total = sqlx::query_scalar( - "SELECT COUNT(*) FROM ladders WHERE status = $1" - ) - .bind(status) - .fetch_one(&state.pool) - .await?; - + let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE status = $1") + .bind(status).fetch_one(&state.pool).await?; let ladders = sqlx::query_as( - r#" - SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, - l.date_created, l.created_by_id, - COUNT(lt.id) as team_count - FROM ladders l - LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id - WHERE l.status = $1 - GROUP BY l.id - ORDER BY l.date_created DESC - LIMIT $2 OFFSET $3 - "# - ) - .bind(status) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, + l.date_created, l.created_by_id, COUNT(lt.id) as team_count + FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id + WHERE l.status = $1 GROUP BY l.id ORDER BY l.date_created DESC LIMIT $2 OFFSET $3"# + ).bind(status).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, ladders) } (None, None) => { - let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders") - .fetch_one(&state.pool) - .await?; - + let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders").fetch_one(&state.pool).await?; let ladders = sqlx::query_as( - r#" - SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, - l.date_created, l.created_by_id, - COUNT(lt.id) as team_count - FROM ladders l - LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id - GROUP BY l.id - ORDER BY l.date_created DESC - LIMIT $1 OFFSET $2 - "# - ) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration, + l.date_created, l.created_by_id, COUNT(lt.id) as team_count + FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id + GROUP BY l.id ORDER BY l.date_created DESC LIMIT $1 OFFSET $2"# + ).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, ladders) } }; - Ok(Json(LadderListResponse { - ladders, - total, - page, - per_page, - })) + Ok(Json(LadderListResponse { ladders, total, page, per_page })) } -/// POST /api/ladders /// Create a new ladder (admin only) +#[utoipa::path( + post, + path = "/api/ladders", + tag = "ladders", + request_body = CreateLadderRequest, + responses( + (status = 201, description = "Ladder created", body = LadderResponse), + (status = 403, description = "Admin only"), + (status = 409, description = "Ladder name exists") + ), + security(("bearer_auth" = [])) +)] pub async fn create_ladder( user: AuthUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json)> { - // Only admins can create ladders if !user.is_admin { return Err(AppError::Forbidden("Only admins can create ladders".to_string())); } - payload.validate()?; - // Check if ladder name already exists - let existing = sqlx::query_scalar::<_, i32>( - "SELECT id FROM ladders WHERE name = $1" - ) - .bind(&payload.name) - .fetch_optional(&state.pool) - .await?; - + let existing = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE name = $1") + .bind(&payload.name).fetch_optional(&state.pool).await?; if existing.is_some() { return Err(AppError::Conflict("Ladder name already exists".to_string())); } let ladder = sqlx::query_as::<_, Ladder>( - r#" - INSERT INTO ladders (name, date_start, date_expiration, logo, created_by_id, status) - VALUES ($1, $2, $3, $4, $5, 0) - RETURNING * - "# - ) - .bind(&payload.name) - .bind(&payload.date_start) - .bind(&payload.date_expiration) - .bind(&payload.logo) - .bind(user.id) - .fetch_one(&state.pool) - .await?; + r#"INSERT INTO ladders (name, date_start, date_expiration, logo, created_by_id, status) + VALUES ($1, $2, $3, $4, $5, 0) RETURNING *"# + ).bind(&payload.name).bind(&payload.date_start).bind(&payload.date_expiration) + .bind(&payload.logo).bind(user.id).fetch_one(&state.pool).await?; Ok((StatusCode::CREATED, Json(ladder.into()))) } -/// GET /api/ladders/:id /// Get ladder details with enrolled teams +#[utoipa::path( + get, + path = "/api/ladders/{id}", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 200, description = "Ladder details", body = LadderDetailResponse), + (status = 404, description = "Ladder not found") + ) +)] pub async fn get_ladder( OptionalAuthUser(user): OptionalAuthUser, State(state): State, Path(ladder_id): Path, ) -> Result> { - let ladder = sqlx::query_as::<_, Ladder>( - "SELECT * FROM ladders WHERE id = $1" - ) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; + let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1") + .bind(ladder_id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; - // Get enrolled teams let teams = sqlx::query_as::<_, LadderTeamResponse>( - r#" - SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo, - t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled - FROM ladder_teams lt - JOIN teams t ON lt.team_id = t.id - WHERE lt.ladder_id = $1 AND t.is_delete = false - ORDER BY t.rank ASC, t.mmr DESC - "# - ) - .bind(ladder_id) - .fetch_all(&state.pool) - .await?; + r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo, + t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled + FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id + WHERE lt.ladder_id = $1 AND t.is_delete = false ORDER BY t.rank ASC, t.mmr DESC"# + ).bind(ladder_id).fetch_all(&state.pool).await?; - // Check if user's team is enrolled let (is_enrolled, user_team_id) = if let Some(ref u) = user { - // Get user's team let user_team = sqlx::query_scalar::<_, i32>( "SELECT team_id FROM team_players WHERE player_id = $1 AND status = 1" - ) - .bind(u.id) - .fetch_optional(&state.pool) - .await?; + ).bind(u.id).fetch_optional(&state.pool).await?; if let Some(team_id) = user_team { let enrolled = sqlx::query_scalar::<_, i32>( "SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2" - ) - .bind(ladder_id) - .bind(team_id) - .fetch_optional(&state.pool) - .await?; - + ).bind(ladder_id).bind(team_id).fetch_optional(&state.pool).await?; (enrolled.is_some(), Some(team_id)) - } else { - (false, None) - } - } else { - (false, None) - }; + } else { (false, None) } + } else { (false, None) }; - Ok(Json(LadderDetailResponse { - ladder: ladder.into(), - teams, - is_enrolled, - user_team_id, - })) + Ok(Json(LadderDetailResponse { ladder: ladder.into(), teams, is_enrolled, user_team_id })) } -/// PUT /api/ladders/:id /// Update ladder (admin only) +#[utoipa::path( + put, + path = "/api/ladders/{id}", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + request_body = UpdateLadderRequest, + responses( + (status = 200, description = "Ladder updated", body = LadderResponse), + (status = 403, description = "Admin only"), + (status = 404, description = "Ladder not found"), + (status = 409, description = "Ladder name exists") + ), + security(("bearer_auth" = [])) +)] pub async fn update_ladder( user: AuthUser, State(state): State, Path(ladder_id): Path, Json(payload): Json, ) -> Result> { - // Only admins can update ladders if !user.is_admin { return Err(AppError::Forbidden("Only admins can update ladders".to_string())); } - payload.validate()?; - // Check if new name conflicts if let Some(ref name) = payload.name { - let existing = sqlx::query_scalar::<_, i32>( - "SELECT id FROM ladders WHERE name = $1 AND id != $2" - ) - .bind(name) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await?; - + let existing = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE name = $1 AND id != $2") + .bind(name).bind(ladder_id).fetch_optional(&state.pool).await?; if existing.is_some() { return Err(AppError::Conflict("Ladder name already exists".to_string())); } } let ladder = sqlx::query_as::<_, Ladder>( - r#" - UPDATE ladders - SET name = COALESCE($1, name), - date_start = COALESCE($2, date_start), - date_expiration = COALESCE($3, date_expiration), - status = COALESCE($4, status), - logo = COALESCE($5, logo) - WHERE id = $6 - RETURNING * - "# - ) - .bind(&payload.name) - .bind(&payload.date_start) - .bind(&payload.date_expiration) - .bind(payload.status) - .bind(&payload.logo) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; + r#"UPDATE ladders SET name = COALESCE($1, name), date_start = COALESCE($2, date_start), + date_expiration = COALESCE($3, date_expiration), status = COALESCE($4, status), + logo = COALESCE($5, logo) WHERE id = $6 RETURNING *"# + ).bind(&payload.name).bind(&payload.date_start).bind(&payload.date_expiration) + .bind(payload.status).bind(&payload.logo).bind(ladder_id) + .fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; Ok(Json(ladder.into())) } -/// DELETE /api/ladders/:id /// Delete ladder (admin only) +#[utoipa::path( + delete, + path = "/api/ladders/{id}", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 204, description = "Ladder deleted"), + (status = 403, description = "Admin only"), + (status = 404, description = "Ladder not found") + ), + security(("bearer_auth" = [])) +)] pub async fn delete_ladder( user: AuthUser, State(state): State, Path(ladder_id): Path, ) -> Result { - // Only admins can delete ladders if !user.is_admin { return Err(AppError::Forbidden("Only admins can delete ladders".to_string())); } - // Delete ladder (cascade will remove ladder_teams entries) let result = sqlx::query("DELETE FROM ladders WHERE id = $1") - .bind(ladder_id) - .execute(&state.pool) - .await?; + .bind(ladder_id).execute(&state.pool).await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Ladder not found".to_string())); @@ -450,107 +359,89 @@ pub async fn delete_ladder( Ok(StatusCode::NO_CONTENT) } -/// POST /api/ladders/:id/enroll -/// Enroll user's team in a ladder (captain only) +/// Enroll team in a ladder (captain only) +#[utoipa::path( + post, + path = "/api/ladders/{id}/enroll", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 201, description = "Team enrolled", body = EnrollmentResponse), + (status = 403, description = "Must be team captain"), + (status = 404, description = "Ladder not found"), + (status = 409, description = "Already enrolled") + ), + security(("bearer_auth" = [])) +)] pub async fn enroll_team( user: AuthUser, State(state): State, Path(ladder_id): Path, ) -> Result<(StatusCode, Json)> { - // Check ladder exists and is open - let ladder = sqlx::query_as::<_, Ladder>( - "SELECT * FROM ladders WHERE id = $1" - ) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; + let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1") + .bind(ladder_id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; if ladder.status != 0 { return Err(AppError::BadRequest("Ladder is not open for enrollment".to_string())); } - // Get user's team (must be captain) let team_id = sqlx::query_scalar::<_, i32>( "SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" - ) - .bind(user.id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?; + ).bind(user.id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?; - // Check if already enrolled let existing = sqlx::query_scalar::<_, i32>( "SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2" - ) - .bind(ladder_id) - .bind(team_id) - .fetch_optional(&state.pool) - .await?; + ).bind(ladder_id).bind(team_id).fetch_optional(&state.pool).await?; if existing.is_some() { return Err(AppError::Conflict("Team is already enrolled in this ladder".to_string())); } - // Enroll team let ladder_team = sqlx::query_as::<_, LadderTeam>( - r#" - INSERT INTO ladder_teams (ladder_id, team_id) - VALUES ($1, $2) - RETURNING * - "# - ) - .bind(ladder_id) - .bind(team_id) - .fetch_one(&state.pool) - .await?; + "INSERT INTO ladder_teams (ladder_id, team_id) VALUES ($1, $2) RETURNING *" + ).bind(ladder_id).bind(team_id).fetch_one(&state.pool).await?; - Ok(( - StatusCode::CREATED, - Json(EnrollmentResponse { - message: "Successfully enrolled in ladder".to_string(), - ladder_team_id: ladder_team.id, - }), - )) + Ok((StatusCode::CREATED, Json(EnrollmentResponse { + message: "Successfully enrolled in ladder".to_string(), + ladder_team_id: ladder_team.id, + }))) } -/// DELETE /api/ladders/:id/enroll /// Withdraw team from a ladder (captain only) +#[utoipa::path( + delete, + path = "/api/ladders/{id}/enroll", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 204, description = "Team withdrawn"), + (status = 403, description = "Must be team captain"), + (status = 404, description = "Ladder not found or not enrolled") + ), + security(("bearer_auth" = [])) +)] pub async fn withdraw_team( user: AuthUser, State(state): State, Path(ladder_id): Path, ) -> Result { - // Get user's team (must be captain) let team_id = sqlx::query_scalar::<_, i32>( "SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" - ) - .bind(user.id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?; + ).bind(user.id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?; - // Check ladder status - can't withdraw from closed ladders - let ladder = sqlx::query_as::<_, Ladder>( - "SELECT * FROM ladders WHERE id = $1" - ) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; + let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1") + .bind(ladder_id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; if ladder.status != 0 { return Err(AppError::BadRequest("Cannot withdraw from a closed ladder".to_string())); } - // Remove enrollment - let result = sqlx::query( - "DELETE FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2" - ) - .bind(ladder_id) - .bind(team_id) - .execute(&state.pool) - .await?; + let result = sqlx::query("DELETE FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2") + .bind(ladder_id).bind(team_id).execute(&state.pool).await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Team is not enrolled in this ladder".to_string())); @@ -559,38 +450,34 @@ pub async fn withdraw_team( Ok(StatusCode::NO_CONTENT) } -/// GET /api/ladders/:id/standings /// Get ladder standings (teams ranked by MMR) +#[utoipa::path( + get, + path = "/api/ladders/{id}/standings", + tag = "ladders", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 200, description = "Ladder standings", body = Vec), + (status = 404, description = "Ladder not found") + ) +)] pub async fn get_ladder_standings( State(state): State, Path(ladder_id): Path, ) -> Result>> { - // Check ladder exists - let exists = sqlx::query_scalar::<_, i32>( - "SELECT id FROM ladders WHERE id = $1" - ) - .bind(ladder_id) - .fetch_optional(&state.pool) - .await?; + let exists = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE id = $1") + .bind(ladder_id).fetch_optional(&state.pool).await?; if exists.is_none() { return Err(AppError::NotFound("Ladder not found".to_string())); } - // Get standings let standings = sqlx::query_as::<_, LadderTeamResponse>( - r#" - SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo, - t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled - FROM ladder_teams lt - JOIN teams t ON lt.team_id = t.id - WHERE lt.ladder_id = $1 AND t.is_delete = false - ORDER BY t.mmr DESC, t.rank ASC - "# - ) - .bind(ladder_id) - .fetch_all(&state.pool) - .await?; + r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo, + t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled + FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id + WHERE lt.ladder_id = $1 AND t.is_delete = false ORDER BY t.mmr DESC, t.rank ASC"# + ).bind(ladder_id).fetch_all(&state.pool).await?; Ok(Json(standings)) } diff --git a/src/handlers/matches.rs b/src/handlers/matches.rs index d6cce09..4257252 100644 --- a/src/handlers/matches.rs +++ b/src/handlers/matches.rs @@ -5,6 +5,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use validator::Validate; use crate::{ @@ -20,7 +21,6 @@ use super::auth::AppState; // Constants for Match Status // ============================================================================ -/// Team status in a match pub mod team_status { pub const CHALLENGING: i32 = 0; pub const PENDING_RESPONSE: i32 = 1; @@ -29,7 +29,6 @@ pub mod team_status { pub const ACCEPTED: i32 = 4; } -/// Match overall status pub mod match_status { pub const PENDING: i32 = 0; pub const SCHEDULED: i32 = 1; @@ -38,7 +37,6 @@ pub mod match_status { pub const CANCELLED: i32 = 4; } -/// Score acceptance status pub mod score_status { pub const PENDING: i32 = 0; pub const ACCEPTED: i32 = 1; @@ -49,7 +47,7 @@ pub mod score_status { // Request/Response Types // ============================================================================ -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, ToSchema)] pub struct ListMatchesQuery { pub team_id: Option, pub ladder_id: Option, @@ -58,7 +56,7 @@ pub struct ListMatchesQuery { pub per_page: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct MatchListResponse { pub matches: Vec, pub total: i64, @@ -66,7 +64,7 @@ pub struct MatchListResponse { pub per_page: i64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct MatchWithTeams { pub id: i32, pub date_created: chrono::DateTime, @@ -84,14 +82,14 @@ pub struct MatchWithTeams { pub created_by_id: i32, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateChallengeRequest { pub opponent_team_id: i32, #[validate(length(min = 1, message = "Challenge date is required"))] - pub challenge_date: String, // ISO 8601 datetime + pub challenge_date: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct MatchResponse { pub id: i32, pub date_created: chrono::DateTime, @@ -122,7 +120,7 @@ impl From for MatchResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct MatchDetailResponse { pub match_info: MatchWithTeams, pub rounds: Vec, @@ -131,7 +129,7 @@ pub struct MatchDetailResponse { pub can_report_score: bool, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct MatchRoundResponse { pub id: i32, pub date_created: chrono::DateTime, @@ -139,10 +137,10 @@ pub struct MatchRoundResponse { pub team_1_score: i32, pub team_2_score: i32, pub score_posted_by_team_id: i32, - pub score_acceptance_status: i32, + pub score_confirmation_status: i32, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct ReportScoreRequest { #[validate(range(min = 0, message = "Score must be non-negative"))] pub team_1_score: i32, @@ -150,7 +148,7 @@ pub struct ReportScoreRequest { pub team_2_score: i32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct RescheduleRequest { pub challenge_date: String, } @@ -159,8 +157,22 @@ pub struct RescheduleRequest { // Handlers // ============================================================================ -/// GET /api/matches /// List matches with filters +#[utoipa::path( + get, + path = "/api/matches", + tag = "matches", + params( + ("team_id" = Option, Query, description = "Filter by team ID"), + ("ladder_id" = Option, Query, description = "Filter by ladder ID"), + ("status" = Option, Query, description = "Filter by match status (0=pending, 1=scheduled, 2=in_progress, 3=done, 4=cancelled)"), + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page") + ), + responses( + (status = 200, description = "List of matches", body = MatchListResponse) + ) +)] pub async fn list_matches( Query(query): Query, State(state): State, @@ -169,7 +181,6 @@ pub async fn list_matches( let per_page = query.per_page.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; - // Build query based on filters let base_query = r#" SELECT m.id, m.date_created, m.date_start, m.challenge_date, m.team_id_1, t1.name as team_1_name, t1.logo as team_1_logo, m.team_1_status, @@ -268,22 +279,25 @@ pub async fn list_matches( } }; - Ok(Json(MatchListResponse { - matches, - total, - page, - per_page, - })) + Ok(Json(MatchListResponse { matches, total, page, per_page })) } -/// GET /api/matches/:id /// Get match details with rounds +#[utoipa::path( + get, + path = "/api/matches/{id}", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + responses( + (status = 200, description = "Match details", body = MatchDetailResponse), + (status = 404, description = "Match not found") + ) +)] pub async fn get_match( user: Option, State(state): State, Path(match_id): Path, ) -> Result> { - // Get match with team info let match_info = sqlx::query_as::<_, MatchWithTeams>( r#" SELECT m.id, m.date_created, m.date_start, m.challenge_date, @@ -301,7 +315,6 @@ pub async fn get_match( .await? .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Get rounds let rounds = sqlx::query_as::<_, MatchRoundResponse>( "SELECT * FROM match_rounds WHERE match_id = $1 ORDER BY date_created ASC" ) @@ -309,7 +322,6 @@ pub async fn get_match( .fetch_all(&state.pool) .await?; - // Check user's team and permissions let (user_team_id, can_accept, can_report_score) = if let Some(ref u) = user { let team_id = get_user_team_id(&state.pool, u.id).await?; @@ -317,12 +329,10 @@ pub async fn get_match( let is_team_1 = tid == match_info.team_id_1; let is_team_2 = tid == match_info.team_id_2; - // Can accept if you're team 2 and status is pending let can_accept = is_team_2 && match_info.team_2_status == team_status::PENDING_RESPONSE && match_info.matche_status == match_status::PENDING; - // Can report score if match is in progress and you're part of it let can_report = (is_team_1 || is_team_2) && match_info.matche_status == match_status::IN_PROGRESS; @@ -334,17 +344,23 @@ pub async fn get_match( (None, false, false) }; - Ok(Json(MatchDetailResponse { - match_info, - rounds, - user_team_id, - can_accept, - can_report_score, - })) + Ok(Json(MatchDetailResponse { match_info, rounds, user_team_id, can_accept, can_report_score })) } -/// POST /api/matches/challenge /// Create a new challenge +#[utoipa::path( + post, + path = "/api/matches/challenge", + tag = "matches", + request_body = CreateChallengeRequest, + responses( + (status = 201, description = "Challenge created", body = MatchResponse), + (status = 403, description = "Not a team captain"), + (status = 404, description = "Opponent team not found"), + (status = 409, description = "Pending match already exists") + ), + security(("bearer_auth" = [])) +)] pub async fn create_challenge( user: AuthUser, State(state): State, @@ -352,7 +368,6 @@ pub async fn create_challenge( ) -> Result<(StatusCode, Json)> { payload.validate()?; - // Get user's team (must be captain) let user_team_id = sqlx::query_scalar::<_, i32>( "SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" ) @@ -361,7 +376,6 @@ pub async fn create_challenge( .await? .ok_or_else(|| AppError::Forbidden("You must be a team captain to create challenges".to_string()))?; - // Validate opponent team exists let opponent_exists = sqlx::query_scalar::<_, i32>( "SELECT id FROM teams WHERE id = $1 AND is_delete = false" ) @@ -373,12 +387,10 @@ pub async fn create_challenge( return Err(AppError::NotFound("Opponent team not found".to_string())); } - // Can't challenge yourself if user_team_id == payload.opponent_team_id { return Err(AppError::BadRequest("Cannot challenge your own team".to_string())); } - // Check for existing pending challenge between teams let existing = sqlx::query_scalar::<_, i32>( r#" SELECT id FROM matches @@ -395,12 +407,10 @@ pub async fn create_challenge( return Err(AppError::Conflict("A pending match already exists between these teams".to_string())); } - // Parse challenge date let challenge_date = chrono::DateTime::parse_from_rfc3339(&payload.challenge_date) .map_err(|_| AppError::BadRequest("Invalid date format. Use ISO 8601 format.".to_string()))? .with_timezone(&chrono::Utc); - // Create match let new_match = sqlx::query_as::<_, Match>( r#" INSERT INTO matches ( @@ -424,8 +434,19 @@ pub async fn create_challenge( Ok((StatusCode::CREATED, Json(new_match.into()))) } -/// POST /api/matches/:id/accept /// Accept a challenge +#[utoipa::path( + post, + path = "/api/matches/{id}/accept", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + responses( + (status = 200, description = "Challenge accepted", body = MatchResponse), + (status = 403, description = "Only challenged team captain can accept"), + (status = 404, description = "Match not found") + ), + security(("bearer_auth" = [])) +)] pub async fn accept_challenge( user: AuthUser, State(state): State, @@ -433,26 +454,20 @@ pub async fn accept_challenge( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - // Get match - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Verify user is captain of team 2 if current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("Only the challenged team captain can accept".to_string())); } - // Verify match is pending if current_match.matche_status != match_status::PENDING { return Err(AppError::BadRequest("Match is not pending acceptance".to_string())); } - // Update match let updated = sqlx::query_as::<_, Match>( r#" UPDATE matches @@ -472,8 +487,19 @@ pub async fn accept_challenge( Ok(Json(updated.into())) } -/// POST /api/matches/:id/reject /// Reject a challenge +#[utoipa::path( + post, + path = "/api/matches/{id}/reject", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + responses( + (status = 200, description = "Challenge rejected", body = MatchResponse), + (status = 403, description = "Only challenged team captain can reject"), + (status = 404, description = "Match not found") + ), + security(("bearer_auth" = [])) +)] pub async fn reject_challenge( user: AuthUser, State(state): State, @@ -481,15 +507,12 @@ pub async fn reject_challenge( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Verify user is captain of team 2 if current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("Only the challenged team captain can reject".to_string())); } @@ -516,8 +539,19 @@ pub async fn reject_challenge( Ok(Json(updated.into())) } -/// POST /api/matches/:id/start /// Start a scheduled match +#[utoipa::path( + post, + path = "/api/matches/{id}/start", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + responses( + (status = 200, description = "Match started", body = MatchResponse), + (status = 403, description = "Not part of this match"), + (status = 404, description = "Match not found") + ), + security(("bearer_auth" = [])) +)] pub async fn start_match( user: AuthUser, State(state): State, @@ -525,15 +559,12 @@ pub async fn start_match( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Verify user is part of the match if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("You are not part of this match".to_string())); } @@ -553,8 +584,19 @@ pub async fn start_match( Ok(Json(updated.into())) } -/// POST /api/matches/:id/cancel /// Cancel a match (before it starts) +#[utoipa::path( + post, + path = "/api/matches/{id}/cancel", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + responses( + (status = 200, description = "Match cancelled", body = MatchResponse), + (status = 403, description = "Not part of this match"), + (status = 404, description = "Match not found") + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_match( user: AuthUser, State(state): State, @@ -562,20 +604,16 @@ pub async fn cancel_match( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Verify user is part of the match if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("You are not part of this match".to_string())); } - // Can only cancel pending or scheduled matches if current_match.matche_status > match_status::SCHEDULED { return Err(AppError::BadRequest("Cannot cancel a match that has started or ended".to_string())); } @@ -591,8 +629,21 @@ pub async fn cancel_match( Ok(Json(updated.into())) } -/// POST /api/matches/:id/score /// Report a score for a match round +#[utoipa::path( + post, + path = "/api/matches/{id}/score", + tag = "matches", + params(("id" = i32, Path, description = "Match ID")), + request_body = ReportScoreRequest, + responses( + (status = 201, description = "Score reported", body = MatchRoundResponse), + (status = 403, description = "Not part of this match"), + (status = 404, description = "Match not found"), + (status = 409, description = "Pending score report exists") + ), + security(("bearer_auth" = [])) +)] pub async fn report_score( user: AuthUser, State(state): State, @@ -603,27 +654,22 @@ pub async fn report_score( let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; - // Verify user is part of the match if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("You are not part of this match".to_string())); } - // Match must be in progress if current_match.matche_status != match_status::IN_PROGRESS { return Err(AppError::BadRequest("Match must be in progress to report scores".to_string())); } - // Check if there's already a pending score report let pending_round = sqlx::query_as::<_, MatchRound>( - "SELECT * FROM match_rounds WHERE match_id = $1 AND score_acceptance_status = $2" + "SELECT * FROM match_rounds WHERE match_id = $1 AND score_confirmation_status = $2" ) .bind(match_id) .bind(score_status::PENDING) @@ -634,10 +680,9 @@ pub async fn report_score( return Err(AppError::Conflict("There is already a pending score report".to_string())); } - // Create round with score let round = sqlx::query_as::<_, MatchRound>( r#" - INSERT INTO match_rounds (match_id, team_1_score, team_2_score, score_posted_by_team_id, score_acceptance_status) + INSERT INTO match_rounds (match_id, team_1_score, team_2_score, score_posted_by_team_id, score_confirmation_status) VALUES ($1, $2, $3, $4, $5) RETURNING * "# @@ -657,12 +702,26 @@ pub async fn report_score( team_1_score: round.team_1_score, team_2_score: round.team_2_score, score_posted_by_team_id: round.score_posted_by_team_id, - score_acceptance_status: round.score_acceptance_status, + score_confirmation_status: round.score_confirmation_status, }))) } -/// POST /api/matches/:id/score/:round_id/accept /// Accept a reported score +#[utoipa::path( + post, + path = "/api/matches/{id}/score/{round_id}/accept", + tag = "matches", + params( + ("id" = i32, Path, description = "Match ID"), + ("round_id" = i32, Path, description = "Round ID") + ), + responses( + (status = 200, description = "Score accepted", body = MatchRoundResponse), + (status = 403, description = "Not part of this match"), + (status = 404, description = "Match or round not found") + ), + security(("bearer_auth" = [])) +)] pub async fn accept_score( user: AuthUser, State(state): State, @@ -670,13 +729,11 @@ pub async fn accept_score( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; let round = sqlx::query_as::<_, MatchRound>( "SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2" @@ -687,39 +744,33 @@ pub async fn accept_score( .await? .ok_or_else(|| AppError::NotFound("Round not found".to_string()))?; - // Must be the other team to accept if round.score_posted_by_team_id == user_team_id { return Err(AppError::BadRequest("Cannot accept your own score report".to_string())); } - // Must be part of the match if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("You are not part of this match".to_string())); } - if round.score_acceptance_status != score_status::PENDING { + if round.score_confirmation_status != score_status::PENDING { return Err(AppError::BadRequest("Score has already been processed".to_string())); } - // Update round let updated_round = sqlx::query_as::<_, MatchRound>( - "UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *" + "UPDATE match_rounds SET score_confirmation_status = $1 WHERE id = $2 RETURNING *" ) .bind(score_status::ACCEPTED) .bind(round_id) .fetch_one(&state.pool) .await?; - // Update match status to done sqlx::query("UPDATE matches SET matche_status = $1 WHERE id = $2") .bind(match_status::DONE) .bind(match_id) .execute(&state.pool) .await?; - // Update team and player ratings using OpenSkill if updated_round.team_1_score != updated_round.team_2_score { - // Only update ratings if there's a clear winner (not a draw) let rating_result = crate::services::rating::process_match_result( &state.pool, current_match.team_id_1, @@ -739,7 +790,6 @@ pub async fn accept_score( } Err(e) => { tracing::error!("Failed to update ratings for match {}: {}", match_id, e); - // Don't fail the request, ratings are secondary } } } @@ -751,12 +801,26 @@ pub async fn accept_score( team_1_score: updated_round.team_1_score, team_2_score: updated_round.team_2_score, score_posted_by_team_id: updated_round.score_posted_by_team_id, - score_acceptance_status: updated_round.score_acceptance_status, + score_confirmation_status: updated_round.score_confirmation_status, })) } -/// POST /api/matches/:id/score/:round_id/reject /// Reject a reported score +#[utoipa::path( + post, + path = "/api/matches/{id}/score/{round_id}/reject", + tag = "matches", + params( + ("id" = i32, Path, description = "Match ID"), + ("round_id" = i32, Path, description = "Round ID") + ), + responses( + (status = 200, description = "Score rejected", body = MatchRoundResponse), + (status = 403, description = "Not part of this match"), + (status = 404, description = "Match or round not found") + ), + security(("bearer_auth" = [])) +)] pub async fn reject_score( user: AuthUser, State(state): State, @@ -764,13 +828,11 @@ pub async fn reject_score( ) -> Result> { let user_team_id = get_user_captain_team(&state.pool, user.id).await?; - let current_match = sqlx::query_as::<_, Match>( - "SELECT * FROM matches WHERE id = $1" - ) - .bind(match_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; + let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1") + .bind(match_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; let round = sqlx::query_as::<_, MatchRound>( "SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2" @@ -781,22 +843,20 @@ pub async fn reject_score( .await? .ok_or_else(|| AppError::NotFound("Round not found".to_string()))?; - // Must be the other team to reject if round.score_posted_by_team_id == user_team_id { return Err(AppError::BadRequest("Cannot reject your own score report".to_string())); } - // Must be part of the match if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id { return Err(AppError::Forbidden("You are not part of this match".to_string())); } - if round.score_acceptance_status != score_status::PENDING { + if round.score_confirmation_status != score_status::PENDING { return Err(AppError::BadRequest("Score has already been processed".to_string())); } let updated_round = sqlx::query_as::<_, MatchRound>( - "UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *" + "UPDATE match_rounds SET score_confirmation_status = $1 WHERE id = $2 RETURNING *" ) .bind(score_status::REJECTED) .bind(round_id) @@ -810,12 +870,26 @@ pub async fn reject_score( team_1_score: updated_round.team_1_score, team_2_score: updated_round.team_2_score, score_posted_by_team_id: updated_round.score_posted_by_team_id, - score_acceptance_status: updated_round.score_acceptance_status, + score_confirmation_status: updated_round.score_confirmation_status, })) } -/// GET /api/my-matches -/// Get current user's team matches +/// Get current users team matches +#[utoipa::path( + get, + path = "/api/my-matches", + tag = "matches", + params( + ("status" = Option, Query, description = "Filter by match status"), + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page") + ), + responses( + (status = 200, description = "List of user team matches", body = MatchListResponse), + (status = 404, description = "Not in a team") + ), + security(("bearer_auth" = [])) +)] pub async fn get_my_matches( user: AuthUser, Query(query): Query, @@ -855,12 +929,7 @@ pub async fn get_my_matches( .fetch_all(&state.pool) .await?; - Ok(Json(MatchListResponse { - matches, - total, - page, - per_page, - })) + Ok(Json(MatchListResponse { matches, total, page, per_page })) } // ============================================================================ diff --git a/src/handlers/players.rs b/src/handlers/players.rs index 4aa163e..2281ef5 100644 --- a/src/handlers/players.rs +++ b/src/handlers/players.rs @@ -5,6 +5,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use crate::{ auth::AuthUser, @@ -17,16 +18,16 @@ use super::auth::AppState; // Request/Response Types // ============================================================================ -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, ToSchema)] pub struct ListPlayersQuery { pub search: Option, pub page: Option, pub per_page: Option, - pub sort_by: Option, // "mmr", "username", "date_registered" - pub sort_order: Option, // "asc", "desc" + pub sort_by: Option, + pub sort_order: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct PlayerListResponse { pub players: Vec, pub total: i64, @@ -34,7 +35,7 @@ pub struct PlayerListResponse { pub per_page: i64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct PlayerSummary { pub id: i32, pub username: String, @@ -47,7 +48,7 @@ pub struct PlayerSummary { pub team_position: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct PlayerProfileResponse { pub id: i32, pub username: String, @@ -65,7 +66,7 @@ pub struct PlayerProfileResponse { pub recent_matches: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct PlayerTeamInfo { pub id: i32, pub name: String, @@ -74,21 +75,20 @@ pub struct PlayerTeamInfo { pub date_joined: chrono::DateTime, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct PlayerStats { pub matches_played: i64, pub matches_won: i64, pub matches_lost: i64, pub win_rate: f32, pub ladders_participated: i64, - // OpenSkill rating stats pub mu: f64, pub sigma: f64, pub ordinal: f64, pub mmr: f64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct PlayerMatchSummary { pub match_id: i32, pub date_played: chrono::DateTime, @@ -96,15 +96,15 @@ pub struct PlayerMatchSummary { pub own_team_name: String, pub own_score: i32, pub opponent_score: i32, - pub result: String, // "win", "loss", "draw" + pub result: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct FeaturedPlayersResponse { pub players: Vec, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct FeaturedPlayerInfo { pub id: i32, pub player_id: i32, @@ -116,13 +116,13 @@ pub struct FeaturedPlayerInfo { pub team_name: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct SetFeaturedPlayerRequest { pub player_id: i32, pub rank: i32, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct LeaderboardResponse { pub players: Vec, pub total: i64, @@ -130,7 +130,7 @@ pub struct LeaderboardResponse { pub per_page: i64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct LeaderboardEntry { pub rank: i64, pub player_id: i32, @@ -143,12 +143,40 @@ pub struct LeaderboardEntry { pub matches_played: Option, } +#[derive(Debug, Serialize, FromRow, ToSchema)] +pub struct RatingHistoryEntry { + pub id: i32, + pub date_created: chrono::DateTime, + pub match_id: Option, + pub old_mu: f64, + pub old_sigma: f64, + pub old_ordinal: f64, + pub new_mu: f64, + pub new_sigma: f64, + pub new_ordinal: f64, + pub opponent_name: Option, +} + // ============================================================================ // Handlers // ============================================================================ -/// GET /api/players /// List all players with pagination and search +#[utoipa::path( + get, + path = "/api/players", + tag = "players", + params( + ("search" = Option, Query, description = "Search by username or name"), + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("sort_by" = Option, Query, description = "Sort by: username, date_registered"), + ("sort_order" = Option, Query, description = "Sort order: asc, desc") + ), + responses( + (status = 200, description = "List of players", body = PlayerListResponse) + ) +)] pub async fn list_players( Query(query): Query, State(state): State, @@ -158,8 +186,6 @@ pub async fn list_players( let offset = (page - 1) * per_page; let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); - - // Determine sort order let sort_column = match query.sort_by.as_deref() { Some("username") => "u.username", Some("date_registered") => "u.date_registered", @@ -173,155 +199,115 @@ pub async fn list_players( let (total, players): (i64, Vec) = if let Some(ref pattern) = search_pattern { let total = sqlx::query_scalar( "SELECT COUNT(*) FROM users WHERE username ILIKE $1 OR firstname ILIKE $1 OR lastname ILIKE $1" - ) - .bind(pattern) - .fetch_one(&state.pool) - .await?; + ).bind(pattern).fetch_one(&state.pool).await?; let query_str = format!( - r#" - SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered, - tp.team_id, t.name as team_name, tp.position as team_position - FROM users u - LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 - LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false - WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1 - ORDER BY {} {} - LIMIT $2 OFFSET $3 - "#, + r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered, + tp.team_id, t.name as team_name, tp.position as team_position + FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false + WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1 + ORDER BY {} {} LIMIT $2 OFFSET $3"#, sort_column, sort_order ); - - let players = sqlx::query_as(&query_str) - .bind(pattern) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + let players = sqlx::query_as(&query_str).bind(pattern).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, players) } else { - let total = sqlx::query_scalar("SELECT COUNT(*) FROM users") - .fetch_one(&state.pool) - .await?; - + let total = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.pool).await?; let query_str = format!( - r#" - SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered, - tp.team_id, t.name as team_name, tp.position as team_position - FROM users u - LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 - LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false - ORDER BY {} {} - LIMIT $1 OFFSET $2 - "#, + r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered, + tp.team_id, t.name as team_name, tp.position as team_position + FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false + ORDER BY {} {} LIMIT $1 OFFSET $2"#, sort_column, sort_order ); - - let players = sqlx::query_as(&query_str) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; - + let players = sqlx::query_as(&query_str).bind(per_page).bind(offset).fetch_all(&state.pool).await?; (total, players) }; - Ok(Json(PlayerListResponse { - players, - total, - page, - per_page, - })) + Ok(Json(PlayerListResponse { players, total, page, per_page })) } -/// GET /api/players/:id /// Get player profile with stats +#[utoipa::path( + get, + path = "/api/players/{id}", + tag = "players", + params(("id" = i32, Path, description = "Player ID")), + responses( + (status = 200, description = "Player profile", body = PlayerProfileResponse), + (status = 404, description = "Player not found") + ) +)] pub async fn get_player( State(state): State, Path(player_id): Path, ) -> Result> { - // Get basic player info including OpenSkill ratings let player = sqlx::query_as::<_, (i32, String, String, String, Option, chrono::DateTime, bool, Option, Option, Option, Option)>( "SELECT id, username, firstname, lastname, profile, date_registered, is_admin, mu, sigma, ordinal, mmr FROM users WHERE id = $1" - ) - .bind(player_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Player not found".to_string()))?; + ).bind(player_id).fetch_optional(&state.pool).await? + .ok_or_else(|| AppError::NotFound("Player not found".to_string()))?; - // Get team info let team_info = sqlx::query_as::<_, (i32, String, String, String, chrono::DateTime)>( - r#" - SELECT t.id, t.name, t.logo, tp.position, tp.date_created - FROM team_players tp - JOIN teams t ON tp.team_id = t.id - WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false - "# - ) - .bind(player_id) - .fetch_optional(&state.pool) - .await?; + r#"SELECT t.id, t.name, t.logo, tp.position, tp.date_created + FROM team_players tp JOIN teams t ON tp.team_id = t.id + WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false"# + ).bind(player_id).fetch_optional(&state.pool).await?; let team = team_info.map(|(id, name, logo, position, date_joined)| PlayerTeamInfo { - id, - name, - logo, - position, - date_joined, + id, name, logo, position, date_joined, }); - // Get match stats let stats = get_player_stats(&state.pool, player_id, team.as_ref().map(|t| t.id)).await?; - - // Get recent matches let recent_matches = if let Some(ref t) = team { get_recent_matches(&state.pool, t.id, 5).await? - } else { - vec![] - }; + } else { vec![] }; Ok(Json(PlayerProfileResponse { - id: player.0, - username: player.1, - firstname: player.2, - lastname: player.3, - profile: player.4, - date_registered: player.5, - is_admin: player.6, - mu: player.7.unwrap_or(25.0), - sigma: player.8.unwrap_or(8.333), - ordinal: player.9.unwrap_or(0.0), - mmr: player.10.unwrap_or(1000.0), - team, - stats, - recent_matches, + id: player.0, username: player.1, firstname: player.2, lastname: player.3, + profile: player.4, date_registered: player.5, is_admin: player.6, + mu: player.7.unwrap_or(25.0), sigma: player.8.unwrap_or(8.333), + ordinal: player.9.unwrap_or(0.0), mmr: player.10.unwrap_or(1000.0), + team, stats, recent_matches, })) } -/// GET /api/players/featured /// Get featured players +#[utoipa::path( + get, + path = "/api/players/featured", + tag = "players", + responses( + (status = 200, description = "Featured players", body = FeaturedPlayersResponse) + ) +)] pub async fn get_featured_players( State(state): State, ) -> Result> { let players = sqlx::query_as::<_, FeaturedPlayerInfo>( - r#" - SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name - FROM featured_players fp - JOIN users u ON fp.player_id = u.id - LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 - LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false - ORDER BY fp.rank ASC - "# - ) - .fetch_all(&state.pool) - .await?; + r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name + FROM featured_players fp JOIN users u ON fp.player_id = u.id + LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false ORDER BY fp.rank ASC"# + ).fetch_all(&state.pool).await?; Ok(Json(FeaturedPlayersResponse { players })) } -/// POST /api/players/featured /// Add/update featured player (admin only) +#[utoipa::path( + post, + path = "/api/players/featured", + tag = "players", + request_body = SetFeaturedPlayerRequest, + responses( + (status = 201, description = "Featured player set", body = FeaturedPlayerInfo), + (status = 403, description = "Admin only"), + (status = 404, description = "Player not found") + ), + security(("bearer_auth" = [])) +)] pub async fn set_featured_player( user: AuthUser, State(state): State, @@ -331,51 +317,38 @@ pub async fn set_featured_player( return Err(AppError::Forbidden("Only admins can manage featured players".to_string())); } - // Verify player exists - let player_exists = sqlx::query_scalar::<_, i32>( - "SELECT id FROM users WHERE id = $1" - ) - .bind(payload.player_id) - .fetch_optional(&state.pool) - .await?; - + let player_exists = sqlx::query_scalar::<_, i32>("SELECT id FROM users WHERE id = $1") + .bind(payload.player_id).fetch_optional(&state.pool).await?; if player_exists.is_none() { return Err(AppError::NotFound("Player not found".to_string())); } - // Upsert featured player - sqlx::query( - r#" - INSERT INTO featured_players (player_id, rank) - VALUES ($1, $2) - ON CONFLICT (player_id) DO UPDATE SET rank = $2 - "# - ) - .bind(payload.player_id) - .bind(payload.rank) - .execute(&state.pool) - .await?; + sqlx::query("INSERT INTO featured_players (player_id, rank) VALUES ($1, $2) ON CONFLICT (player_id) DO UPDATE SET rank = $2") + .bind(payload.player_id).bind(payload.rank).execute(&state.pool).await?; - // Fetch the full featured player info let featured = sqlx::query_as::<_, FeaturedPlayerInfo>( - r#" - SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name - FROM featured_players fp - JOIN users u ON fp.player_id = u.id - LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 - LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false - WHERE fp.player_id = $1 - "# - ) - .bind(payload.player_id) - .fetch_one(&state.pool) - .await?; + r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name + FROM featured_players fp JOIN users u ON fp.player_id = u.id + LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false WHERE fp.player_id = $1"# + ).bind(payload.player_id).fetch_one(&state.pool).await?; Ok((StatusCode::CREATED, Json(featured))) } -/// DELETE /api/players/featured/:player_id /// Remove featured player (admin only) +#[utoipa::path( + delete, + path = "/api/players/featured/{player_id}", + tag = "players", + params(("player_id" = i32, Path, description = "Player ID")), + responses( + (status = 204, description = "Featured player removed"), + (status = 403, description = "Admin only"), + (status = 404, description = "Featured player not found") + ), + security(("bearer_auth" = [])) +)] pub async fn remove_featured_player( user: AuthUser, State(state): State, @@ -386,9 +359,7 @@ pub async fn remove_featured_player( } let result = sqlx::query("DELETE FROM featured_players WHERE player_id = $1") - .bind(player_id) - .execute(&state.pool) - .await?; + .bind(player_id).execute(&state.pool).await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Featured player not found".to_string())); @@ -397,8 +368,19 @@ pub async fn remove_featured_player( Ok(StatusCode::NO_CONTENT) } -/// GET /api/players/leaderboard /// Get player leaderboard (by match wins) +#[utoipa::path( + get, + path = "/api/players/leaderboard", + tag = "players", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page") + ), + responses( + (status = 200, description = "Player leaderboard", body = LeaderboardResponse) + ) +)] pub async fn get_leaderboard( Query(query): Query, State(state): State, @@ -407,101 +389,76 @@ pub async fn get_leaderboard( let per_page = query.per_page.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * per_page; - // Count total players with at least one match let total: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(DISTINCT u.id) - FROM users u - JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 - JOIN teams t ON tp.team_id = t.id AND t.is_delete = false - JOIN ( - SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3 - UNION ALL - SELECT team_id_2 FROM matches WHERE matche_status = 3 - ) m ON t.id = m.team_id - "# - ) - .fetch_one(&state.pool) - .await - .unwrap_or(0); + r#"SELECT COUNT(DISTINCT u.id) FROM users u + JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + JOIN teams t ON tp.team_id = t.id AND t.is_delete = false + JOIN (SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3 + UNION ALL SELECT team_id_2 FROM matches WHERE matche_status = 3) m ON t.id = m.team_id"# + ).fetch_one(&state.pool).await.unwrap_or(0); - // Get leaderboard with match stats let players = sqlx::query_as::<_, LeaderboardEntry>( - r#" - WITH player_matches AS ( - SELECT - u.id as player_id, - u.username, - u.firstname, - u.lastname, - u.profile, - t.name as team_name, - t.id as team_id - FROM users u - JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 + r#"WITH player_matches AS ( + SELECT u.id as player_id, u.username, u.firstname, u.lastname, u.profile, + t.name as team_name, t.id as team_id + FROM users u JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 JOIN teams t ON tp.team_id = t.id AND t.is_delete = false ), match_results AS ( - SELECT - pm.player_id, - COUNT(DISTINCT m.id) as matches_played, - COUNT(DISTINCT CASE - WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score) - OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score) - THEN m.id - END) as matches_won + SELECT pm.player_id, COUNT(DISTINCT m.id) as matches_played, + COUNT(DISTINCT CASE WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score) + OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score) THEN m.id END) as matches_won FROM player_matches pm - LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id) - AND m.matche_status = 3 - LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1 + LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id) AND m.matche_status = 3 + LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1 GROUP BY pm.player_id ) - SELECT - ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank, - pm.player_id, - pm.username, - pm.firstname, - pm.lastname, - pm.profile, - pm.team_name, - mr.matches_won, - mr.matches_played - FROM player_matches pm - LEFT JOIN match_results mr ON pm.player_id = mr.player_id - ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC - LIMIT $1 OFFSET $2 - "# - ) - .bind(per_page) - .bind(offset) - .fetch_all(&state.pool) - .await?; + SELECT ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank, + pm.player_id, pm.username, pm.firstname, pm.lastname, pm.profile, pm.team_name, + mr.matches_won, mr.matches_played + FROM player_matches pm LEFT JOIN match_results mr ON pm.player_id = mr.player_id + ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC LIMIT $1 OFFSET $2"# + ).bind(per_page).bind(offset).fetch_all(&state.pool).await?; - Ok(Json(LeaderboardResponse { - players, - total, - page, - per_page, - })) + Ok(Json(LeaderboardResponse { players, total, page, per_page })) +} + +/// Get a players rating history +#[utoipa::path( + get, + path = "/api/players/rating-history/{player_id}", + tag = "players", + params(("player_id" = i32, Path, description = "Player ID")), + responses( + (status = 200, description = "Rating history", body = Vec) + ) +)] +pub async fn get_rating_history( + State(state): State, + Path(player_id): Path, +) -> Result>> { + let history = sqlx::query_as::<_, RatingHistoryEntry>( + r#"SELECT rh.id, rh.date_created, rh.match_id, rh.old_mu, rh.old_sigma, rh.old_ordinal, + rh.new_mu, rh.new_sigma, rh.new_ordinal, t1.name as opponent_name + FROM rating_history rh + LEFT JOIN matches m ON rh.match_id = m.id + LEFT JOIN teams t1 ON (CASE WHEN m.team_id_1 IN (SELECT tp.team_id FROM team_players tp WHERE tp.player_id = $1 AND tp.status = 1) + THEN m.team_id_2 ELSE m.team_id_1 END = t1.id) + WHERE rh.entity_type = 'player' AND rh.entity_id = $1 + ORDER BY rh.date_created DESC LIMIT 50"# + ).bind(player_id).fetch_all(&state.pool).await?; + + Ok(Json(history)) } // ============================================================================ // Helper Functions // ============================================================================ -async fn get_player_stats( - pool: &sqlx::PgPool, - player_id: i32, - team_id: Option, -) -> Result { - // Get player's OpenSkill rating +async fn get_player_stats(pool: &sqlx::PgPool, player_id: i32, team_id: Option) -> Result { let rating: (Option, Option, Option, Option) = sqlx::query_as( "SELECT mu, sigma, ordinal, mmr FROM users WHERE id = $1" - ) - .bind(player_id) - .fetch_one(pool) - .await - .unwrap_or((None, None, None, None)); + ).bind(player_id).fetch_one(pool).await.unwrap_or((None, None, None, None)); let mu = rating.0.unwrap_or(25.0) as f64; let sigma = rating.1.unwrap_or(8.333333) as f64; @@ -509,161 +466,43 @@ async fn get_player_stats( let mmr = rating.3.unwrap_or(1500.0) as f64; let Some(team_id) = team_id else { - return Ok(PlayerStats { - matches_played: 0, - matches_won: 0, - matches_lost: 0, - win_rate: 0.0, - ladders_participated: 0, - mu, - sigma, - ordinal, - mmr, - }); + return Ok(PlayerStats { matches_played: 0, matches_won: 0, matches_lost: 0, win_rate: 0.0, ladders_participated: 0, mu, sigma, ordinal, mmr }); }; - // Count completed matches let matches_played: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) FROM matches - WHERE (team_id_1 = $1 OR team_id_2 = $1) AND matche_status = 3 - "# - ) - .bind(team_id) - .fetch_one(pool) - .await - .unwrap_or(0); + "SELECT COUNT(*) FROM matches WHERE (team_id_1 = $1 OR team_id_2 = $1) AND matche_status = 3" + ).bind(team_id).fetch_one(pool).await.unwrap_or(0); - // Count wins (based on accepted scores) let matches_won: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(DISTINCT m.id) FROM matches m - JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1 - WHERE m.matche_status = 3 - AND ( - (m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score) - OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score) - ) - "# - ) - .bind(team_id) - .fetch_one(pool) - .await - .unwrap_or(0); + r#"SELECT COUNT(DISTINCT m.id) FROM matches m JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1 + WHERE m.matche_status = 3 AND ((m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score) + OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score))"# + ).bind(team_id).fetch_one(pool).await.unwrap_or(0); let matches_lost = matches_played - matches_won; - let win_rate = if matches_played > 0 { - (matches_won as f32 / matches_played as f32) * 100.0 - } else { - 0.0 - }; + let win_rate = if matches_played > 0 { (matches_won as f32 / matches_played as f32) * 100.0 } else { 0.0 }; + let ladders_participated: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1") + .bind(team_id).fetch_one(pool).await.unwrap_or(0); - // Count ladders participated - let ladders_participated: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1" - ) - .bind(team_id) - .fetch_one(pool) - .await - .unwrap_or(0); - - Ok(PlayerStats { - matches_played, - matches_won, - matches_lost, - win_rate, - ladders_participated, - mu, - sigma, - ordinal, - mmr, - }) + Ok(PlayerStats { matches_played, matches_won, matches_lost, win_rate, ladders_participated, mu, sigma, ordinal, mmr }) } -async fn get_recent_matches( - pool: &sqlx::PgPool, - team_id: i32, - limit: i64, -) -> Result> { +async fn get_recent_matches(pool: &sqlx::PgPool, team_id: i32, limit: i64) -> Result> { let matches = sqlx::query_as::<_, PlayerMatchSummary>( - r#" - SELECT - m.id as match_id, - m.date_start as date_played, - CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name, - CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name, - CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score, - CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score, - CASE - WHEN mr.id IS NULL THEN 'pending' + r#"SELECT m.id as match_id, m.date_start as date_played, + CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name, + CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name, + CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score, + CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score, + CASE WHEN mr.id IS NULL THEN 'pending' WHEN m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score THEN 'win' WHEN m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score THEN 'win' - WHEN mr.team_1_score = mr.team_2_score THEN 'draw' - ELSE 'loss' - END as result - FROM matches m - JOIN teams t1 ON m.team_id_1 = t1.id - JOIN teams t2 ON m.team_id_2 = t2.id - LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1 - WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3 - ORDER BY m.date_start DESC - LIMIT $2 - "# - ) - .bind(team_id) - .bind(limit) - .fetch_all(pool) - .await?; + WHEN mr.team_1_score = mr.team_2_score THEN 'draw' ELSE 'loss' END as result + FROM matches m JOIN teams t1 ON m.team_id_1 = t1.id JOIN teams t2 ON m.team_id_2 = t2.id + LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1 + WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3 + ORDER BY m.date_start DESC LIMIT $2"# + ).bind(team_id).bind(limit).fetch_all(pool).await?; Ok(matches) } - -// Note: Profile upload is now handled by uploads.rs using multipart forms - -/// GET /api/players/rating-history/:player_id -/// Get a player's rating history -pub async fn get_rating_history( - State(state): State, - Path(player_id): Path, -) -> Result>> { - let history = sqlx::query_as::<_, RatingHistoryEntry>( - r#" - SELECT - rh.id, rh.date_created, rh.match_id, - rh.old_mu, rh.old_sigma, rh.old_ordinal, - rh.new_mu, rh.new_sigma, rh.new_ordinal, - t1.name as opponent_name - FROM rating_history rh - LEFT JOIN matches m ON rh.match_id = m.id - LEFT JOIN teams t1 ON ( - CASE - WHEN m.team_id_1 IN (SELECT tp.team_id FROM team_players tp WHERE tp.player_id = $1 AND tp.status = 1) - THEN m.team_id_2 - ELSE m.team_id_1 - END = t1.id - ) - WHERE rh.entity_type = 'player' AND rh.entity_id = $1 - ORDER BY rh.date_created DESC - LIMIT 50 - "# - ) - .bind(player_id) - .fetch_all(&state.pool) - .await?; - - Ok(Json(history)) -} - -#[derive(Debug, Serialize, FromRow)] -pub struct RatingHistoryEntry { - pub id: i32, - pub date_created: chrono::DateTime, - pub match_id: Option, - pub old_mu: f64, - pub old_sigma: f64, - pub old_ordinal: f64, - pub new_mu: f64, - pub new_sigma: f64, - pub new_ordinal: f64, - pub opponent_name: Option, -} diff --git a/src/handlers/teams.rs b/src/handlers/teams.rs index fcc13f7..d3dad24 100644 --- a/src/handlers/teams.rs +++ b/src/handlers/teams.rs @@ -5,6 +5,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use utoipa::ToSchema; use validator::Validate; use crate::{ @@ -20,14 +21,14 @@ use super::auth::AppState; // Request/Response Types // ============================================================================ -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, ToSchema)] pub struct ListTeamsQuery { pub search: Option, pub page: Option, pub per_page: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct TeamListResponse { pub teams: Vec, pub total: i64, @@ -35,7 +36,7 @@ pub struct TeamListResponse { pub per_page: i64, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct TeamWithMemberCount { pub id: i32, pub name: String, @@ -47,21 +48,21 @@ pub struct TeamWithMemberCount { pub member_count: Option, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateTeamRequest { #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] pub name: String, pub bio: Option, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateTeamRequest { #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] pub name: Option, pub bio: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct TeamResponse { pub id: i32, pub name: String, @@ -92,7 +93,7 @@ impl From for TeamResponse { } } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct TeamDetailResponse { pub team: TeamResponse, pub members: Vec, @@ -101,7 +102,7 @@ pub struct TeamDetailResponse { pub pending_request: bool, } -#[derive(Debug, Serialize, FromRow)] +#[derive(Debug, Serialize, FromRow, ToSchema)] pub struct TeamMemberResponse { pub id: i32, pub player_id: i32, @@ -114,12 +115,12 @@ pub struct TeamMemberResponse { pub date_joined: chrono::DateTime, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] pub struct ChangePositionRequest { pub position: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct MyTeamResponse { pub team: Option, pub position: Option, @@ -127,12 +128,29 @@ pub struct MyTeamResponse { pub pending_requests: Vec, } +#[derive(Debug, Serialize, ToSchema)] +pub struct MessageResponse { + pub message: String, +} + // ============================================================================ // Handlers // ============================================================================ -/// GET /api/teams /// List all teams with pagination and search +#[utoipa::path( + get, + path = "/api/teams", + tag = "teams", + params( + ("search" = Option, Query, description = "Search by team name"), + ("page" = Option, Query, description = "Page number (default: 1)"), + ("per_page" = Option, Query, description = "Items per page (default: 20, max: 100)") + ), + responses( + (status = 200, description = "List of teams", body = TeamListResponse) + ) +)] pub async fn list_teams( State(state): State, Query(query): Query, @@ -143,35 +161,24 @@ pub async fn list_teams( let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); - // Get total count let total: i64 = if let Some(ref pattern) = search_pattern { - sqlx::query_scalar( - "SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1" - ) - .bind(pattern) - .fetch_one(&state.pool) - .await? + sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1") + .bind(pattern) + .fetch_one(&state.pool) + .await? } else { - sqlx::query_scalar( - "SELECT COUNT(*) FROM teams WHERE is_delete = false" - ) - .fetch_one(&state.pool) - .await? + sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false") + .fetch_one(&state.pool) + .await? }; - // Get teams with member count let teams: Vec = if let Some(ref pattern) = search_pattern { sqlx::query_as( - r#" - SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, - COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count - FROM teams t - LEFT JOIN team_players tp ON t.id = tp.team_id - WHERE t.is_delete = false AND t.name ILIKE $1 - GROUP BY t.id - ORDER BY t.rank ASC, t.mmr DESC - LIMIT $2 OFFSET $3 - "# + r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, + COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count + FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id + WHERE t.is_delete = false AND t.name ILIKE $1 + GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $2 OFFSET $3"# ) .bind(pattern) .bind(per_page) @@ -180,16 +187,11 @@ pub async fn list_teams( .await? } else { sqlx::query_as( - r#" - SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, - COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count - FROM teams t - LEFT JOIN team_players tp ON t.id = tp.team_id - WHERE t.is_delete = false - GROUP BY t.id - ORDER BY t.rank ASC, t.mmr DESC - LIMIT $1 OFFSET $2 - "# + r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, + COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count + FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id + WHERE t.is_delete = false + GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $1 OFFSET $2"# ) .bind(per_page) .bind(offset) @@ -197,16 +199,21 @@ pub async fn list_teams( .await? }; - Ok(Json(TeamListResponse { - teams, - total, - page, - per_page, - })) + Ok(Json(TeamListResponse { teams, total, page, per_page })) } -/// POST /api/teams /// Create a new team (creator becomes captain) +#[utoipa::path( + post, + path = "/api/teams", + tag = "teams", + request_body = CreateTeamRequest, + responses( + (status = 201, description = "Team created", body = TeamDetailResponse), + (status = 409, description = "Already in a team or team name exists") + ), + security(("bearer_auth" = [])) +)] pub async fn create_team( user: AuthUser, State(state): State, @@ -214,7 +221,6 @@ pub async fn create_team( ) -> Result<(StatusCode, Json)> { payload.validate()?; - // Check if user is already in a team let existing_membership = sqlx::query_scalar::<_, i32>( "SELECT id FROM team_players WHERE player_id = $1 AND status = 1" ) @@ -226,7 +232,6 @@ pub async fn create_team( return Err(AppError::Conflict("You are already a member of a team".to_string())); } - // Check if team name already exists let existing_team = sqlx::query_scalar::<_, i32>( "SELECT id FROM teams WHERE name = $1 AND is_delete = false" ) @@ -238,79 +243,68 @@ pub async fn create_team( return Err(AppError::Conflict("Team name already exists".to_string())); } - // Create team (logo uses default empty string) let team = sqlx::query_as::<_, Team>( - r#" - INSERT INTO teams (name, bio, logo, rank, mmr) - VALUES ($1, $2, '', 0, 1000.0) - RETURNING * - "# + "INSERT INTO teams (name, bio, logo, rank, mmr) VALUES ($1, $2, '', 0, 1000.0) RETURNING *" ) .bind(&payload.name) .bind(&payload.bio) .fetch_one(&state.pool) .await?; - // Add creator as captain sqlx::query( - r#" - INSERT INTO team_players (team_id, player_id, position, status) - VALUES ($1, $2, 'captain', 1) - "# + "INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'captain', 1, '', '')" ) .bind(team.id) .bind(user.id) .execute(&state.pool) .await?; - // Get members (just the captain) let members = get_team_members(&state.pool, team.id).await?; - Ok(( - StatusCode::CREATED, - Json(TeamDetailResponse { - team: team.into(), - members, - is_member: true, - is_captain: true, - pending_request: false, - }), - )) + Ok((StatusCode::CREATED, Json(TeamDetailResponse { + team: team.into(), + members, + is_member: true, + is_captain: true, + pending_request: false, + }))) } -/// GET /api/teams/:id -/// Get team details with members -pub async fn get_team( - OptionalAuthUser(user): OptionalAuthUser, - State(state): State, - Path(team_id): Path, -) -> Result> { - let team = sqlx::query_as::<_, Team>( - "SELECT * FROM teams WHERE id = $1 AND is_delete = false" +/// Get team details by ID +#[utoipa::path( + get, + path = "/api/teams/{id}", + tag = "teams", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 200, description = "Team details", body = TeamDetailResponse), + (status = 404, description = "Team not found") ) - .bind(team_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; +)] +pub async fn get_team( + user: OptionalAuthUser, + State(state): State, + Path(id): Path, +) -> Result> { + let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; - let members = get_team_members(&state.pool, team_id).await?; - - // Check user's relationship with team - let (is_member, is_captain, pending_request) = if let Some(ref u) = user { + let members = get_team_members(&state.pool, team.id).await?; + + let (is_member, is_captain, pending_request) = if let Some(ref auth_user) = user.0 { let membership = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2" ) - .bind(team_id) - .bind(u.id) + .bind(team.id) + .bind(auth_user.id) .fetch_optional(&state.pool) .await?; match membership { - Some(m) => ( - m.status == 1, - m.status == 1 && m.position == "captain", - m.status == 0, - ), + Some(m) => (m.status == 1, m.position == "captain", m.status == 0), None => (false, false, false), } } else { @@ -326,332 +320,330 @@ pub async fn get_team( })) } -/// PUT /api/teams/:id -/// Update team (captain only) +/// Update team details (captain only) +#[utoipa::path( + put, + path = "/api/teams/{id}", + tag = "teams", + params(("id" = i32, Path, description = "Team ID")), + request_body = UpdateTeamRequest, + responses( + (status = 200, description = "Team updated", body = TeamResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "Team not found") + ), + security(("bearer_auth" = [])) +)] pub async fn update_team( user: AuthUser, State(state): State, - Path(team_id): Path, + Path(id): Path, Json(payload): Json, ) -> Result> { payload.validate()?; - // Check if user is captain - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - if !is_captain { - return Err(AppError::Forbidden("Only the captain can update the team".to_string())); - } - - // Check if new name conflicts - if let Some(ref name) = payload.name { - let existing = sqlx::query_scalar::<_, i32>( - "SELECT id FROM teams WHERE name = $1 AND id != $2 AND is_delete = false" - ) - .bind(name) - .bind(team_id) - .fetch_optional(&state.pool) - .await?; - - if existing.is_some() { - return Err(AppError::Conflict("Team name already exists".to_string())); - } - } + let _membership = verify_captain(&state.pool, id, user.id).await?; let team = sqlx::query_as::<_, Team>( - r#" - UPDATE teams - SET name = COALESCE($1, name), - bio = COALESCE($2, bio) - WHERE id = $3 AND is_delete = false - RETURNING * - "# + "UPDATE teams SET name = COALESCE($1, name), bio = COALESCE($2, bio) WHERE id = $3 RETURNING *" ) .bind(&payload.name) .bind(&payload.bio) - .bind(team_id) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; + .bind(id) + .fetch_one(&state.pool) + .await?; Ok(Json(team.into())) } -/// DELETE /api/teams/:id /// Delete team (captain only) +#[utoipa::path( + delete, + path = "/api/teams/{id}", + tag = "teams", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 200, description = "Team deleted", body = MessageResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "Team not found") + ), + security(("bearer_auth" = [])) +)] pub async fn delete_team( user: AuthUser, State(state): State, - Path(team_id): Path, -) -> Result { - // Check if user is captain - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - if !is_captain && !user.is_admin { - return Err(AppError::Forbidden("Only the captain can delete the team".to_string())); - } + Path(id): Path, +) -> Result> { + let _membership = verify_captain(&state.pool, id, user.id).await?; - // Soft delete team - let result = sqlx::query( - "UPDATE teams SET is_delete = true WHERE id = $1" - ) - .bind(team_id) - .execute(&state.pool) - .await?; + sqlx::query("UPDATE teams SET is_delete = true WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await?; - if result.rows_affected() == 0 { - return Err(AppError::NotFound("Team not found".to_string())); - } - - // Remove all team members sqlx::query("DELETE FROM team_players WHERE team_id = $1") - .bind(team_id) + .bind(id) .execute(&state.pool) .await?; - // Remove from ladders - sqlx::query("DELETE FROM ladder_teams WHERE team_id = $1") - .bind(team_id) - .execute(&state.pool) - .await?; - - Ok(StatusCode::NO_CONTENT) + Ok(Json(MessageResponse { message: "Team deleted successfully".to_string() })) } -/// POST /api/teams/:id/join-request /// Request to join a team +#[utoipa::path( + post, + path = "/api/teams/{id}/join-request", + tag = "teams", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 200, description = "Join request sent", body = MessageResponse), + (status = 409, description = "Already in a team or request pending") + ), + security(("bearer_auth" = [])) +)] pub async fn request_join_team( user: AuthUser, State(state): State, - Path(team_id): Path, -) -> Result { - // Check team exists - let team_exists = sqlx::query_scalar::<_, i32>( - "SELECT id FROM teams WHERE id = $1 AND is_delete = false" + Path(id): Path, +) -> Result> { + let _team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; + + let existing = sqlx::query_scalar::<_, i32>( + "SELECT id FROM team_players WHERE player_id = $1 AND (status = 1 OR (team_id = $2 AND status = 0))" ) - .bind(team_id) + .bind(user.id) + .bind(id) .fetch_optional(&state.pool) .await?; - if team_exists.is_none() { - return Err(AppError::NotFound("Team not found".to_string())); + if existing.is_some() { + return Err(AppError::Conflict("Already in a team or request pending".to_string())); } - // Check if user is already in a team (active) - let existing_active = sqlx::query_scalar::<_, i32>( - "SELECT id FROM team_players WHERE player_id = $1 AND status = 1" - ) - .bind(user.id) - .fetch_optional(&state.pool) - .await?; + sqlx::query("INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'member', 0, '', '')") + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; - if existing_active.is_some() { - return Err(AppError::Conflict("You are already a member of a team".to_string())); - } - - // Check if already requested - let existing_request = sqlx::query_scalar::<_, i32>( - "SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2" - ) - .bind(team_id) - .bind(user.id) - .fetch_optional(&state.pool) - .await?; - - if existing_request.is_some() { - return Err(AppError::Conflict("You already have a pending request".to_string())); - } - - // Create join request - sqlx::query( - r#" - INSERT INTO team_players (team_id, player_id, position, status) - VALUES ($1, $2, 'member', 0) - "# - ) - .bind(team_id) - .bind(user.id) - .execute(&state.pool) - .await?; - - Ok(StatusCode::CREATED) + Ok(Json(MessageResponse { message: "Join request sent".to_string() })) } -/// DELETE /api/teams/:id/join-request /// Cancel join request +#[utoipa::path( + delete, + path = "/api/teams/{id}/join-request", + tag = "teams", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 200, description = "Request cancelled", body = MessageResponse), + (status = 404, description = "No pending request found") + ), + security(("bearer_auth" = [])) +)] pub async fn cancel_join_request( user: AuthUser, State(state): State, - Path(team_id): Path, -) -> Result { - let result = sqlx::query( - "DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0" - ) - .bind(team_id) - .bind(user.id) - .execute(&state.pool) - .await?; + Path(id): Path, +) -> Result> { + let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0") + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("No pending request found".to_string())); } - Ok(StatusCode::NO_CONTENT) + Ok(Json(MessageResponse { message: "Join request cancelled".to_string() })) } -/// POST /api/teams/:id/members/:player_id/accept /// Accept a join request (captain only) +#[utoipa::path( + post, + path = "/api/teams/{id}/members/{player_id}/accept", + tag = "teams", + params( + ("id" = i32, Path, description = "Team ID"), + ("player_id" = i32, Path, description = "Player ID to accept") + ), + responses( + (status = 200, description = "Member accepted", body = MessageResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "No pending request from this player") + ), + security(("bearer_auth" = [])) +)] pub async fn accept_member( user: AuthUser, State(state): State, - Path((team_id, player_id)): Path<(i32, i32)>, -) -> Result { - // Check if user is captain - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - if !is_captain { - return Err(AppError::Forbidden("Only the captain can accept members".to_string())); - } + Path((id, player_id)): Path<(i32, i32)>, +) -> Result> { + let _membership = verify_captain(&state.pool, id, user.id).await?; - // Update status to active - let result = sqlx::query( - "UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0" - ) - .bind(team_id) - .bind(player_id) - .execute(&state.pool) - .await?; + let result = sqlx::query("UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0") + .bind(id) + .bind(player_id) + .execute(&state.pool) + .await?; if result.rows_affected() == 0 { - return Err(AppError::NotFound("No pending request found".to_string())); + return Err(AppError::NotFound("No pending request from this player".to_string())); } - Ok(StatusCode::OK) + Ok(Json(MessageResponse { message: "Member accepted".to_string() })) } -/// POST /api/teams/:id/members/:player_id/reject /// Reject a join request (captain only) +#[utoipa::path( + post, + path = "/api/teams/{id}/members/{player_id}/reject", + tag = "teams", + params( + ("id" = i32, Path, description = "Team ID"), + ("player_id" = i32, Path, description = "Player ID to reject") + ), + responses( + (status = 200, description = "Request rejected", body = MessageResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "No pending request from this player") + ), + security(("bearer_auth" = [])) +)] pub async fn reject_member( user: AuthUser, State(state): State, - Path((team_id, player_id)): Path<(i32, i32)>, -) -> Result { - // Check if user is captain - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - if !is_captain { - return Err(AppError::Forbidden("Only the captain can reject members".to_string())); - } + Path((id, player_id)): Path<(i32, i32)>, +) -> Result> { + let _membership = verify_captain(&state.pool, id, user.id).await?; - let result = sqlx::query( - "DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0" - ) - .bind(team_id) - .bind(player_id) - .execute(&state.pool) - .await?; + let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0") + .bind(id) + .bind(player_id) + .execute(&state.pool) + .await?; if result.rows_affected() == 0 { - return Err(AppError::NotFound("No pending request found".to_string())); + return Err(AppError::NotFound("No pending request from this player".to_string())); } - Ok(StatusCode::NO_CONTENT) + Ok(Json(MessageResponse { message: "Request rejected".to_string() })) } -/// DELETE /api/teams/:id/members/:player_id -/// Remove a member or leave team +/// Remove a member from team (captain only) +#[utoipa::path( + delete, + path = "/api/teams/{id}/members/{player_id}", + tag = "teams", + params( + ("id" = i32, Path, description = "Team ID"), + ("player_id" = i32, Path, description = "Player ID to remove") + ), + responses( + (status = 200, description = "Member removed", body = MessageResponse), + (status = 403, description = "Not team captain or cannot remove captain"), + (status = 404, description = "Member not found") + ), + security(("bearer_auth" = [])) +)] pub async fn remove_member( user: AuthUser, State(state): State, - Path((team_id, player_id)): Path<(i32, i32)>, -) -> Result { - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - let is_self = user.id == player_id; + Path((id, player_id)): Path<(i32, i32)>, +) -> Result> { + let _membership = verify_captain(&state.pool, id, user.id).await?; - // Check permission - if !is_captain && !is_self && !user.is_admin { - return Err(AppError::Forbidden("You can only remove yourself or be captain to remove others".to_string())); - } - - // Check if target is captain - let target_membership = sqlx::query_as::<_, TeamPlayer>( + let target = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1" ) - .bind(team_id) + .bind(id) .bind(player_id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound("Member not found".to_string()))?; - // Captain can't leave without transferring or deleting team - if target_membership.position == "captain" && is_self { - return Err(AppError::BadRequest( - "Captain cannot leave. Transfer captaincy or delete the team.".to_string() - )); + if target.position == "captain" { + return Err(AppError::Forbidden("Cannot remove captain".to_string())); } - // Remove member - sqlx::query( - "DELETE FROM team_players WHERE team_id = $1 AND player_id = $2" - ) - .bind(team_id) - .bind(player_id) - .execute(&state.pool) - .await?; + sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2") + .bind(id) + .bind(player_id) + .execute(&state.pool) + .await?; - Ok(StatusCode::NO_CONTENT) + Ok(Json(MessageResponse { message: "Member removed".to_string() })) } -/// PUT /api/teams/:id/members/:player_id/position /// Change member position (captain only) +#[utoipa::path( + put, + path = "/api/teams/{id}/members/{player_id}/position", + tag = "teams", + params( + ("id" = i32, Path, description = "Team ID"), + ("player_id" = i32, Path, description = "Player ID") + ), + request_body = ChangePositionRequest, + responses( + (status = 200, description = "Position changed", body = MessageResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "Member not found") + ), + security(("bearer_auth" = [])) +)] pub async fn change_member_position( user: AuthUser, State(state): State, - Path((team_id, player_id)): Path<(i32, i32)>, + Path((id, player_id)): Path<(i32, i32)>, Json(payload): Json, -) -> Result { - // Validate position - let position = PlayerPosition::from_str(&payload.position) - .ok_or_else(|| AppError::BadRequest("Invalid position. Use: captain, co-captain, or member".to_string()))?; +) -> Result> { + let _membership = verify_captain(&state.pool, id, user.id).await?; - // Check if user is captain - let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; - if !is_captain { - return Err(AppError::Forbidden("Only the captain can change positions".to_string())); + let position = PlayerPosition::from_str(&payload.position) + .ok_or_else(|| AppError::BadRequest("Invalid position".to_string()))?; + + if position == PlayerPosition::Captain && player_id != user.id { + sqlx::query("UPDATE team_players SET position = 'member' WHERE team_id = $1 AND player_id = $2") + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; } - // If promoting to captain, demote current captain - if position == PlayerPosition::Captain && player_id != user.id { - // Demote current captain to co-captain - sqlx::query( - "UPDATE team_players SET position = 'co-captain' WHERE team_id = $1 AND player_id = $2" - ) - .bind(team_id) - .bind(user.id) + let result = sqlx::query("UPDATE team_players SET position = $1 WHERE team_id = $2 AND player_id = $3 AND status = 1") + .bind(position.as_str()) + .bind(id) + .bind(player_id) .execute(&state.pool) .await?; - } - - // Update position - let result = sqlx::query( - "UPDATE team_players SET position = $1 WHERE team_id = $2 AND player_id = $3 AND status = 1" - ) - .bind(position.as_str()) - .bind(team_id) - .bind(player_id) - .execute(&state.pool) - .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Member not found".to_string())); } - Ok(StatusCode::OK) + Ok(Json(MessageResponse { message: "Position updated".to_string() })) } -/// GET /api/my-team /// Get current user's team +#[utoipa::path( + get, + path = "/api/my-team", + tag = "teams", + responses( + (status = 200, description = "User's team info", body = MyTeamResponse) + ), + security(("bearer_auth" = [])) +)] pub async fn get_my_team( user: AuthUser, State(state): State, ) -> Result> { - // Get user's team membership let membership = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE player_id = $1 AND status = 1" ) @@ -659,51 +651,43 @@ pub async fn get_my_team( .fetch_optional(&state.pool) .await?; - let Some(membership) = membership else { - return Ok(Json(MyTeamResponse { + match membership { + Some(m) => { + let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1") + .bind(m.team_id) + .fetch_one(&state.pool) + .await?; + + let members = get_team_members(&state.pool, team.id).await?; + + let pending_requests = if m.position == "captain" || m.position == "co-captain" { + sqlx::query_as::<_, TeamMemberResponse>( + r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, + tp.position, tp.status, tp.date_created as date_joined + FROM team_players tp JOIN users u ON tp.player_id = u.id + WHERE tp.team_id = $1 AND tp.status = 0"# + ) + .bind(team.id) + .fetch_all(&state.pool) + .await? + } else { + vec![] + }; + + Ok(Json(MyTeamResponse { + team: Some(team.into()), + position: Some(m.position), + members, + pending_requests, + })) + } + None => Ok(Json(MyTeamResponse { team: None, position: None, members: vec![], pending_requests: vec![], - })); - }; - - // Get team - let team = sqlx::query_as::<_, Team>( - "SELECT * FROM teams WHERE id = $1 AND is_delete = false" - ) - .bind(membership.team_id) - .fetch_one(&state.pool) - .await?; - - // Get members - let members = get_team_members(&state.pool, membership.team_id).await?; - - // Get pending requests (if captain) - let pending_requests = if membership.position == "captain" { - sqlx::query_as::<_, TeamMemberResponse>( - r#" - SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, - tp.position, tp.status, tp.date_created as date_joined - FROM team_players tp - JOIN users u ON tp.player_id = u.id - WHERE tp.team_id = $1 AND tp.status = 0 - ORDER BY tp.date_created ASC - "# - ) - .bind(membership.team_id) - .fetch_all(&state.pool) - .await? - } else { - vec![] - }; - - Ok(Json(MyTeamResponse { - team: Some(team.into()), - position: Some(membership.position), - members, - pending_requests, - })) + })) + } } // ============================================================================ @@ -712,20 +696,10 @@ pub async fn get_my_team( async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result> { let members = sqlx::query_as::<_, TeamMemberResponse>( - r#" - SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, - tp.position, tp.status, tp.date_created as date_joined - FROM team_players tp - JOIN users u ON tp.player_id = u.id - WHERE tp.team_id = $1 AND tp.status = 1 - ORDER BY - CASE tp.position - WHEN 'captain' THEN 1 - WHEN 'co-captain' THEN 2 - ELSE 3 - END, - tp.date_created ASC - "# + r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, + tp.position, tp.status, tp.date_created as date_joined + FROM team_players tp JOIN users u ON tp.player_id = u.id + WHERE tp.team_id = $1 AND tp.status = 1 ORDER BY tp.position ASC, tp.date_created ASC"# ) .bind(team_id) .fetch_all(pool) @@ -734,18 +708,19 @@ async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result Result { - let is_captain = sqlx::query_scalar::<_, i32>( - "SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1" +async fn verify_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result { + let membership = sqlx::query_as::<_, TeamPlayer>( + "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1" ) .bind(team_id) .bind(user_id) .fetch_optional(pool) - .await?; + .await? + .ok_or_else(|| AppError::Forbidden("Not a member of this team".to_string()))?; - Ok(is_captain.is_some()) + if membership.position != "captain" && membership.position != "co-captain" { + return Err(AppError::Forbidden("Only captain or co-captain can perform this action".to_string())); + } + + Ok(membership) } - -// ============================================================================ -// Logo Upload -// Note: Team logo upload is now handled by uploads.rs using multipart forms diff --git a/src/handlers/uploads.rs b/src/handlers/uploads.rs index fb2e129..90e877a 100644 --- a/src/handlers/uploads.rs +++ b/src/handlers/uploads.rs @@ -6,6 +6,7 @@ use axum::{ Json, }; use serde::Serialize; +use utoipa::ToSchema; use crate::{ auth::AuthUser, @@ -16,199 +17,164 @@ use crate::{ use super::auth::AppState; /// Upload response -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] pub struct UploadResponse { pub url: String, pub message: String, } -/// POST /api/teams/:id/logo /// Upload team logo (captain only) +#[utoipa::path( + post, + path = "/api/teams/{id}/logo", + tag = "uploads", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 200, description = "Logo uploaded", body = UploadResponse), + (status = 403, description = "Not team captain"), + (status = 404, description = "Team not found") + ), + security(("bearer_auth" = [])) +)] pub async fn upload_team_logo( user: AuthUser, State(state): State, Path(team_id): Path, mut multipart: Multipart, ) -> Result> { - // Verify user is captain of this team let is_captain: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1)" - ) - .bind(team_id) - .bind(user.id) - .fetch_one(&state.pool) - .await?; + ).bind(team_id).bind(user.id).fetch_one(&state.pool).await?; if !is_captain && !user.is_admin { return Err(AppError::Forbidden("Only team captain can upload logo".to_string())); } - // Process multipart form let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?; - // Upload file - let url = state.storage.upload_file( - &file_data, - &filename, - &content_type, - UploadType::TeamLogo, - ).await?; + let url = state.storage.upload_file(&file_data, &filename, &content_type, UploadType::TeamLogo).await?; - // Update team logo in database sqlx::query("UPDATE teams SET logo = $1 WHERE id = $2") - .bind(&url) - .bind(team_id) - .execute(&state.pool) - .await?; + .bind(&url).bind(team_id).execute(&state.pool).await?; - Ok(Json(UploadResponse { - url, - message: "Team logo uploaded successfully".to_string(), - })) + Ok(Json(UploadResponse { url, message: "Team logo uploaded successfully".to_string() })) } -/// POST /api/users/me/profile /// Upload user profile picture +#[utoipa::path( + post, + path = "/api/users/me/profile", + tag = "uploads", + responses( + (status = 200, description = "Profile picture uploaded", body = UploadResponse) + ), + security(("bearer_auth" = [])) +)] pub async fn upload_profile_picture( user: AuthUser, State(state): State, mut multipart: Multipart, ) -> Result> { - // Process multipart form let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?; - // Upload file - let url = state.storage.upload_file( - &file_data, - &filename, - &content_type, - UploadType::PlayerProfile, - ).await?; + let url = state.storage.upload_file(&file_data, &filename, &content_type, UploadType::PlayerProfile).await?; - // Update user profile in database sqlx::query("UPDATE users SET profile = $1 WHERE id = $2") - .bind(&url) - .bind(user.id) - .execute(&state.pool) - .await?; + .bind(&url).bind(user.id).execute(&state.pool).await?; - Ok(Json(UploadResponse { - url, - message: "Profile picture uploaded successfully".to_string(), - })) + Ok(Json(UploadResponse { url, message: "Profile picture uploaded successfully".to_string() })) } -/// POST /api/ladders/:id/logo /// Upload ladder logo (admin only) +#[utoipa::path( + post, + path = "/api/ladders/{id}/logo", + tag = "uploads", + params(("id" = i32, Path, description = "Ladder ID")), + responses( + (status = 200, description = "Logo uploaded", body = UploadResponse), + (status = 403, description = "Admin only"), + (status = 404, description = "Ladder not found") + ), + security(("bearer_auth" = [])) +)] pub async fn upload_ladder_logo( user: AuthUser, State(state): State, Path(ladder_id): Path, mut multipart: Multipart, ) -> Result> { - // Only admins can upload ladder logos if !user.is_admin { return Err(AppError::Forbidden("Only admins can upload ladder logos".to_string())); } - // Verify ladder exists let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM ladders WHERE id = $1)") - .bind(ladder_id) - .fetch_one(&state.pool) - .await?; + .bind(ladder_id).fetch_one(&state.pool).await?; if !exists { return Err(AppError::NotFound("Ladder not found".to_string())); } - // Process multipart form let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?; - // Upload file - let url = state.storage.upload_file( - &file_data, - &filename, - &content_type, - UploadType::LadderLogo, - ).await?; + let url = state.storage.upload_file(&file_data, &filename, &content_type, UploadType::LadderLogo).await?; - // Update ladder logo in database sqlx::query("UPDATE ladders SET logo = $1 WHERE id = $2") - .bind(&url) - .bind(ladder_id) - .execute(&state.pool) - .await?; + .bind(&url).bind(ladder_id).execute(&state.pool).await?; - Ok(Json(UploadResponse { - url, - message: "Ladder logo uploaded successfully".to_string(), - })) + Ok(Json(UploadResponse { url, message: "Ladder logo uploaded successfully".to_string() })) } -/// DELETE /api/teams/:id/logo /// Remove team logo (captain only) +#[utoipa::path( + delete, + path = "/api/teams/{id}/logo", + tag = "uploads", + params(("id" = i32, Path, description = "Team ID")), + responses( + (status = 204, description = "Logo deleted"), + (status = 403, description = "Not team captain") + ), + security(("bearer_auth" = [])) +)] pub async fn delete_team_logo( user: AuthUser, State(state): State, Path(team_id): Path, ) -> Result { - // Verify user is captain of this team let is_captain: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1)" - ) - .bind(team_id) - .bind(user.id) - .fetch_one(&state.pool) - .await?; + ).bind(team_id).bind(user.id).fetch_one(&state.pool).await?; if !is_captain && !user.is_admin { return Err(AppError::Forbidden("Only team captain can delete logo".to_string())); } - // Get current logo URL let logo_url: Option = sqlx::query_scalar("SELECT logo FROM teams WHERE id = $1") - .bind(team_id) - .fetch_one(&state.pool) - .await?; + .bind(team_id).fetch_one(&state.pool).await?; - // Delete from storage if exists if let Some(url) = logo_url { - if !url.is_empty() { - let _ = state.storage.delete_file(&url).await; // Ignore errors - } + if !url.is_empty() { let _ = state.storage.delete_file(&url).await; } } - // Clear logo in database sqlx::query("UPDATE teams SET logo = '' WHERE id = $1") - .bind(team_id) - .execute(&state.pool) - .await?; + .bind(team_id).execute(&state.pool).await?; Ok(StatusCode::NO_CONTENT) } -/// Helper function to extract file from multipart form -async fn extract_file_from_multipart( - multipart: &mut Multipart, -) -> Result<(Vec, String, String)> { +async fn extract_file_from_multipart(multipart: &mut Multipart) -> Result<(Vec, String, String)> { while let Some(field) = multipart.next_field().await.map_err(|e| { AppError::BadRequest(format!("Failed to read multipart form: {}", e)) })? { let name = field.name().unwrap_or("").to_string(); if name == "file" || name == "logo" || name == "profile" || name == "image" { - let filename = field.file_name() - .unwrap_or("upload.jpg") - .to_string(); - - let content_type = field.content_type() - .unwrap_or("image/jpeg") - .to_string(); - + let filename = field.file_name().unwrap_or("upload.jpg").to_string(); + let content_type = field.content_type().unwrap_or("image/jpeg").to_string(); let data = field.bytes().await.map_err(|e| { AppError::BadRequest(format!("Failed to read file data: {}", e)) })?; - return Ok((data.to_vec(), filename, content_type)); } } diff --git a/src/models/match_round.rs b/src/models/match_round.rs index 480fe12..a86b511 100644 --- a/src/models/match_round.rs +++ b/src/models/match_round.rs @@ -32,7 +32,7 @@ pub struct MatchRound { pub team_1_score: i32, pub team_2_score: i32, pub score_posted_by_team_id: i32, - pub score_acceptance_status: i32, + pub score_confirmation_status: i32, } /// Submit score request diff --git a/src/openapi.rs b/src/openapi.rs index 3d944a2..860cc10 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1,21 +1,30 @@ use utoipa::OpenApi; -use crate::{ - handlers::{ - auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}, - health::HealthResponse, - users::UserProfileResponse, +use crate::handlers::{ + auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}, + health::HealthResponse, + users::UserProfileResponse, + teams::{ + TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest, + TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest, + MyTeamResponse, MessageResponse, }, - models::{ - featured_player::{FeaturedPlayer, FeaturedPlayerWithUser}, - ladder::{CreateLadder, Ladder, LadderStatus, UpdateLadder}, - ladder_team::{JoinLadderRequest, LadderTeam}, - match_model::{CreateChallengeRequest, Match, MatchStatus, RespondChallengeRequest, TeamChallengeStatus}, - match_round::{MatchRound, ScoreAcceptanceStatus, SubmitScoreRequest}, - team::{CreateTeam, Team, TeamMember, TeamWithStats, UpdateTeam}, - team_player::{ChangePositionRequest, JoinTeamRequest, PlayerPosition, TeamPlayer, TeamPlayerStatus}, - user::{CreateUser, PlayerStats, UpdateUser, User, UserProfile}, + matches::{ + ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest, + MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest, }, + ladders::{ + ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest, + UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse, + EnrollmentResponse, + }, + players::{ + ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfileResponse as PlayerProfile, + PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse, + FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry, + RatingHistoryEntry, + }, + uploads::UploadResponse, }; #[derive(OpenApi)] @@ -24,13 +33,8 @@ use crate::{ title = "VRBattles API", version = "1.0.0", description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.", - contact( - name = "VRBattles Team", - url = "https://vrbattles.gg" - ), - license( - name = "Proprietary" - ) + contact(name = "VRBattles Team", url = "https://vrbattles.gg"), + license(name = "Proprietary") ), servers( (url = "https://api.vrb.gg", description = "Production server"), @@ -54,55 +58,76 @@ use crate::{ crate::handlers::auth::register, // Users crate::handlers::users::get_current_user, + // Teams + crate::handlers::teams::list_teams, + crate::handlers::teams::create_team, + crate::handlers::teams::get_team, + crate::handlers::teams::update_team, + crate::handlers::teams::delete_team, + crate::handlers::teams::request_join_team, + crate::handlers::teams::cancel_join_request, + crate::handlers::teams::accept_member, + crate::handlers::teams::reject_member, + crate::handlers::teams::remove_member, + crate::handlers::teams::change_member_position, + crate::handlers::teams::get_my_team, + // Matches + crate::handlers::matches::list_matches, + crate::handlers::matches::get_match, + crate::handlers::matches::create_challenge, + crate::handlers::matches::accept_challenge, + crate::handlers::matches::reject_challenge, + crate::handlers::matches::start_match, + crate::handlers::matches::cancel_match, + crate::handlers::matches::report_score, + crate::handlers::matches::accept_score, + crate::handlers::matches::reject_score, + crate::handlers::matches::get_my_matches, + // Ladders + crate::handlers::ladders::list_ladders, + crate::handlers::ladders::create_ladder, + crate::handlers::ladders::get_ladder, + crate::handlers::ladders::update_ladder, + crate::handlers::ladders::delete_ladder, + crate::handlers::ladders::enroll_team, + crate::handlers::ladders::withdraw_team, + crate::handlers::ladders::get_ladder_standings, + // Players + crate::handlers::players::list_players, + crate::handlers::players::get_player, + crate::handlers::players::get_featured_players, + crate::handlers::players::set_featured_player, + crate::handlers::players::remove_featured_player, + crate::handlers::players::get_leaderboard, + crate::handlers::players::get_rating_history, + // Uploads + crate::handlers::uploads::upload_team_logo, + crate::handlers::uploads::upload_profile_picture, + crate::handlers::uploads::upload_ladder_logo, + crate::handlers::uploads::delete_team_logo, ), components( schemas( - // Health - HealthResponse, // Auth - LoginRequest, - RegisterRequest, - AuthResponse, - UserResponse, - UserProfileResponse, - // User - User, - UserProfile, - CreateUser, - UpdateUser, - PlayerStats, - // Team - Team, - TeamWithStats, - CreateTeam, - UpdateTeam, - TeamMember, - // Team Player - TeamPlayer, - JoinTeamRequest, - ChangePositionRequest, - PlayerPosition, - TeamPlayerStatus, - // Ladder - Ladder, - LadderStatus, - CreateLadder, - UpdateLadder, - LadderTeam, - JoinLadderRequest, - // Match - Match, - MatchStatus, - TeamChallengeStatus, - CreateChallengeRequest, - RespondChallengeRequest, - // Match Round - MatchRound, - ScoreAcceptanceStatus, - SubmitScoreRequest, - // Featured Player - FeaturedPlayer, - FeaturedPlayerWithUser, + HealthResponse, LoginRequest, RegisterRequest, AuthResponse, UserResponse, UserProfileResponse, + // Teams + TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest, + TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest, + MyTeamResponse, MessageResponse, + // Matches + ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest, + MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest, + // Ladders + ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest, + UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse, + EnrollmentResponse, + // Players + ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfile, + PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse, + FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry, + RatingHistoryEntry, + // Uploads + UploadResponse, ) ), modifiers(&SecurityAddon) @@ -113,16 +138,12 @@ struct SecurityAddon; impl utoipa::Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - if let Some(components) = openapi.components.as_mut() { - components.add_security_scheme( - "bearer_auth", - utoipa::openapi::security::SecurityScheme::Http( - utoipa::openapi::security::Http::new( - utoipa::openapi::security::HttpAuthScheme::Bearer, - ) - .bearer_format("JWT"), - ), - ); - } + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer) + ), + ); } }