Add utoipa OpenAPI annotations to all handlers and fix score_confirmation_status column name

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
root
2026-01-20 08:55:04 +00:00
parent cac4b83140
commit 3fafca2860
8 changed files with 1132 additions and 1383 deletions

View File

@@ -41,8 +41,6 @@ CREATE TABLE IF NOT EXISTS team_players (
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
player_id INTEGER NOT NULL REFERENCES users(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', position VARCHAR(20) NOT NULL DEFAULT 'member',
status INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0,
UNIQUE(team_id, player_id) UNIQUE(team_id, player_id)
@@ -72,12 +70,6 @@ CREATE TABLE IF NOT EXISTS ladder_teams (
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE, ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES teams(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) UNIQUE(ladder_id, team_id)
); );

View File

@@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
use crate::{ use crate::{
@@ -20,15 +21,15 @@ use super::auth::AppState;
// Request/Response Types // Request/Response Types
// ============================================================================ // ============================================================================
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListLaddersQuery { pub struct ListLaddersQuery {
pub search: Option<String>, pub search: Option<String>,
pub status: Option<i32>, // 0=open, 1=closed pub status: Option<i32>,
pub page: Option<i64>, pub page: Option<i64>,
pub per_page: Option<i64>, pub per_page: Option<i64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct LadderListResponse { pub struct LadderListResponse {
pub ladders: Vec<LadderWithTeamCount>, pub ladders: Vec<LadderWithTeamCount>,
pub total: i64, pub total: i64,
@@ -36,7 +37,7 @@ pub struct LadderListResponse {
pub per_page: i64, pub per_page: i64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct LadderWithTeamCount { pub struct LadderWithTeamCount {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@@ -49,7 +50,7 @@ pub struct LadderWithTeamCount {
pub team_count: Option<i64>, pub team_count: Option<i64>,
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateLadderRequest { pub struct CreateLadderRequest {
#[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))] #[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))]
pub name: String, pub name: String,
@@ -58,7 +59,7 @@ pub struct CreateLadderRequest {
pub logo: Option<String>, pub logo: Option<String>,
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateLadderRequest { pub struct UpdateLadderRequest {
#[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))] #[validate(length(min = 2, max = 255, message = "Ladder name must be 2-255 characters"))]
pub name: Option<String>, pub name: Option<String>,
@@ -68,7 +69,7 @@ pub struct UpdateLadderRequest {
pub logo: Option<String>, pub logo: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct LadderResponse { pub struct LadderResponse {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@@ -95,7 +96,7 @@ impl From<Ladder> for LadderResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct LadderDetailResponse { pub struct LadderDetailResponse {
pub ladder: LadderResponse, pub ladder: LadderResponse,
pub teams: Vec<LadderTeamResponse>, pub teams: Vec<LadderTeamResponse>,
@@ -103,7 +104,7 @@ pub struct LadderDetailResponse {
pub user_team_id: Option<i32>, pub user_team_id: Option<i32>,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct LadderTeamResponse { pub struct LadderTeamResponse {
pub id: i32, pub id: i32,
pub team_id: i32, pub team_id: i32,
@@ -114,7 +115,7 @@ pub struct LadderTeamResponse {
pub date_enrolled: chrono::DateTime<chrono::Utc>, pub date_enrolled: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct EnrollmentResponse { pub struct EnrollmentResponse {
pub message: String, pub message: String,
pub ladder_team_id: i32, pub ladder_team_id: i32,
@@ -124,8 +125,21 @@ pub struct EnrollmentResponse {
// Handlers // Handlers
// ============================================================================ // ============================================================================
/// GET /api/ladders
/// List all ladders with pagination and search /// List all ladders with pagination and search
#[utoipa::path(
get,
path = "/api/ladders",
tag = "ladders",
params(
("search" = Option<String>, Query, description = "Search by ladder name"),
("status" = Option<i32>, Query, description = "Filter by status (0=open, 1=closed)"),
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page")
),
responses(
(status = 200, description = "List of ladders", body = LadderListResponse)
)
)]
pub async fn list_ladders( pub async fn list_ladders(
Query(query): Query<ListLaddersQuery>, Query(query): Query<ListLaddersQuery>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -136,312 +150,207 @@ pub async fn list_ladders(
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
// Build dynamic query based on filters
let (total, ladders): (i64, Vec<LadderWithTeamCount>) = match (&search_pattern, query.status) { let (total, ladders): (i64, Vec<LadderWithTeamCount>) = match (&search_pattern, query.status) {
(Some(pattern), Some(status)) => { (Some(pattern), Some(status)) => {
let total = sqlx::query_scalar( let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE name ILIKE $1 AND status = $2")
"SELECT COUNT(*) FROM ladders WHERE name ILIKE $1 AND status = $2" .bind(pattern).bind(status).fetch_one(&state.pool).await?;
)
.bind(pattern)
.bind(status)
.fetch_one(&state.pool)
.await?;
let ladders = sqlx::query_as( let ladders = sqlx::query_as(
r#" r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
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
l.date_created, l.created_by_id, FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
COUNT(lt.id) as team_count WHERE l.name ILIKE $1 AND l.status = $2 GROUP BY l.id
FROM ladders l ORDER BY l.date_created DESC LIMIT $3 OFFSET $4"#
LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id ).bind(pattern).bind(status).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
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) (total, ladders)
} }
(Some(pattern), None) => { (Some(pattern), None) => {
let total = sqlx::query_scalar( let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE name ILIKE $1")
"SELECT COUNT(*) FROM ladders WHERE name ILIKE $1" .bind(pattern).fetch_one(&state.pool).await?;
)
.bind(pattern)
.fetch_one(&state.pool)
.await?;
let ladders = sqlx::query_as( let ladders = sqlx::query_as(
r#" r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
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
l.date_created, l.created_by_id, FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
COUNT(lt.id) as team_count WHERE l.name ILIKE $1 GROUP BY l.id ORDER BY l.date_created DESC LIMIT $2 OFFSET $3"#
FROM ladders l ).bind(pattern).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
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) (total, ladders)
} }
(None, Some(status)) => { (None, Some(status)) => {
let total = sqlx::query_scalar( let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders WHERE status = $1")
"SELECT COUNT(*) FROM ladders WHERE status = $1" .bind(status).fetch_one(&state.pool).await?;
)
.bind(status)
.fetch_one(&state.pool)
.await?;
let ladders = sqlx::query_as( let ladders = sqlx::query_as(
r#" r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
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
l.date_created, l.created_by_id, FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
COUNT(lt.id) as team_count WHERE l.status = $1 GROUP BY l.id ORDER BY l.date_created DESC LIMIT $2 OFFSET $3"#
FROM ladders l ).bind(status).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
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) (total, ladders)
} }
(None, None) => { (None, None) => {
let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders") let total = sqlx::query_scalar("SELECT COUNT(*) FROM ladders").fetch_one(&state.pool).await?;
.fetch_one(&state.pool)
.await?;
let ladders = sqlx::query_as( let ladders = sqlx::query_as(
r#" r#"SELECT l.id, l.name, l.logo, l.status, l.date_start, l.date_expiration,
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
l.date_created, l.created_by_id, FROM ladders l LEFT JOIN ladder_teams lt ON l.id = lt.ladder_id
COUNT(lt.id) as team_count GROUP BY l.id ORDER BY l.date_created DESC LIMIT $1 OFFSET $2"#
FROM ladders l ).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
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) (total, ladders)
} }
}; };
Ok(Json(LadderListResponse { Ok(Json(LadderListResponse { ladders, total, page, per_page }))
ladders,
total,
page,
per_page,
}))
} }
/// POST /api/ladders
/// Create a new ladder (admin only) /// 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( pub async fn create_ladder(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateLadderRequest>, Json(payload): Json<CreateLadderRequest>,
) -> Result<(StatusCode, Json<LadderResponse>)> { ) -> Result<(StatusCode, Json<LadderResponse>)> {
// Only admins can create ladders
if !user.is_admin { if !user.is_admin {
return Err(AppError::Forbidden("Only admins can create ladders".to_string())); return Err(AppError::Forbidden("Only admins can create ladders".to_string()));
} }
payload.validate()?; payload.validate()?;
// Check if ladder name already exists let existing = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE name = $1")
let existing = sqlx::query_scalar::<_, i32>( .bind(&payload.name).fetch_optional(&state.pool).await?;
"SELECT id FROM ladders WHERE name = $1"
)
.bind(&payload.name)
.fetch_optional(&state.pool)
.await?;
if existing.is_some() { if existing.is_some() {
return Err(AppError::Conflict("Ladder name already exists".to_string())); return Err(AppError::Conflict("Ladder name already exists".to_string()));
} }
let ladder = sqlx::query_as::<_, Ladder>( let ladder = sqlx::query_as::<_, Ladder>(
r#" r#"INSERT INTO ladders (name, date_start, date_expiration, logo, created_by_id, status)
INSERT INTO ladders (name, date_start, date_expiration, logo, created_by_id, status) VALUES ($1, $2, $3, $4, $5, 0) RETURNING *"#
VALUES ($1, $2, $3, $4, $5, 0) ).bind(&payload.name).bind(&payload.date_start).bind(&payload.date_expiration)
RETURNING * .bind(&payload.logo).bind(user.id).fetch_one(&state.pool).await?;
"#
)
.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()))) Ok((StatusCode::CREATED, Json(ladder.into())))
} }
/// GET /api/ladders/:id
/// Get ladder details with enrolled teams /// 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( pub async fn get_ladder(
OptionalAuthUser(user): OptionalAuthUser, OptionalAuthUser(user): OptionalAuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
) -> Result<Json<LadderDetailResponse>> { ) -> Result<Json<LadderDetailResponse>> {
let ladder = sqlx::query_as::<_, Ladder>( let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1")
"SELECT * FROM ladders WHERE id = $1" .bind(ladder_id).fetch_optional(&state.pool).await?
)
.bind(ladder_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
// Get enrolled teams
let teams = sqlx::query_as::<_, LadderTeamResponse>( let teams = sqlx::query_as::<_, LadderTeamResponse>(
r#" r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
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 t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
FROM ladder_teams lt FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id
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"#
WHERE lt.ladder_id = $1 AND t.is_delete = false ).bind(ladder_id).fetch_all(&state.pool).await?;
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 { let (is_enrolled, user_team_id) = if let Some(ref u) = user {
// Get user's team
let user_team = sqlx::query_scalar::<_, i32>( let user_team = sqlx::query_scalar::<_, i32>(
"SELECT team_id FROM team_players WHERE player_id = $1 AND status = 1" "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 { if let Some(team_id) = user_team {
let enrolled = sqlx::query_scalar::<_, i32>( let enrolled = sqlx::query_scalar::<_, i32>(
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2" "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)) (enrolled.is_some(), Some(team_id))
} else { } else { (false, None) }
(false, None) } else { (false, None) };
}
} else {
(false, None)
};
Ok(Json(LadderDetailResponse { Ok(Json(LadderDetailResponse { ladder: ladder.into(), teams, is_enrolled, user_team_id }))
ladder: ladder.into(),
teams,
is_enrolled,
user_team_id,
}))
} }
/// PUT /api/ladders/:id
/// Update ladder (admin only) /// 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( pub async fn update_ladder(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
Json(payload): Json<UpdateLadderRequest>, Json(payload): Json<UpdateLadderRequest>,
) -> Result<Json<LadderResponse>> { ) -> Result<Json<LadderResponse>> {
// Only admins can update ladders
if !user.is_admin { if !user.is_admin {
return Err(AppError::Forbidden("Only admins can update ladders".to_string())); return Err(AppError::Forbidden("Only admins can update ladders".to_string()));
} }
payload.validate()?; payload.validate()?;
// Check if new name conflicts
if let Some(ref name) = payload.name { if let Some(ref name) = payload.name {
let existing = sqlx::query_scalar::<_, i32>( let existing = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE name = $1 AND id != $2")
"SELECT id FROM ladders WHERE name = $1 AND id != $2" .bind(name).bind(ladder_id).fetch_optional(&state.pool).await?;
)
.bind(name)
.bind(ladder_id)
.fetch_optional(&state.pool)
.await?;
if existing.is_some() { if existing.is_some() {
return Err(AppError::Conflict("Ladder name already exists".to_string())); return Err(AppError::Conflict("Ladder name already exists".to_string()));
} }
} }
let ladder = sqlx::query_as::<_, Ladder>( let ladder = sqlx::query_as::<_, Ladder>(
r#" r#"UPDATE ladders SET name = COALESCE($1, name), date_start = COALESCE($2, date_start),
UPDATE ladders date_expiration = COALESCE($3, date_expiration), status = COALESCE($4, status),
SET name = COALESCE($1, name), logo = COALESCE($5, logo) WHERE id = $6 RETURNING *"#
date_start = COALESCE($2, date_start), ).bind(&payload.name).bind(&payload.date_start).bind(&payload.date_expiration)
date_expiration = COALESCE($3, date_expiration), .bind(payload.status).bind(&payload.logo).bind(ladder_id)
status = COALESCE($4, status), .fetch_optional(&state.pool).await?
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_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
Ok(Json(ladder.into())) Ok(Json(ladder.into()))
} }
/// DELETE /api/ladders/:id
/// Delete ladder (admin only) /// 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( pub async fn delete_ladder(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
// Only admins can delete ladders
if !user.is_admin { if !user.is_admin {
return Err(AppError::Forbidden("Only admins can delete ladders".to_string())); 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") let result = sqlx::query("DELETE FROM ladders WHERE id = $1")
.bind(ladder_id) .bind(ladder_id).execute(&state.pool).await?;
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(AppError::NotFound("Ladder not found".to_string())); return Err(AppError::NotFound("Ladder not found".to_string()));
@@ -450,107 +359,89 @@ pub async fn delete_ladder(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// POST /api/ladders/:id/enroll /// Enroll team in a ladder (captain only)
/// Enroll user's 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( pub async fn enroll_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
) -> Result<(StatusCode, Json<EnrollmentResponse>)> { ) -> Result<(StatusCode, Json<EnrollmentResponse>)> {
// Check ladder exists and is open let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1")
let ladder = sqlx::query_as::<_, Ladder>( .bind(ladder_id).fetch_optional(&state.pool).await?
"SELECT * FROM ladders WHERE id = $1"
)
.bind(ladder_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
if ladder.status != 0 { if ladder.status != 0 {
return Err(AppError::BadRequest("Ladder is not open for enrollment".to_string())); 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>( let team_id = sqlx::query_scalar::<_, i32>(
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" "SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
) ).bind(user.id).fetch_optional(&state.pool).await?
.bind(user.id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?; .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>( let existing = sqlx::query_scalar::<_, i32>(
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2" "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() { if existing.is_some() {
return Err(AppError::Conflict("Team is already enrolled in this ladder".to_string())); return Err(AppError::Conflict("Team is already enrolled in this ladder".to_string()));
} }
// Enroll team
let ladder_team = sqlx::query_as::<_, LadderTeam>( let ladder_team = sqlx::query_as::<_, LadderTeam>(
r#" "INSERT INTO ladder_teams (ladder_id, team_id) VALUES ($1, $2) RETURNING *"
INSERT INTO ladder_teams (ladder_id, team_id) ).bind(ladder_id).bind(team_id).fetch_one(&state.pool).await?;
VALUES ($1, $2)
RETURNING *
"#
)
.bind(ladder_id)
.bind(team_id)
.fetch_one(&state.pool)
.await?;
Ok(( Ok((StatusCode::CREATED, Json(EnrollmentResponse {
StatusCode::CREATED,
Json(EnrollmentResponse {
message: "Successfully enrolled in ladder".to_string(), message: "Successfully enrolled in ladder".to_string(),
ladder_team_id: ladder_team.id, ladder_team_id: ladder_team.id,
}), })))
))
} }
/// DELETE /api/ladders/:id/enroll
/// Withdraw team from a ladder (captain only) /// 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( pub async fn withdraw_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
// Get user's team (must be captain)
let team_id = sqlx::query_scalar::<_, i32>( let team_id = sqlx::query_scalar::<_, i32>(
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" "SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
) ).bind(user.id).fetch_optional(&state.pool).await?
.bind(user.id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?; .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")
let ladder = sqlx::query_as::<_, Ladder>( .bind(ladder_id).fetch_optional(&state.pool).await?
"SELECT * FROM ladders WHERE id = $1"
)
.bind(ladder_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
if ladder.status != 0 { if ladder.status != 0 {
return Err(AppError::BadRequest("Cannot withdraw from a closed ladder".to_string())); 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")
let result = sqlx::query( .bind(ladder_id).bind(team_id).execute(&state.pool).await?;
"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 { if result.rows_affected() == 0 {
return Err(AppError::NotFound("Team is not enrolled in this ladder".to_string())); 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) Ok(StatusCode::NO_CONTENT)
} }
/// GET /api/ladders/:id/standings
/// Get ladder standings (teams ranked by MMR) /// 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<LadderTeamResponse>),
(status = 404, description = "Ladder not found")
)
)]
pub async fn get_ladder_standings( pub async fn get_ladder_standings(
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
) -> Result<Json<Vec<LadderTeamResponse>>> { ) -> Result<Json<Vec<LadderTeamResponse>>> {
// Check ladder exists let exists = sqlx::query_scalar::<_, i32>("SELECT id FROM ladders WHERE id = $1")
let exists = sqlx::query_scalar::<_, i32>( .bind(ladder_id).fetch_optional(&state.pool).await?;
"SELECT id FROM ladders WHERE id = $1"
)
.bind(ladder_id)
.fetch_optional(&state.pool)
.await?;
if exists.is_none() { if exists.is_none() {
return Err(AppError::NotFound("Ladder not found".to_string())); return Err(AppError::NotFound("Ladder not found".to_string()));
} }
// Get standings
let standings = sqlx::query_as::<_, LadderTeamResponse>( let standings = sqlx::query_as::<_, LadderTeamResponse>(
r#" r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
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 t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
FROM ladder_teams lt FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id
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"#
WHERE lt.ladder_id = $1 AND t.is_delete = false ).bind(ladder_id).fetch_all(&state.pool).await?;
ORDER BY t.mmr DESC, t.rank ASC
"#
)
.bind(ladder_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(standings)) Ok(Json(standings))
} }

View File

@@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
use crate::{ use crate::{
@@ -20,7 +21,6 @@ use super::auth::AppState;
// Constants for Match Status // Constants for Match Status
// ============================================================================ // ============================================================================
/// Team status in a match
pub mod team_status { pub mod team_status {
pub const CHALLENGING: i32 = 0; pub const CHALLENGING: i32 = 0;
pub const PENDING_RESPONSE: i32 = 1; pub const PENDING_RESPONSE: i32 = 1;
@@ -29,7 +29,6 @@ pub mod team_status {
pub const ACCEPTED: i32 = 4; pub const ACCEPTED: i32 = 4;
} }
/// Match overall status
pub mod match_status { pub mod match_status {
pub const PENDING: i32 = 0; pub const PENDING: i32 = 0;
pub const SCHEDULED: i32 = 1; pub const SCHEDULED: i32 = 1;
@@ -38,7 +37,6 @@ pub mod match_status {
pub const CANCELLED: i32 = 4; pub const CANCELLED: i32 = 4;
} }
/// Score acceptance status
pub mod score_status { pub mod score_status {
pub const PENDING: i32 = 0; pub const PENDING: i32 = 0;
pub const ACCEPTED: i32 = 1; pub const ACCEPTED: i32 = 1;
@@ -49,7 +47,7 @@ pub mod score_status {
// Request/Response Types // Request/Response Types
// ============================================================================ // ============================================================================
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListMatchesQuery { pub struct ListMatchesQuery {
pub team_id: Option<i32>, pub team_id: Option<i32>,
pub ladder_id: Option<i32>, pub ladder_id: Option<i32>,
@@ -58,7 +56,7 @@ pub struct ListMatchesQuery {
pub per_page: Option<i64>, pub per_page: Option<i64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct MatchListResponse { pub struct MatchListResponse {
pub matches: Vec<MatchWithTeams>, pub matches: Vec<MatchWithTeams>,
pub total: i64, pub total: i64,
@@ -66,7 +64,7 @@ pub struct MatchListResponse {
pub per_page: i64, pub per_page: i64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct MatchWithTeams { pub struct MatchWithTeams {
pub id: i32, pub id: i32,
pub date_created: chrono::DateTime<chrono::Utc>, pub date_created: chrono::DateTime<chrono::Utc>,
@@ -84,14 +82,14 @@ pub struct MatchWithTeams {
pub created_by_id: i32, pub created_by_id: i32,
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateChallengeRequest { pub struct CreateChallengeRequest {
pub opponent_team_id: i32, pub opponent_team_id: i32,
#[validate(length(min = 1, message = "Challenge date is required"))] #[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 struct MatchResponse {
pub id: i32, pub id: i32,
pub date_created: chrono::DateTime<chrono::Utc>, pub date_created: chrono::DateTime<chrono::Utc>,
@@ -122,7 +120,7 @@ impl From<Match> for MatchResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct MatchDetailResponse { pub struct MatchDetailResponse {
pub match_info: MatchWithTeams, pub match_info: MatchWithTeams,
pub rounds: Vec<MatchRoundResponse>, pub rounds: Vec<MatchRoundResponse>,
@@ -131,7 +129,7 @@ pub struct MatchDetailResponse {
pub can_report_score: bool, pub can_report_score: bool,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct MatchRoundResponse { pub struct MatchRoundResponse {
pub id: i32, pub id: i32,
pub date_created: chrono::DateTime<chrono::Utc>, pub date_created: chrono::DateTime<chrono::Utc>,
@@ -139,10 +137,10 @@ pub struct MatchRoundResponse {
pub team_1_score: i32, pub team_1_score: i32,
pub team_2_score: i32, pub team_2_score: i32,
pub score_posted_by_team_id: 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 { pub struct ReportScoreRequest {
#[validate(range(min = 0, message = "Score must be non-negative"))] #[validate(range(min = 0, message = "Score must be non-negative"))]
pub team_1_score: i32, pub team_1_score: i32,
@@ -150,7 +148,7 @@ pub struct ReportScoreRequest {
pub team_2_score: i32, pub team_2_score: i32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct RescheduleRequest { pub struct RescheduleRequest {
pub challenge_date: String, pub challenge_date: String,
} }
@@ -159,8 +157,22 @@ pub struct RescheduleRequest {
// Handlers // Handlers
// ============================================================================ // ============================================================================
/// GET /api/matches
/// List matches with filters /// List matches with filters
#[utoipa::path(
get,
path = "/api/matches",
tag = "matches",
params(
("team_id" = Option<i32>, Query, description = "Filter by team ID"),
("ladder_id" = Option<i32>, Query, description = "Filter by ladder ID"),
("status" = Option<i32>, Query, description = "Filter by match status (0=pending, 1=scheduled, 2=in_progress, 3=done, 4=cancelled)"),
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page")
),
responses(
(status = 200, description = "List of matches", body = MatchListResponse)
)
)]
pub async fn list_matches( pub async fn list_matches(
Query(query): Query<ListMatchesQuery>, Query(query): Query<ListMatchesQuery>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -169,7 +181,6 @@ pub async fn list_matches(
let per_page = query.per_page.unwrap_or(20).clamp(1, 100); let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * per_page; let offset = (page - 1) * per_page;
// Build query based on filters
let base_query = r#" let base_query = r#"
SELECT m.id, m.date_created, m.date_start, m.challenge_date, 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, 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 { Ok(Json(MatchListResponse { matches, total, page, per_page }))
matches,
total,
page,
per_page,
}))
} }
/// GET /api/matches/:id
/// Get match details with rounds /// 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( pub async fn get_match(
user: Option<AuthUser>, user: Option<AuthUser>,
State(state): State<AppState>, State(state): State<AppState>,
Path(match_id): Path<i32>, Path(match_id): Path<i32>,
) -> Result<Json<MatchDetailResponse>> { ) -> Result<Json<MatchDetailResponse>> {
// Get match with team info
let match_info = sqlx::query_as::<_, MatchWithTeams>( let match_info = sqlx::query_as::<_, MatchWithTeams>(
r#" r#"
SELECT m.id, m.date_created, m.date_start, m.challenge_date, SELECT m.id, m.date_created, m.date_start, m.challenge_date,
@@ -301,7 +315,6 @@ pub async fn get_match(
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
// Get rounds
let rounds = sqlx::query_as::<_, MatchRoundResponse>( let rounds = sqlx::query_as::<_, MatchRoundResponse>(
"SELECT * FROM match_rounds WHERE match_id = $1 ORDER BY date_created ASC" "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) .fetch_all(&state.pool)
.await?; .await?;
// Check user's team and permissions
let (user_team_id, can_accept, can_report_score) = if let Some(ref u) = user { 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?; 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_1 = tid == match_info.team_id_1;
let is_team_2 = tid == match_info.team_id_2; 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 let can_accept = is_team_2
&& match_info.team_2_status == team_status::PENDING_RESPONSE && match_info.team_2_status == team_status::PENDING_RESPONSE
&& match_info.matche_status == match_status::PENDING; && 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) let can_report = (is_team_1 || is_team_2)
&& match_info.matche_status == match_status::IN_PROGRESS; && match_info.matche_status == match_status::IN_PROGRESS;
@@ -334,17 +344,23 @@ pub async fn get_match(
(None, false, false) (None, false, false)
}; };
Ok(Json(MatchDetailResponse { Ok(Json(MatchDetailResponse { match_info, rounds, user_team_id, can_accept, can_report_score }))
match_info,
rounds,
user_team_id,
can_accept,
can_report_score,
}))
} }
/// POST /api/matches/challenge
/// Create a new 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( pub async fn create_challenge(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -352,7 +368,6 @@ pub async fn create_challenge(
) -> Result<(StatusCode, Json<MatchResponse>)> { ) -> Result<(StatusCode, Json<MatchResponse>)> {
payload.validate()?; payload.validate()?;
// Get user's team (must be captain)
let user_team_id = sqlx::query_scalar::<_, i32>( let user_team_id = sqlx::query_scalar::<_, i32>(
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1" "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? .await?
.ok_or_else(|| AppError::Forbidden("You must be a team captain to create challenges".to_string()))?; .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>( let opponent_exists = sqlx::query_scalar::<_, i32>(
"SELECT id FROM teams WHERE id = $1 AND is_delete = false" "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())); return Err(AppError::NotFound("Opponent team not found".to_string()));
} }
// Can't challenge yourself
if user_team_id == payload.opponent_team_id { if user_team_id == payload.opponent_team_id {
return Err(AppError::BadRequest("Cannot challenge your own team".to_string())); return Err(AppError::BadRequest("Cannot challenge your own team".to_string()));
} }
// Check for existing pending challenge between teams
let existing = sqlx::query_scalar::<_, i32>( let existing = sqlx::query_scalar::<_, i32>(
r#" r#"
SELECT id FROM matches 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())); 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) let challenge_date = chrono::DateTime::parse_from_rfc3339(&payload.challenge_date)
.map_err(|_| AppError::BadRequest("Invalid date format. Use ISO 8601 format.".to_string()))? .map_err(|_| AppError::BadRequest("Invalid date format. Use ISO 8601 format.".to_string()))?
.with_timezone(&chrono::Utc); .with_timezone(&chrono::Utc);
// Create match
let new_match = sqlx::query_as::<_, Match>( let new_match = sqlx::query_as::<_, Match>(
r#" r#"
INSERT INTO matches ( INSERT INTO matches (
@@ -424,8 +434,19 @@ pub async fn create_challenge(
Ok((StatusCode::CREATED, Json(new_match.into()))) Ok((StatusCode::CREATED, Json(new_match.into())))
} }
/// POST /api/matches/:id/accept
/// Accept a challenge /// 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( pub async fn accept_challenge(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -433,26 +454,20 @@ pub async fn accept_challenge(
) -> Result<Json<MatchResponse>> { ) -> Result<Json<MatchResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; 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")
let current_match = sqlx::query_as::<_, Match>(
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .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 { if current_match.team_id_2 != user_team_id {
return Err(AppError::Forbidden("Only the challenged team captain can accept".to_string())); 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 { if current_match.matche_status != match_status::PENDING {
return Err(AppError::BadRequest("Match is not pending acceptance".to_string())); return Err(AppError::BadRequest("Match is not pending acceptance".to_string()));
} }
// Update match
let updated = sqlx::query_as::<_, Match>( let updated = sqlx::query_as::<_, Match>(
r#" r#"
UPDATE matches UPDATE matches
@@ -472,8 +487,19 @@ pub async fn accept_challenge(
Ok(Json(updated.into())) Ok(Json(updated.into()))
} }
/// POST /api/matches/:id/reject
/// Reject a challenge /// 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( pub async fn reject_challenge(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -481,15 +507,12 @@ pub async fn reject_challenge(
) -> Result<Json<MatchResponse>> { ) -> Result<Json<MatchResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .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 { if current_match.team_id_2 != user_team_id {
return Err(AppError::Forbidden("Only the challenged team captain can reject".to_string())); 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())) Ok(Json(updated.into()))
} }
/// POST /api/matches/:id/start
/// Start a scheduled match /// 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( pub async fn start_match(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -525,15 +559,12 @@ pub async fn start_match(
) -> Result<Json<MatchResponse>> { ) -> Result<Json<MatchResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .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 { 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())); 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())) Ok(Json(updated.into()))
} }
/// POST /api/matches/:id/cancel
/// Cancel a match (before it starts) /// 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( pub async fn cancel_match(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -562,20 +604,16 @@ pub async fn cancel_match(
) -> Result<Json<MatchResponse>> { ) -> Result<Json<MatchResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .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 { 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())); 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 { if current_match.matche_status > match_status::SCHEDULED {
return Err(AppError::BadRequest("Cannot cancel a match that has started or ended".to_string())); 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())) Ok(Json(updated.into()))
} }
/// POST /api/matches/:id/score
/// Report a score for a match round /// 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( pub async fn report_score(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -603,27 +654,22 @@ pub async fn report_score(
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?; .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 { 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())); 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 { if current_match.matche_status != match_status::IN_PROGRESS {
return Err(AppError::BadRequest("Match must be in progress to report scores".to_string())); 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>( 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(match_id)
.bind(score_status::PENDING) .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())); return Err(AppError::Conflict("There is already a pending score report".to_string()));
} }
// Create round with score
let round = sqlx::query_as::<_, MatchRound>( let round = sqlx::query_as::<_, MatchRound>(
r#" 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) VALUES ($1, $2, $3, $4, $5)
RETURNING * RETURNING *
"# "#
@@ -657,12 +702,26 @@ pub async fn report_score(
team_1_score: round.team_1_score, team_1_score: round.team_1_score,
team_2_score: round.team_2_score, team_2_score: round.team_2_score,
score_posted_by_team_id: round.score_posted_by_team_id, 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 /// 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( pub async fn accept_score(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -670,9 +729,7 @@ pub async fn accept_score(
) -> Result<Json<MatchRoundResponse>> { ) -> Result<Json<MatchRoundResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
@@ -687,39 +744,33 @@ pub async fn accept_score(
.await? .await?
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?; .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 { if round.score_posted_by_team_id == user_team_id {
return Err(AppError::BadRequest("Cannot accept your own score report".to_string())); 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 { 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())); 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())); return Err(AppError::BadRequest("Score has already been processed".to_string()));
} }
// Update round
let updated_round = sqlx::query_as::<_, MatchRound>( 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(score_status::ACCEPTED)
.bind(round_id) .bind(round_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
// Update match status to done
sqlx::query("UPDATE matches SET matche_status = $1 WHERE id = $2") sqlx::query("UPDATE matches SET matche_status = $1 WHERE id = $2")
.bind(match_status::DONE) .bind(match_status::DONE)
.bind(match_id) .bind(match_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Update team and player ratings using OpenSkill
if updated_round.team_1_score != updated_round.team_2_score { 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( let rating_result = crate::services::rating::process_match_result(
&state.pool, &state.pool,
current_match.team_id_1, current_match.team_id_1,
@@ -739,7 +790,6 @@ pub async fn accept_score(
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to update ratings for match {}: {}", match_id, 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_1_score: updated_round.team_1_score,
team_2_score: updated_round.team_2_score, team_2_score: updated_round.team_2_score,
score_posted_by_team_id: updated_round.score_posted_by_team_id, 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 /// 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( pub async fn reject_score(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -764,9 +828,7 @@ pub async fn reject_score(
) -> Result<Json<MatchRoundResponse>> { ) -> Result<Json<MatchRoundResponse>> {
let user_team_id = get_user_captain_team(&state.pool, user.id).await?; let user_team_id = get_user_captain_team(&state.pool, user.id).await?;
let current_match = sqlx::query_as::<_, Match>( let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
"SELECT * FROM matches WHERE id = $1"
)
.bind(match_id) .bind(match_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
@@ -781,22 +843,20 @@ pub async fn reject_score(
.await? .await?
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?; .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 { if round.score_posted_by_team_id == user_team_id {
return Err(AppError::BadRequest("Cannot reject your own score report".to_string())); 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 { 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())); 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())); return Err(AppError::BadRequest("Score has already been processed".to_string()));
} }
let updated_round = sqlx::query_as::<_, MatchRound>( 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(score_status::REJECTED)
.bind(round_id) .bind(round_id)
@@ -810,12 +870,26 @@ pub async fn reject_score(
team_1_score: updated_round.team_1_score, team_1_score: updated_round.team_1_score,
team_2_score: updated_round.team_2_score, team_2_score: updated_round.team_2_score,
score_posted_by_team_id: updated_round.score_posted_by_team_id, 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 users team matches
/// Get current user's team matches #[utoipa::path(
get,
path = "/api/my-matches",
tag = "matches",
params(
("status" = Option<i32>, Query, description = "Filter by match status"),
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, 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( pub async fn get_my_matches(
user: AuthUser, user: AuthUser,
Query(query): Query<ListMatchesQuery>, Query(query): Query<ListMatchesQuery>,
@@ -855,12 +929,7 @@ pub async fn get_my_matches(
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
Ok(Json(MatchListResponse { Ok(Json(MatchListResponse { matches, total, page, per_page }))
matches,
total,
page,
per_page,
}))
} }
// ============================================================================ // ============================================================================

View File

@@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use crate::{ use crate::{
auth::AuthUser, auth::AuthUser,
@@ -17,16 +18,16 @@ use super::auth::AppState;
// Request/Response Types // Request/Response Types
// ============================================================================ // ============================================================================
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListPlayersQuery { pub struct ListPlayersQuery {
pub search: Option<String>, pub search: Option<String>,
pub page: Option<i64>, pub page: Option<i64>,
pub per_page: Option<i64>, pub per_page: Option<i64>,
pub sort_by: Option<String>, // "mmr", "username", "date_registered" pub sort_by: Option<String>,
pub sort_order: Option<String>, // "asc", "desc" pub sort_order: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct PlayerListResponse { pub struct PlayerListResponse {
pub players: Vec<PlayerSummary>, pub players: Vec<PlayerSummary>,
pub total: i64, pub total: i64,
@@ -34,7 +35,7 @@ pub struct PlayerListResponse {
pub per_page: i64, pub per_page: i64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct PlayerSummary { pub struct PlayerSummary {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
@@ -47,7 +48,7 @@ pub struct PlayerSummary {
pub team_position: Option<String>, pub team_position: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct PlayerProfileResponse { pub struct PlayerProfileResponse {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
@@ -65,7 +66,7 @@ pub struct PlayerProfileResponse {
pub recent_matches: Vec<PlayerMatchSummary>, pub recent_matches: Vec<PlayerMatchSummary>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct PlayerTeamInfo { pub struct PlayerTeamInfo {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@@ -74,21 +75,20 @@ pub struct PlayerTeamInfo {
pub date_joined: chrono::DateTime<chrono::Utc>, pub date_joined: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct PlayerStats { pub struct PlayerStats {
pub matches_played: i64, pub matches_played: i64,
pub matches_won: i64, pub matches_won: i64,
pub matches_lost: i64, pub matches_lost: i64,
pub win_rate: f32, pub win_rate: f32,
pub ladders_participated: i64, pub ladders_participated: i64,
// OpenSkill rating stats
pub mu: f64, pub mu: f64,
pub sigma: f64, pub sigma: f64,
pub ordinal: f64, pub ordinal: f64,
pub mmr: f64, pub mmr: f64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct PlayerMatchSummary { pub struct PlayerMatchSummary {
pub match_id: i32, pub match_id: i32,
pub date_played: chrono::DateTime<chrono::Utc>, pub date_played: chrono::DateTime<chrono::Utc>,
@@ -96,15 +96,15 @@ pub struct PlayerMatchSummary {
pub own_team_name: String, pub own_team_name: String,
pub own_score: i32, pub own_score: i32,
pub opponent_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 struct FeaturedPlayersResponse {
pub players: Vec<FeaturedPlayerInfo>, pub players: Vec<FeaturedPlayerInfo>,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct FeaturedPlayerInfo { pub struct FeaturedPlayerInfo {
pub id: i32, pub id: i32,
pub player_id: i32, pub player_id: i32,
@@ -116,13 +116,13 @@ pub struct FeaturedPlayerInfo {
pub team_name: Option<String>, pub team_name: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct SetFeaturedPlayerRequest { pub struct SetFeaturedPlayerRequest {
pub player_id: i32, pub player_id: i32,
pub rank: i32, pub rank: i32,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct LeaderboardResponse { pub struct LeaderboardResponse {
pub players: Vec<LeaderboardEntry>, pub players: Vec<LeaderboardEntry>,
pub total: i64, pub total: i64,
@@ -130,7 +130,7 @@ pub struct LeaderboardResponse {
pub per_page: i64, pub per_page: i64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct LeaderboardEntry { pub struct LeaderboardEntry {
pub rank: i64, pub rank: i64,
pub player_id: i32, pub player_id: i32,
@@ -143,12 +143,40 @@ pub struct LeaderboardEntry {
pub matches_played: Option<i64>, pub matches_played: Option<i64>,
} }
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct RatingHistoryEntry {
pub id: i32,
pub date_created: chrono::DateTime<chrono::Utc>,
pub match_id: Option<i32>,
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<String>,
}
// ============================================================================ // ============================================================================
// Handlers // Handlers
// ============================================================================ // ============================================================================
/// GET /api/players
/// List all players with pagination and search /// List all players with pagination and search
#[utoipa::path(
get,
path = "/api/players",
tag = "players",
params(
("search" = Option<String>, Query, description = "Search by username or name"),
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("sort_by" = Option<String>, Query, description = "Sort by: username, date_registered"),
("sort_order" = Option<String>, Query, description = "Sort order: asc, desc")
),
responses(
(status = 200, description = "List of players", body = PlayerListResponse)
)
)]
pub async fn list_players( pub async fn list_players(
Query(query): Query<ListPlayersQuery>, Query(query): Query<ListPlayersQuery>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -158,8 +186,6 @@ pub async fn list_players(
let offset = (page - 1) * per_page; let offset = (page - 1) * per_page;
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
// Determine sort order
let sort_column = match query.sort_by.as_deref() { let sort_column = match query.sort_by.as_deref() {
Some("username") => "u.username", Some("username") => "u.username",
Some("date_registered") => "u.date_registered", Some("date_registered") => "u.date_registered",
@@ -173,155 +199,115 @@ pub async fn list_players(
let (total, players): (i64, Vec<PlayerSummary>) = if let Some(ref pattern) = search_pattern { let (total, players): (i64, Vec<PlayerSummary>) = if let Some(ref pattern) = search_pattern {
let total = sqlx::query_scalar( let total = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE username ILIKE $1 OR firstname ILIKE $1 OR lastname ILIKE $1" "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!( let query_str = format!(
r#" r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
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 tp.team_id, t.name as team_name, tp.position as team_position
FROM users u FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
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 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 WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1
ORDER BY {} {} ORDER BY {} {} LIMIT $2 OFFSET $3"#,
LIMIT $2 OFFSET $3
"#,
sort_column, sort_order 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) (total, players)
} else { } else {
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users") let total = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.pool).await?;
.fetch_one(&state.pool)
.await?;
let query_str = format!( let query_str = format!(
r#" r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
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 tp.team_id, t.name as team_name, tp.position as team_position
FROM users u FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
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 LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
ORDER BY {} {} ORDER BY {} {} LIMIT $1 OFFSET $2"#,
LIMIT $1 OFFSET $2
"#,
sort_column, sort_order 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) (total, players)
}; };
Ok(Json(PlayerListResponse { Ok(Json(PlayerListResponse { players, total, page, per_page }))
players,
total,
page,
per_page,
}))
} }
/// GET /api/players/:id
/// Get player profile with stats /// 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( pub async fn get_player(
State(state): State<AppState>, State(state): State<AppState>,
Path(player_id): Path<i32>, Path(player_id): Path<i32>,
) -> Result<Json<PlayerProfileResponse>> { ) -> Result<Json<PlayerProfileResponse>> {
// Get basic player info including OpenSkill ratings
let player = sqlx::query_as::<_, (i32, String, String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<f32>, Option<f32>, Option<f32>, Option<f32>)>( let player = sqlx::query_as::<_, (i32, String, String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<f32>, Option<f32>, Option<f32>, Option<f32>)>(
"SELECT id, username, firstname, lastname, profile, date_registered, is_admin, mu, sigma, ordinal, mmr FROM users WHERE id = $1" "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?
.bind(player_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Player not found".to_string()))?; .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<chrono::Utc>)>( let team_info = sqlx::query_as::<_, (i32, String, String, String, chrono::DateTime<chrono::Utc>)>(
r#" r#"SELECT t.id, t.name, t.logo, tp.position, tp.date_created
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
FROM team_players tp WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false"#
JOIN teams t ON tp.team_id = t.id ).bind(player_id).fetch_optional(&state.pool).await?;
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 { let team = team_info.map(|(id, name, logo, position, date_joined)| PlayerTeamInfo {
id, id, name, logo, position, date_joined,
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?; 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 { let recent_matches = if let Some(ref t) = team {
get_recent_matches(&state.pool, t.id, 5).await? get_recent_matches(&state.pool, t.id, 5).await?
} else { } else { vec![] };
vec![]
};
Ok(Json(PlayerProfileResponse { Ok(Json(PlayerProfileResponse {
id: player.0, id: player.0, username: player.1, firstname: player.2, lastname: player.3,
username: player.1, profile: player.4, date_registered: player.5, is_admin: player.6,
firstname: player.2, mu: player.7.unwrap_or(25.0), sigma: player.8.unwrap_or(8.333),
lastname: player.3, ordinal: player.9.unwrap_or(0.0), mmr: player.10.unwrap_or(1000.0),
profile: player.4, team, stats, recent_matches,
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 /// 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( pub async fn get_featured_players(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<FeaturedPlayersResponse>> { ) -> Result<Json<FeaturedPlayersResponse>> {
let players = sqlx::query_as::<_, FeaturedPlayerInfo>( let players = sqlx::query_as::<_, FeaturedPlayerInfo>(
r#" r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
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
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 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 LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false ORDER BY fp.rank ASC"#
ORDER BY fp.rank ASC ).fetch_all(&state.pool).await?;
"#
)
.fetch_all(&state.pool)
.await?;
Ok(Json(FeaturedPlayersResponse { players })) Ok(Json(FeaturedPlayersResponse { players }))
} }
/// POST /api/players/featured
/// Add/update featured player (admin only) /// 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( pub async fn set_featured_player(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -331,51 +317,38 @@ pub async fn set_featured_player(
return Err(AppError::Forbidden("Only admins can manage featured players".to_string())); 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")
let player_exists = sqlx::query_scalar::<_, i32>( .bind(payload.player_id).fetch_optional(&state.pool).await?;
"SELECT id FROM users WHERE id = $1"
)
.bind(payload.player_id)
.fetch_optional(&state.pool)
.await?;
if player_exists.is_none() { if player_exists.is_none() {
return Err(AppError::NotFound("Player not found".to_string())); return Err(AppError::NotFound("Player not found".to_string()));
} }
// Upsert featured player sqlx::query("INSERT INTO featured_players (player_id, rank) VALUES ($1, $2) ON CONFLICT (player_id) DO UPDATE SET rank = $2")
sqlx::query( .bind(payload.player_id).bind(payload.rank).execute(&state.pool).await?;
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?;
// Fetch the full featured player info
let featured = sqlx::query_as::<_, FeaturedPlayerInfo>( let featured = sqlx::query_as::<_, FeaturedPlayerInfo>(
r#" r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
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
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 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 LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false WHERE fp.player_id = $1"#
WHERE fp.player_id = $1 ).bind(payload.player_id).fetch_one(&state.pool).await?;
"#
)
.bind(payload.player_id)
.fetch_one(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(featured))) Ok((StatusCode::CREATED, Json(featured)))
} }
/// DELETE /api/players/featured/:player_id
/// Remove featured player (admin only) /// 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( pub async fn remove_featured_player(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -386,9 +359,7 @@ pub async fn remove_featured_player(
} }
let result = sqlx::query("DELETE FROM featured_players WHERE player_id = $1") let result = sqlx::query("DELETE FROM featured_players WHERE player_id = $1")
.bind(player_id) .bind(player_id).execute(&state.pool).await?;
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(AppError::NotFound("Featured player not found".to_string())); return Err(AppError::NotFound("Featured player not found".to_string()));
@@ -397,8 +368,19 @@ pub async fn remove_featured_player(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// GET /api/players/leaderboard
/// Get player leaderboard (by match wins) /// Get player leaderboard (by match wins)
#[utoipa::path(
get,
path = "/api/players/leaderboard",
tag = "players",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page")
),
responses(
(status = 200, description = "Player leaderboard", body = LeaderboardResponse)
)
)]
pub async fn get_leaderboard( pub async fn get_leaderboard(
Query(query): Query<ListPlayersQuery>, Query(query): Query<ListPlayersQuery>,
State(state): State<AppState>, State(state): State<AppState>,
@@ -407,101 +389,76 @@ pub async fn get_leaderboard(
let per_page = query.per_page.unwrap_or(20).clamp(1, 100); let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * per_page; let offset = (page - 1) * per_page;
// Count total players with at least one match
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
r#" r#"SELECT COUNT(DISTINCT u.id) FROM users u
SELECT COUNT(DISTINCT u.id)
FROM users u
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1 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 teams t ON tp.team_id = t.id AND t.is_delete = false
JOIN ( JOIN (SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
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"#
UNION ALL ).fetch_one(&state.pool).await.unwrap_or(0);
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>( let players = sqlx::query_as::<_, LeaderboardEntry>(
r#" r#"WITH player_matches AS (
WITH player_matches AS ( SELECT u.id as player_id, u.username, u.firstname, u.lastname, u.profile,
SELECT t.name as team_name, t.id as team_id
u.id as player_id, FROM users u JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
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 JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
), ),
match_results AS ( match_results AS (
SELECT SELECT pm.player_id, COUNT(DISTINCT m.id) as matches_played,
pm.player_id, COUNT(DISTINCT CASE WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
COUNT(DISTINCT m.id) as matches_played, OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score) THEN m.id END) as matches_won
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 FROM player_matches pm
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id) 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
AND m.matche_status = 3 LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
GROUP BY pm.player_id GROUP BY pm.player_id
) )
SELECT SELECT ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
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,
pm.player_id, mr.matches_won, mr.matches_played
pm.username, FROM player_matches pm LEFT JOIN match_results mr ON pm.player_id = mr.player_id
pm.firstname, ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC LIMIT $1 OFFSET $2"#
pm.lastname, ).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
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 { Ok(Json(LeaderboardResponse { players, total, page, per_page }))
players, }
total,
page, /// Get a players rating history
per_page, #[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<RatingHistoryEntry>)
)
)]
pub async fn get_rating_history(
State(state): State<AppState>,
Path(player_id): Path<i32>,
) -> Result<Json<Vec<RatingHistoryEntry>>> {
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 // Helper Functions
// ============================================================================ // ============================================================================
async fn get_player_stats( async fn get_player_stats(pool: &sqlx::PgPool, player_id: i32, team_id: Option<i32>) -> Result<PlayerStats> {
pool: &sqlx::PgPool,
player_id: i32,
team_id: Option<i32>,
) -> Result<PlayerStats> {
// Get player's OpenSkill rating
let rating: (Option<f32>, Option<f32>, Option<f32>, Option<f32>) = sqlx::query_as( let rating: (Option<f32>, Option<f32>, Option<f32>, Option<f32>) = sqlx::query_as(
"SELECT mu, sigma, ordinal, mmr FROM users WHERE id = $1" "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 mu = rating.0.unwrap_or(25.0) as f64;
let sigma = rating.1.unwrap_or(8.333333) 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 mmr = rating.3.unwrap_or(1500.0) as f64;
let Some(team_id) = team_id else { let Some(team_id) = team_id else {
return Ok(PlayerStats { return Ok(PlayerStats { matches_played: 0, matches_won: 0, matches_lost: 0, win_rate: 0.0, ladders_participated: 0, mu, sigma, ordinal, mmr });
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( 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"
SELECT COUNT(*) FROM matches ).bind(team_id).fetch_one(pool).await.unwrap_or(0);
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( let matches_won: i64 = sqlx::query_scalar(
r#" 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
SELECT COUNT(DISTINCT m.id) FROM matches m WHERE m.matche_status = 3 AND ((m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score)
JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1 OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score))"#
WHERE m.matche_status = 3 ).bind(team_id).fetch_one(pool).await.unwrap_or(0);
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 matches_lost = matches_played - matches_won;
let win_rate = if matches_played > 0 { let win_rate = if matches_played > 0 { (matches_won as f32 / matches_played as f32) * 100.0 } else { 0.0 };
(matches_won as f32 / matches_played as f32) * 100.0 let ladders_participated: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1")
} else { .bind(team_id).fetch_one(pool).await.unwrap_or(0);
0.0
};
// Count ladders participated Ok(PlayerStats { matches_played, matches_won, matches_lost, win_rate, ladders_participated, mu, sigma, ordinal, mmr })
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,
})
} }
async fn get_recent_matches( async fn get_recent_matches(pool: &sqlx::PgPool, team_id: i32, limit: i64) -> Result<Vec<PlayerMatchSummary>> {
pool: &sqlx::PgPool,
team_id: i32,
limit: i64,
) -> Result<Vec<PlayerMatchSummary>> {
let matches = sqlx::query_as::<_, PlayerMatchSummary>( let matches = sqlx::query_as::<_, PlayerMatchSummary>(
r#" r#"SELECT m.id as match_id, m.date_start as date_played,
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 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 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_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 m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score,
CASE CASE WHEN mr.id IS NULL THEN 'pending'
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_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 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' WHEN mr.team_1_score = mr.team_2_score THEN 'draw' ELSE 'loss' END as result
ELSE 'loss' FROM matches m JOIN teams t1 ON m.team_id_1 = t1.id JOIN teams t2 ON m.team_id_2 = t2.id
END as result LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
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 WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3
ORDER BY m.date_start DESC ORDER BY m.date_start DESC LIMIT $2"#
LIMIT $2 ).bind(team_id).bind(limit).fetch_all(pool).await?;
"#
)
.bind(team_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(matches) 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<AppState>,
Path(player_id): Path<i32>,
) -> Result<Json<Vec<RatingHistoryEntry>>> {
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<chrono::Utc>,
pub match_id: Option<i32>,
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<String>,
}

View File

@@ -5,6 +5,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
use crate::{ use crate::{
@@ -20,14 +21,14 @@ use super::auth::AppState;
// Request/Response Types // Request/Response Types
// ============================================================================ // ============================================================================
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListTeamsQuery { pub struct ListTeamsQuery {
pub search: Option<String>, pub search: Option<String>,
pub page: Option<i64>, pub page: Option<i64>,
pub per_page: Option<i64>, pub per_page: Option<i64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct TeamListResponse { pub struct TeamListResponse {
pub teams: Vec<TeamWithMemberCount>, pub teams: Vec<TeamWithMemberCount>,
pub total: i64, pub total: i64,
@@ -35,7 +36,7 @@ pub struct TeamListResponse {
pub per_page: i64, pub per_page: i64,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct TeamWithMemberCount { pub struct TeamWithMemberCount {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@@ -47,21 +48,21 @@ pub struct TeamWithMemberCount {
pub member_count: Option<i64>, pub member_count: Option<i64>,
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateTeamRequest { pub struct CreateTeamRequest {
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
pub name: String, pub name: String,
pub bio: Option<String>, pub bio: Option<String>,
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateTeamRequest { pub struct UpdateTeamRequest {
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
pub name: Option<String>, pub name: Option<String>,
pub bio: Option<String>, pub bio: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct TeamResponse { pub struct TeamResponse {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
@@ -92,7 +93,7 @@ impl From<Team> for TeamResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct TeamDetailResponse { pub struct TeamDetailResponse {
pub team: TeamResponse, pub team: TeamResponse,
pub members: Vec<TeamMemberResponse>, pub members: Vec<TeamMemberResponse>,
@@ -101,7 +102,7 @@ pub struct TeamDetailResponse {
pub pending_request: bool, pub pending_request: bool,
} }
#[derive(Debug, Serialize, FromRow)] #[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct TeamMemberResponse { pub struct TeamMemberResponse {
pub id: i32, pub id: i32,
pub player_id: i32, pub player_id: i32,
@@ -114,12 +115,12 @@ pub struct TeamMemberResponse {
pub date_joined: chrono::DateTime<chrono::Utc>, pub date_joined: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, ToSchema)]
pub struct ChangePositionRequest { pub struct ChangePositionRequest {
pub position: String, pub position: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct MyTeamResponse { pub struct MyTeamResponse {
pub team: Option<TeamResponse>, pub team: Option<TeamResponse>,
pub position: Option<String>, pub position: Option<String>,
@@ -127,12 +128,29 @@ pub struct MyTeamResponse {
pub pending_requests: Vec<TeamMemberResponse>, pub pending_requests: Vec<TeamMemberResponse>,
} }
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageResponse {
pub message: String,
}
// ============================================================================ // ============================================================================
// Handlers // Handlers
// ============================================================================ // ============================================================================
/// GET /api/teams
/// List all teams with pagination and search /// List all teams with pagination and search
#[utoipa::path(
get,
path = "/api/teams",
tag = "teams",
params(
("search" = Option<String>, Query, description = "Search by team name"),
("page" = Option<i64>, Query, description = "Page number (default: 1)"),
("per_page" = Option<i64>, Query, description = "Items per page (default: 20, max: 100)")
),
responses(
(status = 200, description = "List of teams", body = TeamListResponse)
)
)]
pub async fn list_teams( pub async fn list_teams(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<ListTeamsQuery>, Query(query): Query<ListTeamsQuery>,
@@ -143,35 +161,24 @@ pub async fn list_teams(
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s)); let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
// Get total count
let total: i64 = if let Some(ref pattern) = search_pattern { let total: i64 = if let Some(ref pattern) = search_pattern {
sqlx::query_scalar( sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1")
"SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1"
)
.bind(pattern) .bind(pattern)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await? .await?
} else { } else {
sqlx::query_scalar( sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false")
"SELECT COUNT(*) FROM teams WHERE is_delete = false"
)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await? .await?
}; };
// Get teams with member count
let teams: Vec<TeamWithMemberCount> = if let Some(ref pattern) = search_pattern { let teams: Vec<TeamWithMemberCount> = if let Some(ref pattern) = search_pattern {
sqlx::query_as( sqlx::query_as(
r#" r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
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 COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
FROM teams t FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id
LEFT JOIN team_players tp ON t.id = tp.team_id
WHERE t.is_delete = false AND t.name ILIKE $1 WHERE t.is_delete = false AND t.name ILIKE $1
GROUP BY t.id GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $2 OFFSET $3"#
ORDER BY t.rank ASC, t.mmr DESC
LIMIT $2 OFFSET $3
"#
) )
.bind(pattern) .bind(pattern)
.bind(per_page) .bind(per_page)
@@ -180,16 +187,11 @@ pub async fn list_teams(
.await? .await?
} else { } else {
sqlx::query_as( sqlx::query_as(
r#" r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
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 COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
FROM teams t FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id
LEFT JOIN team_players tp ON t.id = tp.team_id
WHERE t.is_delete = false WHERE t.is_delete = false
GROUP BY t.id GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $1 OFFSET $2"#
ORDER BY t.rank ASC, t.mmr DESC
LIMIT $1 OFFSET $2
"#
) )
.bind(per_page) .bind(per_page)
.bind(offset) .bind(offset)
@@ -197,16 +199,21 @@ pub async fn list_teams(
.await? .await?
}; };
Ok(Json(TeamListResponse { Ok(Json(TeamListResponse { teams, total, page, per_page }))
teams,
total,
page,
per_page,
}))
} }
/// POST /api/teams
/// Create a new team (creator becomes captain) /// 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( pub async fn create_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
@@ -214,7 +221,6 @@ pub async fn create_team(
) -> Result<(StatusCode, Json<TeamDetailResponse>)> { ) -> Result<(StatusCode, Json<TeamDetailResponse>)> {
payload.validate()?; payload.validate()?;
// Check if user is already in a team
let existing_membership = sqlx::query_scalar::<_, i32>( let existing_membership = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE player_id = $1 AND status = 1" "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())); 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>( let existing_team = sqlx::query_scalar::<_, i32>(
"SELECT id FROM teams WHERE name = $1 AND is_delete = false" "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())); return Err(AppError::Conflict("Team name already exists".to_string()));
} }
// Create team (logo uses default empty string)
let team = sqlx::query_as::<_, Team>( 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.name)
.bind(&payload.bio) .bind(&payload.bio)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
// Add creator as captain
sqlx::query( sqlx::query(
r#" "INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'captain', 1, '', '')"
INSERT INTO team_players (team_id, player_id, position, status)
VALUES ($1, $2, 'captain', 1)
"#
) )
.bind(team.id) .bind(team.id)
.bind(user.id) .bind(user.id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Get members (just the captain)
let members = get_team_members(&state.pool, team.id).await?; let members = get_team_members(&state.pool, team.id).await?;
Ok(( Ok((StatusCode::CREATED, Json(TeamDetailResponse {
StatusCode::CREATED,
Json(TeamDetailResponse {
team: team.into(), team: team.into(),
members, members,
is_member: true, is_member: true,
is_captain: true, is_captain: true,
pending_request: false, pending_request: false,
}), })))
))
} }
/// GET /api/teams/:id /// Get team details by ID
/// Get team details with members #[utoipa::path(
pub async fn get_team( get,
OptionalAuthUser(user): OptionalAuthUser, path = "/api/teams/{id}",
State(state): State<AppState>, tag = "teams",
Path(team_id): Path<i32>, params(("id" = i32, Path, description = "Team ID")),
) -> Result<Json<TeamDetailResponse>> { responses(
let team = sqlx::query_as::<_, Team>( (status = 200, description = "Team details", body = TeamDetailResponse),
"SELECT * FROM teams WHERE id = $1 AND is_delete = false" (status = 404, description = "Team not found")
) )
.bind(team_id) )]
pub async fn get_team(
user: OptionalAuthUser,
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<TeamDetailResponse>> {
let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false")
.bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
let members = get_team_members(&state.pool, team_id).await?; 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 auth_user) = user.0 {
let (is_member, is_captain, pending_request) = if let Some(ref u) = user {
let membership = sqlx::query_as::<_, TeamPlayer>( let membership = sqlx::query_as::<_, TeamPlayer>(
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2" "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2"
) )
.bind(team_id) .bind(team.id)
.bind(u.id) .bind(auth_user.id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?;
match membership { match membership {
Some(m) => ( Some(m) => (m.status == 1, m.position == "captain", m.status == 0),
m.status == 1,
m.status == 1 && m.position == "captain",
m.status == 0,
),
None => (false, false, false), None => (false, false, false),
} }
} else { } else {
@@ -326,166 +320,138 @@ pub async fn get_team(
})) }))
} }
/// PUT /api/teams/:id /// Update team details (captain only)
/// Update team (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( pub async fn update_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(id): Path<i32>,
Json(payload): Json<UpdateTeamRequest>, Json(payload): Json<UpdateTeamRequest>,
) -> Result<Json<TeamResponse>> { ) -> Result<Json<TeamResponse>> {
payload.validate()?; payload.validate()?;
// Check if user is captain let _membership = verify_captain(&state.pool, id, user.id).await?;
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 team = sqlx::query_as::<_, Team>( let team = sqlx::query_as::<_, Team>(
r#" "UPDATE teams SET name = COALESCE($1, name), bio = COALESCE($2, bio) WHERE id = $3 RETURNING *"
UPDATE teams
SET name = COALESCE($1, name),
bio = COALESCE($2, bio)
WHERE id = $3 AND is_delete = false
RETURNING *
"#
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&payload.bio) .bind(&payload.bio)
.bind(team_id) .bind(id)
.fetch_optional(&state.pool) .fetch_one(&state.pool)
.await? .await?;
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
Ok(Json(team.into())) Ok(Json(team.into()))
} }
/// DELETE /api/teams/:id
/// Delete team (captain only) /// 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( pub async fn delete_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
// Check if user is captain let _membership = verify_captain(&state.pool, id, user.id).await?;
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()));
}
// Soft delete team sqlx::query("UPDATE teams SET is_delete = true WHERE id = $1")
let result = sqlx::query( .bind(id)
"UPDATE teams SET is_delete = true WHERE id = $1"
)
.bind(team_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .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") sqlx::query("DELETE FROM team_players WHERE team_id = $1")
.bind(team_id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// Remove from ladders Ok(Json(MessageResponse { message: "Team deleted successfully".to_string() }))
sqlx::query("DELETE FROM ladder_teams WHERE team_id = $1")
.bind(team_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
} }
/// POST /api/teams/:id/join-request
/// Request to join a team /// 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( pub async fn request_join_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
// Check team exists let _team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false")
let team_exists = sqlx::query_scalar::<_, i32>( .bind(id)
"SELECT id FROM teams WHERE id = $1 AND is_delete = false"
)
.bind(team_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
if team_exists.is_none() { let existing = sqlx::query_scalar::<_, i32>(
return Err(AppError::NotFound("Team not found".to_string())); "SELECT id FROM team_players WHERE player_id = $1 AND (status = 1 OR (team_id = $2 AND status = 0))"
}
// 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) .bind(user.id)
.bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?;
if existing_active.is_some() { if existing.is_some() {
return Err(AppError::Conflict("You are already a member of a team".to_string())); return Err(AppError::Conflict("Already in a team or request pending".to_string()));
} }
// Check if already requested sqlx::query("INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'member', 0, '', '')")
let existing_request = sqlx::query_scalar::<_, i32>( .bind(id)
"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) .bind(user.id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
Ok(StatusCode::CREATED) Ok(Json(MessageResponse { message: "Join request sent".to_string() }))
} }
/// DELETE /api/teams/:id/join-request
/// Cancel 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( pub async fn cancel_join_request(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
let result = sqlx::query( let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0")
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0" .bind(id)
)
.bind(team_id)
.bind(user.id) .bind(user.id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
@@ -494,146 +460,165 @@ pub async fn cancel_join_request(
return Err(AppError::NotFound("No pending request found".to_string())); 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) /// 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( pub async fn accept_member(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>, Path((id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
// Check if user is captain let _membership = verify_captain(&state.pool, id, user.id).await?;
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()));
}
// 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")
let result = sqlx::query( .bind(id)
"UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0"
)
.bind(team_id)
.bind(player_id) .bind(player_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
if result.rows_affected() == 0 { 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) /// 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( pub async fn reject_member(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>, Path((id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
// Check if user is captain let _membership = verify_captain(&state.pool, id, user.id).await?;
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()));
}
let result = sqlx::query( let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0")
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0" .bind(id)
)
.bind(team_id)
.bind(player_id) .bind(player_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
if result.rows_affected() == 0 { 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 from team (captain only)
/// Remove a member or leave team #[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( pub async fn remove_member(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>, Path((id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?; let _membership = verify_captain(&state.pool, id, user.id).await?;
let is_self = user.id == player_id;
// Check permission let target = sqlx::query_as::<_, TeamPlayer>(
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>(
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1" "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1"
) )
.bind(team_id) .bind(id)
.bind(player_id) .bind(player_id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("Member not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Member not found".to_string()))?;
// Captain can't leave without transferring or deleting team if target.position == "captain" {
if target_membership.position == "captain" && is_self { return Err(AppError::Forbidden("Cannot remove captain".to_string()));
return Err(AppError::BadRequest(
"Captain cannot leave. Transfer captaincy or delete the team.".to_string()
));
} }
// Remove member sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2")
sqlx::query( .bind(id)
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
.bind(player_id) .bind(player_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .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) /// 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( pub async fn change_member_position(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>, Path((id, player_id)): Path<(i32, i32)>,
Json(payload): Json<ChangePositionRequest>, Json(payload): Json<ChangePositionRequest>,
) -> Result<StatusCode> { ) -> Result<Json<MessageResponse>> {
// Validate position let _membership = verify_captain(&state.pool, id, user.id).await?;
let position = PlayerPosition::from_str(&payload.position) let position = PlayerPosition::from_str(&payload.position)
.ok_or_else(|| AppError::BadRequest("Invalid position. Use: captain, co-captain, or member".to_string()))?; .ok_or_else(|| AppError::BadRequest("Invalid position".to_string()))?;
// 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()));
}
// If promoting to captain, demote current captain
if position == PlayerPosition::Captain && player_id != user.id { if position == PlayerPosition::Captain && player_id != user.id {
// Demote current captain to co-captain sqlx::query("UPDATE team_players SET position = 'member' WHERE team_id = $1 AND player_id = $2")
sqlx::query( .bind(id)
"UPDATE team_players SET position = 'co-captain' WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
.bind(user.id) .bind(user.id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
} }
// Update position let result = sqlx::query("UPDATE team_players SET position = $1 WHERE team_id = $2 AND player_id = $3 AND status = 1")
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(position.as_str())
.bind(team_id) .bind(id)
.bind(player_id) .bind(player_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
@@ -642,16 +627,23 @@ pub async fn change_member_position(
return Err(AppError::NotFound("Member not found".to_string())); 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 /// 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( pub async fn get_my_team(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<MyTeamResponse>> { ) -> Result<Json<MyTeamResponse>> {
// Get user's team membership
let membership = sqlx::query_as::<_, TeamPlayer>( let membership = sqlx::query_as::<_, TeamPlayer>(
"SELECT * FROM team_players WHERE player_id = $1 AND status = 1" "SELECT * FROM team_players WHERE player_id = $1 AND status = 1"
) )
@@ -659,39 +651,23 @@ pub async fn get_my_team(
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await?; .await?;
let Some(membership) = membership else { match membership {
return Ok(Json(MyTeamResponse { Some(m) => {
team: None, let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1")
position: None, .bind(m.team_id)
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) .fetch_one(&state.pool)
.await?; .await?;
// Get members let members = get_team_members(&state.pool, team.id).await?;
let members = get_team_members(&state.pool, membership.team_id).await?;
// Get pending requests (if captain) let pending_requests = if m.position == "captain" || m.position == "co-captain" {
let pending_requests = if membership.position == "captain" {
sqlx::query_as::<_, TeamMemberResponse>( sqlx::query_as::<_, TeamMemberResponse>(
r#" r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
tp.position, tp.status, tp.date_created as date_joined tp.position, tp.status, tp.date_created as date_joined
FROM team_players tp FROM team_players tp JOIN users u ON tp.player_id = u.id
JOIN users u ON tp.player_id = u.id WHERE tp.team_id = $1 AND tp.status = 0"#
WHERE tp.team_id = $1 AND tp.status = 0
ORDER BY tp.date_created ASC
"#
) )
.bind(membership.team_id) .bind(team.id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await? .await?
} else { } else {
@@ -700,11 +676,19 @@ pub async fn get_my_team(
Ok(Json(MyTeamResponse { Ok(Json(MyTeamResponse {
team: Some(team.into()), team: Some(team.into()),
position: Some(membership.position), position: Some(m.position),
members, members,
pending_requests, pending_requests,
})) }))
} }
None => Ok(Json(MyTeamResponse {
team: None,
position: None,
members: vec![],
pending_requests: vec![],
}))
}
}
// ============================================================================ // ============================================================================
// Helper Functions // Helper Functions
@@ -712,20 +696,10 @@ pub async fn get_my_team(
async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result<Vec<TeamMemberResponse>> { async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result<Vec<TeamMemberResponse>> {
let members = sqlx::query_as::<_, TeamMemberResponse>( let members = sqlx::query_as::<_, TeamMemberResponse>(
r#" r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
tp.position, tp.status, tp.date_created as date_joined tp.position, tp.status, tp.date_created as date_joined
FROM team_players tp FROM team_players tp JOIN users u ON tp.player_id = u.id
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"#
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
"#
) )
.bind(team_id) .bind(team_id)
.fetch_all(pool) .fetch_all(pool)
@@ -734,18 +708,19 @@ async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result<Vec<TeamM
Ok(members) Ok(members)
} }
async fn check_is_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result<bool> { async fn verify_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result<TeamPlayer> {
let is_captain = sqlx::query_scalar::<_, i32>( let membership = sqlx::query_as::<_, TeamPlayer>(
"SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1" "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1"
) )
.bind(team_id) .bind(team_id)
.bind(user_id) .bind(user_id)
.fetch_optional(pool) .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

View File

@@ -6,6 +6,7 @@ use axum::{
Json, Json,
}; };
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema;
use crate::{ use crate::{
auth::AuthUser, auth::AuthUser,
@@ -16,199 +17,164 @@ use crate::{
use super::auth::AppState; use super::auth::AppState;
/// Upload response /// Upload response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct UploadResponse { pub struct UploadResponse {
pub url: String, pub url: String,
pub message: String, pub message: String,
} }
/// POST /api/teams/:id/logo
/// Upload team logo (captain only) /// 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( pub async fn upload_team_logo(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(team_id): Path<i32>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<UploadResponse>> { ) -> Result<Json<UploadResponse>> {
// Verify user is captain of this team
let is_captain: bool = sqlx::query_scalar( 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)" "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 { if !is_captain && !user.is_admin {
return Err(AppError::Forbidden("Only team captain can upload logo".to_string())); 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?; 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") sqlx::query("UPDATE teams SET logo = $1 WHERE id = $2")
.bind(&url) .bind(&url).bind(team_id).execute(&state.pool).await?;
.bind(team_id)
.execute(&state.pool)
.await?;
Ok(Json(UploadResponse { Ok(Json(UploadResponse { url, message: "Team logo uploaded successfully".to_string() }))
url,
message: "Team logo uploaded successfully".to_string(),
}))
} }
/// POST /api/users/me/profile
/// Upload user profile picture /// 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( pub async fn upload_profile_picture(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<UploadResponse>> { ) -> Result<Json<UploadResponse>> {
// Process multipart form
let (file_data, filename, content_type) = extract_file_from_multipart(&mut multipart).await?; 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") sqlx::query("UPDATE users SET profile = $1 WHERE id = $2")
.bind(&url) .bind(&url).bind(user.id).execute(&state.pool).await?;
.bind(user.id)
.execute(&state.pool)
.await?;
Ok(Json(UploadResponse { Ok(Json(UploadResponse { url, message: "Profile picture uploaded successfully".to_string() }))
url,
message: "Profile picture uploaded successfully".to_string(),
}))
} }
/// POST /api/ladders/:id/logo
/// Upload ladder logo (admin only) /// 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( pub async fn upload_ladder_logo(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(ladder_id): Path<i32>, Path(ladder_id): Path<i32>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<UploadResponse>> { ) -> Result<Json<UploadResponse>> {
// Only admins can upload ladder logos
if !user.is_admin { if !user.is_admin {
return Err(AppError::Forbidden("Only admins can upload ladder logos".to_string())); 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)") let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM ladders WHERE id = $1)")
.bind(ladder_id) .bind(ladder_id).fetch_one(&state.pool).await?;
.fetch_one(&state.pool)
.await?;
if !exists { if !exists {
return Err(AppError::NotFound("Ladder not found".to_string())); 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?; 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") sqlx::query("UPDATE ladders SET logo = $1 WHERE id = $2")
.bind(&url) .bind(&url).bind(ladder_id).execute(&state.pool).await?;
.bind(ladder_id)
.execute(&state.pool)
.await?;
Ok(Json(UploadResponse { Ok(Json(UploadResponse { url, message: "Ladder logo uploaded successfully".to_string() }))
url,
message: "Ladder logo uploaded successfully".to_string(),
}))
} }
/// DELETE /api/teams/:id/logo
/// Remove team logo (captain only) /// 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( pub async fn delete_team_logo(
user: AuthUser, user: AuthUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(team_id): Path<i32>, Path(team_id): Path<i32>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
// Verify user is captain of this team
let is_captain: bool = sqlx::query_scalar( 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)" "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 { if !is_captain && !user.is_admin {
return Err(AppError::Forbidden("Only team captain can delete logo".to_string())); return Err(AppError::Forbidden("Only team captain can delete logo".to_string()));
} }
// Get current logo URL
let logo_url: Option<String> = sqlx::query_scalar("SELECT logo FROM teams WHERE id = $1") let logo_url: Option<String> = sqlx::query_scalar("SELECT logo FROM teams WHERE id = $1")
.bind(team_id) .bind(team_id).fetch_one(&state.pool).await?;
.fetch_one(&state.pool)
.await?;
// Delete from storage if exists
if let Some(url) = logo_url { if let Some(url) = logo_url {
if !url.is_empty() { if !url.is_empty() { let _ = state.storage.delete_file(&url).await; }
let _ = state.storage.delete_file(&url).await; // Ignore errors
}
} }
// Clear logo in database
sqlx::query("UPDATE teams SET logo = '' WHERE id = $1") sqlx::query("UPDATE teams SET logo = '' WHERE id = $1")
.bind(team_id) .bind(team_id).execute(&state.pool).await?;
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Helper function to extract file from multipart form async fn extract_file_from_multipart(multipart: &mut Multipart) -> Result<(Vec<u8>, String, String)> {
async fn extract_file_from_multipart(
multipart: &mut Multipart,
) -> Result<(Vec<u8>, String, String)> {
while let Some(field) = multipart.next_field().await.map_err(|e| { while let Some(field) = multipart.next_field().await.map_err(|e| {
AppError::BadRequest(format!("Failed to read multipart form: {}", e)) AppError::BadRequest(format!("Failed to read multipart form: {}", e))
})? { })? {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
if name == "file" || name == "logo" || name == "profile" || name == "image" { if name == "file" || name == "logo" || name == "profile" || name == "image" {
let filename = field.file_name() let filename = field.file_name().unwrap_or("upload.jpg").to_string();
.unwrap_or("upload.jpg") let content_type = field.content_type().unwrap_or("image/jpeg").to_string();
.to_string();
let content_type = field.content_type()
.unwrap_or("image/jpeg")
.to_string();
let data = field.bytes().await.map_err(|e| { let data = field.bytes().await.map_err(|e| {
AppError::BadRequest(format!("Failed to read file data: {}", e)) AppError::BadRequest(format!("Failed to read file data: {}", e))
})?; })?;
return Ok((data.to_vec(), filename, content_type)); return Ok((data.to_vec(), filename, content_type));
} }
} }

View File

@@ -32,7 +32,7 @@ pub struct MatchRound {
pub team_1_score: i32, pub team_1_score: i32,
pub team_2_score: i32, pub team_2_score: i32,
pub score_posted_by_team_id: i32, pub score_posted_by_team_id: i32,
pub score_acceptance_status: i32, pub score_confirmation_status: i32,
} }
/// Submit score request /// Submit score request

View File

@@ -1,21 +1,30 @@
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{ use crate::handlers::{
handlers::{
auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse}, auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse},
health::HealthResponse, health::HealthResponse,
users::UserProfileResponse, users::UserProfileResponse,
teams::{
TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
MyTeamResponse, MessageResponse,
}, },
models::{ matches::{
featured_player::{FeaturedPlayer, FeaturedPlayerWithUser}, ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
ladder::{CreateLadder, Ladder, LadderStatus, UpdateLadder}, MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
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},
}, },
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)] #[derive(OpenApi)]
@@ -24,13 +33,8 @@ use crate::{
title = "VRBattles API", title = "VRBattles API",
version = "1.0.0", version = "1.0.0",
description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.", description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.",
contact( contact(name = "VRBattles Team", url = "https://vrbattles.gg"),
name = "VRBattles Team", license(name = "Proprietary")
url = "https://vrbattles.gg"
),
license(
name = "Proprietary"
)
), ),
servers( servers(
(url = "https://api.vrb.gg", description = "Production server"), (url = "https://api.vrb.gg", description = "Production server"),
@@ -54,55 +58,76 @@ use crate::{
crate::handlers::auth::register, crate::handlers::auth::register,
// Users // Users
crate::handlers::users::get_current_user, 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( components(
schemas( schemas(
// Health
HealthResponse,
// Auth // Auth
LoginRequest, HealthResponse, LoginRequest, RegisterRequest, AuthResponse, UserResponse, UserProfileResponse,
RegisterRequest, // Teams
AuthResponse, TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
UserResponse, TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
UserProfileResponse, MyTeamResponse, MessageResponse,
// User // Matches
User, ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
UserProfile, MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
CreateUser, // Ladders
UpdateUser, ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest,
PlayerStats, UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse,
// Team EnrollmentResponse,
Team, // Players
TeamWithStats, ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfile,
CreateTeam, PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse,
UpdateTeam, FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry,
TeamMember, RatingHistoryEntry,
// Team Player // Uploads
TeamPlayer, UploadResponse,
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,
) )
), ),
modifiers(&SecurityAddon) modifiers(&SecurityAddon)
@@ -113,16 +138,12 @@ struct SecurityAddon;
impl utoipa::Modify for SecurityAddon { impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() { let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme( components.add_security_scheme(
"bearer_auth", "bearer_auth",
utoipa::openapi::security::SecurityScheme::Http( utoipa::openapi::security::SecurityScheme::Http(
utoipa::openapi::security::Http::new( utoipa::openapi::security::Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)
utoipa::openapi::security::HttpAuthScheme::Bearer,
)
.bearer_format("JWT"),
), ),
); );
} }
} }
}