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
This commit is contained in:
Jon Seager 2026-02-03 16:36:23 +00:00
parent c2e9c7395c
commit a6fad343fc
No known key found for this signature in database
3 changed files with 217 additions and 1 deletions

View file

@ -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<CupSortKey>,
search: Option<&str>,
) -> Result<(Paginated<CupView>, ListNavigator<CupSortKey>), 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<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let (request, search) = query.into_request_and_search::<CupSortKey>();
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<RoastOptionView> = 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<CafeOptionView> = 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<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewCup>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<CupSortKey>();
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<AppState>,
) -> Result<Json<Vec<CupWithDetails>>, 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<AppState>,
_auth_user: AuthenticatedUser,
Path(id): Path<CupId>,
Json(payload): Json<UpdateCup>,
) -> Result<Json<Cup>, 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<CupSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}

View file

@ -2,6 +2,7 @@ pub mod auth;
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes; pub mod cafes;
pub mod cups;
pub mod gear; pub mod gear;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
@ -74,6 +75,13 @@ pub fn app_router(state: AppState) -> axum::Router {
.delete(cafes::delete_cafe), .delete(cafes::delete_cafe),
) )
.route("/nearby-cafes", get(cafes::nearby_cafes)) .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( .route(
"/tokens", "/tokens",
post(tokens::create_token).get(tokens::list_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("/gear", get(gear::gear_page))
.route("/cafes", get(cafes::cafes_page)) .route("/cafes", get(cafes::cafes_page))
.route("/cafes/:slug", get(cafes::cafe_page)) .route("/cafes/:slug", get(cafes::cafe_page))
.route("/cups", get(cups::cups_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,7 +9,7 @@ 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, CafeRepository, GearRepository, RoastRepository, BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, RoastRepository,
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
}; };
use crate::domain::users::NewUser; use crate::domain::users::NewUser;
@ -18,6 +18,7 @@ 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::cafes::SqlCafeRepository;
use crate::infrastructure::repositories::cups::SqlCupRepository;
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;
@ -41,6 +42,7 @@ pub struct AppState {
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 cafe_repo: Arc<dyn CafeRepository>,
pub cup_repo: Arc<dyn CupRepository>,
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>,
@ -58,6 +60,7 @@ impl AppState {
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>, cafe_repo: Arc<dyn CafeRepository>,
cup_repo: Arc<dyn CupRepository>,
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>,
@ -72,6 +75,7 @@ impl AppState {
gear_repo, gear_repo,
brew_repo, brew_repo,
cafe_repo, cafe_repo,
cup_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_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 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 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 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()));
@ -112,6 +117,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
gear_repo, gear_repo,
brew_repo, brew_repo,
cafe_repo, cafe_repo,
cup_repo,
timeline_repo, timeline_repo,
user_repo, user_repo,
token_repo, token_repo,