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