feat: add bags API urls

This commit is contained in:
Jon Seager 2025-11-27 13:59:39 +00:00
parent 14c3079600
commit dbfc6c4775
No known key found for this signature in database
3 changed files with 248 additions and 1 deletions

View file

@ -0,0 +1,231 @@
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};
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewBagSubmission>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
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(),
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<AppState>,
Query(params): Query<BagsQuery>,
) -> Result<Json<Vec<BagWithRoast>>, ApiError> {
let bags = match params.roast_id {
Some(roast_id) => state
.bag_repo
.list_by_roast(roast_id)
.await
.map_err(AppError::from)?,
None => {
// For API list all, we might want to implement list_all in repo or reuse list with pagination
// For now, let's just return empty or implement list_all if needed.
// The spec implies we need list endpoints.
// Let's implement list_all in repo later if needed, or just use list with large page size?
// Actually, let's just use list_by_roast for now as that's the main use case for API likely.
// Or better, let's add list_all to repo.
// For now, I'll return an error if no filter is provided, or empty list.
vec![]
}
};
Ok(Json(bags))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_bag(
State(state): State<AppState>,
Path(id): Path<BagId>,
) -> Result<Json<Bag>, ApiError> {
let bag = state.bag_repo.get(id).await.map_err(AppError::from)?;
Ok(Json(bag))
}
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn update_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
Path(id): Path<BagId>,
Json(payload): Json<UpdateBag>,
) -> Result<Json<Bag>, ApiError> {
let bag = state
.bag_repo
.update(id, payload.clone())
.await
.map_err(AppError::from)?;
if let Some(true) = payload.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(),
occurred_at: chrono::Utc::now(),
title: roast.name.to_string(),
details: vec![TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name,
}],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
}
}
Ok(Json(bag))
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn delete_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<BagId>,
Query(query): Query<ListQuery>,
) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
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())
}
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn finish_bag(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(id): Path<BagId>,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<BagSortKey>();
let bag = state
.bag_repo
.get(id)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let update = UpdateBag {
remaining: Some(0.0),
closed: Some(true),
finished_at: Some(chrono::Utc::now().date_naive()),
};
let _ = state
.bag_repo
.update(id, update)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
// Add 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(),
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!("{:.1}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(map_app_error)
} else {
// Fallback for non-datastar requests (though the UI uses datastar)
Ok(Redirect::to(BAG_PAGE_PATH).into_response())
}
}

View file

@ -1,4 +1,5 @@
pub mod auth;
pub mod bags;
pub mod roasters;
pub mod roasts;
pub mod support;
@ -38,6 +39,13 @@ pub fn app_router(state: AppState) -> axum::Router {
"/roasts/:id",
get(roasts::get_roast).delete(roasts::delete_roast),
)
.route("/bags", get(bags::list_bags).post(bags::create_bag))
.route(
"/bags/:id",
get(bags::get_bag)
.put(bags::update_bag)
.delete(bags::delete_bag),
)
.route(
"/tokens",
post(tokens::create_token).get(tokens::list_tokens),

View file

@ -1,7 +1,7 @@
use anyhow::Result;
use brewlog::application::{ServerConfig, serve};
use brewlog::infrastructure::client::BrewlogClient;
use brewlog::presentation::cli::{Cli, Commands, ServeCommand, roasters, roasts, tokens};
use brewlog::presentation::cli::{Cli, Commands, ServeCommand, bags, roasters, roasts, tokens};
use clap::Parser;
use tracing::{Subscriber, subscriber::set_global_default};
@ -39,6 +39,14 @@ async fn main() -> Result<()> {
Commands::ListRoasts(cmd) => roasts::list_roasts(&client, cmd).await,
Commands::GetRoast(cmd) => roasts::get_roast(&client, cmd).await,
Commands::DeleteRoast(cmd) => roasts::delete_roast(&client, cmd).await,
// Bags
Commands::AddBag(cmd) => bags::add_bag(&client, cmd).await,
Commands::ListBags(cmd) => bags::list_bags(&client, cmd).await,
Commands::GetBag(cmd) => bags::get_bag(&client, cmd).await,
Commands::UpdateBag(cmd) => bags::update_bag(&client, cmd).await,
Commands::DeleteBag(cmd) => bags::delete_bag(&client, cmd).await,
Commands::Serve(_) => unreachable!("serve command handled earlier"),
}
}