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) -> AppResult> { 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 = 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)> = 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 = 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, 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 }))) }