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
This commit is contained in:
Jon Seager 2026-02-03 16:35:56 +00:00
parent 5e2ee63a1e
commit ac7a4f9cf9
No known key found for this signature in database
5 changed files with 170 additions and 1 deletions

View file

@ -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);

112
src/domain/cups.rs Normal file
View file

@ -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<String>,
pub rating: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
pub rating: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateCup {
pub notes: Option<String>,
pub rating: Option<i32>,
}
/// Filter criteria for cup queries.
#[derive(Debug, Default, Clone)]
pub struct CupFilter {
pub cafe_id: Option<CafeId>,
pub roast_id: Option<RoastId>,
}
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<Self> {
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,
}
}
}

View file

@ -58,3 +58,4 @@ define_id!(BagId);
define_id!(GearId); define_id!(GearId);
define_id!(BrewId); define_id!(BrewId);
define_id!(CafeId); define_id!(CafeId);
define_id!(CupId);

View file

@ -1,6 +1,7 @@
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes; pub mod cafes;
pub mod cups;
pub mod errors; pub mod errors;
pub mod gear; pub mod gear;
pub mod ids; pub mod ids;

View file

@ -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::bags::{Bag, BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew};
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; 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::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear};
use crate::domain::ids::{ 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::RoasterSortKey;
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
@ -203,3 +204,18 @@ pub trait CafeRepository: Send + Sync {
Ok(page.items) Ok(page.items)
} }
} }
#[async_trait]
pub trait CupRepository: Send + Sync {
async fn insert(&self, cup: NewCup) -> Result<Cup, RepositoryError>;
async fn get(&self, id: CupId) -> Result<Cup, RepositoryError>;
async fn get_with_details(&self, id: CupId) -> Result<CupWithDetails, RepositoryError>;
async fn list(
&self,
filter: CupFilter,
request: &ListRequest<CupSortKey>,
search: Option<&str>,
) -> Result<Page<CupWithDetails>, RepositoryError>;
async fn update(&self, id: CupId, changes: UpdateCup) -> Result<Cup, RepositoryError>;
async fn delete(&self, id: CupId) -> Result<(), RepositoryError>;
}