From c6ad6a02c7492093e271eb5a070a18ef9626b600 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 14:52:37 +0000 Subject: [PATCH] feat(cafes): add migration and domain layer for cafe entity Add cafes table with name, slug, city, country, latitude, longitude, website, and notes fields. Define domain types (Cafe, NewCafe, UpdateCafe), CafeId typed wrapper, CafeRepository trait, and CafeSortKey enum. --- migrations/0012_add_cafes.sql | 42 +++++++++++++ src/domain/cafes.rs | 108 ++++++++++++++++++++++++++++++++++ src/domain/ids.rs | 1 + src/domain/mod.rs | 1 + src/domain/repositories.rs | 36 +++++++++++- 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 migrations/0012_add_cafes.sql create mode 100644 src/domain/cafes.rs diff --git a/migrations/0012_add_cafes.sql b/migrations/0012_add_cafes.sql new file mode 100644 index 0000000..e485b20 --- /dev/null +++ b/migrations/0012_add_cafes.sql @@ -0,0 +1,42 @@ +-- Add cafes table for tracking coffee shops +CREATE TABLE cafes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + website TEXT, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_cafes_slug ON cafes(slug); + +-- Update timeline_events CHECK constraint to include 'cafe' +-- SQLite requires recreating the table to modify CHECK constraints +DROP TABLE IF EXISTS timeline_events_new; +CREATE TABLE timeline_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast', 'bag', 'gear', 'brew', 'cafe')), + entity_id INTEGER NOT NULL, + action TEXT NOT NULL, + occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + title TEXT NOT NULL, + details_json TEXT, + tasting_notes_json TEXT, + slug TEXT, + roaster_slug TEXT, + brew_data_json TEXT +); + +INSERT INTO timeline_events_new (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) +SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json +FROM timeline_events; + +DROP TABLE timeline_events; +ALTER TABLE timeline_events_new RENAME TO timeline_events; +CREATE INDEX idx_timeline_events_entity ON timeline_events(entity_type, entity_id); +CREATE INDEX idx_timeline_events_occurred_at ON timeline_events(occurred_at DESC); diff --git a/src/domain/cafes.rs b/src/domain/cafes.rs new file mode 100644 index 0000000..c25389c --- /dev/null +++ b/src/domain/cafes.rs @@ -0,0 +1,108 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::CafeId; +use crate::domain::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cafe { + pub id: CafeId, + pub name: String, + pub slug: String, + pub city: String, + pub country: String, + pub latitude: f64, + pub longitude: f64, + pub website: Option, + pub notes: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewCafe { + pub name: String, + pub city: String, + pub country: String, + pub latitude: f64, + pub longitude: f64, + pub website: Option, + pub notes: Option, +} + +impl NewCafe { + pub fn normalize(mut self) -> Self { + self.name = self.name.trim().to_string(); + self.city = self.city.trim().to_string(); + self.country = self.country.trim().to_string(); + self.website = normalize_optional_field(self.website); + self.notes = normalize_optional_field(self.notes); + self + } + + pub fn slug(&self) -> String { + slug::slugify(format!("{}-{}", self.name, self.city)) + } +} + +fn normalize_optional_field(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateCafe { + pub name: Option, + pub city: Option, + pub country: Option, + pub latitude: Option, + pub longitude: Option, + pub website: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CafeSortKey { + CreatedAt, + Name, + City, + Country, +} + +impl SortKey for CafeSortKey { + fn default() -> Self { + CafeSortKey::CreatedAt + } + + fn from_query(value: &str) -> Option { + match value { + "created-at" => Some(CafeSortKey::CreatedAt), + "name" => Some(CafeSortKey::Name), + "city" => Some(CafeSortKey::City), + "country" => Some(CafeSortKey::Country), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + CafeSortKey::CreatedAt => "created-at", + CafeSortKey::Name => "name", + CafeSortKey::City => "city", + CafeSortKey::Country => "country", + } + } + + fn default_direction(self) -> SortDirection { + match self { + CafeSortKey::CreatedAt => SortDirection::Desc, + _ => SortDirection::Asc, + } + } +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 9b6348f..80ca1cf 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -57,3 +57,4 @@ define_id!(SessionId); define_id!(BagId); define_id!(GearId); define_id!(BrewId); +define_id!(CafeId); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index c845c46..a98904f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,5 +1,6 @@ pub mod bags; pub mod brews; +pub mod cafes; pub mod errors; pub mod gear; pub mod ids; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index ab01a96..dd95bf9 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -3,8 +3,11 @@ use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; use crate::domain::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; +use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; -use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId}; +use crate::domain::ids::{ + BagId, BrewId, CafeId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId, +}; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; @@ -169,3 +172,34 @@ pub trait BrewRepository: Send + Sync { ) -> Result, RepositoryError>; async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>; } + +#[async_trait] +pub trait CafeRepository: Send + Sync { + async fn insert(&self, cafe: NewCafe) -> Result; + async fn get(&self, id: CafeId) -> Result; + async fn get_by_slug(&self, slug: &str) -> Result; + async fn list( + &self, + request: &ListRequest, + search: Option<&str>, + ) -> Result, RepositoryError>; + async fn update(&self, id: CafeId, changes: UpdateCafe) -> Result; + async fn delete(&self, id: CafeId) -> Result<(), RepositoryError>; + + async fn list_all(&self) -> Result, RepositoryError> { + let sort_key = ::default(); + let request = ListRequest::::show_all(sort_key, sort_key.default_direction()); + let page = self.list(&request, None).await?; + Ok(page.items) + } + + async fn list_all_sorted( + &self, + sort_key: CafeSortKey, + direction: SortDirection, + ) -> Result, RepositoryError> { + let request = ListRequest::show_all(sort_key, direction); + let page = self.list(&request, None).await?; + Ok(page.items) + } +}