brewlog/src/infrastructure/repositories/roasters.rs
Jon Seager 4f7d31d47a
refactor(roasters): remove notes field from Roaster entity
- 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
2026-02-03 21:18:48 +00:00

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>,
}