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!(GearId);
|
||||||
define_id!(BrewId);
|
define_id!(BrewId);
|
||||||
define_id!(CafeId);
|
define_id!(CafeId);
|
||||||
|
define_id!(CupId);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue