feat!: update to a more human friendly url structure for roasters/roasts

This commit is contained in:
Jon Seager 2025-11-25 21:34:08 +00:00
parent dd0f716437
commit f88a880d07
No known key found for this signature in database
18 changed files with 221 additions and 85 deletions

17
Cargo.lock generated
View file

@ -334,6 +334,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"slug",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -583,6 +584,12 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -2225,6 +2232,16 @@ version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"

View file

@ -36,6 +36,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = "0.4" tower = "0.4"
tower-cookies = "0.10" tower-cookies = "0.10"
slug = "0.1.6"
[dev-dependencies] [dev-dependencies]
portpicker = "0.1" portpicker = "0.1"

View file

@ -7,9 +7,12 @@ CREATE TABLE roasters (
city TEXT, city TEXT,
homepage TEXT, homepage TEXT,
notes TEXT, notes TEXT,
slug TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
); );
CREATE UNIQUE INDEX idx_roasters_slug ON roasters(slug);
CREATE TABLE roasts ( CREATE TABLE roasts (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
roaster_id INTEGER NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, roaster_id INTEGER NOT NULL REFERENCES roasters(id) ON DELETE CASCADE,
@ -19,10 +22,12 @@ CREATE TABLE roasts (
producer TEXT, producer TEXT,
process TEXT, process TEXT,
tasting_notes TEXT, tasting_notes TEXT,
slug TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
); );
CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id); CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id);
CREATE UNIQUE INDEX idx_roasts_roaster_slug ON roasts(roaster_id, slug);
CREATE TABLE timeline_events ( CREATE TABLE timeline_events (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,

View file

@ -16,6 +16,7 @@ use async_trait::async_trait;
pub trait RoasterRepository: Send + Sync { pub trait RoasterRepository: Send + Sync {
async fn insert(&self, roaster: NewRoaster) -> Result<Roaster, RepositoryError>; async fn insert(&self, roaster: NewRoaster) -> Result<Roaster, RepositoryError>;
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError>; async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Roaster, RepositoryError>;
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoasterSortKey>, request: &ListRequest<RoasterSortKey>,
@ -50,6 +51,11 @@ pub trait RoasterRepository: Send + Sync {
pub trait RoastRepository: Send + Sync { pub trait RoastRepository: Send + Sync {
async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>; async fn insert(&self, roast: NewRoast) -> Result<Roast, RepositoryError>;
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>; async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError>;
async fn get_by_slug(
&self,
roaster_id: RoasterId,
slug: &str,
) -> Result<Roast, RepositoryError>;
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoastSortKey>, request: &ListRequest<RoastSortKey>,

View file

@ -8,6 +8,7 @@ use crate::domain::listing::{SortDirection, SortKey};
pub struct Roaster { pub struct Roaster {
pub id: RoasterId, pub id: RoasterId,
pub name: String, pub name: String,
pub slug: String,
pub country: String, pub country: String,
pub city: Option<String>, pub city: Option<String>,
pub homepage: Option<String>, pub homepage: Option<String>,
@ -33,6 +34,14 @@ impl NewRoaster {
self.notes = normalize_optional_field(self.notes); self.notes = normalize_optional_field(self.notes);
self self
} }
pub fn slug(&self) -> String {
let base = match &self.city {
Some(city) => format!("{}-{}", self.name, city),
None => self.name.clone(),
};
slug::slugify(base)
}
} }
fn normalize_optional_field(value: Option<String>) -> Option<String> { fn normalize_optional_field(value: Option<String>) -> Option<String> {

View file

@ -9,6 +9,7 @@ pub struct Roast {
pub id: RoastId, pub id: RoastId,
pub roaster_id: RoasterId, pub roaster_id: RoasterId,
pub name: String, pub name: String,
pub slug: String,
pub origin: Option<String>, pub origin: Option<String>,
pub region: Option<String>, pub region: Option<String>,
pub producer: Option<String>, pub producer: Option<String>,
@ -21,6 +22,7 @@ pub struct Roast {
pub struct RoastWithRoaster { pub struct RoastWithRoaster {
pub roast: Roast, pub roast: Roast,
pub roaster_name: String, pub roaster_name: String,
pub roaster_slug: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -34,6 +36,12 @@ pub struct NewRoast {
pub process: String, pub process: String,
} }
impl NewRoast {
pub fn slug(&self) -> String {
slug::slugify(&self.name)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateRoast { pub struct UpdateRoast {
pub roaster_id: Option<RoasterId>, pub roaster_id: Option<RoasterId>,

View file

@ -19,6 +19,8 @@ pub struct TimelineEvent {
pub title: String, pub title: String,
pub details: Vec<TimelineEventDetail>, pub details: Vec<TimelineEventDetail>,
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
pub slug: Option<String>,
pub roaster_slug: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -38,6 +38,7 @@ impl SqlRoasterRepository {
let RoasterRecord { let RoasterRecord {
id, id,
name, name,
slug,
country, country,
city, city,
homepage, homepage,
@ -48,6 +49,7 @@ impl SqlRoasterRepository {
Roaster { Roaster {
id: RoasterId::from(id), id: RoasterId::from(id),
name, name,
slug,
country, country,
city, city,
homepage, homepage,
@ -100,13 +102,15 @@ impl RoasterRepository for SqlRoasterRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let new_roaster = new_roaster.normalize(); let new_roaster = new_roaster.normalize();
let slug = new_roaster.slug();
let created_at = Utc::now(); let created_at = Utc::now();
let record = query_as::<_, RoasterRecord>( let record = query_as::<_, RoasterRecord>(
"INSERT INTO roasters (name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)\ "INSERT INTO roasters (name, slug, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)\
RETURNING id, name, country, city, homepage, notes, created_at", RETURNING id, name, slug, country, city, homepage, notes, created_at",
) )
.bind(&new_roaster.name) .bind(&new_roaster.name)
.bind(&slug)
.bind(&new_roaster.country) .bind(&new_roaster.country)
.bind(new_roaster.city.as_deref()) .bind(new_roaster.city.as_deref())
.bind(new_roaster.homepage.as_deref()) .bind(new_roaster.homepage.as_deref())
@ -114,7 +118,13 @@ impl RoasterRepository for SqlRoasterRepository {
.bind(created_at) .bind(created_at)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| {
if err.to_string().contains("UNIQUE constraint failed") {
RepositoryError::Conflict("A roaster with this name and city already exists".to_string())
} else {
RepositoryError::unexpected(err.to_string())
}
})?;
let roaster = Self::into_domain(record); let roaster = Self::into_domain(record);
let details_json = Self::details_for_roaster(&roaster)?; let details_json = Self::details_for_roaster(&roaster)?;
@ -141,7 +151,7 @@ impl RoasterRepository for SqlRoasterRepository {
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> { async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {
let record = query_as::<_, RoasterRecord>( let record = query_as::<_, RoasterRecord>(
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters WHERE id = ?",
) )
.bind(i64::from(id)) .bind(i64::from(id))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@ -154,13 +164,28 @@ impl RoasterRepository for SqlRoasterRepository {
} }
} }
async fn get_by_slug(&self, slug: &str) -> Result<Roaster, RepositoryError> {
let record = query_as::<_, RoasterRecord>(
"SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters WHERE slug = ?",
)
.bind(slug)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record {
Some(record) => Ok(Self::into_domain(record)),
None => Err(RepositoryError::NotFound),
}
}
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoasterSortKey>, request: &ListRequest<RoasterSortKey>,
) -> Result<Page<Roaster>, RepositoryError> { ) -> Result<Page<Roaster>, RepositoryError> {
let order_clause = Self::sort_clause(request); let order_clause = Self::sort_clause(request);
let base_query = let base_query =
"SELECT id, name, country, city, homepage, notes, created_at FROM roasters"; "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters";
let count_query = "SELECT COUNT(*) FROM roasters"; let count_query = "SELECT COUNT(*) FROM roasters";
crate::infrastructure::repositories::pagination::paginate( crate::infrastructure::repositories::pagination::paginate(
@ -264,6 +289,7 @@ impl RoasterRepository for SqlRoasterRepository {
struct RoasterRecord { struct RoasterRecord {
id: i64, id: i64,
name: String, name: String,
slug: String,
country: String, country: String,
city: Option<String>, city: Option<String>,
homepage: Option<String>, homepage: Option<String>,

View file

@ -60,6 +60,7 @@ impl RoastRepository for SqlRoastRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let slug = new_roast.slug();
let NewRoast { let NewRoast {
roaster_id, roaster_id,
name, name,
@ -95,11 +96,12 @@ impl RoastRepository for SqlRoastRepository {
let notes_json = Self::encode_notes(&tasting_notes)?; let notes_json = Self::encode_notes(&tasting_notes)?;
let record = query_as::<_, RoastRecord>( let record = query_as::<_, RoastRecord>(
"INSERT INTO roasts (roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\ "INSERT INTO roasts (roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\
RETURNING id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at", RETURNING id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at",
) )
.bind(i64::from(roaster_id)) .bind(i64::from(roaster_id))
.bind(&name) .bind(&name)
.bind(&slug)
.bind(origin_value.as_deref()) .bind(origin_value.as_deref())
.bind(region_value.as_deref()) .bind(region_value.as_deref())
.bind(producer_value.as_deref()) .bind(producer_value.as_deref())
@ -108,7 +110,13 @@ impl RoastRepository for SqlRoastRepository {
.bind(created_at) .bind(created_at)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|err| map_insert_error(err, "unknown roaster reference"))?; .map_err(|err| {
if err.to_string().contains("UNIQUE constraint failed") {
RepositoryError::Conflict("A roast with this name already exists for this roaster".to_string())
} else {
map_insert_error(err, "unknown roaster reference")
}
})?;
let roast = record.into_roast()?; let roast = record.into_roast()?;
@ -179,7 +187,7 @@ impl RoastRepository for SqlRoastRepository {
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> { async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> {
query_as::<_, RoastRecord>( query_as::<_, RoastRecord>(
"SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?",
) )
.bind(i64::from(id)) .bind(i64::from(id))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@ -190,12 +198,30 @@ impl RoastRepository for SqlRoastRepository {
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
async fn get_by_slug(
&self,
roaster_id: RoasterId,
slug: &str,
) -> Result<Roast, RepositoryError> {
query_as::<_, RoastRecord>(
"SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE roaster_id = ? AND slug = ?",
)
.bind(i64::from(roaster_id))
.bind(slug)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(|record| record.into_roast())
.transpose()?
.ok_or(RepositoryError::NotFound)
}
async fn list( async fn list(
&self, &self,
request: &ListRequest<RoastSortKey>, request: &ListRequest<RoastSortKey>,
) -> Result<Page<RoastWithRoaster>, RepositoryError> { ) -> Result<Page<RoastWithRoaster>, RepositoryError> {
let order_clause = Self::order_clause(request); let order_clause = Self::order_clause(request);
let base_query = "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id"; let base_query = "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id";
let count_query = "SELECT COUNT(*) FROM roasts"; let count_query = "SELECT COUNT(*) FROM roasts";
crate::infrastructure::repositories::pagination::paginate( crate::infrastructure::repositories::pagination::paginate(
@ -214,7 +240,7 @@ impl RoastRepository for SqlRoastRepository {
roaster_id: RoasterId, roaster_id: RoasterId,
) -> Result<Vec<RoastWithRoaster>, RepositoryError> { ) -> Result<Vec<RoastWithRoaster>, RepositoryError> {
let records = query_as::<_, RoastWithRoasterRecord>( let records = query_as::<_, RoastWithRoasterRecord>(
"SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC", "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC",
) )
.bind(i64::from(roaster_id)) .bind(i64::from(roaster_id))
.fetch_all(&self.pool) .fetch_all(&self.pool)
@ -361,6 +387,7 @@ struct RoastRecord {
id: i64, id: i64,
roaster_id: i64, roaster_id: i64,
name: String, name: String,
slug: String,
origin: Option<String>, origin: Option<String>,
region: Option<String>, region: Option<String>,
producer: Option<String>, producer: Option<String>,
@ -371,35 +398,24 @@ struct RoastRecord {
impl RoastRecord { impl RoastRecord {
fn into_roast(self) -> Result<Roast, RepositoryError> { fn into_roast(self) -> Result<Roast, RepositoryError> {
let RoastRecord { let tasting_notes = match self.tasting_notes {
id, Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| {
roaster_id,
name,
origin,
region,
producer,
process,
tasting_notes,
created_at,
} = self;
let tasting_notes = match tasting_notes {
Some(raw) if !raw.is_empty() => from_str(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
})?, })?,
_ => Vec::new(), None => Vec::new(),
}; };
Ok(Roast { Ok(Roast {
id: RoastId::from(id), id: RoastId::from(self.id),
roaster_id: RoasterId::from(roaster_id), roaster_id: RoasterId::from(self.roaster_id),
name, name: self.name,
origin, slug: self.slug,
region, origin: self.origin,
producer, region: self.region,
producer: self.producer,
process: self.process,
tasting_notes, tasting_notes,
process, created_at: self.created_at,
created_at,
}) })
} }
} }
@ -409,6 +425,7 @@ struct RoastWithRoasterRecord {
id: i64, id: i64,
roaster_id: i64, roaster_id: i64,
name: String, name: String,
slug: String,
origin: Option<String>, origin: Option<String>,
region: Option<String>, region: Option<String>,
producer: Option<String>, producer: Option<String>,
@ -416,27 +433,33 @@ struct RoastWithRoasterRecord {
tasting_notes: Option<String>, tasting_notes: Option<String>,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
roaster_name: String, roaster_name: String,
roaster_slug: String,
} }
impl RoastWithRoasterRecord { impl RoastWithRoasterRecord {
fn into_with_roaster(self) -> Result<RoastWithRoaster, RepositoryError> { fn into_with_roaster(self) -> Result<RoastWithRoaster, RepositoryError> {
let roaster_name = self.roaster_name.clone(); let tasting_notes = match self.tasting_notes {
let roast = RoastRecord { Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| {
id: self.id, RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
roaster_id: self.roaster_id, })?,
name: self.name, None => Vec::new(),
origin: self.origin, };
region: self.region,
producer: self.producer,
process: self.process,
tasting_notes: self.tasting_notes,
created_at: self.created_at,
}
.into_roast()?;
Ok(RoastWithRoaster { Ok(RoastWithRoaster {
roast, roast: Roast {
roaster_name, id: RoastId::from(self.id),
roaster_id: RoasterId::from(self.roaster_id),
name: self.name,
slug: self.slug,
origin: self.origin,
region: self.region,
producer: self.producer,
process: self.process,
tasting_notes,
created_at: self.created_at,
},
roaster_name: self.roaster_name,
roaster_slug: self.roaster_slug,
}) })
} }
} }

View file

@ -1,12 +1,12 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::from_str;
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::ids::TimelineEventId; use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::TimelineEventRepository; use crate::domain::repositories::TimelineEventRepository;
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey};
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::from_str;
#[derive(Clone)] #[derive(Clone)]
pub struct SqlTimelineEventRepository { pub struct SqlTimelineEventRepository {
@ -30,9 +30,22 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
SortDirection::Desc => "DESC", SortDirection::Desc => "DESC",
}; };
let order_clause = format!("occurred_at {direction_sql}, id DESC"); let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC");
let base_query = "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \ let base_query = "SELECT
FROM timeline_events"; t.id, t.entity_type, t.entity_id, t.occurred_at, t.title, t.details_json, t.tasting_notes_json,
CASE
WHEN t.entity_type = 'roaster' THEN r.slug
WHEN t.entity_type = 'roast' THEN rst.slug
ELSE NULL
END as slug,
CASE
WHEN t.entity_type = 'roast' THEN rst_r.slug
ELSE NULL
END as roaster_slug
FROM timeline_events t
LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id
LEFT JOIN roasts rst ON t.entity_type = 'roast' AND t.entity_id = rst.id
LEFT JOIN roasters rst_r ON rst.roaster_id = rst_r.id";
let count_query = "SELECT COUNT(*) FROM timeline_events"; let count_query = "SELECT COUNT(*) FROM timeline_events";
crate::infrastructure::repositories::pagination::paginate( crate::infrastructure::repositories::pagination::paginate(
@ -56,6 +69,8 @@ struct TimelineEventRecord {
title: String, title: String,
details_json: Option<String>, details_json: Option<String>,
tasting_notes_json: Option<String>, tasting_notes_json: Option<String>,
slug: Option<String>,
roaster_slug: Option<String>,
} }
impl TimelineEventRecord { impl TimelineEventRecord {
@ -88,6 +103,8 @@ impl TimelineEventRecord {
title: self.title, title: self.title,
details, details,
tasting_notes, tasting_notes,
slug: self.slug,
roaster_slug: self.roaster_slug,
}) })
} }
} }

View file

@ -291,6 +291,7 @@ impl From<Roaster> for RoasterView {
fn from(roaster: Roaster) -> Self { fn from(roaster: Roaster) -> Self {
let Roaster { let Roaster {
id, id,
slug,
name, name,
country, country,
city, city,
@ -301,7 +302,7 @@ impl From<Roaster> for RoasterView {
let homepage = homepage.unwrap_or_default(); let homepage = homepage.unwrap_or_default();
let has_homepage = !homepage.is_empty(); let has_homepage = !homepage.is_empty();
let detail_path = format!("/roasters/{id}"); let detail_path = format!("/roasters/{slug}");
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let created_at_label = created_at.format("%Y-%m-%d").to_string(); let created_at_label = created_at.format("%Y-%m-%d").to_string();
@ -338,23 +339,25 @@ pub struct RoastView {
} }
impl RoastView { impl RoastView {
pub fn from_domain(roast: Roast, roaster_name: &str) -> Self { pub fn from_domain(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self {
Self::from_parts(roast, roaster_name) Self::from_parts(roast, roaster_name, roaster_slug)
} }
pub fn from_list_item(item: RoastWithRoaster) -> Self { pub fn from_list_item(item: RoastWithRoaster) -> Self {
let RoastWithRoaster { let RoastWithRoaster {
roast, roast,
roaster_name, roaster_name,
roaster_slug,
} = item; } = item;
Self::from_parts(roast, &roaster_name) Self::from_parts(roast, &roaster_name, &roaster_slug)
} }
fn from_parts(roast: Roast, roaster_name: &str) -> Self { fn from_parts(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self {
let Roast { let Roast {
id: roast_id, id: roast_id,
roaster_id: _, roaster_id: _,
name, name,
slug,
origin, origin,
region, region,
producer, producer,
@ -385,7 +388,7 @@ impl RoastView {
}) })
.collect(); .collect();
let created_at = created_at.format("%Y-%m-%d").to_string(); let created_at = created_at.format("%Y-%m-%d").to_string();
let detail_path = format!("/roasts/{full_id}"); let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}");
Self { Self {
id, id,
@ -444,6 +447,8 @@ impl TimelineEventView {
title, title,
details, details,
tasting_notes, tasting_notes,
slug,
roaster_slug,
} = event; } = event;
let kind_label = match entity_type.as_str() { let kind_label = match entity_type.as_str() {
@ -452,9 +457,13 @@ impl TimelineEventView {
_ => "Event", _ => "Event",
}; };
let link = match entity_type.as_str() { let link = match (entity_type.as_str(), slug, roaster_slug) {
"roaster" => format!("/roasters/{entity_id}"), ("roaster", Some(slug), _) => format!("/roasters/{slug}"),
"roast" => format!("/roasts/{entity_id}"), ("roast", Some(slug), Some(roaster_slug)) => {
format!("/roasters/{roaster_slug}/roasts/{slug}")
}
("roaster", None, _) => format!("/roasters/{entity_id}"),
("roast", None, _) => format!("/roasts/{entity_id}"),
_ => String::from("#"), _ => String::from("#"),
}; };

View file

@ -49,9 +49,12 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/login", get(auth::login_page).post(auth::login_submit)) .route("/login", get(auth::login_page).post(auth::login_submit))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/roasters", get(roasters::roasters_page)) .route("/roasters", get(roasters::roasters_page))
.route("/roasters/:id", get(roasters::roaster_page)) .route("/roasters/:slug", get(roasters::roaster_page))
.route("/roasts", get(roasts::roasts_page)) .route("/roasts", get(roasts::roasts_page))
.route("/roasts/:id", get(roasts::roast_page)) .route(
"/roasters/:roaster_slug/roasts/:roast_slug",
get(roasts::roast_page),
)
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))

View file

@ -73,16 +73,16 @@ pub(crate) async fn roasters_page(
pub(crate) async fn roaster_page( pub(crate) async fn roaster_page(
State(state): State<AppState>, State(state): State<AppState>,
cookies: tower_cookies::Cookies, cookies: tower_cookies::Cookies,
Path(id): Path<RoasterId>, Path(slug): Path<String>,
) -> Result<Html<String>, StatusCode> { ) -> Result<Html<String>, StatusCode> {
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.get(id) .get_by_slug(&slug)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
let roasts = state let roasts = state
.roast_repo .roast_repo
.list_by_roaster(id) .list_by_roaster(roaster.id)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;

View file

@ -82,16 +82,17 @@ pub(crate) async fn roasts_page(
pub(crate) async fn roast_page( pub(crate) async fn roast_page(
State(state): State<AppState>, State(state): State<AppState>,
cookies: tower_cookies::Cookies, cookies: tower_cookies::Cookies,
Path(id): Path<RoastId>, Path((roaster_slug, roast_slug)): Path<(String, String)>,
) -> Result<Html<String>, StatusCode> { ) -> Result<Html<String>, StatusCode> {
let roast = state
.roast_repo
.get(id)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster = state let roaster = state
.roaster_repo .roaster_repo
.get(roast.roaster_id) .get_by_slug(&roaster_slug)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roast = state
.roast_repo
.get_by_slug(roaster.id, &roast_slug)
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
@ -100,7 +101,7 @@ pub(crate) async fn roast_page(
let template = RoastDetailTemplate { let template = RoastDetailTemplate {
nav_active: "roasts", nav_active: "roasts",
is_authenticated, is_authenticated,
roast: RoastView::from_domain(roast, &roaster.name), roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug),
}; };
render_html(template) render_html(template)

View file

@ -1,8 +1,8 @@
use askama::Template;
use axum::async_trait; use axum::async_trait;
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use askama::Template;
use serde::Deserialize; use serde::Deserialize;
use crate::domain::listing::{ use crate::domain::listing::{

View file

@ -9,9 +9,7 @@ use crate::presentation::templates::{TimelineChunkTemplate, TimelineTemplate};
use crate::presentation::views::{ListNavigator, Paginated, TimelineEventView, TimelineMonthView}; use crate::presentation::views::{ListNavigator, Paginated, TimelineEventView, TimelineMonthView};
use crate::server::errors::{AppError, map_app_error}; use crate::server::errors::{AppError, map_app_error};
use crate::server::routes::render_html; use crate::server::routes::render_html;
use crate::server::routes::support::{ use crate::server::routes::support::{ListQuery, is_datastar_request, normalize_request};
ListQuery, is_datastar_request, normalize_request,
};
use crate::server::server::AppState; use crate::server::server::AppState;
const TIMELINE_PAGE_PATH: &str = "/timeline"; const TIMELINE_PAGE_PATH: &str = "/timeline";

View file

@ -36,7 +36,13 @@ fn test_add_roast_with_authentication() {
// First create a roaster // First create a roaster
let roaster_output = run_brewlog( let roaster_output = run_brewlog(
&["add-roaster", "--name", "Test Roasters", "--country", "UK"], &[
"add-roaster",
"--name",
"Test Roasters Add",
"--country",
"UK",
],
&[("BREWLOG_TOKEN", &token)], &[("BREWLOG_TOKEN", &token)],
); );
@ -108,7 +114,13 @@ fn test_list_roasts_shows_added_roast() {
// First create a roaster // First create a roaster
let roaster_output = run_brewlog( let roaster_output = run_brewlog(
&["add-roaster", "--name", "Test Roasters", "--country", "UK"], &[
"add-roaster",
"--name",
"Test Roasters List",
"--country",
"UK",
],
&[("BREWLOG_TOKEN", &token)], &[("BREWLOG_TOKEN", &token)],
); );

View file

@ -97,7 +97,6 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
}, },
) )
.await; .await;
let roaster_id = roaster.id;
sleep(Duration::from_millis(10)).await; sleep(Duration::from_millis(10)).await;
@ -119,7 +118,7 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
"Expected roaster name to appear in timeline HTML, got: {body}" "Expected roaster name to appear in timeline HTML, got: {body}"
); );
assert!( assert!(
body.contains(&format!("/roasters/{}", roaster_id)), body.contains(&format!("/roasters/{}", roaster.slug)),
"Expected roaster detail link in timeline HTML, got: {body}" "Expected roaster detail link in timeline HTML, got: {body}"
); );
} }