sba-scouting/rust/src/api/sync.rs
Cal Corum a18c0431d1 Fix sync JSON parse error: Discord snowflake IDs are strings, not i64
The API returns gmid/gmid2 as quoted strings ("258104532423147520")
to avoid JavaScript precision loss. Changed types to Option<String>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 08:07:03 -06:00

244 lines
8.5 KiB
Rust

use anyhow::Result;
use sqlx::SqlitePool;
use super::client::LeagueApiClient;
#[derive(Debug)]
pub struct SyncResult {
pub teams: i64,
pub players: i64,
pub transactions: i64,
}
/// Sync all teams from the league API for the given season.
/// Uses INSERT OR REPLACE to handle both inserts and updates atomically.
pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result<i64> {
let response = client.get_teams(Some(season), None, true, false).await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.teams {
let manager1_name = data.manager1.and_then(|m| m.name);
let manager2_name = data.manager2.and_then(|m| m.name);
let gm_discord_id = data.gm_discord_id;
let gm2_discord_id = data.gm2_discord_id;
let division_id = data.division.as_ref().map(|d| d.id).flatten();
let division_name = data.division.as_ref().and_then(|d| d.division_name.clone());
let league_abbrev = data.division.as_ref().and_then(|d| d.league_abbrev.clone());
sqlx::query(
"INSERT OR REPLACE INTO teams \
(id, abbrev, short_name, long_name, season, manager1_name, manager2_name, \
gm_discord_id, gm2_discord_id, division_id, division_name, league_abbrev, \
thumbnail, color, dice_color, stadium, salary_cap, synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(data.id)
.bind(data.abbrev.unwrap_or_default())
.bind(data.short_name.unwrap_or_default())
.bind(data.long_name.unwrap_or_default())
.bind(season)
.bind(manager1_name)
.bind(manager2_name)
.bind(gm_discord_id)
.bind(gm2_discord_id)
.bind(division_id)
.bind(division_name)
.bind(league_abbrev)
.bind(data.thumbnail)
.bind(data.color)
.bind(data.dice_color)
.bind(data.stadium)
.bind(data.salary_cap)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "teams", count, None).await?;
Ok(count)
}
/// Sync players from the league API.
/// Uses ON CONFLICT DO UPDATE with an explicit column list that intentionally omits `hand`,
/// since `hand` is populated only from CSV card imports and must not be overwritten by the API.
pub async fn sync_players(
pool: &SqlitePool,
season: i64,
team_id: Option<i64>,
client: &LeagueApiClient,
) -> Result<i64> {
let team_ids = team_id.map(|id| vec![id]);
let response = client
.get_players(Some(season), team_ids.as_deref(), None, None, false)
.await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.players {
let player_team_id = data.team.map(|t| t.id);
sqlx::query(
"INSERT INTO players \
(id, name, season, team_id, swar, card_image, card_image_alt, headshot, vanity_card, \
pos_1, pos_2, pos_3, pos_4, pos_5, pos_6, pos_7, pos_8, \
injury_rating, il_return, demotion_week, strat_code, bbref_id, sbaplayer_id, \
last_game, last_game2, synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
name=excluded.name, \
season=excluded.season, \
team_id=excluded.team_id, \
swar=excluded.swar, \
card_image=excluded.card_image, \
card_image_alt=excluded.card_image_alt, \
headshot=excluded.headshot, \
vanity_card=excluded.vanity_card, \
pos_1=excluded.pos_1, \
pos_2=excluded.pos_2, \
pos_3=excluded.pos_3, \
pos_4=excluded.pos_4, \
pos_5=excluded.pos_5, \
pos_6=excluded.pos_6, \
pos_7=excluded.pos_7, \
pos_8=excluded.pos_8, \
injury_rating=excluded.injury_rating, \
il_return=excluded.il_return, \
demotion_week=excluded.demotion_week, \
strat_code=excluded.strat_code, \
bbref_id=excluded.bbref_id, \
sbaplayer_id=excluded.sbaplayer_id, \
last_game=excluded.last_game, \
last_game2=excluded.last_game2, \
synced_at=excluded.synced_at",
)
.bind(data.id)
.bind(data.name.unwrap_or_default())
.bind(season)
.bind(player_team_id)
.bind(data.swar)
.bind(data.card_image)
.bind(data.card_image_alt)
.bind(data.headshot)
.bind(data.vanity_card)
.bind(data.pos_1)
.bind(data.pos_2)
.bind(data.pos_3)
.bind(data.pos_4)
.bind(data.pos_5)
.bind(data.pos_6)
.bind(data.pos_7)
.bind(data.pos_8)
.bind(data.injury_rating)
.bind(data.il_return)
.bind(data.demotion_week)
.bind(data.strat_code)
.bind(data.bbref_id)
.bind(data.sbaplayer_id)
.bind(data.last_game)
.bind(data.last_game2)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "players", count, None).await?;
Ok(count)
}
/// Sync transactions from the league API for the given season/week range.
/// Skips rows where move_id or player is missing.
/// ON CONFLICT only updates mutable fields (cancelled, frozen, synced_at) to preserve original
/// season/week/team data.
pub async fn sync_transactions(
pool: &SqlitePool,
season: i64,
week_start: i64,
week_end: Option<i64>,
team_abbrev: Option<&str>,
client: &LeagueApiClient,
) -> Result<i64> {
let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect();
let abbrev_slice: Option<&[&str]> =
if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) };
let response = client
.get_transactions(season, week_start, week_end, abbrev_slice, false, false, false)
.await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
for data in response.transactions {
let (Some(move_id), Some(player)) = (data.move_id, data.player) else {
continue;
};
let move_id_str = move_id.to_string();
let player_id = player.id;
// Schema has from_team_id/to_team_id as NOT NULL; use 0 as sentinel for missing teams
let from_team_id = data.oldteam.map(|t| t.id).unwrap_or(0);
let to_team_id = data.newteam.map(|t| t.id).unwrap_or(0);
let week = data.week.unwrap_or(0);
let cancelled = data.cancelled.unwrap_or(false);
let frozen = data.frozen.unwrap_or(false);
sqlx::query(
"INSERT INTO transactions \
(season, week, move_id, player_id, from_team_id, to_team_id, cancelled, frozen, \
synced_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(move_id, player_id) DO UPDATE SET \
cancelled=excluded.cancelled, \
frozen=excluded.frozen, \
synced_at=excluded.synced_at",
)
.bind(season)
.bind(week)
.bind(move_id_str)
.bind(player_id)
.bind(from_team_id)
.bind(to_team_id)
.bind(cancelled)
.bind(frozen)
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
crate::db::queries::update_sync_status(pool, "transactions", count, None).await?;
Ok(count)
}
/// Orchestrate a full sync: teams, players, and transactions for the configured season.
pub async fn sync_all(pool: &SqlitePool, settings: &crate::config::Settings) -> Result<SyncResult> {
let client = LeagueApiClient::new(
&settings.api.base_url,
&settings.api.api_key,
settings.api.timeout,
)?;
let season = settings.team.season as i64;
let teams = sync_teams(pool, season, &client).await?;
let players = sync_players(pool, season, None, &client).await?;
// Start from week 1 (Python defaults to 0 which includes pre-season moves)
let transactions = sync_transactions(pool, season, 1, None, None, &client).await?;
Ok(SyncResult { teams, players, transactions })
}