From ac7a4f9cf9cee9e4209336fe12ec1e6491d7d09d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 16:35:56 +0000 Subject: [PATCH] feat(cups): add migration and domain layer for cup entity - Create cups table with roast_id/cafe_id FKs, optional notes and rating - Update timeline_events CHECK constraint to include 'cup' entity type - Add CupId typed wrapper, Cup/CupWithDetails/NewCup/UpdateCup structs - Add CupFilter, CupSortKey, and CupRepository trait --- migrations/0013_add_cups.sql | 39 ++++++++++++ src/domain/cups.rs | 112 +++++++++++++++++++++++++++++++++++ src/domain/ids.rs | 1 + src/domain/mod.rs | 1 + src/domain/repositories.rs | 18 +++++- 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 migrations/0013_add_cups.sql create mode 100644 src/domain/cups.rs diff --git a/migrations/0013_add_cups.sql b/migrations/0013_add_cups.sql new file mode 100644 index 0000000..5d02837 --- /dev/null +++ b/migrations/0013_add_cups.sql @@ -0,0 +1,39 @@ +-- Add cups table for tracking roasts consumed at cafes +CREATE TABLE cups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + roast_id INTEGER NOT NULL REFERENCES roasts(id) ON DELETE RESTRICT, + cafe_id INTEGER NOT NULL REFERENCES cafes(id) ON DELETE RESTRICT, + notes TEXT, + rating INTEGER CHECK (rating BETWEEN 1 AND 5), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_cups_roast_id ON cups(roast_id); +CREATE INDEX idx_cups_cafe_id ON cups(cafe_id); + +-- Update timeline_events CHECK constraint to include 'cup' +-- 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', 'cup')), + 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/cups.rs b/src/domain/cups.rs new file mode 100644 index 0000000..491019b --- /dev/null +++ b/src/domain/cups.rs @@ -0,0 +1,112 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::ids::{CafeId, CupId, RoastId}; +use super::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cup { + pub id: CupId, + pub roast_id: RoastId, + pub cafe_id: CafeId, + pub notes: Option, + pub rating: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CupWithDetails { + #[serde(flatten)] + pub cup: Cup, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, + pub cafe_name: String, + pub cafe_slug: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewCup { + pub roast_id: RoastId, + pub cafe_id: CafeId, + pub notes: Option, + pub rating: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateCup { + pub notes: Option, + pub rating: Option, +} + +/// Filter criteria for cup queries. +#[derive(Debug, Default, Clone)] +pub struct CupFilter { + pub cafe_id: Option, + pub roast_id: Option, +} + +impl CupFilter { + /// No filter - returns all cups. + pub fn all() -> Self { + Self::default() + } + + /// Filter for cups at a specific cafe. + pub fn for_cafe(cafe_id: CafeId) -> Self { + Self { + cafe_id: Some(cafe_id), + ..Self::default() + } + } + + /// Filter for cups of a specific roast. + pub fn for_roast(roast_id: RoastId) -> Self { + Self { + roast_id: Some(roast_id), + ..Self::default() + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CupSortKey { + CreatedAt, + CafeName, + RoastName, + Rating, +} + +impl SortKey for CupSortKey { + fn default() -> Self { + CupSortKey::CreatedAt + } + + fn from_query(value: &str) -> Option { + match value { + "created-at" => Some(CupSortKey::CreatedAt), + "cafe" => Some(CupSortKey::CafeName), + "roast" => Some(CupSortKey::RoastName), + "rating" => Some(CupSortKey::Rating), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + CupSortKey::CreatedAt => "created-at", + CupSortKey::CafeName => "cafe", + CupSortKey::RoastName => "roast", + CupSortKey::Rating => "rating", + } + } + + fn default_direction(self) -> SortDirection { + match self { + CupSortKey::CreatedAt | CupSortKey::Rating => SortDirection::Desc, + _ => SortDirection::Asc, + } + } +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 80ca1cf..cf7531c 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -58,3 +58,4 @@ define_id!(BagId); define_id!(GearId); define_id!(BrewId); define_id!(CafeId); +define_id!(CupId); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index a98904f..63551ed 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,6 +1,7 @@ pub mod bags; pub mod brews; pub mod cafes; +pub mod cups; pub mod errors; pub mod gear; pub mod ids; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index dd95bf9..2c59ca7 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -4,9 +4,10 @@ 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::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::{ - BagId, BrewId, CafeId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId, + BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId, }; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; @@ -203,3 +204,18 @@ pub trait CafeRepository: Send + Sync { Ok(page.items) } } + +#[async_trait] +pub trait CupRepository: Send + Sync { + async fn insert(&self, cup: NewCup) -> Result; + async fn get(&self, id: CupId) -> Result; + async fn get_with_details(&self, id: CupId) -> Result; + async fn list( + &self, + filter: CupFilter, + request: &ListRequest, + search: Option<&str>, + ) -> Result, RepositoryError>; + async fn update(&self, id: CupId, changes: UpdateCup) -> Result; + async fn delete(&self, id: CupId) -> Result<(), RepositoryError>; +}