a749fba93c
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>
92 lines
3.3 KiB
Rust
92 lines
3.3 KiB
Rust
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(),
|
|
})))
|
|
}
|