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(),
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
player_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
game_name TEXT NOT NULL DEFAULT '',
game_mode TEXT NOT NULL DEFAULT '',
position VARCHAR(20) NOT NULL DEFAULT 'member',
status INTEGER NOT NULL DEFAULT 0,
UNIQUE(team_id, player_id)
@@ -72,12 +70,6 @@ CREATE TABLE IF NOT EXISTS ladder_teams (
date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ladder_id INTEGER NOT NULL REFERENCES ladders(id) ON DELETE CASCADE,
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(255) NOT NULL DEFAULT 'active',
seed INTEGER,
result_position INTEGER,
win_count INTEGER NOT NULL DEFAULT 0,
loss_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(ladder_id, team_id)
);

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use utoipa::ToSchema;
use crate::{
auth::AuthUser,
@@ -17,16 +18,16 @@ use super::auth::AppState;
// Request/Response Types
// ============================================================================
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListPlayersQuery {
pub search: Option<String>,
pub page: Option<i64>,
pub per_page: Option<i64>,
pub sort_by: Option<String>, // "mmr", "username", "date_registered"
pub sort_order: Option<String>, // "asc", "desc"
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct PlayerListResponse {
pub players: Vec<PlayerSummary>,
pub total: i64,
@@ -34,7 +35,7 @@ pub struct PlayerListResponse {
pub per_page: i64,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct PlayerSummary {
pub id: i32,
pub username: String,
@@ -47,7 +48,7 @@ pub struct PlayerSummary {
pub team_position: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct PlayerProfileResponse {
pub id: i32,
pub username: String,
@@ -65,7 +66,7 @@ pub struct PlayerProfileResponse {
pub recent_matches: Vec<PlayerMatchSummary>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct PlayerTeamInfo {
pub id: i32,
pub name: String,
@@ -74,21 +75,20 @@ pub struct PlayerTeamInfo {
pub date_joined: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct PlayerStats {
pub matches_played: i64,
pub matches_won: i64,
pub matches_lost: i64,
pub win_rate: f32,
pub ladders_participated: i64,
// OpenSkill rating stats
pub mu: f64,
pub sigma: f64,
pub ordinal: f64,
pub mmr: f64,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct PlayerMatchSummary {
pub match_id: i32,
pub date_played: chrono::DateTime<chrono::Utc>,
@@ -96,15 +96,15 @@ pub struct PlayerMatchSummary {
pub own_team_name: String,
pub own_score: i32,
pub opponent_score: i32,
pub result: String, // "win", "loss", "draw"
pub result: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct FeaturedPlayersResponse {
pub players: Vec<FeaturedPlayerInfo>,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct FeaturedPlayerInfo {
pub id: i32,
pub player_id: i32,
@@ -116,13 +116,13 @@ pub struct FeaturedPlayerInfo {
pub team_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetFeaturedPlayerRequest {
pub player_id: i32,
pub rank: i32,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct LeaderboardResponse {
pub players: Vec<LeaderboardEntry>,
pub total: i64,
@@ -130,7 +130,7 @@ pub struct LeaderboardResponse {
pub per_page: i64,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct LeaderboardEntry {
pub rank: i64,
pub player_id: i32,
@@ -143,12 +143,40 @@ pub struct LeaderboardEntry {
pub matches_played: Option<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
// ============================================================================
/// GET /api/players
/// 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(
Query(query): Query<ListPlayersQuery>,
State(state): State<AppState>,
@@ -158,8 +186,6 @@ pub async fn list_players(
let offset = (page - 1) * per_page;
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
// Determine sort order
let sort_column = match query.sort_by.as_deref() {
Some("username") => "u.username",
Some("date_registered") => "u.date_registered",
@@ -173,155 +199,115 @@ pub async fn list_players(
let (total, players): (i64, Vec<PlayerSummary>) = if let Some(ref pattern) = search_pattern {
let total = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE username ILIKE $1 OR firstname ILIKE $1 OR lastname ILIKE $1"
)
.bind(pattern)
.fetch_one(&state.pool)
.await?;
).bind(pattern).fetch_one(&state.pool).await?;
let query_str = format!(
r#"
SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
tp.team_id, t.name as team_name, tp.position as team_position
FROM users u
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1
ORDER BY {} {}
LIMIT $2 OFFSET $3
"#,
ORDER BY {} {} LIMIT $2 OFFSET $3"#,
sort_column, sort_order
);
let players = sqlx::query_as(&query_str)
.bind(pattern)
.bind(per_page)
.bind(offset)
.fetch_all(&state.pool)
.await?;
let players = sqlx::query_as(&query_str).bind(pattern).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
(total, players)
} else {
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(&state.pool)
.await?;
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.pool).await?;
let query_str = format!(
r#"
SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
tp.team_id, t.name as team_name, tp.position as team_position
FROM users u
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
ORDER BY {} {}
LIMIT $1 OFFSET $2
"#,
ORDER BY {} {} LIMIT $1 OFFSET $2"#,
sort_column, sort_order
);
let players = sqlx::query_as(&query_str)
.bind(per_page)
.bind(offset)
.fetch_all(&state.pool)
.await?;
let players = sqlx::query_as(&query_str).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
(total, players)
};
Ok(Json(PlayerListResponse {
players,
total,
page,
per_page,
}))
Ok(Json(PlayerListResponse { players, total, page, per_page }))
}
/// GET /api/players/:id
/// Get player profile with stats
#[utoipa::path(
get,
path = "/api/players/{id}",
tag = "players",
params(("id" = i32, Path, description = "Player ID")),
responses(
(status = 200, description = "Player profile", body = PlayerProfileResponse),
(status = 404, description = "Player not found")
)
)]
pub async fn get_player(
State(state): State<AppState>,
Path(player_id): Path<i32>,
) -> 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>)>(
"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()))?;
// Get team info
let team_info = sqlx::query_as::<_, (i32, String, String, String, chrono::DateTime<chrono::Utc>)>(
r#"
SELECT t.id, t.name, t.logo, tp.position, tp.date_created
FROM team_players tp
JOIN teams t ON tp.team_id = t.id
WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false
"#
)
.bind(player_id)
.fetch_optional(&state.pool)
.await?;
r#"SELECT t.id, t.name, t.logo, tp.position, tp.date_created
FROM team_players tp JOIN teams t ON tp.team_id = t.id
WHERE tp.player_id = $1 AND tp.status = 1 AND t.is_delete = false"#
).bind(player_id).fetch_optional(&state.pool).await?;
let team = team_info.map(|(id, name, logo, position, date_joined)| PlayerTeamInfo {
id,
name,
logo,
position,
date_joined,
id, name, logo, position, date_joined,
});
// Get match stats
let stats = get_player_stats(&state.pool, player_id, team.as_ref().map(|t| t.id)).await?;
// Get recent matches
let recent_matches = if let Some(ref t) = team {
get_recent_matches(&state.pool, t.id, 5).await?
} else {
vec![]
};
} else { vec![] };
Ok(Json(PlayerProfileResponse {
id: player.0,
username: player.1,
firstname: player.2,
lastname: player.3,
profile: player.4,
date_registered: player.5,
is_admin: player.6,
mu: player.7.unwrap_or(25.0),
sigma: player.8.unwrap_or(8.333),
ordinal: player.9.unwrap_or(0.0),
mmr: player.10.unwrap_or(1000.0),
team,
stats,
recent_matches,
id: player.0, username: player.1, firstname: player.2, lastname: player.3,
profile: player.4, date_registered: player.5, is_admin: player.6,
mu: player.7.unwrap_or(25.0), sigma: player.8.unwrap_or(8.333),
ordinal: player.9.unwrap_or(0.0), mmr: player.10.unwrap_or(1000.0),
team, stats, recent_matches,
}))
}
/// GET /api/players/featured
/// Get featured players
#[utoipa::path(
get,
path = "/api/players/featured",
tag = "players",
responses(
(status = 200, description = "Featured players", body = FeaturedPlayersResponse)
)
)]
pub async fn get_featured_players(
State(state): State<AppState>,
) -> Result<Json<FeaturedPlayersResponse>> {
let players = sqlx::query_as::<_, FeaturedPlayerInfo>(
r#"
SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
FROM featured_players fp
JOIN users u ON fp.player_id = u.id
r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
FROM featured_players fp JOIN users u ON fp.player_id = u.id
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
ORDER BY fp.rank ASC
"#
)
.fetch_all(&state.pool)
.await?;
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false ORDER BY fp.rank ASC"#
).fetch_all(&state.pool).await?;
Ok(Json(FeaturedPlayersResponse { players }))
}
/// POST /api/players/featured
/// Add/update featured player (admin only)
#[utoipa::path(
post,
path = "/api/players/featured",
tag = "players",
request_body = SetFeaturedPlayerRequest,
responses(
(status = 201, description = "Featured player set", body = FeaturedPlayerInfo),
(status = 403, description = "Admin only"),
(status = 404, description = "Player not found")
),
security(("bearer_auth" = []))
)]
pub async fn set_featured_player(
user: AuthUser,
State(state): State<AppState>,
@@ -331,51 +317,38 @@ pub async fn set_featured_player(
return Err(AppError::Forbidden("Only admins can manage featured players".to_string()));
}
// Verify player exists
let player_exists = sqlx::query_scalar::<_, i32>(
"SELECT id FROM users WHERE id = $1"
)
.bind(payload.player_id)
.fetch_optional(&state.pool)
.await?;
let player_exists = sqlx::query_scalar::<_, i32>("SELECT id FROM users WHERE id = $1")
.bind(payload.player_id).fetch_optional(&state.pool).await?;
if player_exists.is_none() {
return Err(AppError::NotFound("Player not found".to_string()));
}
// Upsert featured player
sqlx::query(
r#"
INSERT INTO featured_players (player_id, rank)
VALUES ($1, $2)
ON CONFLICT (player_id) DO UPDATE SET rank = $2
"#
)
.bind(payload.player_id)
.bind(payload.rank)
.execute(&state.pool)
.await?;
sqlx::query("INSERT INTO featured_players (player_id, rank) VALUES ($1, $2) ON CONFLICT (player_id) DO UPDATE SET rank = $2")
.bind(payload.player_id).bind(payload.rank).execute(&state.pool).await?;
// Fetch the full featured player info
let featured = sqlx::query_as::<_, FeaturedPlayerInfo>(
r#"
SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
FROM featured_players fp
JOIN users u ON fp.player_id = u.id
r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
FROM featured_players fp JOIN users u ON fp.player_id = u.id
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
WHERE fp.player_id = $1
"#
)
.bind(payload.player_id)
.fetch_one(&state.pool)
.await?;
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false WHERE fp.player_id = $1"#
).bind(payload.player_id).fetch_one(&state.pool).await?;
Ok((StatusCode::CREATED, Json(featured)))
}
/// DELETE /api/players/featured/:player_id
/// Remove featured player (admin only)
#[utoipa::path(
delete,
path = "/api/players/featured/{player_id}",
tag = "players",
params(("player_id" = i32, Path, description = "Player ID")),
responses(
(status = 204, description = "Featured player removed"),
(status = 403, description = "Admin only"),
(status = 404, description = "Featured player not found")
),
security(("bearer_auth" = []))
)]
pub async fn remove_featured_player(
user: AuthUser,
State(state): State<AppState>,
@@ -386,9 +359,7 @@ pub async fn remove_featured_player(
}
let result = sqlx::query("DELETE FROM featured_players WHERE player_id = $1")
.bind(player_id)
.execute(&state.pool)
.await?;
.bind(player_id).execute(&state.pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Featured player not found".to_string()));
@@ -397,8 +368,19 @@ pub async fn remove_featured_player(
Ok(StatusCode::NO_CONTENT)
}
/// GET /api/players/leaderboard
/// Get player leaderboard (by match wins)
#[utoipa::path(
get,
path = "/api/players/leaderboard",
tag = "players",
params(
("page" = Option<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(
Query(query): Query<ListPlayersQuery>,
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 offset = (page - 1) * per_page;
// Count total players with at least one match
let total: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT u.id)
FROM users u
r#"SELECT COUNT(DISTINCT u.id) FROM users u
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
JOIN (
SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
UNION ALL
SELECT team_id_2 FROM matches WHERE matche_status = 3
) m ON t.id = m.team_id
"#
)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
JOIN (SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
UNION ALL SELECT team_id_2 FROM matches WHERE matche_status = 3) m ON t.id = m.team_id"#
).fetch_one(&state.pool).await.unwrap_or(0);
// Get leaderboard with match stats
let players = sqlx::query_as::<_, LeaderboardEntry>(
r#"
WITH player_matches AS (
SELECT
u.id as player_id,
u.username,
u.firstname,
u.lastname,
u.profile,
t.name as team_name,
t.id as team_id
FROM users u
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
r#"WITH player_matches AS (
SELECT u.id as player_id, u.username, u.firstname, u.lastname, u.profile,
t.name as team_name, t.id as team_id
FROM users u JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
),
match_results AS (
SELECT
pm.player_id,
COUNT(DISTINCT m.id) as matches_played,
COUNT(DISTINCT CASE
WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score)
THEN m.id
END) as matches_won
SELECT pm.player_id, COUNT(DISTINCT m.id) as matches_played,
COUNT(DISTINCT CASE WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score) THEN m.id END) as matches_won
FROM player_matches pm
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id)
AND m.matche_status = 3
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id) AND m.matche_status = 3
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
GROUP BY pm.player_id
)
SELECT
ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
pm.player_id,
pm.username,
pm.firstname,
pm.lastname,
pm.profile,
pm.team_name,
mr.matches_won,
mr.matches_played
FROM player_matches pm
LEFT JOIN match_results mr ON pm.player_id = mr.player_id
ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC
LIMIT $1 OFFSET $2
"#
)
.bind(per_page)
.bind(offset)
.fetch_all(&state.pool)
.await?;
SELECT ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
pm.player_id, pm.username, pm.firstname, pm.lastname, pm.profile, pm.team_name,
mr.matches_won, mr.matches_played
FROM player_matches pm LEFT JOIN match_results mr ON pm.player_id = mr.player_id
ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC LIMIT $1 OFFSET $2"#
).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
Ok(Json(LeaderboardResponse {
players,
total,
page,
per_page,
}))
Ok(Json(LeaderboardResponse { players, total, page, per_page }))
}
/// Get a players rating history
#[utoipa::path(
get,
path = "/api/players/rating-history/{player_id}",
tag = "players",
params(("player_id" = i32, Path, description = "Player ID")),
responses(
(status = 200, description = "Rating history", body = Vec<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
// ============================================================================
async fn get_player_stats(
pool: &sqlx::PgPool,
player_id: i32,
team_id: Option<i32>,
) -> Result<PlayerStats> {
// Get player's OpenSkill rating
async fn get_player_stats(pool: &sqlx::PgPool, player_id: i32, team_id: Option<i32>) -> Result<PlayerStats> {
let rating: (Option<f32>, Option<f32>, Option<f32>, Option<f32>) = sqlx::query_as(
"SELECT mu, sigma, ordinal, mmr FROM users WHERE id = $1"
)
.bind(player_id)
.fetch_one(pool)
.await
.unwrap_or((None, None, None, None));
).bind(player_id).fetch_one(pool).await.unwrap_or((None, None, None, None));
let mu = rating.0.unwrap_or(25.0) as f64;
let sigma = rating.1.unwrap_or(8.333333) as f64;
@@ -509,161 +466,43 @@ async fn get_player_stats(
let mmr = rating.3.unwrap_or(1500.0) as f64;
let Some(team_id) = team_id else {
return Ok(PlayerStats {
matches_played: 0,
matches_won: 0,
matches_lost: 0,
win_rate: 0.0,
ladders_participated: 0,
mu,
sigma,
ordinal,
mmr,
});
return Ok(PlayerStats { matches_played: 0, matches_won: 0, matches_lost: 0, win_rate: 0.0, ladders_participated: 0, mu, sigma, ordinal, mmr });
};
// Count completed matches
let matches_played: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM matches
WHERE (team_id_1 = $1 OR team_id_2 = $1) AND matche_status = 3
"#
)
.bind(team_id)
.fetch_one(pool)
.await
.unwrap_or(0);
"SELECT COUNT(*) FROM matches WHERE (team_id_1 = $1 OR team_id_2 = $1) AND matche_status = 3"
).bind(team_id).fetch_one(pool).await.unwrap_or(0);
// Count wins (based on accepted scores)
let matches_won: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT m.id) FROM matches m
JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
WHERE m.matche_status = 3
AND (
(m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score)
OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score)
)
"#
)
.bind(team_id)
.fetch_one(pool)
.await
.unwrap_or(0);
r#"SELECT COUNT(DISTINCT m.id) FROM matches m JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
WHERE m.matche_status = 3 AND ((m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score)
OR (m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score))"#
).bind(team_id).fetch_one(pool).await.unwrap_or(0);
let matches_lost = matches_played - matches_won;
let win_rate = if matches_played > 0 {
(matches_won as f32 / matches_played as f32) * 100.0
} else {
0.0
};
let win_rate = if matches_played > 0 { (matches_won as f32 / matches_played as f32) * 100.0 } else { 0.0 };
let ladders_participated: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1")
.bind(team_id).fetch_one(pool).await.unwrap_or(0);
// Count ladders participated
let ladders_participated: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ladder_teams WHERE team_id = $1"
)
.bind(team_id)
.fetch_one(pool)
.await
.unwrap_or(0);
Ok(PlayerStats {
matches_played,
matches_won,
matches_lost,
win_rate,
ladders_participated,
mu,
sigma,
ordinal,
mmr,
})
Ok(PlayerStats { matches_played, matches_won, matches_lost, win_rate, ladders_participated, mu, sigma, ordinal, mmr })
}
async fn get_recent_matches(
pool: &sqlx::PgPool,
team_id: i32,
limit: i64,
) -> Result<Vec<PlayerMatchSummary>> {
async fn get_recent_matches(pool: &sqlx::PgPool, team_id: i32, limit: i64) -> Result<Vec<PlayerMatchSummary>> {
let matches = sqlx::query_as::<_, PlayerMatchSummary>(
r#"
SELECT
m.id as match_id,
m.date_start as date_played,
r#"SELECT m.id as match_id, m.date_start as date_played,
CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name,
CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name,
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score,
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score,
CASE
WHEN mr.id IS NULL THEN 'pending'
CASE WHEN mr.id IS NULL THEN 'pending'
WHEN m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score THEN 'win'
WHEN m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score THEN 'win'
WHEN mr.team_1_score = mr.team_2_score THEN 'draw'
ELSE 'loss'
END as result
FROM matches m
JOIN teams t1 ON m.team_id_1 = t1.id
JOIN teams t2 ON m.team_id_2 = t2.id
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
WHEN mr.team_1_score = mr.team_2_score THEN 'draw' ELSE 'loss' END as result
FROM matches m JOIN teams t1 ON m.team_id_1 = t1.id JOIN teams t2 ON m.team_id_2 = t2.id
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3
ORDER BY m.date_start DESC
LIMIT $2
"#
)
.bind(team_id)
.bind(limit)
.fetch_all(pool)
.await?;
ORDER BY m.date_start DESC LIMIT $2"#
).bind(team_id).bind(limit).fetch_all(pool).await?;
Ok(matches)
}
// Note: Profile upload is now handled by uploads.rs using multipart forms
/// GET /api/players/rating-history/:player_id
/// Get a player's rating history
pub async fn get_rating_history(
State(state): State<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 sqlx::FromRow;
use utoipa::ToSchema;
use validator::Validate;
use crate::{
@@ -20,14 +21,14 @@ use super::auth::AppState;
// Request/Response Types
// ============================================================================
#[derive(Debug, Deserialize, Default)]
#[derive(Debug, Deserialize, Default, ToSchema)]
pub struct ListTeamsQuery {
pub search: Option<String>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct TeamListResponse {
pub teams: Vec<TeamWithMemberCount>,
pub total: i64,
@@ -35,7 +36,7 @@ pub struct TeamListResponse {
pub per_page: i64,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct TeamWithMemberCount {
pub id: i32,
pub name: String,
@@ -47,21 +48,21 @@ pub struct TeamWithMemberCount {
pub member_count: Option<i64>,
}
#[derive(Debug, Deserialize, Validate)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateTeamRequest {
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
pub name: String,
pub bio: Option<String>,
}
#[derive(Debug, Deserialize, Validate)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateTeamRequest {
#[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))]
pub name: Option<String>,
pub bio: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct TeamResponse {
pub id: i32,
pub name: String,
@@ -92,7 +93,7 @@ impl From<Team> for TeamResponse {
}
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct TeamDetailResponse {
pub team: TeamResponse,
pub members: Vec<TeamMemberResponse>,
@@ -101,7 +102,7 @@ pub struct TeamDetailResponse {
pub pending_request: bool,
}
#[derive(Debug, Serialize, FromRow)]
#[derive(Debug, Serialize, FromRow, ToSchema)]
pub struct TeamMemberResponse {
pub id: i32,
pub player_id: i32,
@@ -114,12 +115,12 @@ pub struct TeamMemberResponse {
pub date_joined: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct ChangePositionRequest {
pub position: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct MyTeamResponse {
pub team: Option<TeamResponse>,
pub position: Option<String>,
@@ -127,12 +128,29 @@ pub struct MyTeamResponse {
pub pending_requests: Vec<TeamMemberResponse>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageResponse {
pub message: String,
}
// ============================================================================
// Handlers
// ============================================================================
/// GET /api/teams
/// List all teams with pagination and search
#[utoipa::path(
get,
path = "/api/teams",
tag = "teams",
params(
("search" = Option<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(
State(state): State<AppState>,
Query(query): Query<ListTeamsQuery>,
@@ -143,35 +161,24 @@ pub async fn list_teams(
let search_pattern = query.search.as_ref().map(|s| format!("%{}%", s));
// Get total count
let total: i64 = if let Some(ref pattern) = search_pattern {
sqlx::query_scalar(
"SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1"
)
sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1")
.bind(pattern)
.fetch_one(&state.pool)
.await?
} else {
sqlx::query_scalar(
"SELECT COUNT(*) FROM teams WHERE is_delete = false"
)
sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false")
.fetch_one(&state.pool)
.await?
};
// Get teams with member count
let teams: Vec<TeamWithMemberCount> = if let Some(ref pattern) = search_pattern {
sqlx::query_as(
r#"
SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
FROM teams t
LEFT JOIN team_players tp ON t.id = tp.team_id
FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id
WHERE t.is_delete = false AND t.name ILIKE $1
GROUP BY t.id
ORDER BY t.rank ASC, t.mmr DESC
LIMIT $2 OFFSET $3
"#
GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $2 OFFSET $3"#
)
.bind(pattern)
.bind(per_page)
@@ -180,16 +187,11 @@ pub async fn list_teams(
.await?
} else {
sqlx::query_as(
r#"
SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created,
COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count
FROM teams t
LEFT JOIN team_players tp ON t.id = tp.team_id
FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id
WHERE t.is_delete = false
GROUP BY t.id
ORDER BY t.rank ASC, t.mmr DESC
LIMIT $1 OFFSET $2
"#
GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $1 OFFSET $2"#
)
.bind(per_page)
.bind(offset)
@@ -197,16 +199,21 @@ pub async fn list_teams(
.await?
};
Ok(Json(TeamListResponse {
teams,
total,
page,
per_page,
}))
Ok(Json(TeamListResponse { teams, total, page, per_page }))
}
/// POST /api/teams
/// Create a new team (creator becomes captain)
#[utoipa::path(
post,
path = "/api/teams",
tag = "teams",
request_body = CreateTeamRequest,
responses(
(status = 201, description = "Team created", body = TeamDetailResponse),
(status = 409, description = "Already in a team or team name exists")
),
security(("bearer_auth" = []))
)]
pub async fn create_team(
user: AuthUser,
State(state): State<AppState>,
@@ -214,7 +221,6 @@ pub async fn create_team(
) -> Result<(StatusCode, Json<TeamDetailResponse>)> {
payload.validate()?;
// Check if user is already in a team
let existing_membership = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE player_id = $1 AND status = 1"
)
@@ -226,7 +232,6 @@ pub async fn create_team(
return Err(AppError::Conflict("You are already a member of a team".to_string()));
}
// Check if team name already exists
let existing_team = sqlx::query_scalar::<_, i32>(
"SELECT id FROM teams WHERE name = $1 AND is_delete = false"
)
@@ -238,79 +243,68 @@ pub async fn create_team(
return Err(AppError::Conflict("Team name already exists".to_string()));
}
// Create team (logo uses default empty string)
let team = sqlx::query_as::<_, Team>(
r#"
INSERT INTO teams (name, bio, logo, rank, mmr)
VALUES ($1, $2, '', 0, 1000.0)
RETURNING *
"#
"INSERT INTO teams (name, bio, logo, rank, mmr) VALUES ($1, $2, '', 0, 1000.0) RETURNING *"
)
.bind(&payload.name)
.bind(&payload.bio)
.fetch_one(&state.pool)
.await?;
// Add creator as captain
sqlx::query(
r#"
INSERT INTO team_players (team_id, player_id, position, status)
VALUES ($1, $2, 'captain', 1)
"#
"INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'captain', 1, '', '')"
)
.bind(team.id)
.bind(user.id)
.execute(&state.pool)
.await?;
// Get members (just the captain)
let members = get_team_members(&state.pool, team.id).await?;
Ok((
StatusCode::CREATED,
Json(TeamDetailResponse {
Ok((StatusCode::CREATED, Json(TeamDetailResponse {
team: team.into(),
members,
is_member: true,
is_captain: true,
pending_request: false,
}),
))
})))
}
/// GET /api/teams/:id
/// Get team details with members
pub async fn get_team(
OptionalAuthUser(user): OptionalAuthUser,
State(state): State<AppState>,
Path(team_id): Path<i32>,
) -> Result<Json<TeamDetailResponse>> {
let team = sqlx::query_as::<_, Team>(
"SELECT * FROM teams WHERE id = $1 AND is_delete = false"
/// Get team details by ID
#[utoipa::path(
get,
path = "/api/teams/{id}",
tag = "teams",
params(("id" = i32, Path, description = "Team ID")),
responses(
(status = 200, description = "Team details", body = TeamDetailResponse),
(status = 404, description = "Team not found")
)
.bind(team_id)
)]
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)
.await?
.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 u) = user {
let (is_member, is_captain, pending_request) = if let Some(ref auth_user) = user.0 {
let membership = sqlx::query_as::<_, TeamPlayer>(
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
.bind(u.id)
.bind(team.id)
.bind(auth_user.id)
.fetch_optional(&state.pool)
.await?;
match membership {
Some(m) => (
m.status == 1,
m.status == 1 && m.position == "captain",
m.status == 0,
),
Some(m) => (m.status == 1, m.position == "captain", m.status == 0),
None => (false, false, false),
}
} else {
@@ -326,166 +320,138 @@ pub async fn get_team(
}))
}
/// PUT /api/teams/:id
/// Update team (captain only)
/// Update team details (captain only)
#[utoipa::path(
put,
path = "/api/teams/{id}",
tag = "teams",
params(("id" = i32, Path, description = "Team ID")),
request_body = UpdateTeamRequest,
responses(
(status = 200, description = "Team updated", body = TeamResponse),
(status = 403, description = "Not team captain"),
(status = 404, description = "Team not found")
),
security(("bearer_auth" = []))
)]
pub async fn update_team(
user: AuthUser,
State(state): State<AppState>,
Path(team_id): Path<i32>,
Path(id): Path<i32>,
Json(payload): Json<UpdateTeamRequest>,
) -> Result<Json<TeamResponse>> {
payload.validate()?;
// Check if user is captain
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
if !is_captain {
return Err(AppError::Forbidden("Only the captain can update the team".to_string()));
}
// Check if new name conflicts
if let Some(ref name) = payload.name {
let existing = sqlx::query_scalar::<_, i32>(
"SELECT id FROM teams WHERE name = $1 AND id != $2 AND is_delete = false"
)
.bind(name)
.bind(team_id)
.fetch_optional(&state.pool)
.await?;
if existing.is_some() {
return Err(AppError::Conflict("Team name already exists".to_string()));
}
}
let _membership = verify_captain(&state.pool, id, user.id).await?;
let team = sqlx::query_as::<_, Team>(
r#"
UPDATE teams
SET name = COALESCE($1, name),
bio = COALESCE($2, bio)
WHERE id = $3 AND is_delete = false
RETURNING *
"#
"UPDATE teams SET name = COALESCE($1, name), bio = COALESCE($2, bio) WHERE id = $3 RETURNING *"
)
.bind(&payload.name)
.bind(&payload.bio)
.bind(team_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
.bind(id)
.fetch_one(&state.pool)
.await?;
Ok(Json(team.into()))
}
/// DELETE /api/teams/:id
/// Delete team (captain only)
#[utoipa::path(
delete,
path = "/api/teams/{id}",
tag = "teams",
params(("id" = i32, Path, description = "Team ID")),
responses(
(status = 200, description = "Team deleted", body = MessageResponse),
(status = 403, description = "Not team captain"),
(status = 404, description = "Team not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_team(
user: AuthUser,
State(state): State<AppState>,
Path(team_id): Path<i32>,
) -> Result<StatusCode> {
// Check if user is captain
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
if !is_captain && !user.is_admin {
return Err(AppError::Forbidden("Only the captain can delete the team".to_string()));
}
Path(id): Path<i32>,
) -> Result<Json<MessageResponse>> {
let _membership = verify_captain(&state.pool, id, user.id).await?;
// Soft delete team
let result = sqlx::query(
"UPDATE teams SET is_delete = true WHERE id = $1"
)
.bind(team_id)
sqlx::query("UPDATE teams SET is_delete = true WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Team not found".to_string()));
}
// Remove all team members
sqlx::query("DELETE FROM team_players WHERE team_id = $1")
.bind(team_id)
.bind(id)
.execute(&state.pool)
.await?;
// Remove from ladders
sqlx::query("DELETE FROM ladder_teams WHERE team_id = $1")
.bind(team_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
Ok(Json(MessageResponse { message: "Team deleted successfully".to_string() }))
}
/// POST /api/teams/:id/join-request
/// Request to join a team
#[utoipa::path(
post,
path = "/api/teams/{id}/join-request",
tag = "teams",
params(("id" = i32, Path, description = "Team ID")),
responses(
(status = 200, description = "Join request sent", body = MessageResponse),
(status = 409, description = "Already in a team or request pending")
),
security(("bearer_auth" = []))
)]
pub async fn request_join_team(
user: AuthUser,
State(state): State<AppState>,
Path(team_id): Path<i32>,
) -> Result<StatusCode> {
// Check team exists
let team_exists = sqlx::query_scalar::<_, i32>(
"SELECT id FROM teams WHERE id = $1 AND is_delete = false"
)
.bind(team_id)
Path(id): Path<i32>,
) -> Result<Json<MessageResponse>> {
let _team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false")
.bind(id)
.fetch_optional(&state.pool)
.await?;
.await?
.ok_or_else(|| AppError::NotFound("Team not found".to_string()))?;
if team_exists.is_none() {
return Err(AppError::NotFound("Team not found".to_string()));
}
// Check if user is already in a team (active)
let existing_active = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE player_id = $1 AND status = 1"
let existing = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE player_id = $1 AND (status = 1 OR (team_id = $2 AND status = 0))"
)
.bind(user.id)
.bind(id)
.fetch_optional(&state.pool)
.await?;
if existing_active.is_some() {
return Err(AppError::Conflict("You are already a member of a team".to_string()));
if existing.is_some() {
return Err(AppError::Conflict("Already in a team or request pending".to_string()));
}
// Check if already requested
let existing_request = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
.bind(user.id)
.fetch_optional(&state.pool)
.await?;
if existing_request.is_some() {
return Err(AppError::Conflict("You already have a pending request".to_string()));
}
// Create join request
sqlx::query(
r#"
INSERT INTO team_players (team_id, player_id, position, status)
VALUES ($1, $2, 'member', 0)
"#
)
.bind(team_id)
sqlx::query("INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'member', 0, '', '')")
.bind(id)
.bind(user.id)
.execute(&state.pool)
.await?;
Ok(StatusCode::CREATED)
Ok(Json(MessageResponse { message: "Join request sent".to_string() }))
}
/// DELETE /api/teams/:id/join-request
/// Cancel join request
#[utoipa::path(
delete,
path = "/api/teams/{id}/join-request",
tag = "teams",
params(("id" = i32, Path, description = "Team ID")),
responses(
(status = 200, description = "Request cancelled", body = MessageResponse),
(status = 404, description = "No pending request found")
),
security(("bearer_auth" = []))
)]
pub async fn cancel_join_request(
user: AuthUser,
State(state): State<AppState>,
Path(team_id): Path<i32>,
) -> Result<StatusCode> {
let result = sqlx::query(
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0"
)
.bind(team_id)
Path(id): Path<i32>,
) -> Result<Json<MessageResponse>> {
let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0")
.bind(id)
.bind(user.id)
.execute(&state.pool)
.await?;
@@ -494,146 +460,165 @@ pub async fn cancel_join_request(
return Err(AppError::NotFound("No pending request found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
Ok(Json(MessageResponse { message: "Join request cancelled".to_string() }))
}
/// POST /api/teams/:id/members/:player_id/accept
/// Accept a join request (captain only)
#[utoipa::path(
post,
path = "/api/teams/{id}/members/{player_id}/accept",
tag = "teams",
params(
("id" = i32, Path, description = "Team ID"),
("player_id" = i32, Path, description = "Player ID to accept")
),
responses(
(status = 200, description = "Member accepted", body = MessageResponse),
(status = 403, description = "Not team captain"),
(status = 404, description = "No pending request from this player")
),
security(("bearer_auth" = []))
)]
pub async fn accept_member(
user: AuthUser,
State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> {
// Check if user is captain
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
if !is_captain {
return Err(AppError::Forbidden("Only the captain can accept members".to_string()));
}
Path((id, player_id)): Path<(i32, i32)>,
) -> Result<Json<MessageResponse>> {
let _membership = verify_captain(&state.pool, id, user.id).await?;
// Update status to active
let result = sqlx::query(
"UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0"
)
.bind(team_id)
let result = sqlx::query("UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0")
.bind(id)
.bind(player_id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("No pending request found".to_string()));
return Err(AppError::NotFound("No pending request from this player".to_string()));
}
Ok(StatusCode::OK)
Ok(Json(MessageResponse { message: "Member accepted".to_string() }))
}
/// POST /api/teams/:id/members/:player_id/reject
/// Reject a join request (captain only)
#[utoipa::path(
post,
path = "/api/teams/{id}/members/{player_id}/reject",
tag = "teams",
params(
("id" = i32, Path, description = "Team ID"),
("player_id" = i32, Path, description = "Player ID to reject")
),
responses(
(status = 200, description = "Request rejected", body = MessageResponse),
(status = 403, description = "Not team captain"),
(status = 404, description = "No pending request from this player")
),
security(("bearer_auth" = []))
)]
pub async fn reject_member(
user: AuthUser,
State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> {
// Check if user is captain
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
if !is_captain {
return Err(AppError::Forbidden("Only the captain can reject members".to_string()));
}
Path((id, player_id)): Path<(i32, i32)>,
) -> Result<Json<MessageResponse>> {
let _membership = verify_captain(&state.pool, id, user.id).await?;
let result = sqlx::query(
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0"
)
.bind(team_id)
let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0")
.bind(id)
.bind(player_id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("No pending request found".to_string()));
return Err(AppError::NotFound("No pending request from this player".to_string()));
}
Ok(StatusCode::NO_CONTENT)
Ok(Json(MessageResponse { message: "Request rejected".to_string() }))
}
/// DELETE /api/teams/:id/members/:player_id
/// Remove a member or leave team
/// Remove a member from team (captain only)
#[utoipa::path(
delete,
path = "/api/teams/{id}/members/{player_id}",
tag = "teams",
params(
("id" = i32, Path, description = "Team ID"),
("player_id" = i32, Path, description = "Player ID to remove")
),
responses(
(status = 200, description = "Member removed", body = MessageResponse),
(status = 403, description = "Not team captain or cannot remove captain"),
(status = 404, description = "Member not found")
),
security(("bearer_auth" = []))
)]
pub async fn remove_member(
user: AuthUser,
State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>,
) -> Result<StatusCode> {
let is_captain = check_is_captain(&state.pool, team_id, user.id).await?;
let is_self = user.id == player_id;
Path((id, player_id)): Path<(i32, i32)>,
) -> Result<Json<MessageResponse>> {
let _membership = verify_captain(&state.pool, id, user.id).await?;
// Check permission
if !is_captain && !is_self && !user.is_admin {
return Err(AppError::Forbidden("You can only remove yourself or be captain to remove others".to_string()));
}
// Check if target is captain
let target_membership = sqlx::query_as::<_, TeamPlayer>(
let target = sqlx::query_as::<_, TeamPlayer>(
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1"
)
.bind(team_id)
.bind(id)
.bind(player_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Member not found".to_string()))?;
// Captain can't leave without transferring or deleting team
if target_membership.position == "captain" && is_self {
return Err(AppError::BadRequest(
"Captain cannot leave. Transfer captaincy or delete the team.".to_string()
));
if target.position == "captain" {
return Err(AppError::Forbidden("Cannot remove captain".to_string()));
}
// Remove member
sqlx::query(
"DELETE FROM team_players WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2")
.bind(id)
.bind(player_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
Ok(Json(MessageResponse { message: "Member removed".to_string() }))
}
/// PUT /api/teams/:id/members/:player_id/position
/// Change member position (captain only)
#[utoipa::path(
put,
path = "/api/teams/{id}/members/{player_id}/position",
tag = "teams",
params(
("id" = i32, Path, description = "Team ID"),
("player_id" = i32, Path, description = "Player ID")
),
request_body = ChangePositionRequest,
responses(
(status = 200, description = "Position changed", body = MessageResponse),
(status = 403, description = "Not team captain"),
(status = 404, description = "Member not found")
),
security(("bearer_auth" = []))
)]
pub async fn change_member_position(
user: AuthUser,
State(state): State<AppState>,
Path((team_id, player_id)): Path<(i32, i32)>,
Path((id, player_id)): Path<(i32, i32)>,
Json(payload): Json<ChangePositionRequest>,
) -> Result<StatusCode> {
// Validate position
) -> Result<Json<MessageResponse>> {
let _membership = verify_captain(&state.pool, id, user.id).await?;
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 {
// Demote current captain to co-captain
sqlx::query(
"UPDATE team_players SET position = 'co-captain' WHERE team_id = $1 AND player_id = $2"
)
.bind(team_id)
sqlx::query("UPDATE team_players SET position = 'member' WHERE team_id = $1 AND player_id = $2")
.bind(id)
.bind(user.id)
.execute(&state.pool)
.await?;
}
// 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(team_id)
.bind(id)
.bind(player_id)
.execute(&state.pool)
.await?;
@@ -642,16 +627,23 @@ pub async fn change_member_position(
return Err(AppError::NotFound("Member not found".to_string()));
}
Ok(StatusCode::OK)
Ok(Json(MessageResponse { message: "Position updated".to_string() }))
}
/// GET /api/my-team
/// Get current user's team
#[utoipa::path(
get,
path = "/api/my-team",
tag = "teams",
responses(
(status = 200, description = "User's team info", body = MyTeamResponse)
),
security(("bearer_auth" = []))
)]
pub async fn get_my_team(
user: AuthUser,
State(state): State<AppState>,
) -> Result<Json<MyTeamResponse>> {
// Get user's team membership
let membership = sqlx::query_as::<_, TeamPlayer>(
"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)
.await?;
let Some(membership) = membership else {
return Ok(Json(MyTeamResponse {
team: None,
position: None,
members: vec![],
pending_requests: vec![],
}));
};
// Get team
let team = sqlx::query_as::<_, Team>(
"SELECT * FROM teams WHERE id = $1 AND is_delete = false"
)
.bind(membership.team_id)
match membership {
Some(m) => {
let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1")
.bind(m.team_id)
.fetch_one(&state.pool)
.await?;
// Get members
let members = get_team_members(&state.pool, membership.team_id).await?;
let members = get_team_members(&state.pool, team.id).await?;
// Get pending requests (if captain)
let pending_requests = if membership.position == "captain" {
let pending_requests = if m.position == "captain" || m.position == "co-captain" {
sqlx::query_as::<_, TeamMemberResponse>(
r#"
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
tp.position, tp.status, tp.date_created as date_joined
FROM team_players tp
JOIN users u ON tp.player_id = u.id
WHERE tp.team_id = $1 AND tp.status = 0
ORDER BY tp.date_created ASC
"#
FROM team_players tp JOIN users u ON tp.player_id = u.id
WHERE tp.team_id = $1 AND tp.status = 0"#
)
.bind(membership.team_id)
.bind(team.id)
.fetch_all(&state.pool)
.await?
} else {
@@ -700,11 +676,19 @@ pub async fn get_my_team(
Ok(Json(MyTeamResponse {
team: Some(team.into()),
position: Some(membership.position),
position: Some(m.position),
members,
pending_requests,
}))
}
None => Ok(Json(MyTeamResponse {
team: None,
position: None,
members: vec![],
pending_requests: vec![],
}))
}
}
// ============================================================================
// 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>> {
let members = sqlx::query_as::<_, TeamMemberResponse>(
r#"
SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile,
tp.position, tp.status, tp.date_created as date_joined
FROM team_players tp
JOIN users u ON tp.player_id = u.id
WHERE tp.team_id = $1 AND tp.status = 1
ORDER BY
CASE tp.position
WHEN 'captain' THEN 1
WHEN 'co-captain' THEN 2
ELSE 3
END,
tp.date_created ASC
"#
FROM team_players tp JOIN users u ON tp.player_id = u.id
WHERE tp.team_id = $1 AND tp.status = 1 ORDER BY tp.position ASC, tp.date_created ASC"#
)
.bind(team_id)
.fetch_all(pool)
@@ -734,18 +708,19 @@ async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result<Vec<TeamM
Ok(members)
}
async fn check_is_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result<bool> {
let is_captain = sqlx::query_scalar::<_, i32>(
"SELECT id FROM team_players WHERE team_id = $1 AND player_id = $2 AND position = 'captain' AND status = 1"
async fn verify_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result<TeamPlayer> {
let membership = sqlx::query_as::<_, TeamPlayer>(
"SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1"
)
.bind(team_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
.await?
.ok_or_else(|| AppError::Forbidden("Not a member of this team".to_string()))?;
Ok(is_captain.is_some())
if membership.position != "captain" && membership.position != "co-captain" {
return Err(AppError::Forbidden("Only captain or co-captain can perform this action".to_string()));
}
// ============================================================================
// Logo Upload
// Note: Team logo upload is now handled by uploads.rs using multipart forms
Ok(membership)
}

View File

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

View File

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

View File

@@ -1,21 +1,30 @@
use utoipa::OpenApi;
use crate::{
handlers::{
use crate::handlers::{
auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse},
health::HealthResponse,
users::UserProfileResponse,
teams::{
TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
MyTeamResponse, MessageResponse,
},
models::{
featured_player::{FeaturedPlayer, FeaturedPlayerWithUser},
ladder::{CreateLadder, Ladder, LadderStatus, UpdateLadder},
ladder_team::{JoinLadderRequest, LadderTeam},
match_model::{CreateChallengeRequest, Match, MatchStatus, RespondChallengeRequest, TeamChallengeStatus},
match_round::{MatchRound, ScoreAcceptanceStatus, SubmitScoreRequest},
team::{CreateTeam, Team, TeamMember, TeamWithStats, UpdateTeam},
team_player::{ChangePositionRequest, JoinTeamRequest, PlayerPosition, TeamPlayer, TeamPlayerStatus},
user::{CreateUser, PlayerStats, UpdateUser, User, UserProfile},
matches::{
ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
},
ladders::{
ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest,
UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse,
EnrollmentResponse,
},
players::{
ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfileResponse as PlayerProfile,
PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse,
FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry,
RatingHistoryEntry,
},
uploads::UploadResponse,
};
#[derive(OpenApi)]
@@ -24,13 +33,8 @@ use crate::{
title = "VRBattles API",
version = "1.0.0",
description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.",
contact(
name = "VRBattles Team",
url = "https://vrbattles.gg"
),
license(
name = "Proprietary"
)
contact(name = "VRBattles Team", url = "https://vrbattles.gg"),
license(name = "Proprietary")
),
servers(
(url = "https://api.vrb.gg", description = "Production server"),
@@ -54,55 +58,76 @@ use crate::{
crate::handlers::auth::register,
// Users
crate::handlers::users::get_current_user,
// Teams
crate::handlers::teams::list_teams,
crate::handlers::teams::create_team,
crate::handlers::teams::get_team,
crate::handlers::teams::update_team,
crate::handlers::teams::delete_team,
crate::handlers::teams::request_join_team,
crate::handlers::teams::cancel_join_request,
crate::handlers::teams::accept_member,
crate::handlers::teams::reject_member,
crate::handlers::teams::remove_member,
crate::handlers::teams::change_member_position,
crate::handlers::teams::get_my_team,
// Matches
crate::handlers::matches::list_matches,
crate::handlers::matches::get_match,
crate::handlers::matches::create_challenge,
crate::handlers::matches::accept_challenge,
crate::handlers::matches::reject_challenge,
crate::handlers::matches::start_match,
crate::handlers::matches::cancel_match,
crate::handlers::matches::report_score,
crate::handlers::matches::accept_score,
crate::handlers::matches::reject_score,
crate::handlers::matches::get_my_matches,
// Ladders
crate::handlers::ladders::list_ladders,
crate::handlers::ladders::create_ladder,
crate::handlers::ladders::get_ladder,
crate::handlers::ladders::update_ladder,
crate::handlers::ladders::delete_ladder,
crate::handlers::ladders::enroll_team,
crate::handlers::ladders::withdraw_team,
crate::handlers::ladders::get_ladder_standings,
// Players
crate::handlers::players::list_players,
crate::handlers::players::get_player,
crate::handlers::players::get_featured_players,
crate::handlers::players::set_featured_player,
crate::handlers::players::remove_featured_player,
crate::handlers::players::get_leaderboard,
crate::handlers::players::get_rating_history,
// Uploads
crate::handlers::uploads::upload_team_logo,
crate::handlers::uploads::upload_profile_picture,
crate::handlers::uploads::upload_ladder_logo,
crate::handlers::uploads::delete_team_logo,
),
components(
schemas(
// Health
HealthResponse,
// Auth
LoginRequest,
RegisterRequest,
AuthResponse,
UserResponse,
UserProfileResponse,
// User
User,
UserProfile,
CreateUser,
UpdateUser,
PlayerStats,
// Team
Team,
TeamWithStats,
CreateTeam,
UpdateTeam,
TeamMember,
// Team Player
TeamPlayer,
JoinTeamRequest,
ChangePositionRequest,
PlayerPosition,
TeamPlayerStatus,
// Ladder
Ladder,
LadderStatus,
CreateLadder,
UpdateLadder,
LadderTeam,
JoinLadderRequest,
// Match
Match,
MatchStatus,
TeamChallengeStatus,
CreateChallengeRequest,
RespondChallengeRequest,
// Match Round
MatchRound,
ScoreAcceptanceStatus,
SubmitScoreRequest,
// Featured Player
FeaturedPlayer,
FeaturedPlayerWithUser,
HealthResponse, LoginRequest, RegisterRequest, AuthResponse, UserResponse, UserProfileResponse,
// Teams
TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
MyTeamResponse, MessageResponse,
// Matches
ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
// Ladders
ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest,
UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse,
EnrollmentResponse,
// Players
ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfile,
PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse,
FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry,
RatingHistoryEntry,
// Uploads
UploadResponse,
)
),
modifiers(&SecurityAddon)
@@ -113,16 +138,12 @@ struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
utoipa::openapi::security::SecurityScheme::Http(
utoipa::openapi::security::Http::new(
utoipa::openapi::security::HttpAuthScheme::Bearer,
)
.bearer_format("JWT"),
utoipa::openapi::security::Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)
),
);
}
}
}