use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; 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::bags::{Bag, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::ids::{BagId, RoastId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; use crate::presentation::web::templates::{BagListTemplate, BagsTemplate}; use crate::presentation::web::views::{BagView, ListNavigator, Paginated, RoasterOptionView}; const BAG_PAGE_PATH: &str = "/bags"; const BAG_FRAGMENT_PATH: &str = "/bags#bag-list"; struct BagPageData { open_bags: Vec, bags: Paginated, navigator: ListNavigator, } #[tracing::instrument(skip(state))] async fn load_bag_page( state: &AppState, request: ListRequest, ) -> Result { let open_bags = state.bag_repo.list_open().await.map_err(AppError::from)?; let open_bags_view = open_bags.into_iter().map(BagView::from_domain).collect(); let page = state .bag_repo .list_closed(&request) .await .map_err(AppError::from)?; let (bags, navigator) = crate::application::routes::support::build_page_view( page, request, BagView::from_domain, BAG_PAGE_PATH, BAG_FRAGMENT_PATH, ); Ok(BagPageData { open_bags: open_bags_view, bags, navigator, }) } #[tracing::instrument(skip(state, cookies, headers, query))] pub(crate) async fn bags_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 = crate::application::routes::auth::is_authenticated(&state, &cookies).await; return render_bag_list_fragment(state, request, is_authenticated) .await .map_err(map_app_error); } let roasters = state .roaster_repo .list_all_sorted(RoasterSortKey::Name, SortDirection::Asc) .await .map_err(|err| map_app_error(AppError::from(err)))?; let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect(); let BagPageData { open_bags, bags, navigator, } = load_bag_page(&state, request) .await .map_err(map_app_error)?; let is_authenticated = crate::application::routes::auth::is_authenticated(&state, &cookies).await; let template = BagsTemplate { nav_active: "bags", is_authenticated, open_bags, bags, roaster_options, navigator, }; render_html(template).map(IntoResponse::into_response) } #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_bag( 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_bag = submission.into_new_bag().map_err(ApiError::from)?; let roast = state .roast_repo .get(new_bag.roast_id) .await .map_err(|err| ApiError::from(AppError::from(err)))?; let roaster = state .roaster_repo .get(roast.roaster_id) .await .map_err(|err| ApiError::from(AppError::from(err)))?; let bag = state .bag_repo .insert(new_bag) .await .map_err(AppError::from)?; // Add timeline event let event = NewTimelineEvent { entity_type: "bag".to_string(), entity_id: bag.id.into_inner(), action: "added".to_string(), occurred_at: chrono::Utc::now(), title: roast.name.to_string(), details: vec![ TimelineEventDetail { label: "Roaster".to_string(), value: roaster.name, }, TimelineEventDetail { label: "Amount".to_string(), value: format!("{}g", bag.amount), }, ], tasting_notes: vec![], }; let _ = state.timeline_repo.insert(event).await; if is_datastar_request(&headers) { render_bag_list_fragment(state, request, true) .await .map_err(ApiError::from) } else if matches!(source, PayloadSource::Form) { let target = ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request).page_href(1); Ok(Redirect::to(&target).into_response()) } else { Ok((StatusCode::CREATED, Json(bag)).into_response()) } } #[tracing::instrument(skip(state))] pub(crate) async fn list_bags( State(state): State, Query(params): Query, ) -> Result>, ApiError> { let bags = match params.roast_id { Some(roast_id) => state .bag_repo .list_by_roast(roast_id) .await .map_err(AppError::from)?, None => state.bag_repo.list_all().await.map_err(AppError::from)?, }; Ok(Json(bags)) } #[tracing::instrument(skip(state))] pub(crate) async fn get_bag( State(state): State, Path(id): Path, ) -> Result, ApiError> { let bag = state.bag_repo.get(id).await.map_err(AppError::from)?; Ok(Json(bag)) } #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn update_bag( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, Path(id): Path, Query(query): Query, Query(update_params): Query, payload: Option>, ) -> Result { let request = query.into_request::(); let body_update = payload.map(|Json(p)| p).unwrap_or(UpdateBag { remaining: None, closed: None, finished_at: None, }); let mut update = UpdateBag { remaining: body_update.remaining.or(update_params.remaining), closed: body_update.closed.or(update_params.closed), finished_at: body_update.finished_at.or(update_params.finished_at), }; if let Some(true) = update.closed && update.finished_at.is_none() { update.finished_at = Some(chrono::Utc::now().date_naive()); } let bag = state .bag_repo .update(id, update.clone()) .await .map_err(AppError::from)?; if let Some(true) = update.closed { // Fetch roast and roaster for timeline event if let Ok(roast) = state.roast_repo.get(bag.roast_id).await && let Ok(roaster) = state.roaster_repo.get(roast.roaster_id).await { let event = NewTimelineEvent { entity_type: "bag".to_string(), entity_id: bag.id.into_inner(), action: "finished".to_string(), occurred_at: chrono::Utc::now(), title: roast.name.to_string(), details: vec![ TimelineEventDetail { label: "Roaster".to_string(), value: roaster.name, }, TimelineEventDetail { label: "Amount".to_string(), value: format!("{}g", bag.amount), }, ], tasting_notes: vec![], }; let _ = state.timeline_repo.insert(event).await; } } if is_datastar_request(&headers) { render_bag_list_fragment(state, request, true) .await .map_err(ApiError::from) } else { Ok(Json(bag).into_response()) } } #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn delete_bag( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, Path(id): Path, Query(query): Query, ) -> Result { let request = query.into_request::(); state.bag_repo.delete(id).await.map_err(AppError::from)?; if is_datastar_request(&headers) { render_bag_list_fragment(state, request, true) .await .map_err(ApiError::from) } else { Ok(StatusCode::NO_CONTENT.into_response()) } } #[derive(Debug, Deserialize)] pub struct BagsQuery { pub roast_id: Option, } #[derive(Debug, Deserialize)] pub(crate) struct NewBagSubmission { roast_id: RoastId, roast_date: Option, amount: f64, } impl NewBagSubmission { fn into_new_bag(self) -> Result { let roast_id = self.roast_id; if roast_id.into_inner() <= 0 { return Err(AppError::validation("invalid roast id")); } let roast_date = match self.roast_date { Some(date_str) if !date_str.is_empty() => Some( chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") .map_err(|_| AppError::validation("invalid roast date format"))?, ), _ => None, }; if self.amount <= 0.0 { return Err(AppError::validation("amount must be positive")); } Ok(NewBag { roast_id, roast_date, amount: self.amount, }) } } async fn render_bag_list_fragment( state: AppState, request: ListRequest, is_authenticated: bool, ) -> Result { let BagPageData { open_bags, bags, navigator, } = load_bag_page(&state, request).await?; let template = BagListTemplate { is_authenticated, open_bags, bags, navigator, }; crate::application::routes::support::render_fragment(template, "#bag-list") }