feat(club): persist active home/away kit assignments
CI / Build, lint & test (push) Successful in 2m50s
CI / Build, lint & test (push) Successful in 2m50s
Kits are ownership-backed club items: the owned instance stays in the generic owned_cards inventory and only the two active roles get their own table. This mirrors the squad_managers precedent and keeps every FIFA-specific resourceId/wire concern in the game adapter. * migration 0024: club_kit_assignments(club_id, slot, owned_card_id) with a UNIQUE owned_card_id (one instance cannot hold both roles) and ON DELETE CASCADE from owned_cards so a quick-sell clears the role. * a BEFORE UPDATE OF club_id trigger clears the designation on a market transfer, which moves ownership by UPDATE and so is not covered by the cascade. * set_active_club_kits replaces BOTH slots in one transaction, rejects home == away, and validates each instance against current club ownership, so a half-applied or dangling designation is not representable. * get_active_club_kits revalidates ownership on read, so a stale row can never surface another club's item. * GET/PUT /club/kits expose the pair. Tests cover restart persistence, replace/clear without duplicates, atomic rejection of invalid references, and clearing via delete and transfer.
This commit is contained in:
@@ -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;
|
||||||
@@ -172,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
// ClubB: squad manager assignment (append-only; own lines).
|
// ClubB: squad manager assignment (append-only; own lines).
|
||||||
.route("/club/manager", get(routes::club::get_squad_manager))
|
.route("/club/manager", get(routes::club::get_squad_manager))
|
||||||
.route("/club/manager", put(routes::club::put_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", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
|
|||||||
@@ -163,3 +163,40 @@ pub async fn put_squad_manager(
|
|||||||
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "manager": manager })))
|
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<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<String>,
|
||||||
|
pub away_owned_card_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<SetActiveKitsRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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 })))
|
||||||
|
}
|
||||||
|
|||||||
+197
-8
@@ -240,6 +240,94 @@ pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
|
|||||||
Ok(())
|
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<OwnedCard>,
|
||||||
|
pub away: Option<OwnedCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult<Option<OwnedCard>> {
|
||||||
|
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<ActiveClubKits> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -247,8 +335,7 @@ mod tests {
|
|||||||
|
|
||||||
const TS: &str = "2026-01-01T00:00:00Z";
|
const TS: &str = "2026-01-01T00:00:00Z";
|
||||||
|
|
||||||
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs:
|
/// 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) {
|
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let url = format!("sqlite://{}", dir.path().join("core.db").display());
|
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)
|
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
|
||||||
.execute(&pool).await.expect("club");
|
.execute(&pool).await.expect("club");
|
||||||
}
|
}
|
||||||
for (id, club) in [
|
for (id, club, definition) in [
|
||||||
("mgr", "club-a"),
|
("mgr", "club-a", "def-mgr"),
|
||||||
("mgr2", "club-a"),
|
("mgr2", "club-a", "def-mgr"),
|
||||||
("player", "club-a"),
|
("player", "club-a", "def-player"),
|
||||||
("foreign", "club-b"),
|
("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, ?)")
|
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");
|
.execute(&pool).await.expect("owned card");
|
||||||
}
|
}
|
||||||
// club-a has one squad.
|
// club-a has one squad.
|
||||||
@@ -293,6 +383,13 @@ mod tests {
|
|||||||
.unwrap()
|
.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]
|
#[tokio::test]
|
||||||
async fn manager_persists_across_reload_and_restart() {
|
async fn manager_persists_across_reload_and_restart() {
|
||||||
let (dir, url, pool) = fixture().await;
|
let (dir, url, pool) = fixture().await;
|
||||||
@@ -366,4 +463,96 @@ mod tests {
|
|||||||
let (_dir, _url, pool) = fixture().await;
|
let (_dir, _url, pool) = fixture().await;
|
||||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user