refactor(roasts): require roast metadata

This commit is contained in:
Jon Seager 2025-11-24 11:23:32 +00:00
parent 90390c17ba
commit fb9fb6cc23
No known key found for this signature in database
4 changed files with 62 additions and 83 deletions

View file

@ -13,14 +13,14 @@ pub struct AddRoastCommand {
#[arg(long)] #[arg(long)]
pub name: String, pub name: String,
#[arg(long)] #[arg(long)]
pub origin: Option<String>, pub origin: String,
#[arg(long)] #[arg(long)]
pub region: Option<String>, pub region: String,
#[arg(long)] #[arg(long)]
pub producer: Option<String>, pub producer: String,
#[arg(long)] #[arg(long)]
pub process: Option<String>, pub process: String,
#[arg(long = "tasting-notes")] #[arg(long = "tasting-notes", required = true)]
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
} }

View file

@ -27,11 +27,11 @@ pub struct RoastWithRoaster {
pub struct NewRoast { pub struct NewRoast {
pub roaster_id: String, pub roaster_id: String,
pub name: String, pub name: String,
pub origin: Option<String>, pub origin: String,
pub region: Option<String>, pub region: String,
pub producer: Option<String>, pub producer: String,
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
pub process: Option<String>, pub process: String,
} }
impl NewRoast { impl NewRoast {
@ -40,11 +40,11 @@ impl NewRoast {
id: generate_id(), id: generate_id(),
roaster_id: self.roaster_id, roaster_id: self.roaster_id,
name: self.name, name: self.name,
origin: self.origin, origin: Some(self.origin),
region: self.region, region: Some(self.region),
producer: self.producer, producer: Some(self.producer),
tasting_notes: self.tasting_notes, tasting_notes: self.tasting_notes,
process: self.process, process: Some(self.process),
created_at: Utc::now(), created_at: Utc::now(),
} }
} }

View file

@ -6,7 +6,7 @@ use serde::Deserialize;
use serde::de::{self, Deserializer, SeqAccess, Visitor}; use serde::de::{self, Deserializer, SeqAccess, Visitor};
use std::fmt; use std::fmt;
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster}; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster};
use crate::presentation::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate}; use crate::presentation::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate};
@ -21,28 +21,6 @@ use crate::server::server::AppState;
const ROAST_PAGE_PATH: &str = "/roasts"; const ROAST_PAGE_PATH: &str = "/roasts";
const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list"; const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
fn normalize_request(
request: ListRequest<RoastSortKey>,
page: &Page<RoastWithRoaster>,
) -> ListRequest<RoastSortKey> {
let page_size = if page.showing_all {
PageSize::All
} else {
PageSize::limited(page.page_size)
};
ListRequest::new(
page.page,
page_size,
request.sort_key(),
request.sort_direction(),
)
}
fn roast_navigator(request: ListRequest<RoastSortKey>) -> ListNavigator<RoastSortKey> {
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request)
}
async fn load_roast_page( async fn load_roast_page(
state: &AppState, state: &AppState,
request: ListRequest<RoastSortKey>, request: ListRequest<RoastSortKey>,
@ -53,9 +31,9 @@ async fn load_roast_page(
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
let normalized_request = normalize_request(request, &page); let normalized_request = crate::server::routes::support::normalize_request(request, &page);
let roasts = Paginated::from_page(page, RoastView::from_list_item); let roasts = Paginated::from_page(page, RoastView::from_list_item);
let navigator = roast_navigator(normalized_request); let navigator = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, normalized_request);
Ok((roasts, navigator)) Ok((roasts, navigator))
} }
@ -146,7 +124,7 @@ pub(crate) async fn create_roast(
.await .await
.map_err(ApiError::from) .map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) { } else if matches!(source, PayloadSource::Form) {
let target = roast_navigator(request).page_href(1); let target = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response()) Ok(Redirect::to(&target).into_response())
} else { } else {
Ok((StatusCode::CREATED, Json(roast)).into_response()) Ok((StatusCode::CREATED, Json(roast)).into_response())
@ -203,58 +181,57 @@ pub struct RoastsQuery {
pub(crate) struct NewRoastSubmission { pub(crate) struct NewRoastSubmission {
roaster_id: String, roaster_id: String,
name: String, name: String,
#[serde(default)] origin: String,
origin: Option<String>, region: String,
#[serde(default)] producer: String,
region: Option<String>, #[serde(deserialize_with = "string_or_vec")]
#[serde(default)]
producer: Option<String>,
#[serde(default, deserialize_with = "string_or_vec")]
tasting_notes: Vec<String>, tasting_notes: Vec<String>,
#[serde(default)] process: String,
process: Option<String>,
} }
impl NewRoastSubmission { impl NewRoastSubmission {
fn into_new_roast(self) -> Result<NewRoast, AppError> { fn into_new_roast(self) -> Result<NewRoast, AppError> {
let roaster_id = self.roaster_id.trim().to_string(); fn require(field: &str, value: String) -> Result<String, AppError> {
if roaster_id.is_empty() { let trimmed = value.trim();
return Err(AppError::validation("roaster is required")); if trimmed.is_empty() {
Err(AppError::validation(format!("{field} is required")))
} else {
Ok(trimmed.to_string())
}
} }
let name = self.name.trim().to_string(); let roaster_id = require("roaster", self.roaster_id)?;
if name.is_empty() { let name = require("name", self.name)?;
return Err(AppError::validation("name is required")); 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_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if tasting_notes.is_empty() {
return Err(AppError::validation("tasting notes are required"));
} }
Ok(NewRoast { Ok(NewRoast {
roaster_id, roaster_id,
name, name,
origin: trim_optional(self.origin), origin,
region: trim_optional(self.region), region,
producer: trim_optional(self.producer), producer,
tasting_notes: self tasting_notes,
.tasting_notes process,
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect(),
process: trim_optional(self.process),
}) })
} }
} }
fn trim_optional(value: Option<String>) -> Option<String> { // TODO: If we just make sure that the repository always returns a list, even for one value, or
value.and_then(|raw| { // no values, we can remove this deserializer, I think?
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,

View file

@ -72,31 +72,33 @@
/> />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Origin</span> <span class="text-stone-700">Origin *</span>
<input type="text" name="origin" class="input-field" placeholder="Ethiopia" /> <input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Region</span> <span class="text-stone-700">Region *</span>
<input type="text" name="region" class="input-field" placeholder="Guji" /> <input type="text" name="region" required class="input-field" placeholder="Guji" />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Producer</span> <span class="text-stone-700">Producer *</span>
<input <input
type="text" type="text"
name="producer" name="producer"
required
class="input-field" class="input-field"
placeholder="Chelbesa Cooperative" placeholder="Chelbesa Cooperative"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Process</span> <span class="text-stone-700">Process *</span>
<input type="text" name="process" class="input-field" placeholder="Washed" /> <input type="text" name="process" required class="input-field" placeholder="Washed" />
</label> </label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm"> <label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Tasting Notes (comma or newline separated)</span> <span class="text-stone-700">Tasting Notes * (comma or newline separated)</span>
<textarea <textarea
name="tasting_notes" name="tasting_notes"
rows="2" rows="2"
required
class="input-field" class="input-field"
placeholder="Blueberry, Jasmine" placeholder="Blueberry, Jasmine"
></textarea> ></textarea>