Phase 19: persistent notifications system
CI / Build, lint & test (push) Failing after 58s

New notifications table (migration 0009) stores event-driven alerts
alongside the existing dynamic state notifications (unclaimed objectives,
expiring loans, season ending soon).

Persistent notifications are created automatically during match
processing: one per level gained, one per expired loan card, one per
objective newly completed, and one when a season ends (with
promotion/relegation result and rewards in the body).

GET /notifications now returns a merged list — persistent entries
(newest-first, limit 50) followed by dynamic entries — plus an
unread_count for the badge. Each item carries type, title, body,
is_read, and (for persistent) id and created_at.

PATCH /notifications/:id/read marks a single persistent notification
read. POST /notifications/read-all marks all persistent ones read.

Four new tests: unread_count field, level-up notification creation,
mark-all-read, single-read PATCH. Core now at 77 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:57:24 -07:00
parent 9e481ec072
commit f0dbabc409
9 changed files with 293 additions and 49 deletions
+88 -47
View File
@@ -1,91 +1,132 @@
use axum::{extract::State, Json};
use axum::{
extract::{Path, 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},
error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
};
/// Returns actionable notifications for the current profile.
/// GET /notifications
///
/// 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)
/// Returns persistent (event-driven) notifications merged with dynamic state
/// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread.
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
// ── Persistent notifications ─────────────────────────────────────────────
let persistent = notif_svc::list(&state.pool).await?;
let persistent_unread = persistent.iter().filter(|n| !n.is_read).count() as i64;
// ── Dynamic: unclaimed objective rewards ─────────────────────────────────
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,
}));
}
}
let mut dynamic: Vec<Value> = objectives
.iter()
.filter(|o| o.completed && !o.claimed)
.map(|o| json!({
"id": null,
"type": "objective_complete",
"title": format!("Objective complete: {}", o.definition.title),
"body": format!("Claim your reward for \"{}\" (+{} coins)", o.definition.title, o.definition.reward_coins),
"is_read": false,
"created_at": null,
}))
.collect();
// 2. Loan cards expiring within 2 matches
let expiring_loans: Vec<(String, String, Option<i64>)> = sqlx::query_as(
// ── Dynamic: loan cards expiring within 2 matches ────────────────────────
let expiring: 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",
AND oc.loan_matches_remaining IS NOT NULL AND oc.loan_matches_remaining <= 2",
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
for (owned_id, card_id, remaining) in expiring_loans {
for (owned_id, card_id, remaining) in expiring {
let card_name = state
.card_db
.get(&card_id)
.map(|c| c.name.clone())
.unwrap_or_else(|| card_id.clone());
notifications.push(json!({
dynamic.push(json!({
"id": null,
"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,
"body": format!("{card_name} has {} match(es) remaining on loan.", remaining.unwrap_or(0)),
"is_read": false,
"created_at": null,
"owned_card_id": owned_id,
}));
}
// 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?;
// ── Dynamic: season ending soon ──────────────────────────────────────────
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!({
dynamic.push(json!({
"id": null,
"type": "season_ending",
"title": format!("Season ending — Division {division}"),
"message": format!("{remaining} match(es) left in the season"),
"matches_remaining": remaining,
"body": format!("{remaining} match(es) left in the current season."),
"is_read": false,
"created_at": null,
}));
}
}
// ── Merge: persistent first (newest first), then dynamic ─────────────────
// Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent
.iter()
.map(|n| json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
}))
.chain(dynamic.iter().cloned())
.collect();
let total_unread = persistent_unread + dynamic.len() as i64;
Ok(Json(json!({
"notifications": notifications,
"count": notifications.len(),
"notifications": all,
"unread_count": total_unread,
})))
}
/// PATCH /notifications/:id/read
pub async fn mark_notification_read(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let found = notif_svc::mark_read(&state.pool, &id).await?;
if !found {
return Err(AppError::NotFound(format!("notification '{id}' not found")));
}
Ok(Json(json!({ "marked_read": id })))
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count })))
}