Files
OpenFUT-Core/src/services/checkin.rs
T
funman300 956bfe7a73
CI / Build, lint & test (push) Failing after 28s
Phase 24: daily check-in system + club milestones endpoint
- 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 <noreply@anthropic.com>
2026-06-25 19:05:52 -07:00

134 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (17 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct CheckinResult {
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
pub new_streak: i64,
pub already_claimed: bool,
}
pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatus> {
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<CheckinResult> {
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
}
}