feat(scan): optionally open a bag when scanning coffee

Add "Open a bag of this coffee" checkbox (default: checked) to the
scan form with a configurable amount field (default: 250g). When
enabled, creates a bag and timeline event alongside the roaster/roast.
This commit is contained in:
Jon Seager 2026-02-04 17:41:53 +00:00
parent ec62a1fc7b
commit 91a9900c24
No known key found for this signature in database
2 changed files with 64 additions and 1 deletions

View file

@ -9,9 +9,11 @@ use crate::application::errors::{ApiError, AppError};
use crate::application::routes::roasts::TastingNotesInput;
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::roasters::NewRoaster;
use crate::domain::roasts::NewRoast;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::infrastructure::ai::{self, ExtractionInput};
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
@ -115,6 +117,10 @@ pub(crate) struct BagScanSubmission {
process: String,
#[serde(default = "default_tasting_notes")]
tasting_notes: TastingNotesInput,
#[serde(default)]
open_bag: Option<String>,
#[serde(default)]
bag_amount: Option<f64>,
}
#[derive(Debug, Serialize)]
@ -175,6 +181,7 @@ async fn extract_into_submission(
Ok(())
}
#[allow(clippy::too_many_lines)]
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn submit_scan(
State(state): State<AppState>,
@ -262,6 +269,48 @@ pub(crate) async fn submit_scan(
.await
.map_err(AppError::from)?;
// Optionally create a bag
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,
};
let bag = state
.bag_repo
.insert(new_bag)
.await
.map_err(AppError::from)?;
let event = NewTimelineEvent {
entity_type: "bag".to_string(),
entity_id: bag.id.into_inner(),
action: "added".to_string(),
occurred_at: chrono::Utc::now(),
title: roast.name.clone(),
details: vec![
TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name.clone(),
},
TimelineEventDetail {
label: "Amount".to_string(),
value: format!("{}g", bag.amount),
},
],
tasting_notes: vec![],
slug: Some(roast.slug.clone()),
roaster_slug: Some(roaster.slug.clone()),
brew_data: None,
};
let _ = state.timeline_repo.insert(event).await;
}
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
let roast_id = roast.id.into_inner();

View file

@ -46,6 +46,8 @@
data-signals:_producer="''"
data-signals:_process="''"
data-signals:_tasting-notes="''"
data-signals:_open-bag="true"
data-signals:_bag-amount="250"
>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
@ -89,7 +91,7 @@
<form
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; $_openBag = true; $_bagAmount = 250; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
>
<div>
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
@ -143,6 +145,18 @@
</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-amber-600" data-bind:_open-bag />
<span class="font-semibold text-amber-700">Open a bag of this coffee</span>
</label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Amount (grams)</span>
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount />
</label>
</div>
</div>
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}