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) + } +}