Phase 9: division/season tracking, club customization, pack history, notifications
CI / Build, lint & test (push) Failing after 1m42s

- GET /division returns live season stats (points, record, promotion threshold)
- PUT /club allows updating club name and manager_name
- GET /packs/history returns opened packs with full card definitions
- GET /notifications dynamically surfaces completed objectives, expiring loans, season end
- Club model gains manager_name column (migration 0006 already added it)
- Pack model gains opened_cards and opened_at; pack SELECT queries updated
- 9 new integration tests — all 45 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 16:50:35 -07:00
parent bfd6de6896
commit a749fba93c
11 changed files with 412 additions and 11 deletions
+24
View File
@@ -5,9 +5,33 @@ use crate::{
services::{club as club_svc, profile as profile_svc},
};
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club))
}
#[derive(Deserialize)]
pub struct UpdateClubRequest {
pub name: Option<String>,
pub manager_name: Option<String>,
}
pub async fn put_club(
State(state): State<AppState>,
Json(req): Json<UpdateClubRequest>,
) -> 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 updated = club_svc::update_club(
&state.pool,
&club.id,
req.name.as_deref(),
req.manager_name.as_deref(),
)
.await?;
Ok(Json(json!({ "club": updated })))
}
+32
View File
@@ -0,0 +1,32 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, profile as profile_svc, season as season_svc},
};
pub async fn get_division(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?;
// Ensure a season row exists (idempotent)
let _ = club; // suppress unused warning; we need club_id later if we extend this
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
Ok(Json(json!({
"division": season.division,
"season_number": season.season_number,
"season_points": season.season_points,
"matches_played": season.matches_played,
"matches_remaining": season.matches_remaining(),
"record": {
"wins": season.wins,
"draws": season.draws,
"losses": season.losses,
},
"pts_for_promotion": season.pts_for_promotion(),
"started_at": season.started_at,
})))
}
+2
View File
@@ -1,11 +1,13 @@
pub mod auth;
pub mod cards;
pub mod club;
pub mod division;
pub mod draft;
pub mod events;
pub mod health;
pub mod market;
pub mod matches;
pub mod notifications;
pub mod objectives;
pub mod packs;
pub mod profile;
+91
View File
@@ -0,0 +1,91 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
};
/// Returns actionable notifications for the current profile.
///
/// Built dynamically from DB state — no separate notifications table.
/// Covers:
/// - Completed objectives whose reward hasn't been claimed yet
/// - Loan cards that will expire within 2 matches
/// - Season ending soon (≤2 matches remaining)
pub async fn get_notifications(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 mut notifications: Vec<Value> = Vec::new();
// 1. Completed but unclaimed objectives
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
for obj in &objectives {
if obj.completed && !obj.claimed {
notifications.push(json!({
"type": "objective_complete",
"objective_id": obj.definition.id,
"title": format!("Objective complete: {}", obj.definition.title),
"message": format!("Claim your reward for \"{}\"", obj.definition.title),
"reward_coins": obj.definition.reward_coins,
"reward_pack": obj.definition.reward_pack_id,
}));
}
}
// 2. Loan cards expiring within 2 matches
let expiring_loans: Vec<(String, String, Option<i64>)> = sqlx::query_as(
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
AND oc.loan_matches_remaining <= 2",
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
for (owned_id, card_id, remaining) in expiring_loans {
let card_name = state
.card_db
.get(&card_id)
.map(|c| c.name.clone())
.unwrap_or_else(|| card_id.clone());
notifications.push(json!({
"type": "loan_expiring",
"owned_card_id": owned_id,
"card_id": card_id,
"title": format!("Loan expiring: {card_name}"),
"message": format!(
"{card_name} has {} match(es) remaining on loan",
remaining.unwrap_or(0)
),
"matches_remaining": remaining,
}));
}
// 3. Season ending soon (≤2 matches remaining)
let season_row: Option<(i64, i64)> = sqlx::query_as(
"SELECT matches_played, division FROM seasons WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_optional(&state.pool)
.await?;
if let Some((played, division)) = season_row {
let remaining = (10 - played).max(0);
if remaining <= 2 && remaining > 0 {
notifications.push(json!({
"type": "season_ending",
"title": format!("Season ending — Division {division}"),
"message": format!("{remaining} match(es) left in the season"),
"matches_remaining": remaining,
}));
}
}
Ok(Json(json!({
"notifications": notifications,
"count": notifications.len(),
})))
}
+44
View File
@@ -57,6 +57,50 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
Ok(Json(json!({ "packs": with_defs })))
}
/// Return recently opened packs with the card IDs they contained.
pub async fn get_pack_history(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 opened = sqlx::query_as::<_, crate::models::pack::Pack>(
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at \
FROM packs WHERE club_id = ? AND opened = 1 ORDER BY opened_at DESC LIMIT 50",
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
let history: Vec<Value> = opened
.iter()
.map(|p| {
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
// Expand card_ids into full card definitions
let cards: Vec<Value> = p
.opened_cards
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default()
.iter()
.map(|id| {
json!({
"card_id": id,
"card": state.card_db.get(id),
})
})
.collect();
json!({
"pack_id": p.id,
"definition_id": p.definition_id,
"name": def.map(|d| &d.name),
"opened_at": p.opened_at,
"cards": cards,
})
})
.collect();
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn post_open_pack(
State(state): State<AppState>,
Path(pack_id): Path<String>,