f70cf4415c
Divergent development line off origin/main (11a811d): a broad refactor across routes/services/models/app + a large integration_test expansion (+1000), plus an untracked game-independent inventory query service and Docker files. Preserved verbatim before moving the canonical Core checkout to the committed migration trunk (66c88fb). Reconciling this refactor with the migration trunk is a separate user decision; nothing here is lost.
135 lines
5.1 KiB
Rust
135 lines
5.1 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
Json,
|
|
};
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::{
|
|
app::AppState,
|
|
error::{AppError, AppResult},
|
|
services::{
|
|
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
|
|
},
|
|
};
|
|
|
|
/// GET /notifications
|
|
///
|
|
/// 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?;
|
|
|
|
// ── 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?;
|
|
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();
|
|
|
|
// ── 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 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 {
|
|
let card_name = state
|
|
.card_db
|
|
.get(&card_id)
|
|
.map(|c| c.name.clone())
|
|
.unwrap_or_else(|| card_id.clone());
|
|
dynamic.push(json!({
|
|
"id": null,
|
|
"type": "loan_expiring",
|
|
"title": format!("Loan expiring: {card_name}"),
|
|
"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,
|
|
}));
|
|
}
|
|
|
|
// ── 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 {
|
|
dynamic.push(json!({
|
|
"id": null,
|
|
"type": "season_ending",
|
|
"title": format!("Season ending — Division {division}"),
|
|
"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": 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 })))
|
|
}
|