From a6fad343fcd3cdb68b960c53de8de0b27ffa61c3 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 16:36:23 +0000 Subject: [PATCH] feat(cups): add route handlers and wire into application state - Add cup_repo to AppState and server startup - Register API routes: GET/POST /cups, GET/PUT/DELETE /cups/:id - Register web route: GET /cups with Datastar fragment support - Rating validation (1-5) on create and update --- src/application/routes/cups.rs | 201 +++++++++++++++++++++++++++++++++ src/application/routes/mod.rs | 9 ++ src/application/server.rs | 8 +- 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/application/routes/cups.rs diff --git a/src/application/routes/cups.rs b/src/application/routes/cups.rs new file mode 100644 index 0000000..5b8ce79 --- /dev/null +++ b/src/application/routes/cups.rs @@ -0,0 +1,201 @@ +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; + +use super::macros::{define_delete_handler, define_enriched_get_handler}; +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{ApiError, AppError, map_app_error}; +use crate::application::routes::render_html; +use crate::application::routes::support::{ + FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, +}; +use crate::application::server::AppState; +use crate::domain::cafes::CafeSortKey; +use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; +use crate::domain::ids::CupId; +use crate::domain::listing::{ListRequest, SortDirection}; +use crate::presentation::web::templates::{CupListTemplate, CupsTemplate}; +use crate::presentation::web::views::{ + CafeOptionView, CupView, ListNavigator, Paginated, RoastOptionView, +}; + +const CUP_PAGE_PATH: &str = "/cups"; +const CUP_FRAGMENT_PATH: &str = "/cups#cup-list"; + +#[tracing::instrument(skip(state))] +async fn load_cup_page( + state: &AppState, + request: ListRequest, + search: Option<&str>, +) -> Result<(Paginated, ListNavigator), AppError> { + let page = state + .cup_repo + .list(CupFilter::all(), &request, search) + .await + .map_err(AppError::from)?; + + Ok(crate::application::routes::support::build_page_view( + page, + request, + CupView::from_domain, + CUP_PAGE_PATH, + CUP_FRAGMENT_PATH, + search.map(String::from), + )) +} + +#[tracing::instrument(skip(state, cookies, headers, query))] +pub(crate) async fn cups_page( + State(state): State, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let (request, search) = query.into_request_and_search::(); + + if is_datastar_request(&headers) { + let is_authenticated = super::is_authenticated(&state, &cookies).await; + return render_cup_list_fragment(state, request, search, is_authenticated) + .await + .map_err(map_app_error); + } + + let (cups, navigator) = load_cup_page(&state, request, search.as_deref()) + .await + .map_err(map_app_error)?; + + let is_authenticated = super::is_authenticated(&state, &cookies).await; + + let roast_options: Vec = state + .roast_repo + .list_all() + .await + .map_err(|e| map_app_error(AppError::from(e)))? + .into_iter() + .map(RoastOptionView::from) + .collect(); + + let cafe_options: Vec = state + .cafe_repo + .list_all_sorted(CafeSortKey::Name, SortDirection::Asc) + .await + .map_err(|e| map_app_error(AppError::from(e)))? + .into_iter() + .map(CafeOptionView::from) + .collect(); + + let template = CupsTemplate { + nav_active: "cups", + is_authenticated, + cups, + roast_options, + cafe_options, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +#[tracing::instrument(skip(state, _auth_user, headers, query))] +pub(crate) async fn create_cup( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Query(query): Query, + payload: FlexiblePayload, +) -> Result { + let (request, search) = query.into_request_and_search::(); + let (new_cup, source) = payload.into_parts(); + + if let Some(rating) = new_cup.rating + && !(1..=5).contains(&rating) + { + return Err(AppError::validation("rating must be between 1 and 5").into()); + } + + let cup = state + .cup_repo + .insert(new_cup) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_cup_list_fragment(state, request, search, true) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = + ListNavigator::new(CUP_PAGE_PATH, CUP_FRAGMENT_PATH, request, search).page_href(1); + Ok(Redirect::to(&target).into_response()) + } else { + Ok((StatusCode::CREATED, Json(cup)).into_response()) + } +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn list_cups( + State(state): State, +) -> Result>, ApiError> { + let request = ListRequest::show_all(CupSortKey::CreatedAt, SortDirection::Desc); + let page = state + .cup_repo + .list(CupFilter::all(), &request, None) + .await + .map_err(AppError::from)?; + Ok(Json(page.items)) +} + +define_enriched_get_handler!(get_cup, CupId, CupWithDetails, cup_repo, get_with_details); + +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn update_cup( + State(state): State, + _auth_user: AuthenticatedUser, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let has_changes = payload.notes.is_some() || payload.rating.is_some(); + + if !has_changes { + return Err(AppError::validation("no changes provided").into()); + } + + if let Some(rating) = payload.rating.as_ref() + && !(1..=5).contains(rating) + { + return Err(AppError::validation("rating must be between 1 and 5").into()); + } + + let cup = state + .cup_repo + .update(id, payload) + .await + .map_err(AppError::from)?; + Ok(Json(cup)) +} + +define_delete_handler!( + delete_cup, + CupId, + CupSortKey, + cup_repo, + render_cup_list_fragment +); + +async fn render_cup_list_fragment( + state: AppState, + request: ListRequest, + search: Option, + is_authenticated: bool, +) -> Result { + let (cups, navigator) = load_cup_page(&state, request, search.as_deref()).await?; + + let template = CupListTemplate { + is_authenticated, + cups, + navigator, + }; + + crate::application::routes::support::render_fragment(template, "#cup-list") +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 977825c..bec1482 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod bags; pub mod brews; pub mod cafes; +pub mod cups; pub mod gear; mod macros; pub mod roasters; @@ -74,6 +75,13 @@ pub fn app_router(state: AppState) -> axum::Router { .delete(cafes::delete_cafe), ) .route("/nearby-cafes", get(cafes::nearby_cafes)) + .route("/cups", get(cups::list_cups).post(cups::create_cup)) + .route( + "/cups/:id", + get(cups::get_cup) + .put(cups::update_cup) + .delete(cups::delete_cup), + ) .route( "/tokens", post(tokens::create_token).get(tokens::list_tokens), @@ -96,6 +104,7 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/gear", get(gear::gear_page)) .route("/cafes", get(cafes::cafes_page)) .route("/cafes/:slug", get(cafes::cafe_page)) + .route("/cups", get(cups::cups_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) .route("/favicon.ico", get(favicon)) diff --git a/src/application/server.rs b/src/application/server.rs index 504a415..7f96ef1 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -9,7 +9,7 @@ use tracing::info; use crate::application::routes::app_router; use crate::domain::repositories::{ - BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository, + BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use crate::domain::users::NewUser; @@ -18,6 +18,7 @@ use crate::infrastructure::database::Database; use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::cafes::SqlCafeRepository; +use crate::infrastructure::repositories::cups::SqlCupRepository; use crate::infrastructure::repositories::gear::SqlGearRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; @@ -41,6 +42,7 @@ pub struct AppState { pub gear_repo: Arc, pub brew_repo: Arc, pub cafe_repo: Arc, + pub cup_repo: Arc, pub timeline_repo: Arc, pub user_repo: Arc, pub token_repo: Arc, @@ -58,6 +60,7 @@ impl AppState { gear_repo: Arc, brew_repo: Arc, cafe_repo: Arc, + cup_repo: Arc, timeline_repo: Arc, user_repo: Arc, token_repo: Arc, @@ -72,6 +75,7 @@ impl AppState { gear_repo, brew_repo, cafe_repo, + cup_repo, timeline_repo, user_repo, token_repo, @@ -94,6 +98,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); + let cup_repo = Arc::new(SqlCupRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -112,6 +117,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { gear_repo, brew_repo, cafe_repo, + cup_repo, timeline_repo, user_repo, token_repo,