From 202dafb9b992010465e4c78bab0c90164a4f8671 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 12:27:25 +0000 Subject: [PATCH] feat(scan): show cards for existing roasters/roasts during bag scan After AI extraction, check if the roaster and roast already exist by slug. When matched, show compact summary cards instead of full edit forms. Each card has a "Change" link to revert to the form if the match is wrong. - Add match_existing_entities() for slug-based roaster/roast lookup - Return _matched-roaster-id and _matched-roast-id signals from extraction - Add submit_existing_roast() path to skip creation when roast exists - Dynamic submit buttons: Save Roaster & Roast / Save Roast / Open Bag - Hidden inputs bound to signals handle all form submission cleanly --- src/application/routes/api/scan.rs | 127 ++++++++++++++++++ templates/pages/add.html | 2 + templates/pages/home.html | 2 + .../partials/forms/scan_result_form.html | 92 +++++++++++-- tests/server/scan_api.rs | 95 ++++++++++++- 5 files changed, 304 insertions(+), 14 deletions(-) diff --git a/src/application/routes/api/scan.rs b/src/application/routes/api/scan.rs index 3aef01d..aac5b77 100644 --- a/src/application/routes/api/scan.rs +++ b/src/application/routes/api/scan.rs @@ -12,6 +12,7 @@ use crate::application::routes::support::{FlexiblePayload, is_datastar_request}; use crate::application::server::AppState; use crate::domain::bags::NewBag; use crate::domain::errors::RepositoryError; +use crate::domain::ids::RoastId; use crate::domain::roasters::NewRoaster; use crate::domain::roasts::NewRoast; use crate::infrastructure::ai::{self, ExtractionInput, Usage}; @@ -42,6 +43,9 @@ pub(crate) async fn extract_bag_scan( usage, ); + // Try to match existing roaster/roast by slug + let (matched_roaster_id, matched_roast_id) = match_existing_entities(&state, &result).await; + if is_datastar_request(&headers) { use serde_json::Value; @@ -91,6 +95,8 @@ pub(crate) async fn extract_bag_scan( ), ("_tasting-notes", Value::String(tasting_notes)), ("_scan-extracted", Value::Bool(true)), + ("_matched-roaster-id", Value::String(matched_roaster_id)), + ("_matched-roast-id", Value::String(matched_roast_id)), ]; crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) } else { @@ -98,6 +104,51 @@ pub(crate) async fn extract_bag_scan( } } +/// Check if the extracted roaster/roast already exist by slug matching. +/// Returns `(matched_roaster_id, matched_roast_id)` as strings (empty if no match). +async fn match_existing_entities( + state: &AppState, + result: &ai::ExtractedBagScan, +) -> (String, String) { + let roaster_name = result.roaster.name.as_deref().unwrap_or_default(); + let roaster_country = result.roaster.country.as_deref().unwrap_or_default(); + if roaster_name.is_empty() { + return (String::new(), String::new()); + } + + let temp_roaster = NewRoaster { + name: roaster_name.to_string(), + country: roaster_country.to_string(), + city: result.roaster.city.clone(), + homepage: None, + } + .normalize(); + let roaster_slug = temp_roaster.slug(); + + let Ok(existing_roaster) = state.roaster_repo.get_by_slug(&roaster_slug).await else { + return (String::new(), String::new()); + }; + + let matched_roaster_id = existing_roaster.id.into_inner().to_string(); + + let roast_name = result.roast.name.as_deref().unwrap_or_default(); + if roast_name.is_empty() { + return (matched_roaster_id, String::new()); + } + + let roast_slug = slug::slugify(roast_name); + let matched_roast_id = match state + .roast_repo + .get_by_slug(existing_roaster.id, &roast_slug) + .await + { + Ok(r) => r.id.into_inner().to_string(), + Err(_) => String::new(), + }; + + (matched_roaster_id, matched_roast_id) +} + fn default_tasting_notes() -> TastingNotesInput { TastingNotesInput::Text(String::new()) } @@ -130,6 +181,8 @@ pub(crate) struct BagScanSubmission { open_bag: Option, #[serde(default)] bag_amount: Option, + #[serde(default)] + matched_roast_id: Option, } #[derive(Debug, Serialize)] @@ -202,6 +255,11 @@ pub(crate) async fn submit_scan( ) -> Result { let (mut submission, _) = payload.into_parts(); + // If the roast already exists (matched during extraction), skip creation + if let Some(roast_id) = parse_matched_roast_id(submission.matched_roast_id.as_ref()) { + return submit_existing_roast(&state, &headers, roast_id, &submission).await; + } + // Check for raw input (image/prompt triggers extraction first) let has_raw_input = submission.image.as_deref().is_some_and(|s| !s.is_empty()) || submission.prompt.as_deref().is_some_and(|s| !s.is_empty()); @@ -323,3 +381,72 @@ pub(crate) async fn submit_scan( Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response()) } } + +fn parse_matched_roast_id(value: Option<&String>) -> Option { + value + .map(String::as_str) + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) + .map(RoastId::from) +} + +/// Handle submission when the roast already exists — only create a bag if requested. +async fn submit_existing_roast( + state: &AppState, + headers: &HeaderMap, + roast_id: RoastId, + submission: &BagScanSubmission, +) -> Result { + let roast_with_roaster = state + .roast_repo + .get_with_roaster(roast_id) + .await + .map_err(AppError::from)?; + + let roast = &roast_with_roaster.roast; + let roaster_slug = &roast_with_roaster.roaster_slug; + + let wants_bag = submission + .open_bag + .as_deref() + .is_some_and(|v| v == "true" || v == "on"); + if wants_bag { + let amount = submission.bag_amount.unwrap_or(250.0); + let new_bag = NewBag { + roast_id: roast.id, + roast_date: None, + amount, + }; + state + .bag_service + .create(new_bag) + .await + .map_err(AppError::from)?; + info!(roast_id = %roast.id, roast_name = %roast.name, "scan opened bag for existing roast"); + } + + let redirect = format!("/roasters/{}/roasts/{}", roaster_slug, roast.slug); + let roast_id_raw = roast.id.into_inner(); + + if is_datastar_request(headers) { + use serde_json::Value; + let signals = vec![ + ("_roast-id", Value::String(roast_id_raw.to_string())), + ("_scan-success", Value::String(roast.name.clone())), + ( + "_roaster-name", + Value::String(roast_with_roaster.roaster_name.clone()), + ), + ]; + crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok(( + StatusCode::CREATED, + Json(ScanResult { + redirect, + roast_id: roast_id_raw, + }), + ) + .into_response()) + } +} diff --git a/templates/pages/add.html b/templates/pages/add.html index 88bcfac..dd091a5 100644 --- a/templates/pages/add.html +++ b/templates/pages/add.html @@ -26,6 +26,8 @@ data-signals:_tasting-notes="''" data-signals:_open-bag="true" data-signals:_bag-amount="250" + data-signals:_matched-roaster-id="''" + data-signals:_matched-roast-id="''" >
diff --git a/templates/pages/home.html b/templates/pages/home.html index 2e42a46..fc1b077 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -48,6 +48,8 @@ data-signals:_tasting-notes="''" data-signals:_open-bag="true" data-signals:_bag-amount="250" + data-signals:_matched-roaster-id="''" + data-signals:_matched-roast-id="''" >
diff --git a/templates/partials/forms/scan_result_form.html b/templates/partials/forms/scan_result_form.html index ef9c255..3bfb0d9 100644 --- a/templates/partials/forms/scan_result_form.html +++ b/templates/partials/forms/scan_result_form.html @@ -1,55 +1,98 @@ -
+ + + + + + + + + + + + + + +
+

Roaster

+
+
+ {% call icons::fire("h-4 w-4 text-accent shrink-0") %} + + +
+ +
+
+ +

Roaster

If this roaster already exists, it will be matched automatically.

-
+ + +
+

Roast

+
+
+ {% call icons::bag("h-4 w-4 text-accent shrink-0") %} + + +
+ +
+
+ +

Roast

Details about this specific coffee.

+