use crate::{ db::Pool, error::{AppError, AppResult}, models::objective::{ ClaimRewardResult, ObjectiveDefinition, ObjectiveProgress, ObjectiveWithProgress, }, }; use anyhow::Context; use sqlx::{Sqlite, Transaction}; use std::path::Path; use uuid::Uuid; pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result> { let dir = Path::new(data_dir).join("objectives"); let mut defs = Vec::new(); if !dir.exists() { return Ok(defs); } for entry in std::fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { let content = std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?; let batch: Vec = serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?; defs.extend(batch); } } Ok(defs) } pub async fn get_objectives_with_progress( pool: &Pool, profile_id: &str, defs: &[ObjectiveDefinition], ) -> AppResult> { let progress_rows = sqlx::query_as::<_, ObjectiveProgress>( "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ?" ) .bind(profile_id) .fetch_all(pool) .await?; let result = defs .iter() .map(|def| { let prog = progress_rows.iter().find(|p| p.objective_id == def.id); ObjectiveWithProgress { definition: def.clone(), current: prog.map(|p| p.current).unwrap_or(0), completed: prog.map(|p| p.completed).unwrap_or(false), claimed: prog.map(|p| p.claimed).unwrap_or(false), } }) .collect(); Ok(result) } /// Increment a metric for all objectives that track it. pub async fn increment_metric( pool: &Pool, profile_id: &str, defs: &[ObjectiveDefinition], metric: &str, amount: i64, ) -> AppResult> { let mut completed_ids = Vec::new(); for def in defs.iter().filter(|d| d.metric.as_str() == metric) { let existing = sqlx::query_as::<_, ObjectiveProgress>( "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?" ) .bind(profile_id) .bind(&def.id) .fetch_optional(pool) .await?; let now = chrono::Utc::now().to_rfc3339(); if let Some(prog) = existing { if prog.completed { continue; } let new_val = (prog.current + amount).min(def.target); let now_complete = new_val >= def.target; sqlx::query( "UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?" ) .bind(new_val) .bind(now_complete) .bind(&now) .bind(&prog.id) .execute(pool) .await?; if now_complete { completed_ids.push(def.id.clone()); } } else { let new_val = amount.min(def.target); let now_complete = new_val >= def.target; let id = Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)" ) .bind(&id) .bind(profile_id) .bind(&def.id) .bind(new_val) .bind(now_complete) .bind(&now) .execute(pool) .await?; if now_complete { completed_ids.push(def.id.clone()); } } } Ok(completed_ids) } /// Transaction-scoped [`increment_metric`] for the atomic match-completion path. /// Same semantics, but every read/write runs inside the caller's transaction so /// objective progress commits (or rolls back) together with the coins, XP, and /// statistics of the same match. `now` is threaded so one match stamps a single /// timestamp. pub async fn increment_metric_tx( tx: &mut Transaction<'_, Sqlite>, profile_id: &str, defs: &[ObjectiveDefinition], metric: &str, amount: i64, now: &str, ) -> AppResult> { let mut completed_ids = Vec::new(); for def in defs.iter().filter(|d| d.metric.as_str() == metric) { let existing = sqlx::query_as::<_, ObjectiveProgress>( "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?" ) .bind(profile_id) .bind(&def.id) .fetch_optional(&mut **tx) .await?; if let Some(prog) = existing { if prog.completed { continue; } let new_val = (prog.current + amount).min(def.target); let now_complete = new_val >= def.target; sqlx::query( "UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?" ) .bind(new_val) .bind(now_complete) .bind(now) .bind(&prog.id) .execute(&mut **tx) .await?; if now_complete { completed_ids.push(def.id.clone()); } } else { let new_val = amount.min(def.target); let now_complete = new_val >= def.target; let id = Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)" ) .bind(&id) .bind(profile_id) .bind(&def.id) .bind(new_val) .bind(now_complete) .bind(now) .execute(&mut **tx) .await?; if now_complete { completed_ids.push(def.id.clone()); } } } Ok(completed_ids) } pub async fn claim_objective( pool: &Pool, profile_id: &str, club_id: &str, defs: &[ObjectiveDefinition], objective_id: &str, ) -> AppResult { let prog = sqlx::query_as::<_, ObjectiveProgress>( "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?" ) .bind(profile_id) .bind(objective_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("objective not started yet".into()))?; if !prog.completed { return Err(AppError::BadRequest("objective not completed yet".into())); } if prog.claimed { return Err(AppError::Conflict("reward already claimed".into())); } let def = defs .iter() .find(|d| d.id == objective_id) .ok_or_else(|| AppError::NotFound(format!("objective '{objective_id}' not found")))?; sqlx::query("UPDATE objective_progress SET claimed = 1 WHERE id = ?") .bind(&prog.id) .execute(pool) .await?; if def.reward_coins > 0 { crate::services::club::add_coins(pool, club_id, def.reward_coins).await?; } if def.reward_xp > 0 { crate::services::profile::add_xp(pool, profile_id, def.reward_xp).await?; } if let Some(pack_id) = &def.reward_pack_id { crate::services::pack::grant_pack(pool, club_id, pack_id).await?; } Ok(ClaimRewardResult { objective_id: objective_id.to_string(), coins_granted: def.reward_coins, xp_granted: def.reward_xp, pack_granted: def.reward_pack_id.clone(), }) }