feat(core): reclassify the content_kind of already-imported owned rows
CI / Build, lint & test (push) Failing after 1m57s

A profile import is once-only — the same fingerprint no-ops and a different one
is refused — so a taxonomy correction cannot arrive by re-importing. Every club
imported before content_kind existed still records its coaches, kits and
consumables as players, because Core defaults an unstated row to `player`.

Core stays generic: the caller supplies card_id -> kind, since only the game
adapter can map its own taxonomy. One transaction, idempotent, scoped to a
single game so a shared database cannot be reclassified across games, and an
assignment nobody owns is reported rather than invented.
This commit is contained in:
funman300
2026-08-21 19:55:39 +00:00
parent 36bc594924
commit c896545cf0
3 changed files with 224 additions and 0 deletions
+19
View File
@@ -57,6 +57,25 @@ async fn main() -> Result<()> {
return Ok(());
}
// `openfut-core reclassify <request.json>` — correct the content_kind of
// already-imported owned rows. A profile import is once-only, so a taxonomy
// fix cannot arrive by re-importing; the adapter supplies card_id -> kind
// because only it can map its own taxonomy. Idempotent.
if std::env::args().nth(1).as_deref() == Some("reclassify") {
let path = std::env::args()
.nth(2)
.context("usage: openfut-core reclassify <request.json>")?;
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?;
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read reclassify request {path}"))?;
let req: openfut_core::services::import::ReclassifyRequest =
serde_json::from_str(&raw).context("parse reclassify request JSON")?;
let outcome = openfut_core::services::import::reclassify_owned_content(&pool, &req).await?;
println!("{}", serde_json::to_string_pretty(&outcome)?);
return Ok(());
}
info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
+89
View File
@@ -371,3 +371,92 @@ pub async fn apply_profile_import(
squad_slots,
})
}
/// One adapter-supplied classification: "every owned row of this definition is
/// really this kind of content".
#[derive(Debug, Clone, Deserialize)]
pub struct ContentKindAssignment {
pub card_id: String,
pub content_kind: ContentKind,
}
/// Request for [`reclassify_owned_content`].
#[derive(Debug, Clone, Deserialize)]
pub struct ReclassifyRequest {
pub game_id: String,
pub assignments: Vec<ContentKindAssignment>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ReclassifyOutcome {
/// Rows whose `content_kind` actually changed.
pub updated: usize,
/// Rows already carrying the requested kind (a rerun updates nothing).
pub unchanged: usize,
/// Assignments naming a definition this game owns no copy of.
pub unmatched_definitions: Vec<String>,
}
/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction.
///
/// A profile import is once-only (same fingerprint no-ops, a different one is
/// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an
/// unstated row to `player`, which means every pre-taxonomy import durably
/// recorded coaches, kits and consumables as players — wrong in the ownership
/// authority even where a catalog-driven wire looked right.
///
/// Core stays generic: the caller supplies `card_id -> kind`, because only the
/// game adapter can map its own taxonomy. Idempotent, and scoped to one game's
/// clubs so a shared database cannot be reclassified across games.
pub async fn reclassify_owned_content(
pool: &Pool,
req: &ReclassifyRequest,
) -> Result<ReclassifyOutcome> {
if req.assignments.is_empty() {
bail!("reclassify request has zero assignments");
}
let mut tx = pool.begin().await?;
let mut updated = 0usize;
let mut unchanged = 0usize;
let mut unmatched = Vec::new();
for a in &req.assignments {
// Scope by game through the owning club, so the same definition id in
// another game is never touched.
let present: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \
JOIN profiles p ON p.id = c.profile_id \
WHERE p.game_id = ? AND o.card_id = ?",
)
.bind(&req.game_id)
.bind(&a.card_id)
.fetch_one(&mut *tx)
.await
.context("count owned rows for definition")?;
if present == 0 {
unmatched.push(a.card_id.clone());
continue;
}
let changed = sqlx::query(
"UPDATE owned_cards SET content_kind = ? \
WHERE card_id = ? AND content_kind != ? AND club_id IN \
(SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \
WHERE p.game_id = ?)",
)
.bind(a.content_kind.as_str())
.bind(&a.card_id)
.bind(a.content_kind.as_str())
.bind(&req.game_id)
.execute(&mut *tx)
.await
.context("update owned content_kind")?
.rows_affected() as usize;
updated += changed;
unchanged += present as usize - changed;
}
tx.commit().await?;
Ok(ReclassifyOutcome {
updated,
unchanged,
unmatched_definitions: unmatched,
})
}