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:
parent
5e2ee63a1e
commit
ac7a4f9cf9
5 changed files with 170 additions and 1 deletions
39
migrations/0013_add_cups.sql
Normal file
39
migrations/0013_add_cups.sql
Normal 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
112
src/domain/cups.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,3 +58,4 @@ define_id!(BagId);
|
|||
define_id!(GearId);
|
||||
define_id!(BrewId);
|
||||
define_id!(CafeId);
|
||||
define_id!(CupId);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue