use crate::{ db::Pool, error::{AppError, AppResult}, models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile}, }; use chrono::Utc; /// Fetch the active profile for a game. Single-profile-per-game: there is exactly /// one profile row per `game_id`, so we take the earliest for that game. pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult { sqlx::query_as::<_, Profile>( "SELECT id, username, level, xp, game_id, created_at, updated_at \ FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1", ) .bind(game_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into())) } pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult { // Single-player per game: one profile per game_id, not one globally. let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?") .bind(game_id) .fetch_one(pool) .await?; if existing > 0 { return Err(AppError::Conflict( "a profile already exists for this game; OpenFUT is single-player per game".into(), )); } let profile = Profile::new(username, game_id); sqlx::query( "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&profile.id) .bind(&profile.username) .bind(profile.level) .bind(profile.xp) .bind(&profile.game_id) .bind(profile.created_at) .bind(profile.updated_at) .execute(pool) .await?; Ok(profile) } pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> { let now = Utc::now(); sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?") .bind(xp) .bind(now) .bind(profile_id) .execute(pool) .await?; Ok(()) } /// Add XP, check for level-ups, grant rewards, and return every level gained. /// Callers should use this instead of `add_xp` when level-up feedback matters. pub async fn add_xp_with_levelup( pool: &Pool, profile_id: &str, club_id: &str, xp_to_add: i64, ) -> AppResult> { let profile = sqlx::query_as::<_, Profile>( "SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?", ) .bind(profile_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?; let old_level = level_for_xp(profile.xp); let new_total_xp = profile.xp + xp_to_add; let new_level = level_for_xp(new_total_xp); let now = Utc::now(); sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?") .bind(new_total_xp) .bind(new_level) .bind(now) .bind(profile_id) .execute(pool) .await?; let mut events = Vec::new(); for lvl in (old_level + 1)..=new_level { let coins = coins_for_level(lvl); let pack = pack_for_level(lvl).map(String::from); if coins > 0 { crate::services::club::add_coins(pool, club_id, coins).await?; } if let Some(ref pack_id) = pack { crate::services::pack::grant_pack(pool, club_id, pack_id).await?; } tracing::info!(profile_id, new_level = lvl, coins, "level up"); events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack }); } Ok(events) }