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:
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod notification;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
|
||||
@@ -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<Vec<Notification>> {
|
||||
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<bool> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE is_read = 0")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
Reference in New Issue
Block a user