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
This commit is contained in:
parent
4a7b054de1
commit
202dafb9b9
5 changed files with 304 additions and 14 deletions
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
bag_amount: Option<f64>,
|
||||
#[serde(default)]
|
||||
matched_roast_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -202,6 +255,11 @@ pub(crate) async fn submit_scan(
|
|||
) -> Result<Response, ApiError> {
|
||||
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<RoastId> {
|
||||
value
|
||||
.map(String::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse::<i64>().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<Response, ApiError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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="''"
|
||||
>
|
||||
<!-- Input: photo or text -->
|
||||
<div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
|
||||
|
|
|
|||
|
|
@ -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="''"
|
||||
>
|
||||
<!-- Input: photo or text (shown when not yet extracted) -->
|
||||
<div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
|
||||
|
|
|
|||
|
|
@ -1,55 +1,98 @@
|
|||
<div>
|
||||
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
||||
<input type="hidden" name="matched_roast_id" data-attr:value="$_matchedRoastId" />
|
||||
<input type="hidden" name="roaster_name" data-attr:value="$_roasterName" />
|
||||
<input type="hidden" name="roaster_country" data-attr:value="$_roasterCountry" />
|
||||
<input type="hidden" name="roaster_city" data-attr:value="$_roasterCity" />
|
||||
<input type="hidden" name="roaster_homepage" data-attr:value="$_roasterHomepage" />
|
||||
<input type="hidden" name="roast_name" data-attr:value="$_roastName" />
|
||||
<input type="hidden" name="origin" data-attr:value="$_origin" />
|
||||
<input type="hidden" name="region" data-attr:value="$_region" />
|
||||
<input type="hidden" name="producer" data-attr:value="$_producer" />
|
||||
<input type="hidden" name="process" data-attr:value="$_process" />
|
||||
<input type="hidden" name="tasting_notes" data-attr:value="$_tastingNotes" />
|
||||
|
||||
<!-- Roaster: card (matched) -->
|
||||
<div data-show="$_matchedRoasterId">
|
||||
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
||||
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{% call icons::fire("h-4 w-4 text-accent shrink-0") %}
|
||||
<span class="font-medium text-text" data-text="$_roasterName"></span>
|
||||
<span class="text-xs text-text-muted" data-show="$_roasterCountry" data-text="$_roasterCountry" style="display:none"></span>
|
||||
</div>
|
||||
<button type="button" class="text-xs text-text-muted hover:text-text"
|
||||
data-on:click="$_matchedRoasterId = ''; $_matchedRoastId = ''">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Roaster: form (not matched) -->
|
||||
<div data-show="!$_matchedRoasterId" style="display:none">
|
||||
<h3 class="text-base font-semibold text-text">Roaster</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">If this roaster already exists, it will be matched automatically.</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Name *</span>
|
||||
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
|
||||
<input type="text" class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Country *</span>
|
||||
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" data-bind:_roaster-country />
|
||||
<input type="text" class="input-field" placeholder="United States" data-bind:_roaster-country />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">City</span>
|
||||
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||
<input type="text" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Homepage</span>
|
||||
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
|
||||
<input type="url" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- Roast: card (matched) -->
|
||||
<div data-show="$_matchedRoastId">
|
||||
<h3 class="text-base font-semibold text-text">Roast</h3>
|
||||
<div class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{% call icons::bag("h-4 w-4 text-accent shrink-0") %}
|
||||
<span class="font-medium text-text" data-text="$_roastName"></span>
|
||||
<span class="text-xs text-text-muted" data-show="$_origin" data-text="$_origin" style="display:none"></span>
|
||||
</div>
|
||||
<button type="button" class="text-xs text-text-muted hover:text-text"
|
||||
data-on:click="$_matchedRoastId = ''">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Roast: form (not matched) -->
|
||||
<div data-show="!$_matchedRoastId" style="display:none">
|
||||
<h3 class="text-base font-semibold text-text">Roast</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">Details about this specific coffee.</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Roast Name *</span>
|
||||
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
|
||||
<input type="text" class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Origin *</span>
|
||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||
<input type="text" class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Region *</span>
|
||||
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
|
||||
<input type="text" class="input-field" placeholder="Guji" data-bind:_region />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Producer *</span>
|
||||
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
|
||||
<input type="text" class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Process *</span>
|
||||
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
|
||||
<input type="text" class="input-field" placeholder="Washed" data-bind:_process />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-text">Tasting Notes * (comma separated)</span>
|
||||
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
|
||||
<textarea rows="2" class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="open_bag" value="true" class="accent-orange-700" data-bind:_open-bag />
|
||||
|
|
@ -70,15 +113,38 @@
|
|||
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_scanExtracted = false"
|
||||
data-on:click="$_scanExtracted = false; $_matchedRoasterId = ''; $_matchedRoastId = ''"
|
||||
class="rounded-md border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface-alt"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<!-- Neither matched: full create -->
|
||||
<button
|
||||
type="submit"
|
||||
data-show="!$_matchedRoasterId"
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
|
||||
>
|
||||
Save Roaster & Roast
|
||||
</button>
|
||||
<!-- Roaster matched, roast not: create roast only -->
|
||||
<button
|
||||
type="submit"
|
||||
data-show="$_matchedRoasterId && !$_matchedRoastId" style="display:none"
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
|
||||
>
|
||||
Save Roast
|
||||
</button>
|
||||
<!-- Both matched + open bag: create bag only -->
|
||||
<button
|
||||
type="submit"
|
||||
data-show="$_matchedRoastId && $_openBag" style="display:none"
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
|
||||
>
|
||||
Open Bag
|
||||
</button>
|
||||
<!-- Both matched + no bag: nothing to do -->
|
||||
<p data-show="$_matchedRoastId && !$_openBag" style="display:none"
|
||||
class="text-sm text-text-secondary">
|
||||
This roast already exists. Check “Open a bag” above to add a new bag.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
use crate::helpers::{create_default_roaster, spawn_app_with_auth};
|
||||
use crate::helpers::{create_default_roast, create_default_roaster, spawn_app_with_auth};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScanResult {
|
||||
|
|
@ -220,3 +220,96 @@ async fn scan_requires_tasting_notes_for_manual_submission() {
|
|||
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_with_matched_roast_id_creates_bag_only() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Pre-create roaster and roast
|
||||
let roaster = create_default_roaster(&app).await;
|
||||
let roast = create_default_roast(&app, roaster.id).await;
|
||||
|
||||
// Submit scan with matched_roast_id — should skip roaster/roast creation
|
||||
let payload = serde_json::json!({
|
||||
"matched_roast_id": roast.id.into_inner().to_string(),
|
||||
"open_bag": "true",
|
||||
"bag_amount": 250.0,
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(app.api_url("/scan"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request");
|
||||
|
||||
assert_eq!(response.status(), 201);
|
||||
|
||||
let result: ScanResult = response.json().await.expect("Failed to parse response");
|
||||
assert_eq!(result.roast_id, roast.id.into_inner());
|
||||
assert!(
|
||||
result.redirect.contains(&roast.slug),
|
||||
"Redirect should reference existing roast slug, got: {}",
|
||||
result.redirect
|
||||
);
|
||||
|
||||
// Verify no new roast was created (still just the one)
|
||||
let roasts_response = client
|
||||
.get(app.api_url("/roasts"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list roasts");
|
||||
let roasts: Vec<serde_json::Value> = roasts_response
|
||||
.json()
|
||||
.await
|
||||
.expect("Failed to parse roasts");
|
||||
assert_eq!(roasts.len(), 1, "Should not create a new roast");
|
||||
|
||||
// Verify bag was created
|
||||
let bags_response = client
|
||||
.get(app.api_url("/bags"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list bags");
|
||||
let bags: Vec<serde_json::Value> = bags_response.json().await.expect("Failed to parse bags");
|
||||
assert_eq!(bags.len(), 1, "A bag should have been created");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_with_matched_roast_id_no_bag_returns_existing() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Pre-create roaster and roast
|
||||
let roaster = create_default_roaster(&app).await;
|
||||
let roast = create_default_roast(&app, roaster.id).await;
|
||||
|
||||
// Submit scan with matched_roast_id but no open_bag
|
||||
let payload = serde_json::json!({
|
||||
"matched_roast_id": roast.id.into_inner().to_string(),
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(app.api_url("/scan"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request");
|
||||
|
||||
assert_eq!(response.status(), 201);
|
||||
|
||||
let result: ScanResult = response.json().await.expect("Failed to parse response");
|
||||
assert_eq!(result.roast_id, roast.id.into_inner());
|
||||
|
||||
// Verify no bag was created
|
||||
let bags_response = client
|
||||
.get(app.api_url("/bags"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to list bags");
|
||||
let bags: Vec<serde_json::Value> = bags_response.json().await.expect("Failed to parse bags");
|
||||
assert_eq!(bags.len(), 0, "No bag should have been created");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue