brewlog/src/domain/cups.rs
Jon Seager e01b6d0a1c
feat: add optional created_at to entity creation and updates
Add `created_at: Option<DateTime<Utc>>` through all layers so CLI users
can backdate entities at creation/update time. When omitted, falls back
to `Utc::now()`.

- Domain: add field to all New*/Update* structs with serde(default)
- Domain: timeline events use entity created_at instead of Utc::now()
- Repos: unify INSERT to explicit Rust-side created_at with unwrap_or_else
- Repos: add created_at to UPDATE dynamic query builders
- Routes: add field to submission structs and has_changes guards
- Clients: pass created_at through manual JSON client methods
- CLI: add --created-at flag with parse_created_at helper (RFC 3339 or YYYY-MM-DD)
2026-02-07 09:48:20 +00:00

139 lines
3.7 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::ids::{CafeId, CupId, RoastId};
use super::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cup {
pub id: CupId,
pub roast_id: RoastId,
pub cafe_id: CafeId,
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,
pub cafe_city: String,
}
impl CupWithDetails {
pub fn to_timeline_event(&self) -> NewTimelineEvent {
NewTimelineEvent {
entity_type: "cup".to_string(),
entity_id: self.cup.id.into_inner(),
action: "added".to_string(),
occurred_at: self.cup.created_at,
title: self.roast_name.clone(),
details: vec![
TimelineEventDetail {
label: "Coffee".to_string(),
value: self.roast_name.clone(),
},
TimelineEventDetail {
label: "Roaster".to_string(),
value: self.roaster_name.clone(),
},
TimelineEventDetail {
label: "Cafe".to_string(),
value: self.cafe_name.clone(),
},
],
tasting_notes: vec![],
slug: Some(self.roast_slug.clone()),
roaster_slug: Some(self.roaster_slug.clone()),
brew_data: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCup {
pub roast_id: RoastId,
pub cafe_id: CafeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
/// 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,
CafeCity,
RoastName,
RoasterName,
}
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),
"city" => Some(CupSortKey::CafeCity),
"roast" => Some(CupSortKey::RoastName),
"roaster" => Some(CupSortKey::RoasterName),
_ => None,
}
}
fn query_value(self) -> &'static str {
match self {
CupSortKey::CreatedAt => "created-at",
CupSortKey::CafeName => "cafe",
CupSortKey::CafeCity => "city",
CupSortKey::RoastName => "roast",
CupSortKey::RoasterName => "roaster",
}
}
fn default_direction(self) -> SortDirection {
match self {
CupSortKey::CreatedAt => SortDirection::Desc,
_ => SortDirection::Asc,
}
}
}