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:
@@ -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);
|
||||
@@ -124,3 +124,42 @@ pub async fn get_milestones(State(state): State<AppState>, 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<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 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<String>,
|
||||
}
|
||||
|
||||
/// 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<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SetManagerRequest>,
|
||||
) -> 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?;
|
||||
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 })))
|
||||
}
|
||||
|
||||
+246
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user