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.
This commit is contained in:
Jon Seager 2026-02-03 14:52:44 +00:00
parent c6ad6a02c7
commit 677b77d70d
No known key found for this signature in database
5 changed files with 443 additions and 1 deletions

View file

@ -7,8 +7,9 @@ use serde_json::{from_str, to_string};
use crate::domain::bags::Bag; use crate::domain::bags::Bag;
use crate::domain::brews::Brew; use crate::domain::brews::Brew;
use crate::domain::cafes::Cafe;
use crate::domain::gear::{Gear, GearCategory}; 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::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail};
@ -23,6 +24,8 @@ pub struct BackupData {
pub roasts: Vec<Roast>, pub roasts: Vec<Roast>,
pub bags: Vec<Bag>, pub bags: Vec<Bag>,
pub brews: Vec<Brew>, pub brews: Vec<Brew>,
#[serde(default)]
pub cafes: Vec<Cafe>,
pub timeline_events: Vec<TimelineEvent>, pub timeline_events: Vec<TimelineEvent>,
} }
@ -41,6 +44,7 @@ impl BackupService {
let roasts = self.export_roasts().await?; let roasts = self.export_roasts().await?;
let bags = self.export_bags().await?; let bags = self.export_bags().await?;
let brews = self.export_brews().await?; let brews = self.export_brews().await?;
let cafes = self.export_cafes().await?;
let timeline_events = self.export_timeline_events().await?; let timeline_events = self.export_timeline_events().await?;
Ok(BackupData { Ok(BackupData {
@ -51,6 +55,7 @@ impl BackupService {
roasts, roasts,
bags, bags,
brews, brews,
cafes,
timeline_events, timeline_events,
}) })
} }
@ -69,6 +74,7 @@ impl BackupService {
self.restore_roasts(&mut tx, &data.roasts).await?; self.restore_roasts(&mut tx, &data.roasts).await?;
self.restore_bags(&mut tx, &data.bags).await?; self.restore_bags(&mut tx, &data.bags).await?;
self.restore_brews(&mut tx, &data.brews).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) self.restore_timeline_events(&mut tx, &data.timeline_events)
.await?; .await?;
@ -143,6 +149,17 @@ impl BackupService {
Ok(records.into_iter().map(BrewRecord::into_domain).collect()) Ok(records.into_iter().map(BrewRecord::into_domain).collect())
} }
async fn export_cafes(&self) -> anyhow::Result<Vec<Cafe>> {
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<Vec<TimelineEvent>> { async fn export_timeline_events(&self) -> anyhow::Result<Vec<TimelineEvent>> {
let records = sqlx::query_as::<_, TimelineEventRecord>( 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", "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", "bags",
"gear", "gear",
"brews", "brews",
"cafes",
"timeline_events", "timeline_events",
]; ];
@ -325,6 +343,34 @@ impl BackupService {
Ok(()) 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( async fn restore_timeline_events(
&self, &self,
tx: &mut DatabaseTransaction<'_>, 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<String>,
notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
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)] #[derive(sqlx::FromRow)]
struct TimelineEventRecord { struct TimelineEventRecord {
id: i64, id: i64,

View file

@ -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<Cafe> {
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<Vec<Cafe>> {
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<Cafe> {
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<Cafe> {
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),
}
}
}

View file

@ -1,5 +1,6 @@
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes;
pub mod gear; pub mod gear;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
@ -66,6 +67,10 @@ impl BrewlogClient {
brews::BrewsClient::new(self) brews::BrewsClient::new(self)
} }
pub fn cafes(&self) -> cafes::CafesClient<'_> {
cafes::CafesClient::new(self)
}
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> { pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
self.base_url self.base_url
.join(path) .join(path)

View file

@ -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<CafeSortKey>) -> 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<String, RepositoryError> {
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<Cafe, RepositoryError> {
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::<Option<&str>>(None)
.bind(&cafe.slug)
.bind::<Option<&str>>(None)
.bind::<Option<&str>>(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<Cafe, RepositoryError> {
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<Cafe, RepositoryError> {
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<CafeSortKey>,
search: Option<&str>,
) -> Result<Page<Cafe>, 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<Cafe, RepositoryError> {
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<String>,
notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}

View file

@ -1,5 +1,6 @@
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes;
pub mod gear; pub mod gear;
mod macros; mod macros;
pub mod pagination; pub mod pagination;