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:
parent
c2e9c7395c
commit
a6fad343fc
3 changed files with 217 additions and 1 deletions
201
src/application/routes/cups.rs
Normal file
201
src/application/routes/cups.rs
Normal 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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<dyn GearRepository>,
|
||||
pub brew_repo: Arc<dyn BrewRepository>,
|
||||
pub cafe_repo: Arc<dyn CafeRepository>,
|
||||
pub cup_repo: Arc<dyn CupRepository>,
|
||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
pub token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -58,6 +60,7 @@ impl AppState {
|
|||
gear_repo: Arc<dyn GearRepository>,
|
||||
brew_repo: Arc<dyn BrewRepository>,
|
||||
cafe_repo: Arc<dyn CafeRepository>,
|
||||
cup_repo: Arc<dyn CupRepository>,
|
||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
token_repo: Arc<dyn TokenRepository>,
|
||||
|
|
@ -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<dyn UserRepository> =
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue