feat!: update to a more human friendly url structure for roasters/roasts
This commit is contained in:
parent
dd0f716437
commit
f88a880d07
18 changed files with 221 additions and 85 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
})?,
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RoastWithRoaster {
|
||||||
|
roast: Roast {
|
||||||
|
id: RoastId::from(self.id),
|
||||||
|
roaster_id: RoasterId::from(self.roaster_id),
|
||||||
name: self.name,
|
name: self.name,
|
||||||
|
slug: self.slug,
|
||||||
origin: self.origin,
|
origin: self.origin,
|
||||||
region: self.region,
|
region: self.region,
|
||||||
producer: self.producer,
|
producer: self.producer,
|
||||||
process: self.process,
|
process: self.process,
|
||||||
tasting_notes: self.tasting_notes,
|
tasting_notes,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
}
|
},
|
||||||
.into_roast()?;
|
roaster_name: self.roaster_name,
|
||||||
|
roaster_slug: self.roaster_slug,
|
||||||
Ok(RoastWithRoaster {
|
|
||||||
roast,
|
|
||||||
roaster_name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("#"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue