From 1781bff48d86a2bab2ef7251dc9f8e7461009d30 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 14:52:51 +0000 Subject: [PATCH] feat(cafes): add route handlers and wire into application state Add cafe route handlers (CRUD API + web pages with Datastar fragment support). Register cafe_repo in AppState and mount API and web routes. --- src/application/routes/cafes.rs | 193 ++++++++++++++++++++++++++++++++ src/application/routes/mod.rs | 10 ++ src/application/server.rs | 10 +- 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/application/routes/cafes.rs diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs new file mode 100644 index 0000000..8f7eabc --- /dev/null +++ b/src/application/routes/cafes.rs @@ -0,0 +1,193 @@ +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_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::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; +use crate::domain::ids::CafeId; +use crate::domain::listing::{ListRequest, SortDirection}; +use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate}; +use crate::presentation::web::views::{CafeView, ListNavigator, Paginated}; + +const CAFE_PAGE_PATH: &str = "/cafes"; +const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list"; + +#[tracing::instrument(skip(state))] +async fn load_cafe_page( + state: &AppState, + request: ListRequest, + search: Option<&str>, +) -> Result<(Paginated, ListNavigator), AppError> { + let page = state + .cafe_repo + .list(&request, search) + .await + .map_err(AppError::from)?; + + Ok(crate::application::routes::support::build_page_view( + page, + request, + CafeView::from, + CAFE_PAGE_PATH, + CAFE_FRAGMENT_PATH, + search.map(String::from), + )) +} + +#[tracing::instrument(skip(state, cookies, headers, query))] +pub(crate) async fn cafes_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_cafe_list_fragment(state, request, search, is_authenticated) + .await + .map_err(map_app_error); + } + + let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref()) + .await + .map_err(map_app_error)?; + + let is_authenticated = super::is_authenticated(&state, &cookies).await; + + let template = CafesTemplate { + nav_active: "cafes", + is_authenticated, + cafes, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn cafe_page( + State(state): State, + cookies: tower_cookies::Cookies, + Path(slug): Path, +) -> Result { + let cafe = state + .cafe_repo + .get_by_slug(&slug) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let cafe_view = CafeView::from(cafe); + let is_authenticated = super::is_authenticated(&state, &cookies).await; + + let template = CafeDetailTemplate { + nav_active: "cafes", + is_authenticated, + cafe: cafe_view, + }; + + render_html(template).map(IntoResponse::into_response) +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn list_cafes(State(state): State) -> Result>, ApiError> { + let cafes = state + .cafe_repo + .list_all_sorted(CafeSortKey::Name, SortDirection::Asc) + .await + .map_err(AppError::from)?; + Ok(Json(cafes)) +} + +#[tracing::instrument(skip(state, _auth_user, headers, query))] +pub(crate) async fn create_cafe( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Query(query): Query, + payload: FlexiblePayload, +) -> Result { + let (request, search) = query.into_request_and_search::(); + let (new_cafe, source) = payload.into_parts(); + let new_cafe = new_cafe.normalize(); + let cafe = state + .cafe_repo + .insert(new_cafe) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_cafe_list_fragment(state, request, search, true) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = + ListNavigator::new(CAFE_PAGE_PATH, CAFE_FRAGMENT_PATH, request, search).page_href(1); + Ok(Redirect::to(&target).into_response()) + } else { + Ok((StatusCode::CREATED, Json(cafe)).into_response()) + } +} + +define_get_handler!(get_cafe, CafeId, Cafe, cafe_repo); + +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn update_cafe( + State(state): State, + _auth_user: AuthenticatedUser, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let has_changes = payload.name.is_some() + || payload.city.is_some() + || payload.country.is_some() + || payload.latitude.is_some() + || payload.longitude.is_some() + || payload.website.is_some() + || payload.notes.is_some(); + + if !has_changes { + return Err(AppError::validation("no changes provided").into()); + } + + let cafe = state + .cafe_repo + .update(id, payload) + .await + .map_err(AppError::from)?; + Ok(Json(cafe)) +} + +define_delete_handler!( + delete_cafe, + CafeId, + CafeSortKey, + cafe_repo, + render_cafe_list_fragment +); + +async fn render_cafe_list_fragment( + state: AppState, + request: ListRequest, + search: Option, + is_authenticated: bool, +) -> Result { + let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref()).await?; + + let template = CafeListTemplate { + is_authenticated, + cafes, + navigator, + }; + + crate::application::routes::support::render_fragment(template, "#cafe-list") +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 713bf30..b1623aa 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod bags; pub mod brews; +pub mod cafes; pub mod gear; mod macros; pub mod roasters; @@ -65,6 +66,13 @@ pub fn app_router(state: AppState) -> axum::Router { "/brews/:id", get(brews::get_brew).delete(brews::delete_brew), ) + .route("/cafes", get(cafes::list_cafes).post(cafes::create_cafe)) + .route( + "/cafes/:id", + get(cafes::get_cafe) + .put(cafes::update_cafe) + .delete(cafes::delete_cafe), + ) .route( "/tokens", post(tokens::create_token).get(tokens::list_tokens), @@ -85,6 +93,8 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/bags", get(bags::bags_page)) .route("/brews", get(brews::brews_page)) .route("/gear", get(gear::gear_page)) + .route("/cafes", get(cafes::cafes_page)) + .route("/cafes/:slug", get(cafes::cafe_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 bd6299c..50a84f0 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -9,14 +9,15 @@ use tracing::info; use crate::application::routes::app_router; use crate::domain::repositories::{ - BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository, - SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, + BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository, + RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use crate::domain::users::NewUser; use crate::infrastructure::auth::hash_password; 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::gear::SqlGearRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; @@ -39,6 +40,7 @@ pub struct AppState { pub bag_repo: Arc, pub gear_repo: Arc, pub brew_repo: Arc, + pub cafe_repo: Arc, pub timeline_repo: Arc, pub user_repo: Arc, pub token_repo: Arc, @@ -53,6 +55,7 @@ impl AppState { bag_repo: Arc, gear_repo: Arc, brew_repo: Arc, + cafe_repo: Arc, timeline_repo: Arc, user_repo: Arc, token_repo: Arc, @@ -64,6 +67,7 @@ impl AppState { bag_repo, gear_repo, brew_repo, + cafe_repo, timeline_repo, user_repo, token_repo, @@ -83,6 +87,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); 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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -100,6 +105,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { bag_repo, gear_repo, brew_repo, + cafe_repo, timeline_repo, user_repo, token_repo,