brewlog/src/application/routes/roasts.rs
Jon Seager 4bdfc661f9
refactor: consolidate entity pages into unified /data and /add views
Replace per-entity pages (/roasters, /roasts, /bags, /brews, /gear,
/cafes, /cups) and detail pages with a single tabbed /data view and
a dedicated /add page for entity creation.

- Add /data route with tab-based navigation using Datastar
- Add /add route consolidating all create forms
- Remove per-entity page handlers and standalone templates
- Remove detail page routes, handlers, and templates
- Update ListNavigator to accept String paths for query-param URLs
- Update home page and timeline links to use new /data?type=X paths
- Update nav to reference /data instead of individual entity pages
2026-02-04 20:51:30 +00:00

354 lines
10 KiB
Rust

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize;
use super::macros::{
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
};
use crate::application::server::AppState;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::infrastructure::ai::{self, ExtractionInput};
use crate::presentation::web::templates::{RoastListTemplate, RoastOptionsTemplate};
use crate::presentation::web::views::{ListNavigator, Paginated, RoastView};
const ROAST_PAGE_PATH: &str = "/data?type=roasts";
const ROAST_FRAGMENT_PATH: &str = "/data?type=roasts#roast-list";
#[tracing::instrument(skip(state))]
pub(super) async fn load_roast_page(
state: &AppState,
request: ListRequest<RoastSortKey>,
search: Option<&str>,
) -> Result<(Paginated<RoastView>, ListNavigator<RoastSortKey>), AppError> {
let page = state
.roast_repo
.list(&request, search)
.await
.map_err(AppError::from)?;
Ok(crate::application::routes::support::build_page_view(
page,
request,
RoastView::from_list_item,
ROAST_PAGE_PATH,
ROAST_FRAGMENT_PATH,
search.map(String::from),
))
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_roast(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Query(query): Query<ListQuery>,
payload: FlexiblePayload<NewRoastSubmission>,
) -> Result<Response, ApiError> {
let (request, search) = query.into_request_and_search::<RoastSortKey>();
let (submission, source) = payload.into_parts();
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
state
.roaster_repo
.get(new_roast.roaster_id)
.await
.map_err(|err| ApiError::from(AppError::from(err)))?;
let roast = state
.roast_repo
.insert(new_roast)
.await
.map_err(AppError::from)?;
if is_datastar_request(&headers) {
render_roast_list_fragment(state, request, search, true)
.await
.map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) {
let target =
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
} else {
let enriched = state
.roast_repo
.get_with_roaster(roast.id)
.await
.map_err(AppError::from)?;
Ok((StatusCode::CREATED, Json(enriched)).into_response())
}
}
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn list_roasts(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<RoastsQuery>,
) -> Result<Response, ApiError> {
let roaster_id = match params.roaster_id.as_deref() {
Some(s) if !s.is_empty() => {
let s = s.trim();
Some(s.parse::<RoasterId>().map_err(|_| {
tracing::warn!("Invalid roaster_id: '{}'", s);
ApiError::from(AppError::validation(format!("Invalid roaster_id: '{s}'")))
})?)
}
_ => None,
};
let roasts = match roaster_id {
Some(roaster_id) => state
.roast_repo
.list_by_roaster(roaster_id)
.await
.map_err(AppError::from)?,
None => {
if is_datastar_request(&headers) {
vec![]
} else {
state.roast_repo.list_all().await.map_err(AppError::from)?
}
}
};
if is_datastar_request(&headers) {
let template = RoastOptionsTemplate { roasts };
crate::application::routes::support::render_fragment(template, "#roast-select-options")
.map_err(ApiError::from)
} else {
Ok(Json(roasts).into_response())
}
}
define_enriched_get_handler!(
get_roast,
RoastId,
RoastWithRoaster,
roast_repo,
get_with_roaster
);
define_delete_handler!(
delete_roast,
RoastId,
RoastSortKey,
roast_repo,
render_roast_list_fragment
);
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn update_roast(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
Path(id): Path<RoastId>,
Json(payload): Json<UpdateRoast>,
) -> Result<Json<RoastWithRoaster>, ApiError> {
let has_changes = payload.roaster_id.is_some()
|| payload.name.is_some()
|| payload.origin.is_some()
|| payload.region.is_some()
|| payload.producer.is_some()
|| payload.tasting_notes.is_some()
|| payload.process.is_some();
if !has_changes {
return Err(AppError::validation("no changes provided").into());
}
state
.roast_repo
.update(id, payload)
.await
.map_err(AppError::from)?;
let enriched = state
.roast_repo
.get_with_roaster(id)
.await
.map_err(AppError::from)?;
Ok(Json(enriched))
}
#[derive(Debug, Deserialize)]
pub struct RoastsQuery {
pub roaster_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewRoastSubmission {
roaster_id: RoasterId,
name: String,
origin: String,
region: String,
producer: String,
tasting_notes: TastingNotesInput,
process: String,
}
impl NewRoastSubmission {
fn into_new_roast(self) -> Result<NewRoast, AppError> {
fn require(field: &str, value: String) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(AppError::validation(format!("{field} is required")))
} else {
Ok(trimmed.to_string())
}
}
let roaster_id = self.roaster_id;
if roaster_id.into_inner() <= 0 {
return Err(AppError::validation("invalid roaster id"));
}
let name = require("name", self.name)?;
let origin = require("origin", self.origin)?;
let region = require("region", self.region)?;
let producer = require("producer", self.producer)?;
let process = require("process", self.process)?;
let tasting_notes = self.tasting_notes.into_vec();
if tasting_notes.is_empty() {
return Err(AppError::validation("tasting notes are required"));
}
Ok(NewRoast {
roaster_id,
name,
origin,
region,
producer,
tasting_notes,
process,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum TastingNotesInput {
List(Vec<String>),
Text(String),
}
impl TastingNotesInput {
pub(crate) fn into_vec(self) -> Vec<String> {
match self {
TastingNotesInput::List(values) => values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect(),
TastingNotesInput::Text(value) => value
.split([',', '\n'])
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect(),
}
}
}
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn extract_roast_info(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
payload: FlexiblePayload<ExtractionInput>,
) -> Result<Response, ApiError> {
let (input, _) = payload.into_parts();
let result = ai::extract_roast(
&state.http_client,
&state.openrouter_api_key,
&state.openrouter_model,
&input,
)
.await
.map_err(ApiError::from)?;
if is_datastar_request(&headers) {
use serde_json::Value;
// Try to match extracted roaster name to an existing roaster ID
let roaster_id = if let Some(ref roaster_name) = result.roaster_name {
match_roaster_id(&state, roaster_name)
.await
.unwrap_or_default()
} else {
String::new()
};
let tasting_notes = result
.tasting_notes
.as_ref()
.map(|notes| notes.join(", "))
.unwrap_or_default();
let signals = vec![
(
"_roast-name",
Value::String(result.name.unwrap_or_default()),
),
("_origin", Value::String(result.origin.unwrap_or_default())),
("_region", Value::String(result.region.unwrap_or_default())),
(
"_producer",
Value::String(result.producer.unwrap_or_default()),
),
(
"_process",
Value::String(result.process.unwrap_or_default()),
),
("_tasting-notes", Value::String(tasting_notes)),
("_roaster-id", Value::String(roaster_id)),
("_extracted", Value::Bool(true)),
];
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
} else {
Ok(Json(result).into_response())
}
}
/// Fuzzy-match a roaster name against existing roasters and return the matched ID.
async fn match_roaster_id(state: &AppState, roaster_name: &str) -> Option<String> {
let roasters = state
.roaster_repo
.list_all_sorted(
crate::domain::roasters::RoasterSortKey::Name,
SortDirection::Asc,
)
.await
.ok()?;
let lower = roaster_name.to_lowercase();
// Exact match first
if let Some(roaster) = roasters.iter().find(|r| r.name.to_lowercase() == lower) {
return Some(roaster.id.to_string());
}
// Substring match (either direction)
roasters
.iter()
.find(|r| {
let r_lower = r.name.to_lowercase();
r_lower.contains(&lower) || lower.contains(&r_lower)
})
.map(|r| r.id.to_string())
}
define_list_fragment_renderer!(
render_roast_list_fragment,
RoastSortKey,
load_roast_page,
RoastListTemplate { roasts },
"#roast-list"
);