brewlog/src/domain/cafes.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

135 lines
3.6 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::ids::CafeId;
use crate::domain::listing::{SortDirection, SortKey};
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
#[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 created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Cafe {
pub fn to_timeline_event(&self) -> NewTimelineEvent {
NewTimelineEvent {
entity_type: "cafe".to_string(),
entity_id: self.id.into_inner(),
action: "added".to_string(),
occurred_at: self.created_at,
title: self.name.clone(),
details: vec![
TimelineEventDetail {
label: "City".to_string(),
value: self.city.clone(),
},
TimelineEventDetail {
label: "Country".to_string(),
value: self.country.clone(),
},
],
tasting_notes: vec![],
slug: Some(self.slug.clone()),
roaster_slug: None,
brew_data: None,
}
}
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
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
}
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
#[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,
}
}
}