use crate::extractors::GameId; use crate::{ app::AppState, error::{AppError, AppResult}, models::{card::ActiveSlot, club::Club}, services::{ checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc, }, }; use axum::{extract::State, Json}; use serde::Deserialize; use serde_json::{json, Value}; use std::str::FromStr; pub async fn get_club(State(state): State, game: GameId) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; Ok(Json(club)) } #[derive(Deserialize)] pub struct UpdateClubRequest { pub name: Option, pub manager_name: Option, } pub async fn put_club( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let updated = club_svc::update_club( &state.pool, &club.id, req.name.as_deref(), req.manager_name.as_deref(), ) .await?; Ok(Json(json!({ "club": updated }))) } pub async fn get_checkin_status( State(state): State, game: GameId, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let status = checkin_svc::get_status(&state.pool, &profile.id).await?; Ok(Json(json!({ "available": status.available, "streak_day": status.streak_day, "next_reward_coins": status.next_reward_coins, "next_reward_pack": status.next_reward_pack, "last_checked_in": status.last_checked_in, }))) } pub async fn post_checkin(State(state): State, game: GameId) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?; Ok(Json(json!({ "coins_awarded": r.coins_awarded, "pack_awarded": r.pack_awarded, "new_streak": r.new_streak, "already_claimed": r.already_claimed, }))) } pub async fn get_milestones(State(state): State, game: GameId) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; let seasons_completed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?") .bind(&profile.id) .fetch_one(&state.pool) .await .unwrap_or(0); let highest_division: i64 = sqlx::query_scalar( "SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?", ) .bind(&profile.id) .fetch_one(&state.pool) .await .unwrap_or(10); let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?") .bind(&club.id) .fetch_one(&state.pool) .await .unwrap_or(0); let sbcs_completed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1") .bind(&club.id) .fetch_one(&state.pool) .await .unwrap_or(0); let total_checkins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?") .bind(&profile.id) .fetch_one(&state.pool) .await .unwrap_or(0); Ok(Json(json!({ "total_wins": stats.matches_won, "total_draws": stats.matches_drawn, "total_losses": stats.matches_lost, "total_matches": stats.matches_played, "total_goals_scored": stats.goals_scored, "total_goals_conceded": stats.goals_conceded, "best_win_streak": stats.best_win_streak, "total_packs_opened": stats.packs_opened, "seasons_completed": seasons_completed, "highest_division_reached": highest_division, "cards_owned": cards_owned, "sbcs_completed": sbcs_completed, "total_checkins": total_checkins, "club_level": club.level, }))) } /// The owned card assigned as the active squad's manager, or `null`. Generic: /// Core returns the ownership-backed assignment; the FIFA 17 adapter shapes the /// manager wire item from it (itemType/contract/chemistry are adapter concerns). pub async fn get_squad_manager( State(state): State, game: GameId, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?; Ok(Json(json!({ "manager": manager }))) } /// A manager write. The three states are DISTINCT and must stay that way: /// /// | body | meaning | /// | --- | --- | /// | `{}` — field absent | say nothing about the manager; leave it as it is | /// | `{"owned_card_id": null}` | explicitly remove the current manager | /// | `{"owned_card_id": ""}` | assign that owned card | /// /// A plain `Option` collapsed the first two into `None`, so a caller /// that simply had nothing to say silently deleted the assignment. That is how a /// FIFA 17 client with a destroyed squad model wiped a real manager row. The /// double option keeps "absent" and "null" apart. #[derive(Deserialize)] pub struct SetManagerRequest { #[serde(default, deserialize_with = "deserialize_present_option")] pub owned_card_id: Option>, } /// Deserialize a field that is present-but-null into `Some(None)`, leaving an /// absent field as `None` (supplied by `#[serde(default)]`). fn deserialize_present_option<'de, D>(d: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { Option::::deserialize(d).map(Some) } /// Assign, explicitly remove, or leave unchanged the active squad's manager. /// Fail-closed: the card must be owned by this club and the club must have a /// squad. Returns the resulting assignment. pub async fn put_squad_manager( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; match req.owned_card_id { Some(Some(owned_card_id)) => { club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await? } // Explicit null: a deliberate removal, which is a legitimate operation. Some(None) => club_svc::clear_squad_manager(&state.pool, &club.id).await?, // Absent: this request expresses no manager decision. Touch nothing. None => {} } let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?; Ok(Json(json!({ "manager": manager }))) } /// Every active club-item designation, slot-keyed and EXPLICIT: all five slots /// are always present, an empty slot being `null`. A caller therefore never has /// to guess whether a missing key means "no item" or "unsupported slot". fn active_items_body(items: &club_svc::ActiveClubItems) -> AppResult { let mut body = serde_json::Map::new(); for slot in ActiveSlot::ALL { body.insert( slot.as_str().to_string(), serde_json::to_value(items.get(slot))?, ); } Ok(Value::Object(body)) } /// Return the club's ownership-backed active item designations. pub async fn get_active_items( State(state): State, game: GameId, ) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let items = club_svc::get_active_club_items(&state.pool, &club.id).await?; Ok(Json(json!({ "active_items": active_items_body(&items)? }))) } #[derive(Deserialize)] pub struct SetActiveItemRequest { /// Which club role to write: home_kit | away_kit | badge | ball | stadium. /// /// Taken as a string and parsed here so an unknown slot comes back as this /// crate's `400 {"error": …}` envelope, like every other bad request, rather /// than axum's plain-text deserialization rejection. pub slot: String, /// The owned instance to designate, or `null`/absent to clear the slot. #[serde(default)] pub owned_card_id: Option, } /// Write ONE active club-item designation. Core enforces ownership and that the /// slot admits the item's `content_kind`; game adapters own their own mapping /// from a wire item onto that generic kind. pub async fn put_active_item( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { let slot = ActiveSlot::from_str(&req.slot).map_err(AppError::BadRequest)?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; match req.owned_card_id { Some(owned_card_id) => { club_svc::set_active_club_item(&state.pool, &club.id, slot, &owned_card_id).await? } None => club_svc::clear_active_club_item(&state.pool, &club.id, slot).await?, } let items = club_svc::get_active_club_items(&state.pool, &club.id).await?; Ok(Json(json!({ "active_items": active_items_body(&items)? }))) }