diff --git a/src/app.rs b/src/app.rs index f453a1d..28bf31d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -122,6 +122,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/auth/local", post(routes::auth::post_auth_local)) .route("/profile", get(routes::profile::get_profile)) .route("/club", get(routes::club::get_club)) + .route("/club", put(routes::club::put_club)) .route("/cards", get(routes::cards::get_cards)) .route("/cards/:card_id", get(routes::cards::get_card)) .route("/collection", get(routes::cards::get_collection)) @@ -130,6 +131,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { delete(routes::cards::delete_owned_card), ) .route("/packs", get(routes::packs::get_packs)) + .route("/packs/history", get(routes::packs::get_pack_history)) .route("/packs/buy", post(routes::packs::post_buy_pack)) .route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) .route("/squad", get(routes::squad::get_squad)) @@ -169,6 +171,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { ) .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) + .route("/division", get(routes::division::get_division)) + .route("/notifications", get(routes::notifications::get_notifications)) .route("/draft/squad", get(routes::draft::get_draft_squad)) .route("/draft/start", post(routes::draft::post_draft_start)) .route("/draft/sessions/:id", get(routes::draft::get_draft_session)) diff --git a/src/models/club.rs b/src/models/club.rs index f5fdd86..389327a 100644 --- a/src/models/club.rs +++ b/src/models/club.rs @@ -11,6 +11,7 @@ pub struct Club { pub level: i64, pub created_at: DateTime, pub updated_at: DateTime, + pub manager_name: Option, } impl Club { @@ -24,6 +25,7 @@ impl Club { level: 1, created_at: now, updated_at: now, + manager_name: Some("Player Manager".to_string()), } } } diff --git a/src/models/pack.rs b/src/models/pack.rs index b7eeed4..a4fa7c0 100644 --- a/src/models/pack.rs +++ b/src/models/pack.rs @@ -29,6 +29,8 @@ pub struct Pack { pub definition_id: String, pub opened: bool, pub created_at: String, + pub opened_cards: Option, + pub opened_at: Option, } /// The result of opening a pack. diff --git a/src/routes/club.rs b/src/routes/club.rs index 4330687..993b3b5 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -5,9 +5,33 @@ use crate::{ services::{club as club_svc, profile as profile_svc}, }; use axum::{extract::State, Json}; +use serde::Deserialize; +use serde_json::{json, Value}; pub async fn get_club(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?; Ok(Json(club)) } + +#[derive(Deserialize)] +pub struct UpdateClubRequest { + pub name: Option, + pub manager_name: Option, +} + +pub async fn put_club( + State(state): State, + Json(req): Json, +) -> 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 updated = club_svc::update_club( + &state.pool, + &club.id, + req.name.as_deref(), + req.manager_name.as_deref(), + ) + .await?; + Ok(Json(json!({ "club": updated }))) +} diff --git a/src/routes/division.rs b/src/routes/division.rs new file mode 100644 index 0000000..fa593ef --- /dev/null +++ b/src/routes/division.rs @@ -0,0 +1,32 @@ +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::{ + app::AppState, + error::AppResult, + services::{club as club_svc, profile as profile_svc, season as season_svc}, +}; + +pub async fn get_division(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?; + + // Ensure a season row exists (idempotent) + let _ = club; // suppress unused warning; we need club_id later if we extend this + let season = season_svc::get_or_create(&state.pool, &profile.id).await?; + + Ok(Json(json!({ + "division": season.division, + "season_number": season.season_number, + "season_points": season.season_points, + "matches_played": season.matches_played, + "matches_remaining": season.matches_remaining(), + "record": { + "wins": season.wins, + "draws": season.draws, + "losses": season.losses, + }, + "pts_for_promotion": season.pts_for_promotion(), + "started_at": season.started_at, + }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8c720aa..5d4bad6 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,11 +1,13 @@ pub mod auth; pub mod cards; pub mod club; +pub mod division; pub mod draft; pub mod events; pub mod health; pub mod market; pub mod matches; +pub mod notifications; pub mod objectives; pub mod packs; pub mod profile; diff --git a/src/routes/notifications.rs b/src/routes/notifications.rs new file mode 100644 index 0000000..8506f49 --- /dev/null +++ b/src/routes/notifications.rs @@ -0,0 +1,91 @@ +use axum::{extract::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}, +}; + +/// Returns actionable notifications for the current profile. +/// +/// 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) +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 + 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, + })); + } + } + + // 2. Loan cards expiring within 2 matches + let expiring_loans: 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", + ) + .bind(&club.id) + .fetch_all(&state.pool) + .await?; + + for (owned_id, card_id, remaining) in expiring_loans { + let card_name = state + .card_db + .get(&card_id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| card_id.clone()); + notifications.push(json!({ + "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, + })); + } + + // 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?; + + if let Some((played, division)) = season_row { + let remaining = (10 - played).max(0); + if remaining <= 2 && remaining > 0 { + notifications.push(json!({ + "type": "season_ending", + "title": format!("Season ending — Division {division}"), + "message": format!("{remaining} match(es) left in the season"), + "matches_remaining": remaining, + })); + } + } + + Ok(Json(json!({ + "notifications": notifications, + "count": notifications.len(), + }))) +} diff --git a/src/routes/packs.rs b/src/routes/packs.rs index 15c9a88..0f7c2b6 100644 --- a/src/routes/packs.rs +++ b/src/routes/packs.rs @@ -57,6 +57,50 @@ pub async fn get_packs(State(state): State) -> AppResult> Ok(Json(json!({ "packs": with_defs }))) } +/// Return recently opened packs with the card IDs they contained. +pub async fn get_pack_history(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 opened = sqlx::query_as::<_, crate::models::pack::Pack>( + "SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at \ + FROM packs WHERE club_id = ? AND opened = 1 ORDER BY opened_at DESC LIMIT 50", + ) + .bind(&club.id) + .fetch_all(&state.pool) + .await?; + + let history: Vec = opened + .iter() + .map(|p| { + let def = state.pack_defs.iter().find(|d| d.id == p.definition_id); + // Expand card_ids into full card definitions + let cards: Vec = p + .opened_cards + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default() + .iter() + .map(|id| { + json!({ + "card_id": id, + "card": state.card_db.get(id), + }) + }) + .collect(); + json!({ + "pack_id": p.id, + "definition_id": p.definition_id, + "name": def.map(|d| &d.name), + "opened_at": p.opened_at, + "cards": cards, + }) + }) + .collect(); + + Ok(Json(json!({ "history": history, "total": history.len() }))) +} + pub async fn post_open_pack( State(state): State, Path(pack_id): Path, diff --git a/src/services/club.rs b/src/services/club.rs index 3937a80..b31f9b7 100644 --- a/src/services/club.rs +++ b/src/services/club.rs @@ -5,19 +5,30 @@ use crate::{ }; use chrono::Utc; +const CLUB_SELECT: &str = + "SELECT id, profile_id, name, coins, level, created_at, updated_at, manager_name \ + FROM clubs"; + pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult { - sqlx::query_as::<_, Club>( - "SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1" - ) - .bind(profile_id) - .fetch_optional(pool) - .await? - .ok_or_else(|| AppError::NotFound("club not found".into())) + sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE profile_id = ? LIMIT 1")) + .bind(profile_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("club not found".into())) +} + +pub async fn get_club_by_id(pool: &Pool, club_id: &str) -> AppResult { + sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE id = ? LIMIT 1")) + .bind(club_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound("club not found".into())) } pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> { sqlx::query( - "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at, manager_name) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&club.id) .bind(&club.profile_id) @@ -26,11 +37,38 @@ pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> { .bind(club.level) .bind(club.created_at) .bind(club.updated_at) + .bind(&club.manager_name) .execute(pool) .await?; Ok(()) } +pub async fn update_club( + pool: &Pool, + club_id: &str, + name: Option<&str>, + manager_name: Option<&str>, +) -> AppResult { + let now = Utc::now(); + if let Some(n) = name { + sqlx::query("UPDATE clubs SET name = ?, updated_at = ? WHERE id = ?") + .bind(n) + .bind(now) + .bind(club_id) + .execute(pool) + .await?; + } + if let Some(m) = manager_name { + sqlx::query("UPDATE clubs SET manager_name = ?, updated_at = ? WHERE id = ?") + .bind(m) + .bind(now) + .bind(club_id) + .execute(pool) + .await?; + } + get_club_by_id(pool, club_id).await +} + pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult { let now = Utc::now(); sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") diff --git a/src/services/pack.rs b/src/services/pack.rs index 0f52ef6..1e3199f 100644 --- a/src/services/pack.rs +++ b/src/services/pack.rs @@ -39,6 +39,8 @@ pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppR definition_id: definition_id.to_string(), opened: false, created_at: chrono::Utc::now().to_rfc3339(), + opened_cards: None, + opened_at: None, }; sqlx::query( "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)", @@ -61,7 +63,7 @@ pub async fn open_pack( pack_id: &str, ) -> AppResult { let pack = sqlx::query_as::<_, Pack>( - "SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?" + "SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE id = ? AND club_id = ?" ) .bind(pack_id) .bind(club_id) @@ -122,7 +124,13 @@ pub async fn open_pack( } } - sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?") + let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::>()) + .unwrap_or_default(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?") + .bind(&card_ids_json) + .bind(&now) .bind(pack_id) .execute(pool) .await?; @@ -152,7 +160,7 @@ pub async fn buy_pack( pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult> { let packs = sqlx::query_as::<_, Pack>( - "SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0" + "SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE club_id = ? AND opened = 0" ) .bind(club_id) .fetch_all(pool) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9d78ff1..3205f32 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -830,3 +830,157 @@ async fn test_market_my_listings_initially_empty() { assert_eq!(json["total"], 0); assert!(json["listings"].as_array().unwrap().is_empty()); } + +// ── Phase 9: Division, Loans, Pack history, Club customization, Notifications ─ + +#[tokio::test] +async fn test_division_endpoint_starts_at_5() { + let app = build_test_app().await; + auth(&app, "DivPlayer").await; + + let (status, json) = json_get(&app, "/division").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["division"], 5, "new players start in division 5"); + assert_eq!(json["season_points"], 0); + assert_eq!(json["matches_played"], 0); + assert_eq!(json["matches_remaining"], 10); +} + +#[tokio::test] +async fn test_division_updates_after_wins() { + let app = build_test_app().await; + auth(&app, "DivWinner").await; + + // Play 3 wins + for _ in 0..3 { + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 2, "goals_against": 0, "mode": "squad_battles" + })).await; + } + + let (_, div) = json_get(&app, "/division").await; + assert_eq!(div["matches_played"], 3); + assert_eq!(div["season_points"], 9, "3 wins = 9 pts"); + assert_eq!(div["record"]["wins"], 3); +} + +#[tokio::test] +async fn test_season_ends_after_10_matches_and_promotes() { + let app = build_test_app().await; + auth(&app, "SeasonPlayer").await; + + // Win all 10 matches of the season + for _ in 0..10 { + let (s, result) = json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + assert_eq!(s, StatusCode::OK); + let _ = result; // just check it doesn't error + } + + // 10 wins = 30 pts → should promote from div 5 to div 4 + let (_, div) = json_get(&app, "/division").await; + assert_eq!(div["division"], 4, "30 pts should promote from div 5 to div 4"); + assert_eq!(div["matches_played"], 0, "season counter reset"); + assert_eq!(div["season_number"], 2, "season 2 started"); +} + +#[tokio::test] +async fn test_pack_history_after_opening() { + let app = build_test_app().await; + auth(&app, "HistoryPlayer").await; + + // Initially empty + let (s, hist) = json_get(&app, "/packs/history").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(hist["total"], 0); + + // Open the starter pack + let (_, packs) = json_get(&app, "/packs").await; + let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap(); + json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await; + + // Should now appear in history + let (s, hist) = json_get(&app, "/packs/history").await; + assert_eq!(s, StatusCode::OK); + assert_eq!(hist["total"], 1, "opened pack should appear in history"); + assert!(hist["history"][0]["opened_at"].is_string()); + assert!(hist["history"][0]["cards"].is_array()); + assert!(!hist["history"][0]["cards"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn test_club_customization() { + let app = build_test_app().await; + auth(&app, "CustomClub").await; + + let resp = app.clone().oneshot( + Request::builder() + .method("PUT") + .uri("/club") + .header("content-type", "application/json") + .body(Body::from(serde_json::json!({ + "name": "Galaxy FC", + "manager_name": "Alex Ferguson Jr." + }).to_string())) + .unwrap() + ).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["club"]["name"], "Galaxy FC"); + assert_eq!(json["club"]["manager_name"], "Alex Ferguson Jr."); + + // Verify persisted + let (_, club) = json_get(&app, "/club").await; + assert_eq!(club["name"], "Galaxy FC"); + assert_eq!(club["manager_name"], "Alex Ferguson Jr."); +} + +#[tokio::test] +async fn test_notifications_empty_on_fresh_profile() { + let app = build_test_app().await; + auth(&app, "NotifPlayer").await; + + let (s, json) = json_get(&app, "/notifications").await; + assert_eq!(s, StatusCode::OK); + assert!(json["notifications"].is_array()); + // Fresh profile has no completed objectives, so count may be 0 + let _ = json["count"].as_u64(); +} + +#[tokio::test] +async fn test_notifications_include_completed_objectives() { + let app = build_test_app().await; + auth(&app, "ObjNotifPlayer").await; + + // Play enough matches to complete the "daily_play_1_match" objective + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 1, "goals_against": 0, "mode": "squad_battles" + })).await; + + let (s, json) = json_get(&app, "/notifications").await; + assert_eq!(s, StatusCode::OK); + let notifs = json["notifications"].as_array().unwrap(); + let has_obj_notif = notifs.iter().any(|n| n["type"] == "objective_complete"); + assert!(has_obj_notif, "completed objectives should appear in notifications"); +} + +#[tokio::test] +async fn test_match_result_returns_season_info() { + let app = build_test_app().await; + auth(&app, "SeasonMatchPlayer").await; + + let (s, result) = json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 2, "goals_against": 1, "mode": "squad_battles" + })).await; + assert_eq!(s, StatusCode::OK); + // expired_loans should be present (empty array for no loan cards) + assert!(result["expired_loans"].is_array()); + // season_end is None for non-final match + assert!(result["season_end"].is_null()); +}