From 9036f5f411e8f21ac933ce18318343c8c709008f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 16:43:28 +0000 Subject: [PATCH] feat(club): ownership-backed squad manager assignment Add a generic, durable squad->manager assignment (migration 0023 squad_managers) so a manager persists across squad save, reload, and server restart, backed by authoritative Core owned_cards. - squad_managers(squad_id PK, owned_card_id, updated_at) with ON DELETE CASCADE on both FKs: quick-selling the manager auto-clears the assignment (no resurrection); one manager per squad (no duplicates). - club::{get,set,clear}_squad_manager validate club ownership of both the squad and the card, and re-check ownership on read (defends against a stale row left by a market transfer). - GET/PUT /club/manager routes expose the assignment; FIFA wire meaning stays in the adapter. Tests: persistence across reload+restart (headline), reassignment replace/no-duplicate, clear/no-resurrection, cascade on quick-sell, foreign-card rejection. --- migrations/0023_squad_managers.sql | 25 +++ src/routes/club.rs | 39 +++++ src/services/club.rs | 247 ++++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 migrations/0023_squad_managers.sql diff --git a/migrations/0023_squad_managers.sql b/migrations/0023_squad_managers.sql new file mode 100644 index 0000000..4617e05 --- /dev/null +++ b/migrations/0023_squad_managers.sql @@ -0,0 +1,25 @@ +-- Squad manager assignment: an owned item assigned as a squad's manager. +-- +-- Generic, game-neutral canonical state. Core does not know what a "manager" +-- means to any game; it only records that one owned item (`owned_card_id`) is +-- assigned to a squad in the manager role. The FIFA 17 adapter owns the wire +-- meaning (itemType "manager", contract, chemistry) exactly as it owns player +-- item shaping — Core just persists the ownership-backed assignment durably and +-- atomically, so a manager survives squad save / reload / server restart. +-- +-- One manager per squad: `squad_id` is the primary key, so a re-assignment +-- REPLACEs rather than accumulating (no duplicate-manager rows). +-- +-- `owned_card_id` references `owned_cards(id)` with ON DELETE CASCADE: quick +-- selling / discarding the manager card (a DELETE on owned_cards) removes the +-- assignment automatically, so a sold manager is never resurrected on the next +-- squad read. Reads additionally re-check the manager still belongs to the club +-- (see `club::get_squad_manager`), defending against a stale row left by a +-- market transfer (which UPDATEs owner rather than deleting). +CREATE TABLE IF NOT EXISTS squad_managers ( + squad_id TEXT PRIMARY KEY NOT NULL REFERENCES squads(id) ON DELETE CASCADE, + owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_squad_managers_owned ON squad_managers(owned_card_id); diff --git a/src/routes/club.rs b/src/routes/club.rs index 31683bd..84a63ad 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -124,3 +124,42 @@ pub async fn get_milestones(State(state): State, game: GameId) -> AppR "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 }))) +} + +#[derive(Deserialize)] +pub struct SetManagerRequest { + /// The owned card to assign as manager, or `null`/absent to clear it. + pub owned_card_id: Option, +} + +/// Assign (or, with a null/absent `owned_card_id`, clear) 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(owned_card_id) => { + club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await? + } + None => club_svc::clear_squad_manager(&state.pool, &club.id).await?, + } + let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?; + Ok(Json(json!({ "manager": manager }))) +} diff --git a/src/services/club.rs b/src/services/club.rs index f804628..8c95b74 100644 --- a/src/services/club.rs +++ b/src/services/club.rs @@ -1,7 +1,7 @@ use crate::{ db::Pool, error::{AppError, AppResult}, - models::club::Club, + models::{card::OwnedCard, club::Club}, }; use chrono::Utc; @@ -126,3 +126,248 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult AppResult> { + Ok( + sqlx::query_scalar::<_, String>( + "SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1", + ) + .bind(club_id) + .fetch_optional(pool) + .await?, + ) +} + +/// The owned card assigned as the manager of `club_id`'s active squad, if any. +pub async fn get_squad_manager(pool: &Pool, club_id: &str) -> AppResult> { + let Some(squad_id) = active_squad_id(pool, club_id).await? else { + return Ok(None); + }; + get_squad_manager_for_squad(pool, &squad_id, club_id).await +} + +/// The owned card assigned as `squad_id`'s manager, re-validated to still belong +/// to `club_id`. The club-ownership re-check means a stale assignment left by a +/// market transfer (which moves ownership by UPDATE, bypassing ON DELETE +/// CASCADE) never surfaces a manager the club no longer owns. +pub async fn get_squad_manager_for_squad( + pool: &Pool, + squad_id: &str, + club_id: &str, +) -> AppResult> { + Ok(sqlx::query_as::<_, OwnedCard>(&format!( + "{OWNED_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \ + AND club_id = ?" + )) + .bind(squad_id) + .bind(club_id) + .fetch_optional(pool) + .await?) +} + +/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing +/// any existing assignment. Fail-closed: both the squad and the owned card MUST +/// belong to `club_id`, so a client can neither manage another club's squad nor +/// assign a card it does not own. One manager per squad (the PK REPLACE), so a +/// re-assignment never accumulates duplicate rows. +pub async fn set_squad_manager( + pool: &Pool, + club_id: &str, + owned_card_id: &str, +) -> AppResult<()> { + let squad_id = active_squad_id(pool, club_id) + .await? + .ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?; + set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await +} + +/// Squad-scoped variant of [`set_squad_manager`]. +pub async fn set_squad_manager_for_squad( + pool: &Pool, + club_id: &str, + squad_id: &str, + owned_card_id: &str, +) -> AppResult<()> { + let squad_ok = + sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?") + .bind(squad_id) + .bind(club_id) + .fetch_optional(pool) + .await?; + if squad_ok.is_none() { + return Err(AppError::NotFound(format!("squad '{squad_id}' not found"))); + } + let card_ok = + sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?") + .bind(owned_card_id) + .bind(club_id) + .fetch_optional(pool) + .await?; + if card_ok.is_none() { + return Err(AppError::NotFound(format!( + "owned card '{owned_card_id}' not found" + ))); + } + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \ + VALUES (?, ?, ?)", + ) + .bind(squad_id) + .bind(owned_card_id) + .bind(&now) + .execute(pool) + .await?; + Ok(()) +} + +/// Remove the manager assignment from `club_id`'s active squad (idempotent — a +/// club with no squad or no manager is a successful no-op). +pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> { + sqlx::query( + "DELETE FROM squad_managers WHERE squad_id IN \ + (SELECT id FROM squads WHERE club_id = ?)", + ) + .bind(club_id) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + const TS: &str = "2026-01-01T00:00:00Z"; + + /// A file-backed pool (so a "restart" can reopen the same DB) with two clubs: + /// club-a owns `mgr` + `mgr2` + `player`, club-b owns `foreign`. + async fn fixture() -> (tempfile::TempDir, String, db::Pool) { + let dir = tempfile::tempdir().expect("tempdir"); + let url = format!("sqlite://{}", dir.path().join("core.db").display()); + let pool = db::init_pool(&url, 5).await.expect("init pool"); + db::run_migrations(&pool).await.expect("migrations"); + + for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] { + sqlx::query( + "INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(profile) + .bind(profile) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("profile"); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS) + .execute(&pool).await.expect("club"); + } + for (id, club) in [ + ("mgr", "club-a"), + ("mgr2", "club-a"), + ("player", "club-a"), + ("foreign", "club-b"), + ] { + sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)") + .bind(id).bind(club).bind("def-mgr").bind(TS) + .execute(&pool).await.expect("owned card"); + } + // club-a has one squad. + sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)") + .bind(TS).bind(TS).execute(&pool).await.expect("squad"); + (dir, url, pool) + } + + async fn manager_rows(pool: &db::Pool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_managers") + .fetch_one(pool) + .await + .unwrap() + } + + #[tokio::test] + async fn manager_persists_across_reload_and_restart() { + let (dir, url, pool) = fixture().await; + + // SAVE. + set_squad_manager(&pool, "club-a", "mgr").await.expect("assign"); + // RELOAD (same pool). + let got = get_squad_manager(&pool, "club-a").await.unwrap(); + assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr")); + + // RESTART: close the pool and reopen the same DB file. + pool.close().await; + let reopened = db::init_pool(&url, 5).await.expect("reopen"); + db::run_migrations(&reopened).await.expect("migrations"); + let after = get_squad_manager(&reopened, "club-a").await.unwrap(); + assert_eq!( + after.as_ref().map(|c| c.id.as_str()), + Some("mgr"), + "manager assignment must survive a server restart" + ); + drop(dir); + } + + #[tokio::test] + async fn reassignment_replaces_and_never_duplicates() { + let (_dir, _url, pool) = fixture().await; + set_squad_manager(&pool, "club-a", "mgr").await.unwrap(); + set_squad_manager(&pool, "club-a", "mgr2").await.unwrap(); + assert_eq!(manager_rows(&pool).await, 1, "one manager per squad"); + let got = get_squad_manager(&pool, "club-a").await.unwrap(); + assert_eq!(got.map(|c| c.id), Some("mgr2".to_string())); + } + + #[tokio::test] + async fn clear_removes_and_no_resurrection() { + let (_dir, _url, pool) = fixture().await; + set_squad_manager(&pool, "club-a", "mgr").await.unwrap(); + clear_squad_manager(&pool, "club-a").await.unwrap(); + assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none()); + assert_eq!(manager_rows(&pool).await, 0); + // Clearing again is an idempotent no-op. + clear_squad_manager(&pool, "club-a").await.unwrap(); + } + + #[tokio::test] + async fn rejects_card_the_club_does_not_own() { + let (_dir, _url, pool) = fixture().await; + let err = set_squad_manager(&pool, "club-a", "foreign").await; + assert!(err.is_err(), "cannot assign a card owned by another club"); + assert_eq!(manager_rows(&pool).await, 0); + } + + #[tokio::test] + async fn quick_sell_of_manager_cascades_the_assignment_away() { + let (_dir, _url, pool) = fixture().await; + set_squad_manager(&pool, "club-a", "mgr").await.unwrap(); + // A quick-sell/discard DELETEs the owned row; ON DELETE CASCADE must + // remove the assignment so the sold manager is never resurrected. + sqlx::query("DELETE FROM owned_cards WHERE id = 'mgr'") + .execute(&pool) + .await + .expect("delete owned card"); + assert_eq!(manager_rows(&pool).await, 0); + assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none()); + } + + #[tokio::test] + async fn no_manager_when_none_assigned() { + let (_dir, _url, pool) = fixture().await; + assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none()); + } +}