Files
vrbattles-api/src/handlers/players.rs
2026-01-20 05:41:25 +00:00

670 lines
20 KiB
Rust

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