From 677b77d70dc9b6e15eb4b3e947c71f99d007207d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 14:52:44 +0000 Subject: [PATCH] feat(cafes): add SQL repository, HTTP client, and backup support Implement SqlCafeRepository with CRUD operations and timeline event creation. Add CafesClient for CLI HTTP access. Wire cafes into backup export/restore and empty-database verification. --- src/infrastructure/backup.rs | 81 ++++++- src/infrastructure/client/cafes.rs | 82 +++++++ src/infrastructure/client/mod.rs | 5 + src/infrastructure/repositories/cafes.rs | 275 +++++++++++++++++++++++ src/infrastructure/repositories/mod.rs | 1 + 5 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/infrastructure/client/cafes.rs create mode 100644 src/infrastructure/repositories/cafes.rs diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index 97e3b26..00b2cc4 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -7,8 +7,9 @@ use serde_json::{from_str, to_string}; use crate::domain::bags::Bag; use crate::domain::brews::Brew; +use crate::domain::cafes::Cafe; use crate::domain::gear::{Gear, GearCategory}; -use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, TimelineEventId}; +use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId}; use crate::domain::roasters::Roaster; use crate::domain::roasts::Roast; use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail}; @@ -23,6 +24,8 @@ pub struct BackupData { pub roasts: Vec, pub bags: Vec, pub brews: Vec, + #[serde(default)] + pub cafes: Vec, pub timeline_events: Vec, } @@ -41,6 +44,7 @@ impl BackupService { let roasts = self.export_roasts().await?; let bags = self.export_bags().await?; let brews = self.export_brews().await?; + let cafes = self.export_cafes().await?; let timeline_events = self.export_timeline_events().await?; Ok(BackupData { @@ -51,6 +55,7 @@ impl BackupService { roasts, bags, brews, + cafes, timeline_events, }) } @@ -69,6 +74,7 @@ impl BackupService { self.restore_roasts(&mut tx, &data.roasts).await?; self.restore_bags(&mut tx, &data.bags).await?; self.restore_brews(&mut tx, &data.brews).await?; + self.restore_cafes(&mut tx, &data.cafes).await?; self.restore_timeline_events(&mut tx, &data.timeline_events) .await?; @@ -143,6 +149,17 @@ impl BackupService { Ok(records.into_iter().map(BrewRecord::into_domain).collect()) } + async fn export_cafes(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, CafeRecord>( + "SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export cafes")?; + + Ok(records.into_iter().map(CafeRecord::into_domain).collect()) + } + async fn export_timeline_events(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, TimelineEventRecord>( "SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id", @@ -166,6 +183,7 @@ impl BackupService { "bags", "gear", "brews", + "cafes", "timeline_events", ]; @@ -325,6 +343,34 @@ impl BackupService { Ok(()) } + async fn restore_cafes( + &self, + tx: &mut DatabaseTransaction<'_>, + cafes: &[Cafe], + ) -> anyhow::Result<()> { + for cafe in cafes { + sqlx::query( + "INSERT INTO cafes (id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(cafe.id)) + .bind(&cafe.name) + .bind(&cafe.slug) + .bind(&cafe.city) + .bind(&cafe.country) + .bind(cafe.latitude) + .bind(cafe.longitude) + .bind(cafe.website.as_deref()) + .bind(cafe.notes.as_deref()) + .bind(cafe.created_at) + .bind(cafe.updated_at) + .execute(&mut **tx) + .await + .context("failed to restore cafe")?; + } + + Ok(()) + } + async fn restore_timeline_events( &self, tx: &mut DatabaseTransaction<'_>, @@ -521,6 +567,39 @@ impl BrewRecord { } } +#[derive(sqlx::FromRow)] +struct CafeRecord { + id: i64, + name: String, + slug: String, + city: String, + country: String, + latitude: f64, + longitude: f64, + website: Option, + notes: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl CafeRecord { + fn into_domain(self) -> Cafe { + Cafe { + id: CafeId::from(self.id), + name: self.name, + slug: self.slug, + city: self.city, + country: self.country, + latitude: self.latitude, + longitude: self.longitude, + website: self.website, + notes: self.notes, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + #[derive(sqlx::FromRow)] struct TimelineEventRecord { id: i64, diff --git a/src/infrastructure/client/cafes.rs b/src/infrastructure/client/cafes.rs new file mode 100644 index 0000000..e361507 --- /dev/null +++ b/src/infrastructure/client/cafes.rs @@ -0,0 +1,82 @@ +use anyhow::{Context, Result}; +use reqwest::StatusCode; + +use crate::domain::cafes::{Cafe, NewCafe, UpdateCafe}; +use crate::domain::ids::CafeId; + +use super::BrewlogClient; + +pub struct CafesClient<'a> { + inner: &'a BrewlogClient, +} + +impl<'a> CafesClient<'a> { + pub(crate) fn new(inner: &'a BrewlogClient) -> Self { + Self { inner } + } + + pub async fn create(&self, payload: &NewCafe) -> Result { + let url = self.inner.endpoint("api/v1/cafes")?; + let response = self + .inner + .request(reqwest::Method::POST, url) + .json(payload) + .send() + .await + .context("failed to issue create cafe request")?; + + self.inner.handle_response(response).await + } + + pub async fn list(&self) -> Result> { + let url = self.inner.endpoint("api/v1/cafes")?; + let response = self + .inner + .request(reqwest::Method::GET, url) + .send() + .await + .context("failed to issue list cafes request")?; + + self.inner.handle_response(response).await + } + + pub async fn get(&self, id: CafeId) -> Result { + let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?; + let response = self + .inner + .request(reqwest::Method::GET, url) + .send() + .await + .context("failed to issue get cafe request")?; + + self.inner.handle_response(response).await + } + + pub async fn update(&self, id: CafeId, payload: &UpdateCafe) -> Result { + let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?; + let response = self + .inner + .request(reqwest::Method::PUT, url) + .json(payload) + .send() + .await + .context("failed to issue update cafe request")?; + + self.inner.handle_response(response).await + } + + pub async fn delete(&self, id: CafeId) -> Result<()> { + let url = self.inner.endpoint(&format!("api/v1/cafes/{id}"))?; + let response = self + .inner + .request(reqwest::Method::DELETE, url) + .send() + .await + .context("failed to issue delete cafe request")?; + + match response.status() { + StatusCode::NO_CONTENT => Ok(()), + _ => Err(self.inner.response_error(response).await), + } + } +} diff --git a/src/infrastructure/client/mod.rs b/src/infrastructure/client/mod.rs index 962b898..6b0698b 100644 --- a/src/infrastructure/client/mod.rs +++ b/src/infrastructure/client/mod.rs @@ -1,5 +1,6 @@ pub mod bags; pub mod brews; +pub mod cafes; pub mod gear; pub mod roasters; pub mod roasts; @@ -66,6 +67,10 @@ impl BrewlogClient { brews::BrewsClient::new(self) } + pub fn cafes(&self) -> cafes::CafesClient<'_> { + cafes::CafesClient::new(self) + } + pub(crate) fn endpoint(&self, path: &str) -> Result { self.base_url .join(path) diff --git a/src/infrastructure/repositories/cafes.rs b/src/infrastructure/repositories/cafes.rs new file mode 100644 index 0000000..0fa0b03 --- /dev/null +++ b/src/infrastructure/repositories/cafes.rs @@ -0,0 +1,275 @@ +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::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; +use crate::domain::ids::CafeId; +use crate::domain::listing::{ListRequest, Page, SortDirection}; +use crate::domain::repositories::CafeRepository; +use crate::domain::timeline::TimelineEventDetail; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlCafeRepository { + pool: DatabasePool, +} + +impl SqlCafeRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn order_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + match request.sort_key() { + CafeSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"), + CafeSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), + CafeSortKey::City => format!("LOWER(city) {dir_sql}, LOWER(name) ASC"), + CafeSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"), + } + } + + fn into_domain(record: CafeRecord) -> Cafe { + let CafeRecord { + id, + name, + slug, + city, + country, + latitude, + longitude, + website, + notes, + created_at, + updated_at, + } = record; + + Cafe { + id: CafeId::from(id), + name, + slug, + city, + country, + latitude, + longitude, + website, + notes, + created_at, + updated_at, + } + } + + fn details_for_cafe(cafe: &Cafe) -> Result { + let website_value = cafe + .website + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "—".to_string()); + + let details = vec![ + TimelineEventDetail { + label: "City".to_string(), + value: cafe.city.clone(), + }, + TimelineEventDetail { + label: "Country".to_string(), + value: cafe.country.clone(), + }, + TimelineEventDetail { + label: "Website".to_string(), + value: website_value, + }, + ]; + + serde_json::to_string(&details).map_err(|err| { + RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) + }) + } +} + +#[async_trait] +impl CafeRepository for SqlCafeRepository { + async fn insert(&self, new_cafe: NewCafe) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let new_cafe = new_cafe.normalize(); + let slug = new_cafe.slug(); + let now = Utc::now(); + + let record = query_as::<_, CafeRecord>( + "INSERT INTO cafes (name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\ + RETURNING id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at", + ) + .bind(&new_cafe.name) + .bind(&slug) + .bind(&new_cafe.city) + .bind(&new_cafe.country) + .bind(new_cafe.latitude) + .bind(new_cafe.longitude) + .bind(new_cafe.website.as_deref()) + .bind(new_cafe.notes.as_deref()) + .bind(now) + .bind(now) + .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 cafe with this name and city already exists", + ); + } + RepositoryError::unexpected(err.to_string()) + })?; + + let cafe = Self::into_domain(record); + let details_json = Self::details_for_cafe(&cafe)?; + + 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("cafe") + .bind(i64::from(cafe.id)) + .bind("added") + .bind(cafe.created_at) + .bind(&cafe.name) + .bind(details_json) + .bind::>(None) + .bind(&cafe.slug) + .bind::>(None) + .bind::>(None) + .execute(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + tx.commit() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(cafe) + } + + async fn get(&self, id: CafeId) -> Result { + let record = query_as::<_, CafeRecord>( + "SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes 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 { + let record = query_as::<_, CafeRecord>( + "SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes 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, + search: Option<&str>, + ) -> Result, RepositoryError> { + use crate::infrastructure::repositories::pagination::SearchFilter; + + let order_clause = Self::order_clause(request); + let base_query = "SELECT id, name, slug, city, country, latitude, longitude, website, notes, created_at, updated_at FROM cafes"; + let count_query = "SELECT COUNT(*) FROM cafes"; + let sf = search.and_then(|t| SearchFilter::new(t, vec!["name", "city", "country"])); + + 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: CafeId, changes: UpdateCafe) -> Result { + let mut builder = QueryBuilder::new("UPDATE cafes SET updated_at = CURRENT_TIMESTAMP"); + let mut sep = true; + + push_update_field!(builder, sep, "name", changes.name); + push_update_field!(builder, sep, "city", changes.city); + push_update_field!(builder, sep, "country", changes.country); + push_update_field!(builder, sep, "latitude", changes.latitude); + push_update_field!(builder, sep, "longitude", changes.longitude); + push_update_field!(builder, sep, "website", changes.website); + push_update_field!(builder, sep, "notes", changes.notes); + let _ = sep; + + 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: CafeId) -> Result<(), RepositoryError> { + let result = query("DELETE FROM cafes 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 CafeRecord { + id: i64, + name: String, + slug: String, + city: String, + country: String, + latitude: f64, + longitude: f64, + website: Option, + notes: Option, + created_at: DateTime, + updated_at: DateTime, +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index e771d0a..e65ba71 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,5 +1,6 @@ pub mod bags; pub mod brews; +pub mod cafes; pub mod gear; mod macros; pub mod pagination;