- Add migration 0016 to drop notes column from roasters table - Remove notes from domain structs, repositories, views, CLI, routes - Remove notes from AI extraction prompts and ExtractedRoaster struct - Remove notes from scan page form and submission handler - Update templates, bootstrap script, backup module, and tests
271 lines
8.8 KiB
Rust
271 lines
8.8 KiB
Rust
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{QueryBuilder, query, query_as};
|
|
|
|
use super::macros::push_update_field;
|
|
use crate::domain::RepositoryError;
|
|
use crate::domain::ids::RoasterId;
|
|
use crate::domain::listing::{ListRequest, Page, SortDirection};
|
|
use crate::domain::repositories::RoasterRepository;
|
|
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
|
use crate::domain::timeline::TimelineEventDetail;
|
|
use crate::infrastructure::database::DatabasePool;
|
|
|
|
#[derive(Clone)]
|
|
pub struct SqlRoasterRepository {
|
|
pool: DatabasePool,
|
|
}
|
|
|
|
impl SqlRoasterRepository {
|
|
pub fn new(pool: DatabasePool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
fn order_clause(request: &ListRequest<RoasterSortKey>) -> String {
|
|
let dir_sql = match request.sort_direction() {
|
|
SortDirection::Asc => "ASC",
|
|
SortDirection::Desc => "DESC",
|
|
};
|
|
|
|
match request.sort_key() {
|
|
RoasterSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"),
|
|
RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"),
|
|
RoasterSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"),
|
|
RoasterSortKey::City => format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC"),
|
|
}
|
|
}
|
|
|
|
fn into_domain(record: RoasterRecord) -> Roaster {
|
|
let RoasterRecord {
|
|
id,
|
|
name,
|
|
slug,
|
|
country,
|
|
city,
|
|
homepage,
|
|
created_at,
|
|
} = record;
|
|
|
|
Roaster {
|
|
id: RoasterId::from(id),
|
|
name,
|
|
slug,
|
|
country,
|
|
city,
|
|
homepage,
|
|
created_at,
|
|
}
|
|
}
|
|
|
|
fn details_for_roaster(roaster: &Roaster) -> Result<String, RepositoryError> {
|
|
let homepage_value = roaster
|
|
.homepage
|
|
.as_ref()
|
|
.filter(|value| !value.is_empty())
|
|
.cloned()
|
|
.unwrap_or_else(|| "—".to_string());
|
|
|
|
let details = vec![
|
|
TimelineEventDetail {
|
|
label: "Country".to_string(),
|
|
value: roaster.country.clone(),
|
|
},
|
|
TimelineEventDetail {
|
|
label: "City".to_string(),
|
|
value: roaster
|
|
.city
|
|
.as_ref()
|
|
.filter(|value| !value.is_empty())
|
|
.cloned()
|
|
.unwrap_or_else(|| "—".to_string()),
|
|
},
|
|
TimelineEventDetail {
|
|
label: "Homepage".to_string(),
|
|
value: homepage_value,
|
|
},
|
|
];
|
|
|
|
serde_json::to_string(&details).map_err(|err| {
|
|
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RoasterRepository for SqlRoasterRepository {
|
|
async fn insert(&self, new_roaster: NewRoaster) -> Result<Roaster, RepositoryError> {
|
|
let mut tx = self
|
|
.pool
|
|
.begin()
|
|
.await
|
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
|
|
|
let new_roaster = new_roaster.normalize();
|
|
let slug = new_roaster.slug();
|
|
let created_at = Utc::now();
|
|
|
|
let record = query_as::<_, RoasterRecord>(
|
|
"INSERT INTO roasters (name, slug, country, city, homepage, created_at) VALUES (?, ?, ?, ?, ?, ?)\
|
|
RETURNING id, name, slug, country, city, homepage, created_at",
|
|
)
|
|
.bind(&new_roaster.name)
|
|
.bind(&slug)
|
|
.bind(&new_roaster.country)
|
|
.bind(new_roaster.city.as_deref())
|
|
.bind(new_roaster.homepage.as_deref())
|
|
.bind(created_at)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.map_err(|err| {
|
|
if let sqlx::Error::Database(db_err) = &err
|
|
&& db_err.is_unique_violation()
|
|
{
|
|
return RepositoryError::conflict(
|
|
"A roaster with this name and city already exists",
|
|
);
|
|
}
|
|
RepositoryError::unexpected(err.to_string())
|
|
})?;
|
|
|
|
let roaster = Self::into_domain(record);
|
|
let details_json = Self::details_for_roaster(&roaster)?;
|
|
|
|
query(
|
|
"INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind("roaster")
|
|
.bind(i64::from(roaster.id))
|
|
.bind("added")
|
|
.bind(roaster.created_at)
|
|
.bind(&roaster.name)
|
|
.bind(details_json)
|
|
.bind::<Option<&str>>(None)
|
|
.bind(&roaster.slug) // slug = roaster's own slug
|
|
.bind::<Option<&str>>(None) // roaster_slug not applicable for roaster events
|
|
.bind::<Option<&str>>(None) // brew_data_json not applicable
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
|
|
|
Ok(roaster)
|
|
}
|
|
|
|
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {
|
|
let record = query_as::<_, RoasterRecord>(
|
|
"SELECT id, name, slug, country, city, homepage, created_at FROM roasters WHERE id = ?",
|
|
)
|
|
.bind(i64::from(id))
|
|
.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 get_by_slug(&self, slug: &str) -> Result<Roaster, RepositoryError> {
|
|
let record = query_as::<_, RoasterRecord>(
|
|
"SELECT id, name, slug, country, city, homepage, 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(
|
|
&self,
|
|
request: &ListRequest<RoasterSortKey>,
|
|
search: Option<&str>,
|
|
) -> Result<Page<Roaster>, RepositoryError> {
|
|
use crate::infrastructure::repositories::pagination::SearchFilter;
|
|
|
|
let order_clause = Self::order_clause(request);
|
|
let base_query = "SELECT id, name, slug, country, city, homepage, created_at FROM roasters";
|
|
let count_query = "SELECT COUNT(*) FROM roasters";
|
|
let sf =
|
|
search.and_then(|t| SearchFilter::new(t, vec!["name", "country", "COALESCE(city,'')"]));
|
|
|
|
crate::infrastructure::repositories::pagination::paginate(
|
|
&self.pool,
|
|
request,
|
|
base_query,
|
|
count_query,
|
|
&order_clause,
|
|
sf.as_ref(),
|
|
|record| Ok(Self::into_domain(record)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
id: RoasterId,
|
|
changes: UpdateRoaster,
|
|
) -> Result<Roaster, RepositoryError> {
|
|
let mut builder = QueryBuilder::new("UPDATE roasters SET ");
|
|
let mut sep = false;
|
|
|
|
push_update_field!(builder, sep, "name", changes.name);
|
|
push_update_field!(builder, sep, "country", changes.country);
|
|
push_update_field!(builder, sep, "city", changes.city);
|
|
push_update_field!(builder, sep, "homepage", changes.homepage);
|
|
|
|
if !sep {
|
|
return Err(RepositoryError::unexpected(
|
|
"No fields provided for update".to_string(),
|
|
));
|
|
}
|
|
|
|
builder.push(" WHERE id = ");
|
|
builder.push_bind(i64::from(id));
|
|
|
|
let result = builder
|
|
.build()
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(RepositoryError::NotFound);
|
|
}
|
|
|
|
self.get(id).await
|
|
}
|
|
|
|
async fn delete(&self, id: RoasterId) -> Result<(), RepositoryError> {
|
|
let result = query("DELETE FROM roasters WHERE id = ?")
|
|
.bind(i64::from(id))
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(RepositoryError::NotFound);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct RoasterRecord {
|
|
id: i64,
|
|
name: String,
|
|
slug: String,
|
|
country: String,
|
|
city: Option<String>,
|
|
homepage: Option<String>,
|
|
created_at: DateTime<Utc>,
|
|
}
|