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:
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1")
|
||||
.bind(ladder_id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
// Get enrolled teams
|
||||
let teams = sqlx::query_as::<_, LadderTeamResponse>(
|
||||
r#"
|
||||
SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt
|
||||
JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false
|
||||
ORDER BY t.rank ASC, t.mmr DESC
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false ORDER BY t.rank ASC, t.mmr DESC"#
|
||||
).bind(ladder_id).fetch_all(&state.pool).await?;
|
||||
|
||||
// Check if user's team is enrolled
|
||||
let (is_enrolled, user_team_id) = if let Some(ref u) = user {
|
||||
// Get user's team
|
||||
let user_team = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND status = 1"
|
||||
)
|
||||
.bind(u.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
).bind(u.id).fetch_optional(&state.pool).await?;
|
||||
|
||||
if let Some(team_id) = user_team {
|
||||
let enrolled = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
).bind(ladder_id).bind(team_id).fetch_optional(&state.pool).await?;
|
||||
(enrolled.is_some(), Some(team_id))
|
||||
} else {
|
||||
(false, None)
|
||||
}
|
||||
} else {
|
||||
(false, None)
|
||||
};
|
||||
} else { (false, None) }
|
||||
} else { (false, None) };
|
||||
|
||||
Ok(Json(LadderDetailResponse {
|
||||
ladder: ladder.into(),
|
||||
teams,
|
||||
is_enrolled,
|
||||
user_team_id,
|
||||
}))
|
||||
Ok(Json(LadderDetailResponse { ladder: ladder.into(), teams, is_enrolled, user_team_id }))
|
||||
}
|
||||
|
||||
/// PUT /api/ladders/:id
|
||||
/// Update ladder (admin only)
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/ladders/{id}",
|
||||
tag = "ladders",
|
||||
params(("id" = i32, Path, description = "Ladder ID")),
|
||||
request_body = UpdateLadderRequest,
|
||||
responses(
|
||||
(status = 200, description = "Ladder updated", body = LadderResponse),
|
||||
(status = 403, description = "Admin only"),
|
||||
(status = 404, description = "Ladder not found"),
|
||||
(status = 409, description = "Ladder name exists")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_ladder(
|
||||
user: AuthUser,
|
||||
State(state): State<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?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
r#"UPDATE ladders SET name = COALESCE($1, name), date_start = COALESCE($2, date_start),
|
||||
date_expiration = COALESCE($3, date_expiration), status = COALESCE($4, status),
|
||||
logo = COALESCE($5, logo) WHERE id = $6 RETURNING *"#
|
||||
).bind(&payload.name).bind(&payload.date_start).bind(&payload.date_expiration)
|
||||
.bind(payload.status).bind(&payload.logo).bind(ladder_id)
|
||||
.fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
Ok(Json(ladder.into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/ladders/:id
|
||||
/// Delete ladder (admin only)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/ladders/{id}",
|
||||
tag = "ladders",
|
||||
params(("id" = i32, Path, description = "Ladder ID")),
|
||||
responses(
|
||||
(status = 204, description = "Ladder deleted"),
|
||||
(status = 403, description = "Admin only"),
|
||||
(status = 404, description = "Ladder not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_ladder(
|
||||
user: AuthUser,
|
||||
State(state): State<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?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1")
|
||||
.bind(ladder_id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
if ladder.status != 0 {
|
||||
return Err(AppError::BadRequest("Ladder is not open for enrollment".to_string()));
|
||||
}
|
||||
|
||||
// Get user's team (must be captain)
|
||||
let team_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT team_id FROM team_players WHERE player_id = $1 AND position = 'captain' AND status = 1"
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?;
|
||||
).bind(user.id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to enroll".to_string()))?;
|
||||
|
||||
// Check if already enrolled
|
||||
let existing = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT id FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
).bind(ladder_id).bind(team_id).fetch_optional(&state.pool).await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("Team is already enrolled in this ladder".to_string()));
|
||||
}
|
||||
|
||||
// Enroll team
|
||||
let ladder_team = sqlx::query_as::<_, LadderTeam>(
|
||||
r#"
|
||||
INSERT INTO ladder_teams (ladder_id, team_id)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
"INSERT INTO ladder_teams (ladder_id, team_id) VALUES ($1, $2) RETURNING *"
|
||||
).bind(ladder_id).bind(team_id).fetch_one(&state.pool).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(EnrollmentResponse {
|
||||
message: "Successfully enrolled in ladder".to_string(),
|
||||
ladder_team_id: ladder_team.id,
|
||||
}),
|
||||
))
|
||||
Ok((StatusCode::CREATED, Json(EnrollmentResponse {
|
||||
message: "Successfully enrolled in ladder".to_string(),
|
||||
ladder_team_id: ladder_team.id,
|
||||
})))
|
||||
}
|
||||
|
||||
/// DELETE /api/ladders/:id/enroll
|
||||
/// Withdraw team from a ladder (captain only)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/ladders/{id}/enroll",
|
||||
tag = "ladders",
|
||||
params(("id" = i32, Path, description = "Ladder ID")),
|
||||
responses(
|
||||
(status = 204, description = "Team withdrawn"),
|
||||
(status = 403, description = "Must be team captain"),
|
||||
(status = 404, description = "Ladder not found or not enrolled")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn withdraw_team(
|
||||
user: AuthUser,
|
||||
State(state): State<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?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?;
|
||||
).bind(user.id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::Forbidden("You must be a team captain to withdraw".to_string()))?;
|
||||
|
||||
// Check ladder status - can't withdraw from closed ladders
|
||||
let ladder = sqlx::query_as::<_, Ladder>(
|
||||
"SELECT * FROM ladders WHERE id = $1"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
let ladder = sqlx::query_as::<_, Ladder>("SELECT * FROM ladders WHERE id = $1")
|
||||
.bind(ladder_id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::NotFound("Ladder not found".to_string()))?;
|
||||
|
||||
if ladder.status != 0 {
|
||||
return Err(AppError::BadRequest("Cannot withdraw from a closed ladder".to_string()));
|
||||
}
|
||||
|
||||
// Remove enrollment
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2"
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.bind(team_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM ladder_teams WHERE ladder_id = $1 AND team_id = $2")
|
||||
.bind(ladder_id).bind(team_id).execute(&state.pool).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Team is not enrolled in this ladder".to_string()));
|
||||
@@ -559,38 +450,34 @@ pub async fn withdraw_team(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// GET /api/ladders/:id/standings
|
||||
/// Get ladder standings (teams ranked by MMR)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/ladders/{id}/standings",
|
||||
tag = "ladders",
|
||||
params(("id" = i32, Path, description = "Ladder ID")),
|
||||
responses(
|
||||
(status = 200, description = "Ladder standings", body = Vec<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,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt
|
||||
JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false
|
||||
ORDER BY t.mmr DESC, t.rank ASC
|
||||
"#
|
||||
)
|
||||
.bind(ladder_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
r#"SELECT lt.id, lt.team_id, t.name as team_name, t.logo as team_logo,
|
||||
t.rank as team_rank, t.mmr as team_mmr, lt.date_created as date_enrolled
|
||||
FROM ladder_teams lt JOIN teams t ON lt.team_id = t.id
|
||||
WHERE lt.ladder_id = $1 AND t.is_delete = false ORDER BY t.mmr DESC, t.rank ASC"#
|
||||
).bind(ladder_id).fetch_all(&state.pool).await?;
|
||||
|
||||
Ok(Json(standings))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is captain of team 2
|
||||
if current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("Only the challenged team captain can accept".to_string()));
|
||||
}
|
||||
|
||||
// Verify match is pending
|
||||
if current_match.matche_status != match_status::PENDING {
|
||||
return Err(AppError::BadRequest("Match is not pending acceptance".to_string()));
|
||||
}
|
||||
|
||||
// Update match
|
||||
let updated = sqlx::query_as::<_, Match>(
|
||||
r#"
|
||||
UPDATE matches
|
||||
@@ -472,8 +487,19 @@ pub async fn accept_challenge(
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/reject
|
||||
/// Reject a challenge
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/reject",
|
||||
tag = "matches",
|
||||
params(("id" = i32, Path, description = "Match ID")),
|
||||
responses(
|
||||
(status = 200, description = "Challenge rejected", body = MatchResponse),
|
||||
(status = 403, description = "Only challenged team captain can reject"),
|
||||
(status = 404, description = "Match not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn reject_challenge(
|
||||
user: AuthUser,
|
||||
State(state): State<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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is captain of team 2
|
||||
if current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("Only the challenged team captain can reject".to_string()));
|
||||
}
|
||||
@@ -516,8 +539,19 @@ pub async fn reject_challenge(
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/start
|
||||
/// Start a scheduled match
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/start",
|
||||
tag = "matches",
|
||||
params(("id" = i32, Path, description = "Match ID")),
|
||||
responses(
|
||||
(status = 200, description = "Match started", body = MatchResponse),
|
||||
(status = 403, description = "Not part of this match"),
|
||||
(status = 404, description = "Match not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn start_match(
|
||||
user: AuthUser,
|
||||
State(state): State<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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
@@ -553,8 +584,19 @@ pub async fn start_match(
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/cancel
|
||||
/// Cancel a match (before it starts)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/cancel",
|
||||
tag = "matches",
|
||||
params(("id" = i32, Path, description = "Match ID")),
|
||||
responses(
|
||||
(status = 200, description = "Match cancelled", body = MatchResponse),
|
||||
(status = 403, description = "Not part of this match"),
|
||||
(status = 404, description = "Match not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn cancel_match(
|
||||
user: AuthUser,
|
||||
State(state): State<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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
// Can only cancel pending or scheduled matches
|
||||
if current_match.matche_status > match_status::SCHEDULED {
|
||||
return Err(AppError::BadRequest("Cannot cancel a match that has started or ended".to_string()));
|
||||
}
|
||||
@@ -591,8 +629,21 @@ pub async fn cancel_match(
|
||||
Ok(Json(updated.into()))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score
|
||||
/// Report a score for a match round
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/score",
|
||||
tag = "matches",
|
||||
params(("id" = i32, Path, description = "Match ID")),
|
||||
request_body = ReportScoreRequest,
|
||||
responses(
|
||||
(status = 201, description = "Score reported", body = MatchRoundResponse),
|
||||
(status = 403, description = "Not part of this match"),
|
||||
(status = 404, description = "Match not found"),
|
||||
(status = 409, description = "Pending score report exists")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn report_score(
|
||||
user: AuthUser,
|
||||
State(state): State<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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
// Verify user is part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
// Match must be in progress
|
||||
if current_match.matche_status != match_status::IN_PROGRESS {
|
||||
return Err(AppError::BadRequest("Match must be in progress to report scores".to_string()));
|
||||
}
|
||||
|
||||
// Check if there's already a pending score report
|
||||
let pending_round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE match_id = $1 AND score_acceptance_status = $2"
|
||||
"SELECT * FROM match_rounds WHERE match_id = $1 AND score_confirmation_status = $2"
|
||||
)
|
||||
.bind(match_id)
|
||||
.bind(score_status::PENDING)
|
||||
@@ -634,10 +680,9 @@ pub async fn report_score(
|
||||
return Err(AppError::Conflict("There is already a pending score report".to_string()));
|
||||
}
|
||||
|
||||
// Create round with score
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
r#"
|
||||
INSERT INTO match_rounds (match_id, team_1_score, team_2_score, score_posted_by_team_id, score_acceptance_status)
|
||||
INSERT INTO match_rounds (match_id, team_1_score, team_2_score, score_posted_by_team_id, score_confirmation_status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
"#
|
||||
@@ -657,12 +702,26 @@ pub async fn report_score(
|
||||
team_1_score: round.team_1_score,
|
||||
team_2_score: round.team_2_score,
|
||||
score_posted_by_team_id: round.score_posted_by_team_id,
|
||||
score_acceptance_status: round.score_acceptance_status,
|
||||
score_confirmation_status: round.score_confirmation_status,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score/:round_id/accept
|
||||
/// Accept a reported score
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/score/{round_id}/accept",
|
||||
tag = "matches",
|
||||
params(
|
||||
("id" = i32, Path, description = "Match ID"),
|
||||
("round_id" = i32, Path, description = "Round ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Score accepted", body = MatchRoundResponse),
|
||||
(status = 403, description = "Not part of this match"),
|
||||
(status = 404, description = "Match or round not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn accept_score(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -670,13 +729,11 @@ 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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2"
|
||||
@@ -687,39 +744,33 @@ pub async fn accept_score(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?;
|
||||
|
||||
// Must be the other team to accept
|
||||
if round.score_posted_by_team_id == user_team_id {
|
||||
return Err(AppError::BadRequest("Cannot accept your own score report".to_string()));
|
||||
}
|
||||
|
||||
// Must be part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
if round.score_acceptance_status != score_status::PENDING {
|
||||
if round.score_confirmation_status != score_status::PENDING {
|
||||
return Err(AppError::BadRequest("Score has already been processed".to_string()));
|
||||
}
|
||||
|
||||
// Update round
|
||||
let updated_round = sqlx::query_as::<_, MatchRound>(
|
||||
"UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *"
|
||||
"UPDATE match_rounds SET score_confirmation_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(score_status::ACCEPTED)
|
||||
.bind(round_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Update match status to done
|
||||
sqlx::query("UPDATE matches SET matche_status = $1 WHERE id = $2")
|
||||
.bind(match_status::DONE)
|
||||
.bind(match_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Update team and player ratings using OpenSkill
|
||||
if updated_round.team_1_score != updated_round.team_2_score {
|
||||
// Only update ratings if there's a clear winner (not a draw)
|
||||
let rating_result = crate::services::rating::process_match_result(
|
||||
&state.pool,
|
||||
current_match.team_id_1,
|
||||
@@ -739,7 +790,6 @@ pub async fn accept_score(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update ratings for match {}: {}", match_id, e);
|
||||
// Don't fail the request, ratings are secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,12 +801,26 @@ pub async fn accept_score(
|
||||
team_1_score: updated_round.team_1_score,
|
||||
team_2_score: updated_round.team_2_score,
|
||||
score_posted_by_team_id: updated_round.score_posted_by_team_id,
|
||||
score_acceptance_status: updated_round.score_acceptance_status,
|
||||
score_confirmation_status: updated_round.score_confirmation_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/matches/:id/score/:round_id/reject
|
||||
/// Reject a reported score
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/matches/{id}/score/{round_id}/reject",
|
||||
tag = "matches",
|
||||
params(
|
||||
("id" = i32, Path, description = "Match ID"),
|
||||
("round_id" = i32, Path, description = "Round ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Score rejected", body = MatchRoundResponse),
|
||||
(status = 403, description = "Not part of this match"),
|
||||
(status = 404, description = "Match or round not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn reject_score(
|
||||
user: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -764,13 +828,11 @@ 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"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
let current_match = sqlx::query_as::<_, Match>("SELECT * FROM matches WHERE id = $1")
|
||||
.bind(match_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Match not found".to_string()))?;
|
||||
|
||||
let round = sqlx::query_as::<_, MatchRound>(
|
||||
"SELECT * FROM match_rounds WHERE id = $1 AND match_id = $2"
|
||||
@@ -781,22 +843,20 @@ pub async fn reject_score(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Round not found".to_string()))?;
|
||||
|
||||
// Must be the other team to reject
|
||||
if round.score_posted_by_team_id == user_team_id {
|
||||
return Err(AppError::BadRequest("Cannot reject your own score report".to_string()));
|
||||
}
|
||||
|
||||
// Must be part of the match
|
||||
if current_match.team_id_1 != user_team_id && current_match.team_id_2 != user_team_id {
|
||||
return Err(AppError::Forbidden("You are not part of this match".to_string()));
|
||||
}
|
||||
|
||||
if round.score_acceptance_status != score_status::PENDING {
|
||||
if round.score_confirmation_status != score_status::PENDING {
|
||||
return Err(AppError::BadRequest("Score has already been processed".to_string()));
|
||||
}
|
||||
|
||||
let updated_round = sqlx::query_as::<_, MatchRound>(
|
||||
"UPDATE match_rounds SET score_acceptance_status = $1 WHERE id = $2 RETURNING *"
|
||||
"UPDATE match_rounds SET score_confirmation_status = $1 WHERE id = $2 RETURNING *"
|
||||
)
|
||||
.bind(score_status::REJECTED)
|
||||
.bind(round_id)
|
||||
@@ -810,12 +870,26 @@ pub async fn reject_score(
|
||||
team_1_score: updated_round.team_1_score,
|
||||
team_2_score: updated_round.team_2_score,
|
||||
score_posted_by_team_id: updated_round.score_posted_by_team_id,
|
||||
score_acceptance_status: updated_round.score_acceptance_status,
|
||||
score_confirmation_status: updated_round.score_confirmation_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/my-matches
|
||||
/// Get current user's team matches
|
||||
/// Get current users team matches
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/my-matches",
|
||||
tag = "matches",
|
||||
params(
|
||||
("status" = Option<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 }))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1
|
||||
ORDER BY {} {}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
WHERE u.username ILIKE $1 OR u.firstname ILIKE $1 OR u.lastname ILIKE $1
|
||||
ORDER BY {} {} LIMIT $2 OFFSET $3"#,
|
||||
sort_column, sort_order
|
||||
);
|
||||
|
||||
let players = sqlx::query_as(&query_str)
|
||||
.bind(pattern)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let players = sqlx::query_as(&query_str).bind(pattern).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
|
||||
(total, players)
|
||||
} else {
|
||||
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
let total = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.pool).await?;
|
||||
let query_str = format!(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
ORDER BY {} {}
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
r#"SELECT u.id, u.username, u.firstname, u.lastname, u.profile, u.date_registered,
|
||||
tp.team_id, t.name as team_name, tp.position as team_position
|
||||
FROM users u LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
ORDER BY {} {} LIMIT $1 OFFSET $2"#,
|
||||
sort_column, sort_order
|
||||
);
|
||||
|
||||
let players = sqlx::query_as(&query_str)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let players = sqlx::query_as(&query_str).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
|
||||
(total, players)
|
||||
};
|
||||
|
||||
Ok(Json(PlayerListResponse {
|
||||
players,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
Ok(Json(PlayerListResponse { players, total, page, per_page }))
|
||||
}
|
||||
|
||||
/// GET /api/players/:id
|
||||
/// Get player profile with stats
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/players/{id}",
|
||||
tag = "players",
|
||||
params(("id" = i32, Path, description = "Player ID")),
|
||||
responses(
|
||||
(status = 200, description = "Player profile", body = PlayerProfileResponse),
|
||||
(status = 404, description = "Player not found")
|
||||
)
|
||||
)]
|
||||
pub async fn get_player(
|
||||
State(state): State<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?
|
||||
.ok_or_else(|| AppError::NotFound("Player not found".to_string()))?;
|
||||
).bind(player_id).fetch_optional(&state.pool).await?
|
||||
.ok_or_else(|| AppError::NotFound("Player not found".to_string()))?;
|
||||
|
||||
// Get team info
|
||||
let team_info = sqlx::query_as::<_, (i32, String, String, String, chrono::DateTime<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
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
ORDER BY fp.rank ASC
|
||||
"#
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
|
||||
FROM featured_players fp JOIN users u ON fp.player_id = u.id
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false ORDER BY fp.rank ASC"#
|
||||
).fetch_all(&state.pool).await?;
|
||||
|
||||
Ok(Json(FeaturedPlayersResponse { players }))
|
||||
}
|
||||
|
||||
/// POST /api/players/featured
|
||||
/// Add/update featured player (admin only)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/players/featured",
|
||||
tag = "players",
|
||||
request_body = SetFeaturedPlayerRequest,
|
||||
responses(
|
||||
(status = 201, description = "Featured player set", body = FeaturedPlayerInfo),
|
||||
(status = 403, description = "Admin only"),
|
||||
(status = 404, description = "Player not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_featured_player(
|
||||
user: AuthUser,
|
||||
State(state): State<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
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
WHERE fp.player_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(payload.player_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
r#"SELECT fp.id, fp.player_id, fp.rank, u.username, u.firstname, u.lastname, u.profile, t.name as team_name
|
||||
FROM featured_players fp JOIN users u ON fp.player_id = u.id
|
||||
LEFT JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
LEFT JOIN teams t ON tp.team_id = t.id AND t.is_delete = false WHERE fp.player_id = $1"#
|
||||
).bind(payload.player_id).fetch_one(&state.pool).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(featured)))
|
||||
}
|
||||
|
||||
/// DELETE /api/players/featured/:player_id
|
||||
/// Remove featured player (admin only)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/players/featured/{player_id}",
|
||||
tag = "players",
|
||||
params(("player_id" = i32, Path, description = "Player ID")),
|
||||
responses(
|
||||
(status = 204, description = "Featured player removed"),
|
||||
(status = 403, description = "Admin only"),
|
||||
(status = 404, description = "Featured player not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_featured_player(
|
||||
user: AuthUser,
|
||||
State(state): State<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
|
||||
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
JOIN (
|
||||
SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
|
||||
UNION ALL
|
||||
SELECT team_id_2 FROM matches WHERE matche_status = 3
|
||||
) m ON t.id = m.team_id
|
||||
"#
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
r#"SELECT COUNT(DISTINCT u.id) FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
JOIN (SELECT team_id_1 as team_id FROM matches WHERE matche_status = 3
|
||||
UNION ALL SELECT team_id_2 FROM matches WHERE matche_status = 3) m ON t.id = m.team_id"#
|
||||
).fetch_one(&state.pool).await.unwrap_or(0);
|
||||
|
||||
// Get leaderboard with match stats
|
||||
let players = sqlx::query_as::<_, LeaderboardEntry>(
|
||||
r#"
|
||||
WITH player_matches AS (
|
||||
SELECT
|
||||
u.id as player_id,
|
||||
u.username,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
u.profile,
|
||||
t.name as team_name,
|
||||
t.id as team_id
|
||||
FROM users u
|
||||
JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
r#"WITH player_matches AS (
|
||||
SELECT u.id as player_id, u.username, u.firstname, u.lastname, u.profile,
|
||||
t.name as team_name, t.id as team_id
|
||||
FROM users u JOIN team_players tp ON u.id = tp.player_id AND tp.status = 1
|
||||
JOIN teams t ON tp.team_id = t.id AND t.is_delete = false
|
||||
),
|
||||
match_results AS (
|
||||
SELECT
|
||||
pm.player_id,
|
||||
COUNT(DISTINCT m.id) as matches_played,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
|
||||
OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score)
|
||||
THEN m.id
|
||||
END) as matches_won
|
||||
SELECT pm.player_id, COUNT(DISTINCT m.id) as matches_played,
|
||||
COUNT(DISTINCT CASE WHEN (m.team_id_1 = pm.team_id AND mr.team_1_score > mr.team_2_score)
|
||||
OR (m.team_id_2 = pm.team_id AND mr.team_2_score > mr.team_1_score) THEN m.id END) as matches_won
|
||||
FROM player_matches pm
|
||||
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id)
|
||||
AND m.matche_status = 3
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
|
||||
LEFT JOIN matches m ON (m.team_id_1 = pm.team_id OR m.team_id_2 = pm.team_id) AND m.matche_status = 3
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
|
||||
GROUP BY pm.player_id
|
||||
)
|
||||
SELECT
|
||||
ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
|
||||
pm.player_id,
|
||||
pm.username,
|
||||
pm.firstname,
|
||||
pm.lastname,
|
||||
pm.profile,
|
||||
pm.team_name,
|
||||
mr.matches_won,
|
||||
mr.matches_played
|
||||
FROM player_matches pm
|
||||
LEFT JOIN match_results mr ON pm.player_id = mr.player_id
|
||||
ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#
|
||||
)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC) as rank,
|
||||
pm.player_id, pm.username, pm.firstname, pm.lastname, pm.profile, pm.team_name,
|
||||
mr.matches_won, mr.matches_played
|
||||
FROM player_matches pm LEFT JOIN match_results mr ON pm.player_id = mr.player_id
|
||||
ORDER BY COALESCE(mr.matches_won, 0) DESC, pm.username ASC LIMIT $1 OFFSET $2"#
|
||||
).bind(per_page).bind(offset).fetch_all(&state.pool).await?;
|
||||
|
||||
Ok(Json(LeaderboardResponse {
|
||||
players,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
}))
|
||||
Ok(Json(LeaderboardResponse { players, total, page, per_page }))
|
||||
}
|
||||
|
||||
/// Get a players rating history
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/players/rating-history/{player_id}",
|
||||
tag = "players",
|
||||
params(("player_id" = i32, Path, description = "Player ID")),
|
||||
responses(
|
||||
(status = 200, description = "Rating history", body = Vec<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,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score,
|
||||
CASE
|
||||
WHEN mr.id IS NULL THEN 'pending'
|
||||
r#"SELECT m.id as match_id, m.date_start as date_played,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t2.name ELSE t1.name END as opponent_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN t1.name ELSE t2.name END as own_team_name,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_1_score, 0) ELSE COALESCE(mr.team_2_score, 0) END as own_score,
|
||||
CASE WHEN m.team_id_1 = $1 THEN COALESCE(mr.team_2_score, 0) ELSE COALESCE(mr.team_1_score, 0) END as opponent_score,
|
||||
CASE WHEN mr.id IS NULL THEN 'pending'
|
||||
WHEN m.team_id_1 = $1 AND mr.team_1_score > mr.team_2_score THEN 'win'
|
||||
WHEN m.team_id_2 = $1 AND mr.team_2_score > mr.team_1_score THEN 'win'
|
||||
WHEN mr.team_1_score = mr.team_2_score THEN 'draw'
|
||||
ELSE 'loss'
|
||||
END as result
|
||||
FROM matches m
|
||||
JOIN teams t1 ON m.team_id_1 = t1.id
|
||||
JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_acceptance_status = 1
|
||||
WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3
|
||||
ORDER BY m.date_start DESC
|
||||
LIMIT $2
|
||||
"#
|
||||
)
|
||||
.bind(team_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
WHEN mr.team_1_score = mr.team_2_score THEN 'draw' ELSE 'loss' END as result
|
||||
FROM matches m JOIN teams t1 ON m.team_id_1 = t1.id JOIN teams t2 ON m.team_id_2 = t2.id
|
||||
LEFT JOIN match_rounds mr ON m.id = mr.match_id AND mr.score_confirmation_status = 1
|
||||
WHERE (m.team_id_1 = $1 OR m.team_id_2 = $1) AND m.matche_status = 3
|
||||
ORDER BY m.date_start DESC LIMIT $2"#
|
||||
).bind(team_id).bind(limit).fetch_all(pool).await?;
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
// Note: Profile upload is now handled by uploads.rs using multipart forms
|
||||
|
||||
/// GET /api/players/rating-history/:player_id
|
||||
/// Get a player's rating history
|
||||
pub async fn get_rating_history(
|
||||
State(state): State<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>,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
175
src/openapi.rs
175
src/openapi.rs
@@ -1,21 +1,30 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
handlers::{
|
||||
auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse},
|
||||
health::HealthResponse,
|
||||
users::UserProfileResponse,
|
||||
use crate::handlers::{
|
||||
auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse},
|
||||
health::HealthResponse,
|
||||
users::UserProfileResponse,
|
||||
teams::{
|
||||
TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
|
||||
TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
|
||||
MyTeamResponse, MessageResponse,
|
||||
},
|
||||
models::{
|
||||
featured_player::{FeaturedPlayer, FeaturedPlayerWithUser},
|
||||
ladder::{CreateLadder, Ladder, LadderStatus, UpdateLadder},
|
||||
ladder_team::{JoinLadderRequest, LadderTeam},
|
||||
match_model::{CreateChallengeRequest, Match, MatchStatus, RespondChallengeRequest, TeamChallengeStatus},
|
||||
match_round::{MatchRound, ScoreAcceptanceStatus, SubmitScoreRequest},
|
||||
team::{CreateTeam, Team, TeamMember, TeamWithStats, UpdateTeam},
|
||||
team_player::{ChangePositionRequest, JoinTeamRequest, PlayerPosition, TeamPlayer, TeamPlayerStatus},
|
||||
user::{CreateUser, PlayerStats, UpdateUser, User, UserProfile},
|
||||
matches::{
|
||||
ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
|
||||
MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
|
||||
},
|
||||
ladders::{
|
||||
ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest,
|
||||
UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse,
|
||||
EnrollmentResponse,
|
||||
},
|
||||
players::{
|
||||
ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfileResponse as PlayerProfile,
|
||||
PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse,
|
||||
FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry,
|
||||
RatingHistoryEntry,
|
||||
},
|
||||
uploads::UploadResponse,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
@@ -24,13 +33,8 @@ use crate::{
|
||||
title = "VRBattles API",
|
||||
version = "1.0.0",
|
||||
description = "VRBattles Esports Platform API - Manage teams, matches, tournaments, and rankings for competitive VR gaming.",
|
||||
contact(
|
||||
name = "VRBattles Team",
|
||||
url = "https://vrbattles.gg"
|
||||
),
|
||||
license(
|
||||
name = "Proprietary"
|
||||
)
|
||||
contact(name = "VRBattles Team", url = "https://vrbattles.gg"),
|
||||
license(name = "Proprietary")
|
||||
),
|
||||
servers(
|
||||
(url = "https://api.vrb.gg", description = "Production server"),
|
||||
@@ -54,55 +58,76 @@ use crate::{
|
||||
crate::handlers::auth::register,
|
||||
// Users
|
||||
crate::handlers::users::get_current_user,
|
||||
// Teams
|
||||
crate::handlers::teams::list_teams,
|
||||
crate::handlers::teams::create_team,
|
||||
crate::handlers::teams::get_team,
|
||||
crate::handlers::teams::update_team,
|
||||
crate::handlers::teams::delete_team,
|
||||
crate::handlers::teams::request_join_team,
|
||||
crate::handlers::teams::cancel_join_request,
|
||||
crate::handlers::teams::accept_member,
|
||||
crate::handlers::teams::reject_member,
|
||||
crate::handlers::teams::remove_member,
|
||||
crate::handlers::teams::change_member_position,
|
||||
crate::handlers::teams::get_my_team,
|
||||
// Matches
|
||||
crate::handlers::matches::list_matches,
|
||||
crate::handlers::matches::get_match,
|
||||
crate::handlers::matches::create_challenge,
|
||||
crate::handlers::matches::accept_challenge,
|
||||
crate::handlers::matches::reject_challenge,
|
||||
crate::handlers::matches::start_match,
|
||||
crate::handlers::matches::cancel_match,
|
||||
crate::handlers::matches::report_score,
|
||||
crate::handlers::matches::accept_score,
|
||||
crate::handlers::matches::reject_score,
|
||||
crate::handlers::matches::get_my_matches,
|
||||
// Ladders
|
||||
crate::handlers::ladders::list_ladders,
|
||||
crate::handlers::ladders::create_ladder,
|
||||
crate::handlers::ladders::get_ladder,
|
||||
crate::handlers::ladders::update_ladder,
|
||||
crate::handlers::ladders::delete_ladder,
|
||||
crate::handlers::ladders::enroll_team,
|
||||
crate::handlers::ladders::withdraw_team,
|
||||
crate::handlers::ladders::get_ladder_standings,
|
||||
// Players
|
||||
crate::handlers::players::list_players,
|
||||
crate::handlers::players::get_player,
|
||||
crate::handlers::players::get_featured_players,
|
||||
crate::handlers::players::set_featured_player,
|
||||
crate::handlers::players::remove_featured_player,
|
||||
crate::handlers::players::get_leaderboard,
|
||||
crate::handlers::players::get_rating_history,
|
||||
// Uploads
|
||||
crate::handlers::uploads::upload_team_logo,
|
||||
crate::handlers::uploads::upload_profile_picture,
|
||||
crate::handlers::uploads::upload_ladder_logo,
|
||||
crate::handlers::uploads::delete_team_logo,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
// Health
|
||||
HealthResponse,
|
||||
// Auth
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
UserResponse,
|
||||
UserProfileResponse,
|
||||
// User
|
||||
User,
|
||||
UserProfile,
|
||||
CreateUser,
|
||||
UpdateUser,
|
||||
PlayerStats,
|
||||
// Team
|
||||
Team,
|
||||
TeamWithStats,
|
||||
CreateTeam,
|
||||
UpdateTeam,
|
||||
TeamMember,
|
||||
// Team Player
|
||||
TeamPlayer,
|
||||
JoinTeamRequest,
|
||||
ChangePositionRequest,
|
||||
PlayerPosition,
|
||||
TeamPlayerStatus,
|
||||
// Ladder
|
||||
Ladder,
|
||||
LadderStatus,
|
||||
CreateLadder,
|
||||
UpdateLadder,
|
||||
LadderTeam,
|
||||
JoinLadderRequest,
|
||||
// Match
|
||||
Match,
|
||||
MatchStatus,
|
||||
TeamChallengeStatus,
|
||||
CreateChallengeRequest,
|
||||
RespondChallengeRequest,
|
||||
// Match Round
|
||||
MatchRound,
|
||||
ScoreAcceptanceStatus,
|
||||
SubmitScoreRequest,
|
||||
// Featured Player
|
||||
FeaturedPlayer,
|
||||
FeaturedPlayerWithUser,
|
||||
HealthResponse, LoginRequest, RegisterRequest, AuthResponse, UserResponse, UserProfileResponse,
|
||||
// Teams
|
||||
TeamListResponse, TeamWithMemberCount, CreateTeamRequest, UpdateTeamRequest,
|
||||
TeamResponse, TeamDetailResponse, TeamMemberResponse, ChangePositionRequest,
|
||||
MyTeamResponse, MessageResponse,
|
||||
// Matches
|
||||
ListMatchesQuery, MatchListResponse, MatchWithTeams, CreateChallengeRequest,
|
||||
MatchResponse, MatchDetailResponse, MatchRoundResponse, ReportScoreRequest,
|
||||
// Ladders
|
||||
ListLaddersQuery, LadderListResponse, LadderWithTeamCount, CreateLadderRequest,
|
||||
UpdateLadderRequest, LadderResponse, LadderDetailResponse, LadderTeamResponse,
|
||||
EnrollmentResponse,
|
||||
// Players
|
||||
ListPlayersQuery, PlayerListResponse, PlayerSummary, PlayerProfile,
|
||||
PlayerTeamInfo, PlayerStats, PlayerMatchSummary, FeaturedPlayersResponse,
|
||||
FeaturedPlayerInfo, SetFeaturedPlayerRequest, LeaderboardResponse, LeaderboardEntry,
|
||||
RatingHistoryEntry,
|
||||
// Uploads
|
||||
UploadResponse,
|
||||
)
|
||||
),
|
||||
modifiers(&SecurityAddon)
|
||||
@@ -113,16 +138,12 @@ struct SecurityAddon;
|
||||
|
||||
impl utoipa::Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
utoipa::openapi::security::SecurityScheme::Http(
|
||||
utoipa::openapi::security::Http::new(
|
||||
utoipa::openapi::security::HttpAuthScheme::Bearer,
|
||||
)
|
||||
.bearer_format("JWT"),
|
||||
),
|
||||
);
|
||||
}
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
utoipa::openapi::security::SecurityScheme::Http(
|
||||
utoipa::openapi::security::Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user