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.
This commit is contained in:
Jon Seager 2026-02-03 14:52:51 +00:00
parent 677b77d70d
commit 1781bff48d
No known key found for this signature in database
3 changed files with 211 additions and 2 deletions

View file

@ -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<CafeSortKey>,
search: Option<&str>,
) -> Result<(Paginated<CafeView>, ListNavigator<CafeSortKey>), 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<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let (request, search) = query.into_request_and_search::<CafeSortKey>();
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<AppState>,
cookies: tower_cookies::Cookies,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
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<AppState>) -> Result<Json<Vec<Cafe>>, 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<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewCafe>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<CafeSortKey>();
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<AppState>,
_auth_user: AuthenticatedUser,
Path(id): Path<CafeId>,
Json(payload): Json<UpdateCafe>,
) -> Result<Json<Cafe>, 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<CafeSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}

View file

@ -1,6 +1,7 @@
pub mod auth; pub mod auth;
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes;
pub mod gear; pub mod gear;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
@ -65,6 +66,13 @@ pub fn app_router(state: AppState) -> axum::Router {
"/brews/:id", "/brews/:id",
get(brews::get_brew).delete(brews::delete_brew), 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( .route(
"/tokens", "/tokens",
post(tokens::create_token).get(tokens::list_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("/bags", get(bags::bags_page))
.route("/brews", get(brews::brews_page)) .route("/brews", get(brews::brews_page))
.route("/gear", get(gear::gear_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("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))

View file

@ -9,14 +9,15 @@ use tracing::info;
use crate::application::routes::app_router; use crate::application::routes::app_router;
use crate::domain::repositories::{ use crate::domain::repositories::{
BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository, BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository,
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
}; };
use crate::domain::users::NewUser; use crate::domain::users::NewUser;
use crate::infrastructure::auth::hash_password; use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database; use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::bags::SqlBagRepository; use crate::infrastructure::repositories::bags::SqlBagRepository;
use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::brews::SqlBrewRepository;
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
use crate::infrastructure::repositories::gear::SqlGearRepository; use crate::infrastructure::repositories::gear::SqlGearRepository;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository;
@ -39,6 +40,7 @@ pub struct AppState {
pub bag_repo: Arc<dyn BagRepository>, pub bag_repo: Arc<dyn BagRepository>,
pub gear_repo: Arc<dyn GearRepository>, pub gear_repo: Arc<dyn GearRepository>,
pub brew_repo: Arc<dyn BrewRepository>, pub brew_repo: Arc<dyn BrewRepository>,
pub cafe_repo: Arc<dyn CafeRepository>,
pub timeline_repo: Arc<dyn TimelineEventRepository>, pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>, pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>, pub token_repo: Arc<dyn TokenRepository>,
@ -53,6 +55,7 @@ impl AppState {
bag_repo: Arc<dyn BagRepository>, bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>, gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>, brew_repo: Arc<dyn BrewRepository>,
cafe_repo: Arc<dyn CafeRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>, token_repo: Arc<dyn TokenRepository>,
@ -64,6 +67,7 @@ impl AppState {
bag_repo, bag_repo,
gear_repo, gear_repo,
brew_repo, brew_repo,
cafe_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_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 bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool()));
let gear_repo = Arc::new(SqlGearRepository::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 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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
@ -100,6 +105,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
bag_repo, bag_repo,
gear_repo, gear_repo,
brew_repo, brew_repo,
cafe_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_repo, token_repo,