diff --git a/migrations/0024_club_kit_assignments.sql b/migrations/0024_club_kit_assignments.sql new file mode 100644 index 0000000..1218dec --- /dev/null +++ b/migrations/0024_club_kit_assignments.sql @@ -0,0 +1,23 @@ +-- Active home/away kits for a club. Ownership remains the generic owned_cards +-- inventory; this table only records which owned instances occupy the two kit +-- roles. FIFA-specific resource ids and wire shapes stay in the FIFA17 adapter. +CREATE TABLE IF NOT EXISTS club_kit_assignments ( + club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + slot TEXT NOT NULL CHECK (slot IN ('home', 'away')), + owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE, + updated_at TEXT NOT NULL, + PRIMARY KEY (club_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_club_kit_assignments_owned + ON club_kit_assignments(owned_card_id); + +-- Market transfers change owned_cards.club_id by UPDATE rather than DELETE. +-- Remove any old-club active designation before ownership moves so a stale row +-- cannot hide or block the item for its new owner. +CREATE TRIGGER IF NOT EXISTS clear_club_kit_assignment_before_transfer +BEFORE UPDATE OF club_id ON owned_cards +WHEN OLD.club_id <> NEW.club_id +BEGIN + DELETE FROM club_kit_assignments WHERE owned_card_id = OLD.id; +END; diff --git a/src/app.rs b/src/app.rs index b46632e..6722e86 100644 --- a/src/app.rs +++ b/src/app.rs @@ -172,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { // ClubB: squad manager assignment (append-only; own lines). .route("/club/manager", get(routes::club::get_squad_manager)) .route("/club/manager", put(routes::club::put_squad_manager)) + .route("/club/kits", get(routes::club::get_active_kits)) + .route("/club/kits", put(routes::club::put_active_kits)) .route("/cards", get(routes::cards::get_cards)) .route("/cards/:card_id", get(routes::cards::get_card)) .route("/collection", get(routes::cards::get_collection)) diff --git a/src/routes/club.rs b/src/routes/club.rs index 84a63ad..88bdd17 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -163,3 +163,40 @@ pub async fn put_squad_manager( let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?; Ok(Json(json!({ "manager": manager }))) } + +/// Return the club's ownership-backed active home/away kit assignments. +pub async fn get_active_kits( + 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 kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?; + Ok(Json(json!({ "home": kits.home, "away": kits.away }))) +} + +#[derive(Deserialize)] +pub struct SetActiveKitsRequest { + pub home_owned_card_id: Option, + pub away_owned_card_id: Option, +} + +/// Atomically replace both active kit assignments. Core enforces ownership and +/// distinct instances; game adapters enforce their own definition taxonomy. +pub async fn put_active_kits( + 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?; + club_svc::set_active_club_kits( + &state.pool, + &club.id, + req.home_owned_card_id.as_deref(), + req.away_owned_card_id.as_deref(), + ) + .await?; + let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?; + Ok(Json(json!({ "home": kits.home, "away": kits.away }))) +} diff --git a/src/services/club.rs b/src/services/club.rs index 38cdda2..06f4932 100644 --- a/src/services/club.rs +++ b/src/services/club.rs @@ -240,6 +240,94 @@ pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> { Ok(()) } +// ───────────────────────── active club kits ──────────────────────────────── + +/// The ownership-backed home and away kit assignments for one club. +#[derive(Debug, Clone, Default)] +pub struct ActiveClubKits { + pub home: Option, + pub away: Option, +} + +async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult> { + Ok(sqlx::query_as::<_, OwnedCard>(&format!( + "{OWNED_SELECT} WHERE id = ( \ + SELECT owned_card_id FROM club_kit_assignments WHERE club_id = ? AND slot = ? \ + ) AND club_id = ?" + )) + .bind(club_id) + .bind(slot) + .bind(club_id) + .fetch_optional(pool) + .await?) +} + +/// Read both active kit roles. Each assignment is revalidated against current +/// ownership, so a stale/corrupt row never surfaces another club's item. +pub async fn get_active_club_kits(pool: &Pool, club_id: &str) -> AppResult { + Ok(ActiveClubKits { + home: get_club_kit_slot(pool, club_id, "home").await?, + away: get_club_kit_slot(pool, club_id, "away").await?, + }) +} + +/// Atomically replace both active kit roles. Core enforces generic ownership and +/// distinct-instance invariants; the game adapter validates that each definition +/// is a kit before asking Core to assign it. +pub async fn set_active_club_kits( + pool: &Pool, + club_id: &str, + home_owned_card_id: Option<&str>, + away_owned_card_id: Option<&str>, +) -> AppResult<()> { + if home_owned_card_id.is_some() && home_owned_card_id == away_owned_card_id { + return Err(AppError::BadRequest( + "home and away kits must be different owned items".into(), + )); + } + + let mut tx = pool.begin().await?; + for owned_card_id in [home_owned_card_id, away_owned_card_id] + .into_iter() + .flatten() + { + let owned = sqlx::query_scalar::<_, String>( + "SELECT id FROM owned_cards WHERE id = ? AND club_id = ?", + ) + .bind(owned_card_id) + .bind(club_id) + .fetch_optional(&mut *tx) + .await?; + if owned.is_none() { + return Err(AppError::NotFound(format!( + "owned card '{owned_card_id}' not found" + ))); + } + } + + sqlx::query("DELETE FROM club_kit_assignments WHERE club_id = ?") + .bind(club_id) + .execute(&mut *tx) + .await?; + let now = Utc::now().to_rfc3339(); + for (slot, owned_card_id) in [("home", home_owned_card_id), ("away", away_owned_card_id)] { + if let Some(owned_card_id) = owned_card_id { + sqlx::query( + "INSERT INTO club_kit_assignments \ + (club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(club_id) + .bind(slot) + .bind(owned_card_id) + .bind(&now) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -247,8 +335,7 @@ mod tests { 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`. + /// A file-backed pool (so a "restart" can reopen the same DB) with two clubs. async fn fixture() -> (tempfile::TempDir, String, db::Pool) { let dir = tempfile::tempdir().expect("tempdir"); let url = format!("sqlite://{}", dir.path().join("core.db").display()); @@ -270,14 +357,17 @@ mod tests { .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"), + for (id, club, definition) in [ + ("mgr", "club-a", "def-mgr"), + ("mgr2", "club-a", "def-mgr"), + ("player", "club-a", "def-player"), + ("kit-home", "club-a", "def-kit-home"), + ("kit-away", "club-a", "def-kit-away"), + ("kit-away-2", "club-a", "def-kit-away-2"), + ("foreign", "club-b", "def-kit-foreign"), ] { 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) + .bind(id).bind(club).bind(definition).bind(TS) .execute(&pool).await.expect("owned card"); } // club-a has one squad. @@ -293,6 +383,13 @@ mod tests { .unwrap() } + async fn kit_rows(pool: &db::Pool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_kit_assignments") + .fetch_one(pool) + .await + .unwrap() + } + #[tokio::test] async fn manager_persists_across_reload_and_restart() { let (dir, url, pool) = fixture().await; @@ -366,4 +463,96 @@ mod tests { let (_dir, _url, pool) = fixture().await; assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none()); } + #[tokio::test] + async fn kits_persist_across_reload_and_restart() { + let (dir, url, pool) = fixture().await; + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away")) + .await + .expect("assign kits"); + let current = get_active_club_kits(&pool, "club-a").await.unwrap(); + assert_eq!( + current.home.as_ref().map(|item| item.id.as_str()), + Some("kit-home") + ); + assert_eq!( + current.away.as_ref().map(|item| item.id.as_str()), + Some("kit-away") + ); + + pool.close().await; + let reopened = db::init_pool(&url, 5).await.expect("reopen"); + db::run_migrations(&reopened).await.expect("migrations"); + let persisted = get_active_club_kits(&reopened, "club-a").await.unwrap(); + assert_eq!(persisted.home.map(|item| item.id), Some("kit-home".into())); + assert_eq!(persisted.away.map(|item| item.id), Some("kit-away".into())); + drop(dir); + } + + #[tokio::test] + async fn kits_replace_clear_and_never_duplicate() { + let (_dir, _url, pool) = fixture().await; + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away")) + .await + .unwrap(); + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away-2")) + .await + .unwrap(); + assert_eq!(kit_rows(&pool).await, 2); + let current = get_active_club_kits(&pool, "club-a").await.unwrap(); + assert_eq!(current.away.map(|item| item.id), Some("kit-away-2".into())); + + set_active_club_kits(&pool, "club-a", None, None) + .await + .unwrap(); + assert_eq!(kit_rows(&pool).await, 0); + } + + #[tokio::test] + async fn kits_reject_invalid_references_atomically() { + let (_dir, _url, pool) = fixture().await; + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away")) + .await + .unwrap(); + + assert!(set_active_club_kits(&pool, "club-a", Some("foreign"), None) + .await + .is_err()); + assert!( + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-home")) + .await + .is_err() + ); + + let unchanged = get_active_club_kits(&pool, "club-a").await.unwrap(); + assert_eq!(unchanged.home.map(|item| item.id), Some("kit-home".into())); + assert_eq!(unchanged.away.map(|item| item.id), Some("kit-away".into())); + assert_eq!(kit_rows(&pool).await, 2); + } + + #[tokio::test] + async fn kit_delete_and_transfer_clear_active_designations() { + let (_dir, _url, pool) = fixture().await; + set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away")) + .await + .unwrap(); + + sqlx::query("DELETE FROM owned_cards WHERE id = 'kit-home'") + .execute(&pool) + .await + .expect("quick sell kit"); + let after_delete = get_active_club_kits(&pool, "club-a").await.unwrap(); + assert!(after_delete.home.is_none()); + assert_eq!( + after_delete.away.map(|item| item.id), + Some("kit-away".into()) + ); + + sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'") + .execute(&pool) + .await + .expect("transfer kit"); + assert_eq!(kit_rows(&pool).await, 0); + let after_transfer = get_active_club_kits(&pool, "club-a").await.unwrap(); + assert!(after_transfer.home.is_none() && after_transfer.away.is_none()); + } }