diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs new file mode 100644 index 0000000..b702e89 --- /dev/null +++ b/src/application/routes/gear.rs @@ -0,0 +1,243 @@ +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Json; +use serde::Deserialize; + +use super::macros::{define_delete_handler, define_get_handler}; +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{map_app_error, ApiError, AppError}; +use crate::application::routes::render_html; +use crate::application::routes::support::{ + is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, +}; +use crate::application::server::AppState; +use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; +use crate::domain::ids::GearId; +use crate::domain::listing::{ListRequest, SortDirection}; +use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; +use crate::presentation::web::templates::{GearListTemplate, GearTemplate}; +use crate::presentation::web::views::{GearView, ListNavigator, Paginated}; + +const GEAR_PAGE_PATH: &str = "/gear"; +const GEAR_FRAGMENT_PATH: &str = "/gear#gear-list"; + +#[tracing::instrument(skip(state))] +async fn load_gear_page( + state: &AppState, + request: ListRequest, +) -> Result, AppError> { + let page = state + .gear_repo + .list(GearFilter::all(), &request) + .await + .map_err(AppError::from)?; + + let (gear, _navigator) = crate::application::routes::support::build_page_view( + page, + request, + GearView::from_domain, + GEAR_PAGE_PATH, + GEAR_FRAGMENT_PATH, + ); + + Ok(gear) +} + +#[tracing::instrument(skip(state, cookies, headers, query))] +pub(crate) async fn gear_page( + State(state): State, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + let is_authenticated = super::is_authenticated(&state, &cookies).await; + return render_gear_list_fragment(state, request, is_authenticated) + .await + .map_err(map_app_error); + } + + let gear = load_gear_page(&state, request) + .await + .map_err(map_app_error)?; + + let is_authenticated = super::is_authenticated(&state, &cookies).await; + let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request); + + let template = GearTemplate { + nav_active: "gear", + is_authenticated, + gear, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +#[tracing::instrument(skip(state, _auth_user, headers, query))] +pub(crate) async fn create_gear( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Query(query): Query, + payload: FlexiblePayload, +) -> Result { + let request = query.into_request::(); + let (submission, source) = payload.into_parts(); + let new_gear = submission.into_new_gear().map_err(ApiError::from)?; + + let gear = state + .gear_repo + .insert(new_gear) + .await + .map_err(AppError::from)?; + + // Add timeline event + let event = NewTimelineEvent { + entity_type: "gear".to_string(), + entity_id: gear.id.into_inner(), + action: "added".to_string(), + occurred_at: chrono::Utc::now(), + title: format!("{} {}", gear.make, gear.model), + details: vec![ + TimelineEventDetail { + label: "Category".to_string(), + value: gear.category.display_label().to_string(), + }, + TimelineEventDetail { + label: "Make".to_string(), + value: gear.make.clone(), + }, + TimelineEventDetail { + label: "Model".to_string(), + value: gear.model.clone(), + }, + ], + tasting_notes: vec![], + }; + let _ = state.timeline_repo.insert(event).await; + + if is_datastar_request(&headers) { + render_gear_list_fragment(state, request, true) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request).page_href(1); + Ok(Redirect::to(&target).into_response()) + } else { + Ok((StatusCode::CREATED, Json(gear)).into_response()) + } +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn list_gear( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let filter = match params.category { + Some(ref cat_str) => { + let category = GearCategory::from_str(cat_str) + .ok_or_else(|| AppError::validation("invalid category"))?; + GearFilter::for_category(category) + } + None => GearFilter::all(), + }; + let request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc); + let page = state + .gear_repo + .list(filter, &request) + .await + .map_err(AppError::from)?; + Ok(Json(page.items)) +} + +define_get_handler!(get_gear, GearId, Gear, gear_repo); + +#[tracing::instrument(skip(state, _auth_user, headers, query))] +pub(crate) async fn update_gear( + State(state): State, + _auth_user: AuthenticatedUser, + headers: HeaderMap, + Path(id): Path, + Query(query): Query, + payload: Json, +) -> Result { + let request = query.into_request::(); + + let gear = state + .gear_repo + .update(id, payload.0) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_gear_list_fragment(state, request, true) + .await + .map_err(ApiError::from) + } else { + Ok(Json(gear).into_response()) + } +} + +define_delete_handler!( + delete_gear, + GearId, + GearSortKey, + gear_repo, + render_gear_list_fragment +); + +#[derive(Debug, Deserialize)] +pub struct GearQuery { + pub category: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct NewGearSubmission { + category: String, + make: String, + model: String, + notes: Option, +} + +impl NewGearSubmission { + fn into_new_gear(self) -> Result { + let category = GearCategory::from_str(&self.category) + .ok_or_else(|| AppError::validation("invalid category"))?; + + if self.make.trim().is_empty() { + return Err(AppError::validation("make cannot be empty")); + } + + if self.model.trim().is_empty() { + return Err(AppError::validation("model cannot be empty")); + } + + Ok(NewGear { + category, + make: self.make, + model: self.model, + notes: self.notes.filter(|s| !s.trim().is_empty()), + }) + } +} + +async fn render_gear_list_fragment( + state: AppState, + request: ListRequest, + is_authenticated: bool, +) -> Result { + let gear = load_gear_page(&state, request).await?; + let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request); + + let template = GearListTemplate { + is_authenticated, + gear, + navigator, + }; + + crate::application::routes::support::render_fragment(template, "#gear-list") +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 972bd00..4a9c8cf 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod bags; +pub mod gear; mod macros; pub mod roasters; pub mod roasts; @@ -49,6 +50,13 @@ pub fn app_router(state: AppState) -> axum::Router { .put(bags::update_bag) .delete(bags::delete_bag), ) + .route("/gear", get(gear::list_gear).post(gear::create_gear)) + .route( + "/gear/:id", + get(gear::get_gear) + .put(gear::update_gear) + .delete(gear::delete_gear), + ) .route( "/tokens", post(tokens::create_token).get(tokens::list_tokens), @@ -67,6 +75,7 @@ pub fn app_router(state: AppState) -> axum::Router { get(roasts::roast_page), ) .route("/bags", get(bags::bags_page)) + .route("/gear", get(gear::gear_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 b1a7d02..bfdf2fa 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -9,13 +9,14 @@ use tracing::info; use crate::application::routes::app_router; use crate::domain::repositories::{ - BagRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, - TokenRepository, UserRepository, + BagRepository, 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::gear::SqlGearRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::sessions::SqlSessionRepository; @@ -35,6 +36,7 @@ pub struct AppState { pub roaster_repo: Arc, pub roast_repo: Arc, pub bag_repo: Arc, + pub gear_repo: Arc, pub timeline_repo: Arc, pub user_repo: Arc, pub token_repo: Arc, @@ -46,6 +48,7 @@ impl AppState { roaster_repo: Arc, roast_repo: Arc, bag_repo: Arc, + gear_repo: Arc, timeline_repo: Arc, user_repo: Arc, token_repo: Arc, @@ -55,6 +58,7 @@ impl AppState { roaster_repo, roast_repo, bag_repo, + gear_repo, timeline_repo, user_repo, token_repo, @@ -72,6 +76,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -87,6 +92,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { roaster_repo, roast_repo, bag_repo, + gear_repo, timeline_repo, user_repo, token_repo,