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 { 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, client: &LeagueApiClient, ) -> Result { 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, team_abbrev: Option<&str>, client: &LeagueApiClient, ) -> Result { 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 { 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 }) }