Phase 24: daily check-in system + club milestones endpoint
CI / Build, lint & test (push) Failing after 28s
CI / Build, lint & test (push) Failing after 28s
- 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>
This commit is contained in:
@@ -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);
|
||||||
@@ -135,6 +135,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/profile", get(routes::profile::get_profile))
|
.route("/profile", get(routes::profile::get_profile))
|
||||||
.route("/club", get(routes::club::get_club))
|
.route("/club", get(routes::club::get_club))
|
||||||
.route("/club", put(routes::club::put_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", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
|
|||||||
+88
-1
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::club::Club,
|
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 axum::{extract::State, Json};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -35,3 +35,90 @@ pub async fn put_club(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({ "club": updated })))
|
Ok(Json(json!({ "club": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
|||||||
@@ -1789,3 +1789,64 @@ async fn test_division_response_has_zone_fields() {
|
|||||||
assert_eq!(json["relegation_pts"], 3);
|
assert_eq!(json["relegation_pts"], 3);
|
||||||
assert_eq!(json["season_length"], 10);
|
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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user