From f0dbabc409c0c7e4f0c2c5a9e45e928f6e08fadc Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 17:57:24 -0700 Subject: [PATCH] Phase 19: persistent notifications system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- migrations/0009_notifications.sql | 13 +++ src/app.rs | 4 +- src/models/mod.rs | 1 + src/models/notification.rs | 25 ++++++ src/routes/notifications.rs | 135 +++++++++++++++++++----------- src/services/match_service.rs | 34 +++++++- src/services/mod.rs | 1 + src/services/notification.rs | 49 +++++++++++ tests/integration_test.rs | 80 ++++++++++++++++++ 9 files changed, 293 insertions(+), 49 deletions(-) create mode 100644 migrations/0009_notifications.sql create mode 100644 src/models/notification.rs create mode 100644 src/services/notification.rs diff --git a/migrations/0009_notifications.sql b/migrations/0009_notifications.sql new file mode 100644 index 0000000..80ba33c --- /dev/null +++ b/migrations/0009_notifications.sql @@ -0,0 +1,13 @@ +-- Persistent event-driven notifications (level-ups, loan expiry, season end, etc.) +-- Dynamic state notifications (expiring loans, unclaimed objectives) remain in code. +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read); +CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications (created_at DESC); diff --git a/src/app.rs b/src/app.rs index 6cd90ef..26105a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,7 +18,7 @@ use anyhow::Result; use axum::{ extract::DefaultBodyLimit, middleware, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, Router, }; use std::sync::Arc; @@ -195,6 +195,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", put(routes::settings::put_settings)) .route("/division", get(routes::division::get_division)) .route("/notifications", get(routes::notifications::get_notifications)) + .route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) + .route("/notifications/:id/read", patch(routes::notifications::mark_notification_read)) .route("/fut-champs", get(routes::fut_champs::get_fut_champs)) .route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs)) .route("/fut-champs/history", get(routes::fut_champs::get_champs_history)) diff --git a/src/models/mod.rs b/src/models/mod.rs index 15fdfdf..6a18b9e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod card; pub mod chemistry_style; +pub mod notification; pub mod club; pub mod draft; pub mod fut_champs; diff --git a/src/models/notification.rs b/src/models/notification.rs new file mode 100644 index 0000000..2618d87 --- /dev/null +++ b/src/models/notification.rs @@ -0,0 +1,25 @@ +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Notification { + pub id: String, + pub kind: String, + pub title: String, + pub body: String, + pub is_read: bool, + pub created_at: String, +} + +impl Notification { + pub fn new(kind: &str, title: &str, body: &str) -> Self { + Self { + id: Uuid::new_v4().to_string(), + kind: kind.to_string(), + title: title.to_string(), + body: body.to_string(), + is_read: false, + created_at: chrono::Utc::now().to_rfc3339(), + } + } +} diff --git a/src/routes/notifications.rs b/src/routes/notifications.rs index 8506f49..bfe6752 100644 --- a/src/routes/notifications.rs +++ b/src/routes/notifications.rs @@ -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) -> AppResult> { 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 = 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 = 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)> = sqlx::query_as( + // ── Dynamic: loan cards expiring within 2 matches ──────────────────────── + let expiring: Vec<(String, String, Option)> = 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 = 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, + Path(id): Path, +) -> AppResult> { + 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, +) -> AppResult> { + let count = notif_svc::mark_all_read(&state.pool).await?; + Ok(Json(json!({ "marked_read": count }))) +} diff --git a/src/services/match_service.rs b/src/services/match_service.rs index 440c526..d4b5ec0 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -6,7 +6,7 @@ use crate::{ match_result::{Match, MatchRewardResult, SubmitMatchRequest}, objective::ObjectiveDefinition, }, - services::{card_db::CardDb, club, objective, profile, season as season_svc, statistics}, + services::{card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics}, }; use rand::{seq::SliceRandom, Rng}; @@ -136,6 +136,16 @@ pub async fn process_match( club::add_coins(pool, club_id, coins).await?; let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?; + + for ev in &level_ups { + let body = if let Some(ref pack) = ev.pack_granted { + format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted) + } else { + format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted) + }; + let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await; + } + statistics::record_match( pool, profile_id, @@ -171,13 +181,35 @@ pub async fn process_match( objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; objectives_updated.append(&mut c); + for obj_id in &objectives_updated { + let title = "Objective complete!"; + let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id); + let _ = notification::create(pool, "objective_complete", title, &body).await; + } + // Decrement loan matches remaining for squad starters; collect expired loan IDs. let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?; + for owned_id in &expired_loans { + let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club."); + let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await; + } + // Update season progress (creates the season row if it doesn't exist yet). season_svc::get_or_create(pool, profile_id).await?; let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?; + if let Some(ref se) = season_end { + use crate::models::season::SeasonResult; + let direction = match se.result { + SeasonResult::Promoted => "Promoted", + SeasonResult::Relegated => "Relegated", + SeasonResult::Maintained => "Maintained", + }; + let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default()); + let _ = notification::create(pool, "season_end", "Season complete!", &body).await; + } + Ok(MatchRewardResult { match_record, coins_awarded: coins, diff --git a/src/services/mod.rs b/src/services/mod.rs index 15f0099..3065a33 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod card_db; pub mod club; +pub mod notification; pub mod draft; pub mod event; pub mod fut_champs; diff --git a/src/services/notification.rs b/src/services/notification.rs new file mode 100644 index 0000000..02285f3 --- /dev/null +++ b/src/services/notification.rs @@ -0,0 +1,49 @@ +use crate::{db::Pool, error::AppResult, models::notification::Notification}; + +pub async fn create(pool: &Pool, kind: &str, title: &str, body: &str) -> AppResult<()> { + let n = Notification::new(kind, title, body); + sqlx::query( + "INSERT INTO notifications (id, kind, title, body, is_read, created_at) \ + VALUES (?, ?, ?, ?, 0, ?)", + ) + .bind(&n.id) + .bind(&n.kind) + .bind(&n.title) + .bind(&n.body) + .bind(&n.created_at) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list(pool: &Pool) -> AppResult> { + sqlx::query_as::<_, Notification>( + "SELECT id, kind, title, body, is_read, created_at \ + FROM notifications ORDER BY created_at DESC LIMIT 50", + ) + .fetch_all(pool) + .await + .map_err(Into::into) +} + +pub async fn mark_read(pool: &Pool, id: &str) -> AppResult { + let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(r.rows_affected() > 0) +} + +pub async fn mark_all_read(pool: &Pool) -> AppResult { + let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE is_read = 0") + .execute(pool) + .await?; + Ok(r.rows_affected() as i64) +} + +pub async fn unread_count(pool: &Pool) -> AppResult { + sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE is_read = 0") + .fetch_one(pool) + .await + .map_err(Into::into) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ebfd20b..2208d22 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1513,3 +1513,83 @@ async fn test_level_up_grants_milestone_pack_at_level_5() { assert_eq!(pack_for_level(10), Some("silver_pack")); assert_eq!(pack_for_level(7), None); } + +// ── Phase 19: Persistent notifications ─────────────────────────────────────── + +#[tokio::test] +async fn test_notifications_have_unread_count() { + let app = build_test_app().await; + auth(&app, "NotifCountPlayer").await; + + let (status, json) = json_get(&app, "/notifications").await; + assert_eq!(status, StatusCode::OK); + assert!(json["unread_count"].is_number(), "unread_count missing"); + assert!(json["notifications"].is_array(), "notifications not an array"); +} + +#[tokio::test] +async fn test_level_up_creates_persistent_notification() { + let app = build_test_app().await; + auth(&app, "LevelUpNotifPlayer").await; + + // Play several wins to guarantee crossing the 500 XP threshold (level 2) + for _ in 0..5 { + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + } + + let (status, json) = json_get(&app, "/notifications").await; + assert_eq!(status, StatusCode::OK); + let notifs = json["notifications"].as_array().unwrap(); + let has_level_up = notifs.iter().any(|n| n["type"] == "level_up"); + assert!(has_level_up, "level_up notification not found after gaining levels"); +} + +#[tokio::test] +async fn test_mark_all_notifications_read() { + let app = build_test_app().await; + auth(&app, "MarkReadPlayer").await; + + // Generate a notification via level-up + for _ in 0..5 { + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + } + + // Mark all persistent notifications read + let (status, json) = json_post(&app, "/notifications/read-all", serde_json::json!({})).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["marked_read"].as_i64().unwrap_or(0) >= 0); +} + +#[tokio::test] +async fn test_mark_single_notification_read() { + let app = build_test_app().await; + auth(&app, "SingleReadPlayer").await; + + // Generate level-up notifications + for _ in 0..5 { + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + } + + let (_, json) = json_get(&app, "/notifications").await; + let notifs = json["notifications"].as_array().unwrap(); + // Find one with an id (persistent) and PATCH it + let persistent = notifs.iter().find(|n| n["id"].is_string()); + if let Some(n) = persistent { + let id = n["id"].as_str().unwrap(); + let uri = format!("/notifications/{id}/read"); + let resp = app.clone().oneshot( + Request::builder().method("PATCH").uri(&uri) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } +}