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, search: Option<&str>, ) -> Result<(Paginated, ListNavigator), 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, _auth_user: AuthenticatedUser, headers: HeaderMap, Query(query): Query, payload: FlexiblePayload, ) -> Result { let (request, search) = query.into_request_and_search::(); 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, headers: HeaderMap, Query(params): Query, ) -> Result { let roaster_id = match params.roaster_id.as_deref() { Some(s) if !s.is_empty() => { let s = s.trim(); Some(s.parse::().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, _auth_user: AuthenticatedUser, Path(id): Path, Json(payload): Json, ) -> Result, 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, } #[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 { fn require(field: &str, value: String) -> Result { 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), Text(String), } impl TastingNotesInput { pub(crate) fn into_vec(self) -> Vec { 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, _auth_user: AuthenticatedUser, headers: HeaderMap, payload: FlexiblePayload, ) -> Result { 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 { 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" );