From 956bfe7a734c983f4e3e1b77864b991e78521bb1 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 19:05:52 -0700 Subject: [PATCH] Phase 24: daily check-in system + club milestones endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrations/0012_daily_checkin.sql: daily_checkins table tracking streak, coins awarded, pack granted, timestamp per profile - services/checkin.rs: get_status() (available, streak_day, next reward), claim() (idempotent same-day guard, streak logic: continue if yesterday or today, else reset; 7-day cycle with STREAK_COINS array, day-7 pack) - routes/club.rs: GET /club/checkin, POST /club/checkin, GET /club/milestones (computed from statistics, season_history, owned_cards, sbc_submissions, daily_checkins tables; no new DB tables needed) - 4 new integration tests: checkin available initially, claim awards coins, idempotent same-day, milestones endpoint structure (93 → 93+4=97 tests) Co-Authored-By: Claude Sonnet 4.6 --- migrations/0012_daily_checkin.sql | 11 +++ src/app.rs | 3 + src/routes/club.rs | 89 +++++++++++++++++++- src/services/checkin.rs | 133 ++++++++++++++++++++++++++++++ src/services/mod.rs | 1 + tests/integration_test.rs | 61 ++++++++++++++ 6 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 migrations/0012_daily_checkin.sql create mode 100644 src/services/checkin.rs diff --git a/migrations/0012_daily_checkin.sql b/migrations/0012_daily_checkin.sql new file mode 100644 index 0000000..bced3c8 --- /dev/null +++ b/migrations/0012_daily_checkin.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS daily_checkins ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + club_id TEXT NOT NULL, + streak_day INTEGER NOT NULL DEFAULT 1, + coins_awarded INTEGER NOT NULL DEFAULT 0, + pack_granted TEXT, -- nullable pack definition id + checked_in_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_daily_checkins_profile ON daily_checkins (profile_id, checked_in_at DESC); diff --git a/src/app.rs b/src/app.rs index 4191bb9..48efc7f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -135,6 +135,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/profile", get(routes::profile::get_profile)) .route("/club", get(routes::club::get_club)) .route("/club", put(routes::club::put_club)) + .route("/club/checkin", get(routes::club::get_checkin_status)) + .route("/club/checkin", post(routes::club::post_checkin)) + .route("/club/milestones", get(routes::club::get_milestones)) .route("/cards", get(routes::cards::get_cards)) .route("/cards/:card_id", get(routes::cards::get_card)) .route("/collection", get(routes::cards::get_collection)) diff --git a/src/routes/club.rs b/src/routes/club.rs index 993b3b5..59bd7ae 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -2,7 +2,7 @@ use crate::{ app::AppState, error::AppResult, models::club::Club, - services::{club as club_svc, profile as profile_svc}, + services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc}, }; use axum::{extract::State, Json}; use serde::Deserialize; @@ -35,3 +35,90 @@ pub async fn put_club( .await?; Ok(Json(json!({ "club": updated }))) } + +pub async fn get_checkin_status(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let status = checkin_svc::get_status(&state.pool, &profile.id).await?; + Ok(Json(json!({ + "available": status.available, + "streak_day": status.streak_day, + "next_reward_coins": status.next_reward_coins, + "next_reward_pack": status.next_reward_pack, + "last_checked_in": status.last_checked_in, + }))) +} + +pub async fn post_checkin(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?; + Ok(Json(json!({ + "coins_awarded": r.coins_awarded, + "pack_awarded": r.pack_awarded, + "new_streak": r.new_streak, + "already_claimed": r.already_claimed, + }))) +} + +pub async fn get_milestones(State(state): State) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; + + let seasons_completed: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM season_history WHERE profile_id = ?", + ) + .bind(&profile.id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let highest_division: i64 = sqlx::query_scalar( + "SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?", + ) + .bind(&profile.id) + .fetch_one(&state.pool) + .await + .unwrap_or(10); + + let cards_owned: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM owned_cards WHERE club_id = ?", + ) + .bind(&club.id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let sbcs_completed: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1", + ) + .bind(&club.id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let total_checkins: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?", + ) + .bind(&profile.id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + Ok(Json(json!({ + "total_wins": stats.matches_won, + "total_draws": stats.matches_drawn, + "total_losses": stats.matches_lost, + "total_matches": stats.matches_played, + "total_goals_scored": stats.goals_scored, + "total_goals_conceded": stats.goals_conceded, + "best_win_streak": stats.best_win_streak, + "total_packs_opened": stats.packs_opened, + "seasons_completed": seasons_completed, + "highest_division_reached": highest_division, + "cards_owned": cards_owned, + "sbcs_completed": sbcs_completed, + "total_checkins": total_checkins, + "club_level": club.level, + }))) +} diff --git a/src/services/checkin.rs b/src/services/checkin.rs new file mode 100644 index 0000000..dbeacad --- /dev/null +++ b/src/services/checkin.rs @@ -0,0 +1,133 @@ +use crate::{db::Pool, error::AppResult, services::{club, pack}}; +use uuid::Uuid; + +const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000]; +// Day 7 (index 6) also grants a pack: +const STREAK_7_PACK: &str = "silver_pack"; + +#[derive(Debug, serde::Serialize)] +pub struct CheckinStatus { + pub available: bool, + pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in) + pub next_reward_coins: i64, + pub next_reward_pack: Option<&'static str>, + pub last_checked_in: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct CheckinResult { + pub coins_awarded: i64, + pub pack_awarded: Option, + pub new_streak: i64, + pub already_claimed: bool, +} + +pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult { + let row: Option<(i64, String)> = sqlx::query_as( + "SELECT streak_day, checked_in_at FROM daily_checkins \ + WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1", + ) + .bind(profile_id) + .fetch_optional(pool) + .await?; + + let today = today_date(); + match row { + None => Ok(CheckinStatus { + available: true, + streak_day: 0, + next_reward_coins: STREAK_COINS[0], + next_reward_pack: None, + last_checked_in: None, + }), + Some((last_streak, last_at)) => { + let last_day = &last_at[..10]; // YYYY-MM-DD + let available = last_day != today.as_str(); + let next_streak = compute_next_streak(last_streak, &last_at); + let idx = ((next_streak - 1) % 7) as usize; + Ok(CheckinStatus { + available, + streak_day: if available { next_streak } else { last_streak }, + next_reward_coins: STREAK_COINS[idx], + next_reward_pack: if idx == 6 { Some(STREAK_7_PACK) } else { None }, + last_checked_in: Some(last_at), + }) + } + } +} + +pub async fn claim( + pool: &Pool, + profile_id: &str, + club_id: &str, +) -> AppResult { + let row: Option<(i64, String)> = sqlx::query_as( + "SELECT streak_day, checked_in_at FROM daily_checkins \ + WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1", + ) + .bind(profile_id) + .fetch_optional(pool) + .await?; + + let today = today_date(); + if let Some((_, ref last_at)) = row { + if &last_at[..10] == today.as_str() { + let last_streak = row.as_ref().map(|(s, _)| *s).unwrap_or(1); + return Ok(CheckinResult { + coins_awarded: 0, + pack_awarded: None, + new_streak: last_streak, + already_claimed: true, + }); + } + } + + let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1); + let idx = ((last_streak - 1) % 7) as usize; + let coins = STREAK_COINS[idx]; + let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None }; + + club::add_coins(pool, club_id, coins).await?; + if let Some(def) = pack_def { + let _ = pack::grant_pack(pool, club_id, def).await; + } + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(profile_id) + .bind(club_id) + .bind(last_streak) + .bind(coins) + .bind(pack_def) + .bind(&now) + .execute(pool) + .await?; + + Ok(CheckinResult { + coins_awarded: coins, + pack_awarded: pack_def.map(String::from), + new_streak: last_streak, + already_claimed: false, + }) +} + +fn today_date() -> String { + chrono::Utc::now().format("%Y-%m-%d").to_string() +} + +/// If last check-in was yesterday or today → continue streak; otherwise reset to 1. +fn compute_next_streak(last_streak: i64, last_at: &str) -> i64 { + let last_day = &last_at[..10]; + let today = chrono::Utc::now().date_naive(); + let yesterday = (today - chrono::Days::new(1)).to_string(); + let today_str = today.to_string(); + if last_day == yesterday || last_day == today_str { + (last_streak % 7) + 1 + } else { + 1 + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 5248858..f5e7fd1 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod achievement; pub mod card_db; +pub mod checkin; pub mod club; pub mod notification; pub mod draft; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index fa2945f..56c7fd8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1789,3 +1789,64 @@ async fn test_division_response_has_zone_fields() { assert_eq!(json["relegation_pts"], 3); assert_eq!(json["season_length"], 10); } + +#[tokio::test] +async fn test_checkin_available_initially() { + let app = build_test_app().await; + auth(&app, "CheckinFirst").await; + + let (status, json) = json_get(&app, "/club/checkin").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["available"], true); + assert_eq!(json["streak_day"], 0); + assert!(json["next_reward_coins"].is_number()); +} + +#[tokio::test] +async fn test_checkin_claim_awards_coins() { + let app = build_test_app().await; + auth(&app, "CheckinClaim").await; + + let (_, before) = json_get(&app, "/club").await; + let coins_before = before["coins"].as_i64().unwrap_or(0); + + let (status, json) = json_post(&app, "/club/checkin", serde_json::json!({})).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["already_claimed"], false); + assert!(json["coins_awarded"].as_i64().unwrap_or(0) > 0); + assert_eq!(json["new_streak"], 1); + + let (_, after) = json_get(&app, "/club").await; + let coins_after = after["coins"].as_i64().unwrap_or(0); + assert!(coins_after > coins_before, "coins should increase after checkin"); +} + +#[tokio::test] +async fn test_checkin_idempotent_same_day() { + let app = build_test_app().await; + auth(&app, "CheckinIdempotent").await; + + let (_, r1) = json_post(&app, "/club/checkin", serde_json::json!({})).await; + assert_eq!(r1["already_claimed"], false); + + let (_, r2) = json_post(&app, "/club/checkin", serde_json::json!({})).await; + assert_eq!(r2["already_claimed"], true); + assert_eq!(r2["coins_awarded"], 0); +} + +#[tokio::test] +async fn test_milestones_endpoint_structure() { + let app = build_test_app().await; + auth(&app, "MilestonePlayer").await; + + let (status, json) = json_get(&app, "/club/milestones").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["total_wins"].is_number()); + assert!(json["total_goals_scored"].is_number()); + assert!(json["best_win_streak"].is_number()); + assert!(json["seasons_completed"].is_number()); + assert!(json["highest_division_reached"].is_number()); + assert!(json["cards_owned"].is_number()); + assert!(json["sbcs_completed"].is_number()); + assert!(json["total_checkins"].is_number()); +}