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, pub page: Option, pub per_page: Option, pub sort_by: Option, // "mmr", "username", "date_registered" pub sort_order: Option, // "asc", "desc" } #[derive(Debug, Serialize)] pub struct PlayerListResponse { pub players: Vec, 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, pub date_registered: chrono::DateTime, pub team_id: Option, pub team_name: Option, pub team_position: Option, } #[derive(Debug, Serialize)] pub struct PlayerProfileResponse { pub id: i32, pub username: String, pub firstname: String, pub lastname: String, pub profile: Option, pub date_registered: chrono::DateTime, pub is_admin: bool, pub mu: f32, pub sigma: f32, pub ordinal: f32, pub mmr: f32, pub team: Option, pub stats: PlayerStats, pub recent_matches: Vec, } #[derive(Debug, Serialize)] pub struct PlayerTeamInfo { pub id: i32, pub name: String, pub logo: String, pub position: String, pub date_joined: chrono::DateTime, } #[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, 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, } #[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, pub team_name: Option, } #[derive(Debug, Deserialize)] pub struct SetFeaturedPlayerRequest { pub player_id: i32, pub rank: i32, } #[derive(Debug, Serialize)] pub struct LeaderboardResponse { pub players: Vec, 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, pub team_name: Option, pub matches_won: Option, pub matches_played: Option, } // ============================================================================ // Handlers // ============================================================================ /// GET /api/players /// List all players with pagination and search pub async fn list_players( Query(query): Query, State(state): State, ) -> Result> { 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) = 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, Path(player_id): Path, ) -> Result> { // Get basic player info including OpenSkill ratings let player = sqlx::query_as::<_, (i32, String, String, String, Option, chrono::DateTime, bool, Option, Option, Option, Option)>( "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)>( 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, ) -> Result> { 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, Json(payload): Json, ) -> Result<(StatusCode, Json)> { 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, Path(player_id): Path, ) -> Result { 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, State(state): State, ) -> Result> { 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, ) -> Result { // Get player's OpenSkill rating let rating: (Option, Option, Option, Option) = 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> { 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, Path(player_id): Path, ) -> Result>> { 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, pub match_id: Option, 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, }