feat(cafes): add migration and domain layer for cafe entity

Add cafes table with name, slug, city, country, latitude, longitude,
website, and notes fields. Define domain types (Cafe, NewCafe, UpdateCafe),
CafeId typed wrapper, CafeRepository trait, and CafeSortKey enum.
This commit is contained in:
Jon Seager 2026-02-03 14:52:37 +00:00
parent f1e8984ffa
commit c6ad6a02c7
No known key found for this signature in database
5 changed files with 187 additions and 1 deletions

View file

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

108
src/domain/cafes.rs Normal file
View file

@ -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<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
pub notes: Option<String>,
}
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<String>) -> Option<String> {
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<String>,
pub city: Option<String>,
pub country: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub website: Option<String>,
pub notes: Option<String>,
}
#[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<Self> {
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,
}
}
}

View file

@ -57,3 +57,4 @@ define_id!(SessionId);
define_id!(BagId); define_id!(BagId);
define_id!(GearId); define_id!(GearId);
define_id!(BrewId); define_id!(BrewId);
define_id!(CafeId);

View file

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

View file

@ -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::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::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; 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::RoasterSortKey;
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
@ -169,3 +172,34 @@ pub trait BrewRepository: Send + Sync {
) -> Result<Page<BrewWithDetails>, RepositoryError>; ) -> Result<Page<BrewWithDetails>, RepositoryError>;
async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>; async fn delete(&self, id: BrewId) -> Result<(), RepositoryError>;
} }
#[async_trait]
pub trait CafeRepository: Send + Sync {
async fn insert(&self, cafe: NewCafe) -> Result<Cafe, RepositoryError>;
async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Cafe, RepositoryError>;
async fn list(
&self,
request: &ListRequest<CafeSortKey>,
search: Option<&str>,
) -> Result<Page<Cafe>, RepositoryError>;
async fn update(&self, id: CafeId, changes: UpdateCafe) -> Result<Cafe, RepositoryError>;
async fn delete(&self, id: CafeId) -> Result<(), RepositoryError>;
async fn list_all(&self) -> Result<Vec<Cafe>, RepositoryError> {
let sort_key = <CafeSortKey as SortKey>::default();
let request = ListRequest::<CafeSortKey>::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<Vec<Cafe>, RepositoryError> {
let request = ListRequest::show_all(sort_key, direction);
let page = self.list(&request, None).await?;
Ok(page.items)
}
}