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.
This commit is contained in:
funman300
2026-08-20 16:43:28 +00:00
parent b0306a9b1d
commit 9036f5f411
3 changed files with 310 additions and 1 deletions
+246 -1
View File
@@ -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<i
.await?;
Ok(new_balance)
}
// ─────────────────────── squad manager assignment ───────────────────────────
//
// Generic, ownership-backed canonical state: one owned item assigned as a
// squad's manager (migration 0023 `squad_managers`). Core stores the assignment
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
// meaning of "manager" (itemType/contract/chemistry), never Core.
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
/// The club's most-recently-updated squad id (its "active" squad), matching the
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
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<Option<OwnedCard>> {
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<Option<OwnedCard>> {
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());
}
}