727 lines
22 KiB
Rust
727 lines
22 KiB
Rust
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<String>,
|
|
pub page: Option<i64>,
|
|
pub per_page: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct TeamListResponse {
|
|
pub teams: Vec<TeamWithMemberCount>,
|
|
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<String>,
|
|
pub rank: i32,
|
|
pub mmr: f32,
|
|
pub date_created: chrono::DateTime<chrono::Utc>,
|
|
pub member_count: Option<i64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub bio: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct TeamResponse {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub logo: String,
|
|
pub bio: Option<String>,
|
|
pub rank: i32,
|
|
pub mmr: f32,
|
|
pub mu: f32,
|
|
pub sigma: f32,
|
|
pub ordinal: f32,
|
|
pub date_created: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
impl From<Team> 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<TeamMemberResponse>,
|
|
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<String>,
|
|
pub position: String,
|
|
pub status: i32,
|
|
pub date_joined: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
pub struct ChangePositionRequest {
|
|
pub position: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
pub struct MyTeamResponse {
|
|
pub team: Option<TeamResponse>,
|
|
pub position: Option<String>,
|
|
pub members: Vec<TeamMemberResponse>,
|
|
pub pending_requests: Vec<TeamMemberResponse>,
|
|
}
|
|
|
|
#[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<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(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<ListTeamsQuery>,
|
|
) -> Result<Json<TeamListResponse>> {
|
|
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<TeamWithMemberCount> = 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<AppState>,
|
|
Json(payload): Json<CreateTeamRequest>,
|
|
) -> Result<(StatusCode, Json<TeamDetailResponse>)> {
|
|
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<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)
|
|
.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<AppState>,
|
|
Path(id): Path<i32>,
|
|
Json(payload): Json<UpdateTeamRequest>,
|
|
) -> Result<Json<TeamResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path((id, player_id)): Path<(i32, i32)>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path((id, player_id)): Path<(i32, i32)>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path((id, player_id)): Path<(i32, i32)>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
Path((id, player_id)): Path<(i32, i32)>,
|
|
Json(payload): Json<ChangePositionRequest>,
|
|
) -> Result<Json<MessageResponse>> {
|
|
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<AppState>,
|
|
) -> Result<Json<MyTeamResponse>> {
|
|
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<Vec<TeamMemberResponse>> {
|
|
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<TeamPlayer> {
|
|
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)
|
|
}
|