refactor(roasts): require roast metadata
This commit is contained in:
parent
90390c17ba
commit
fb9fb6cc23
4 changed files with 62 additions and 83 deletions
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue