use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use utoipa::ToSchema; use validator::Validate; use crate::{ auth::{AuthUser, OptionalAuthUser}, error::{AppError, Result}, models::team::Team, models::team_player::{PlayerPosition, TeamPlayer}, }; use super::auth::AppState; // ============================================================================ // Request/Response Types // ============================================================================ #[derive(Debug, Deserialize, Default, ToSchema)] pub struct ListTeamsQuery { pub search: Option, pub page: Option, pub per_page: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct TeamListResponse { pub teams: Vec, pub total: i64, pub page: i64, pub per_page: i64, } #[derive(Debug, Serialize, FromRow, ToSchema)] pub struct TeamWithMemberCount { pub id: i32, pub name: String, pub logo: String, pub bio: Option, pub rank: i32, pub mmr: f32, pub date_created: chrono::DateTime, pub member_count: Option, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateTeamRequest { #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] pub name: String, pub bio: Option, } #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateTeamRequest { #[validate(length(min = 2, max = 255, message = "Team name must be 2-255 characters"))] pub name: Option, pub bio: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct TeamResponse { pub id: i32, pub name: String, pub logo: String, pub bio: Option, pub rank: i32, pub mmr: f32, pub mu: f32, pub sigma: f32, pub ordinal: f32, pub date_created: chrono::DateTime, } impl From for TeamResponse { fn from(team: Team) -> Self { TeamResponse { id: team.id, name: team.name, logo: team.logo, bio: team.bio, rank: team.rank, mmr: team.mmr, mu: team.mu.unwrap_or(25.0), sigma: team.sigma.unwrap_or(8.333), ordinal: team.ordinal.unwrap_or(0.0), date_created: team.date_created, } } } #[derive(Debug, Serialize, ToSchema)] pub struct TeamDetailResponse { pub team: TeamResponse, pub members: Vec, pub is_member: bool, pub is_captain: bool, pub pending_request: bool, } #[derive(Debug, Serialize, FromRow, ToSchema)] pub struct TeamMemberResponse { pub id: i32, pub player_id: i32, pub username: String, pub firstname: String, pub lastname: String, pub profile: Option, pub position: String, pub status: i32, pub date_joined: chrono::DateTime, } #[derive(Debug, Deserialize, ToSchema)] pub struct ChangePositionRequest { pub position: String, } #[derive(Debug, Serialize, ToSchema)] pub struct MyTeamResponse { pub team: Option, pub position: Option, pub members: Vec, pub pending_requests: Vec, } #[derive(Debug, Serialize, ToSchema)] pub struct MessageResponse { pub message: String, } // ============================================================================ // Handlers // ============================================================================ /// List all teams with pagination and search #[utoipa::path( get, path = "/api/teams", tag = "teams", params( ("search" = Option, Query, description = "Search by team name"), ("page" = Option, Query, description = "Page number (default: 1)"), ("per_page" = Option, Query, description = "Items per page (default: 20, max: 100)") ), responses( (status = 200, description = "List of teams", body = TeamListResponse) ) )] pub async fn list_teams( State(state): State, Query(query): Query, ) -> 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)); let total: i64 = if let Some(ref pattern) = search_pattern { sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false AND name ILIKE $1") .bind(pattern) .fetch_one(&state.pool) .await? } else { sqlx::query_scalar("SELECT COUNT(*) FROM teams WHERE is_delete = false") .fetch_one(&state.pool) .await? }; let teams: Vec = if let Some(ref pattern) = search_pattern { sqlx::query_as( r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id WHERE t.is_delete = false AND t.name ILIKE $1 GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $2 OFFSET $3"# ) .bind(pattern) .bind(per_page) .bind(offset) .fetch_all(&state.pool) .await? } else { sqlx::query_as( r#"SELECT t.id, t.name, t.logo, t.bio, t.rank, t.mmr, t.date_created, COUNT(tp.id) FILTER (WHERE tp.status = 1) as member_count FROM teams t LEFT JOIN team_players tp ON t.id = tp.team_id WHERE t.is_delete = false GROUP BY t.id ORDER BY t.rank ASC, t.mmr DESC LIMIT $1 OFFSET $2"# ) .bind(per_page) .bind(offset) .fetch_all(&state.pool) .await? }; Ok(Json(TeamListResponse { teams, total, page, per_page })) } /// Create a new team (creator becomes captain) #[utoipa::path( post, path = "/api/teams", tag = "teams", request_body = CreateTeamRequest, responses( (status = 201, description = "Team created", body = TeamDetailResponse), (status = 409, description = "Already in a team or team name exists") ), security(("bearer_auth" = [])) )] pub async fn create_team( user: AuthUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json)> { payload.validate()?; let existing_membership = sqlx::query_scalar::<_, i32>( "SELECT id FROM team_players WHERE player_id = $1 AND status = 1" ) .bind(user.id) .fetch_optional(&state.pool) .await?; if existing_membership.is_some() { return Err(AppError::Conflict("You are already a member of a team".to_string())); } let existing_team = sqlx::query_scalar::<_, i32>( "SELECT id FROM teams WHERE name = $1 AND is_delete = false" ) .bind(&payload.name) .fetch_optional(&state.pool) .await?; if existing_team.is_some() { return Err(AppError::Conflict("Team name already exists".to_string())); } let team = sqlx::query_as::<_, Team>( "INSERT INTO teams (name, bio, logo, rank, mmr) VALUES ($1, $2, '', 0, 1000.0) RETURNING *" ) .bind(&payload.name) .bind(&payload.bio) .fetch_one(&state.pool) .await?; sqlx::query( "INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'captain', 1, '', '')" ) .bind(team.id) .bind(user.id) .execute(&state.pool) .await?; let members = get_team_members(&state.pool, team.id).await?; Ok((StatusCode::CREATED, Json(TeamDetailResponse { team: team.into(), members, is_member: true, is_captain: true, pending_request: false, }))) } /// Get team details by ID #[utoipa::path( get, path = "/api/teams/{id}", tag = "teams", params(("id" = i32, Path, description = "Team ID")), responses( (status = 200, description = "Team details", body = TeamDetailResponse), (status = 404, description = "Team not found") ) )] pub async fn get_team( user: OptionalAuthUser, State(state): State, Path(id): Path, ) -> Result> { let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false") .bind(id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; let members = get_team_members(&state.pool, team.id).await?; let (is_member, is_captain, pending_request) = if let Some(ref auth_user) = user.0 { let membership = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2" ) .bind(team.id) .bind(auth_user.id) .fetch_optional(&state.pool) .await?; match membership { Some(m) => (m.status == 1, m.position == "captain", m.status == 0), None => (false, false, false), } } else { (false, false, false) }; Ok(Json(TeamDetailResponse { team: team.into(), members, is_member, is_captain, pending_request, })) } /// Update team details (captain only) #[utoipa::path( put, path = "/api/teams/{id}", tag = "teams", params(("id" = i32, Path, description = "Team ID")), request_body = UpdateTeamRequest, responses( (status = 200, description = "Team updated", body = TeamResponse), (status = 403, description = "Not team captain"), (status = 404, description = "Team not found") ), security(("bearer_auth" = [])) )] pub async fn update_team( user: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result> { payload.validate()?; let _membership = verify_captain(&state.pool, id, user.id).await?; let team = sqlx::query_as::<_, Team>( "UPDATE teams SET name = COALESCE($1, name), bio = COALESCE($2, bio) WHERE id = $3 RETURNING *" ) .bind(&payload.name) .bind(&payload.bio) .bind(id) .fetch_one(&state.pool) .await?; Ok(Json(team.into())) } /// Delete team (captain only) #[utoipa::path( delete, path = "/api/teams/{id}", tag = "teams", params(("id" = i32, Path, description = "Team ID")), responses( (status = 200, description = "Team deleted", body = MessageResponse), (status = 403, description = "Not team captain"), (status = 404, description = "Team not found") ), security(("bearer_auth" = [])) )] pub async fn delete_team( user: AuthUser, State(state): State, Path(id): Path, ) -> Result> { let _membership = verify_captain(&state.pool, id, user.id).await?; sqlx::query("UPDATE teams SET is_delete = true WHERE id = $1") .bind(id) .execute(&state.pool) .await?; sqlx::query("DELETE FROM team_players WHERE team_id = $1") .bind(id) .execute(&state.pool) .await?; Ok(Json(MessageResponse { message: "Team deleted successfully".to_string() })) } /// Request to join a team #[utoipa::path( post, path = "/api/teams/{id}/join-request", tag = "teams", params(("id" = i32, Path, description = "Team ID")), responses( (status = 200, description = "Join request sent", body = MessageResponse), (status = 409, description = "Already in a team or request pending") ), security(("bearer_auth" = [])) )] pub async fn request_join_team( user: AuthUser, State(state): State, Path(id): Path, ) -> Result> { let _team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1 AND is_delete = false") .bind(id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound("Team not found".to_string()))?; let existing = sqlx::query_scalar::<_, i32>( "SELECT id FROM team_players WHERE player_id = $1 AND (status = 1 OR (team_id = $2 AND status = 0))" ) .bind(user.id) .bind(id) .fetch_optional(&state.pool) .await?; if existing.is_some() { return Err(AppError::Conflict("Already in a team or request pending".to_string())); } sqlx::query("INSERT INTO team_players (team_id, player_id, position, status, game_name, game_mode) VALUES ($1, $2, 'member', 0, '', '')") .bind(id) .bind(user.id) .execute(&state.pool) .await?; Ok(Json(MessageResponse { message: "Join request sent".to_string() })) } /// Cancel join request #[utoipa::path( delete, path = "/api/teams/{id}/join-request", tag = "teams", params(("id" = i32, Path, description = "Team ID")), responses( (status = 200, description = "Request cancelled", body = MessageResponse), (status = 404, description = "No pending request found") ), security(("bearer_auth" = [])) )] pub async fn cancel_join_request( user: AuthUser, State(state): State, Path(id): Path, ) -> Result> { let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0") .bind(id) .bind(user.id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("No pending request found".to_string())); } Ok(Json(MessageResponse { message: "Join request cancelled".to_string() })) } /// Accept a join request (captain only) #[utoipa::path( post, path = "/api/teams/{id}/members/{player_id}/accept", tag = "teams", params( ("id" = i32, Path, description = "Team ID"), ("player_id" = i32, Path, description = "Player ID to accept") ), responses( (status = 200, description = "Member accepted", body = MessageResponse), (status = 403, description = "Not team captain"), (status = 404, description = "No pending request from this player") ), security(("bearer_auth" = [])) )] pub async fn accept_member( user: AuthUser, State(state): State, Path((id, player_id)): Path<(i32, i32)>, ) -> Result> { let _membership = verify_captain(&state.pool, id, user.id).await?; let result = sqlx::query("UPDATE team_players SET status = 1 WHERE team_id = $1 AND player_id = $2 AND status = 0") .bind(id) .bind(player_id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("No pending request from this player".to_string())); } Ok(Json(MessageResponse { message: "Member accepted".to_string() })) } /// Reject a join request (captain only) #[utoipa::path( post, path = "/api/teams/{id}/members/{player_id}/reject", tag = "teams", params( ("id" = i32, Path, description = "Team ID"), ("player_id" = i32, Path, description = "Player ID to reject") ), responses( (status = 200, description = "Request rejected", body = MessageResponse), (status = 403, description = "Not team captain"), (status = 404, description = "No pending request from this player") ), security(("bearer_auth" = [])) )] pub async fn reject_member( user: AuthUser, State(state): State, Path((id, player_id)): Path<(i32, i32)>, ) -> Result> { let _membership = verify_captain(&state.pool, id, user.id).await?; let result = sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 0") .bind(id) .bind(player_id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("No pending request from this player".to_string())); } Ok(Json(MessageResponse { message: "Request rejected".to_string() })) } /// Remove a member from team (captain only) #[utoipa::path( delete, path = "/api/teams/{id}/members/{player_id}", tag = "teams", params( ("id" = i32, Path, description = "Team ID"), ("player_id" = i32, Path, description = "Player ID to remove") ), responses( (status = 200, description = "Member removed", body = MessageResponse), (status = 403, description = "Not team captain or cannot remove captain"), (status = 404, description = "Member not found") ), security(("bearer_auth" = [])) )] pub async fn remove_member( user: AuthUser, State(state): State, Path((id, player_id)): Path<(i32, i32)>, ) -> Result> { let _membership = verify_captain(&state.pool, id, user.id).await?; let target = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1" ) .bind(id) .bind(player_id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound("Member not found".to_string()))?; if target.position == "captain" { return Err(AppError::Forbidden("Cannot remove captain".to_string())); } sqlx::query("DELETE FROM team_players WHERE team_id = $1 AND player_id = $2") .bind(id) .bind(player_id) .execute(&state.pool) .await?; Ok(Json(MessageResponse { message: "Member removed".to_string() })) } /// Change member position (captain only) #[utoipa::path( put, path = "/api/teams/{id}/members/{player_id}/position", tag = "teams", params( ("id" = i32, Path, description = "Team ID"), ("player_id" = i32, Path, description = "Player ID") ), request_body = ChangePositionRequest, responses( (status = 200, description = "Position changed", body = MessageResponse), (status = 403, description = "Not team captain"), (status = 404, description = "Member not found") ), security(("bearer_auth" = [])) )] pub async fn change_member_position( user: AuthUser, State(state): State, Path((id, player_id)): Path<(i32, i32)>, Json(payload): Json, ) -> Result> { let _membership = verify_captain(&state.pool, id, user.id).await?; let position = PlayerPosition::from_str(&payload.position) .ok_or_else(|| AppError::BadRequest("Invalid position".to_string()))?; if position == PlayerPosition::Captain && player_id != user.id { sqlx::query("UPDATE team_players SET position = 'member' WHERE team_id = $1 AND player_id = $2") .bind(id) .bind(user.id) .execute(&state.pool) .await?; } 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(id) .bind(player_id) .execute(&state.pool) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Member not found".to_string())); } Ok(Json(MessageResponse { message: "Position updated".to_string() })) } /// Get current user's team #[utoipa::path( get, path = "/api/my-team", tag = "teams", responses( (status = 200, description = "User's team info", body = MyTeamResponse) ), security(("bearer_auth" = [])) )] pub async fn get_my_team( user: AuthUser, State(state): State, ) -> Result> { let membership = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE player_id = $1 AND status = 1" ) .bind(user.id) .fetch_optional(&state.pool) .await?; match membership { Some(m) => { let team = sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1") .bind(m.team_id) .fetch_one(&state.pool) .await?; let members = get_team_members(&state.pool, team.id).await?; let pending_requests = if m.position == "captain" || m.position == "co-captain" { sqlx::query_as::<_, TeamMemberResponse>( r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, tp.position, tp.status, tp.date_created as date_joined FROM team_players tp JOIN users u ON tp.player_id = u.id WHERE tp.team_id = $1 AND tp.status = 0"# ) .bind(team.id) .fetch_all(&state.pool) .await? } else { vec![] }; Ok(Json(MyTeamResponse { team: Some(team.into()), position: Some(m.position), members, pending_requests, })) } None => Ok(Json(MyTeamResponse { team: None, position: None, members: vec![], pending_requests: vec![], })) } } // ============================================================================ // Helper Functions // ============================================================================ async fn get_team_members(pool: &sqlx::PgPool, team_id: i32) -> Result> { let members = sqlx::query_as::<_, TeamMemberResponse>( r#"SELECT tp.id, tp.player_id, u.username, u.firstname, u.lastname, u.profile, tp.position, tp.status, tp.date_created as date_joined FROM team_players tp JOIN users u ON tp.player_id = u.id WHERE tp.team_id = $1 AND tp.status = 1 ORDER BY tp.position ASC, tp.date_created ASC"# ) .bind(team_id) .fetch_all(pool) .await?; Ok(members) } async fn verify_captain(pool: &sqlx::PgPool, team_id: i32, user_id: i32) -> Result { let membership = sqlx::query_as::<_, TeamPlayer>( "SELECT * FROM team_players WHERE team_id = $1 AND player_id = $2 AND status = 1" ) .bind(team_id) .bind(user_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::Forbidden("Not a member of this team".to_string()))?; 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) }