diff --git a/src/application/routes/api/bags.rs b/src/application/routes/api/bags.rs index 889c2e6..ba4cebf 100644 --- a/src/application/routes/api/bags.rs +++ b/src/application/routes/api/bags.rs @@ -2,6 +2,7 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use chrono::{DateTime, Utc}; use serde::Deserialize; use tracing::info; @@ -125,6 +126,7 @@ pub(crate) async fn update_bag( remaining: None, closed: None, finished_at: None, + created_at: None, }, |Json(p)| p, ); @@ -133,6 +135,7 @@ pub(crate) async fn update_bag( remaining: body_update.remaining.or(update_params.remaining), closed: body_update.closed.or(update_params.closed), finished_at: body_update.finished_at.or(update_params.finished_at), + created_at: body_update.created_at, }; let bag = if let Some(true) = update.closed { @@ -183,6 +186,8 @@ pub(crate) struct NewBagSubmission { roast_id: RoastId, roast_date: Option, amount: f64, + #[serde(default)] + created_at: Option>, } impl NewBagSubmission { @@ -208,6 +213,7 @@ impl NewBagSubmission { roast_id, roast_date, amount: self.amount, + created_at: self.created_at, }) } } diff --git a/src/application/routes/api/brews.rs b/src/application/routes/api/brews.rs index 40c67eb..491f47a 100644 --- a/src/application/routes/api/brews.rs +++ b/src/application/routes/api/brews.rs @@ -2,6 +2,7 @@ use axum::Json; use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer}; use tracing::info; @@ -188,6 +189,8 @@ pub(crate) struct NewBrewSubmission { water_temp: f64, #[serde(default, deserialize_with = "deserialize_quick_notes")] quick_notes: Vec, + #[serde(default)] + created_at: Option>, } impl NewBrewSubmission { @@ -217,6 +220,7 @@ impl NewBrewSubmission { water_volume: self.water_volume, water_temp: self.water_temp, quick_notes: self.quick_notes, + created_at: self.created_at, }) } } diff --git a/src/application/routes/api/cafes.rs b/src/application/routes/api/cafes.rs index a3fbb4c..9825299 100644 --- a/src/application/routes/api/cafes.rs +++ b/src/application/routes/api/cafes.rs @@ -100,7 +100,8 @@ pub(crate) async fn update_cafe( || payload.country.is_some() || payload.latitude.is_some() || payload.longitude.is_some() - || payload.website.is_some(); + || payload.website.is_some() + || payload.created_at.is_some(); if !has_changes { return Err(AppError::validation("no changes provided").into()); diff --git a/src/application/routes/api/checkin.rs b/src/application/routes/api/checkin.rs index 7d6ce0e..f3747cf 100644 --- a/src/application/routes/api/checkin.rs +++ b/src/application/routes/api/checkin.rs @@ -65,6 +65,7 @@ pub(crate) async fn submit_checkin( latitude: submission.cafe_lat, longitude: submission.cafe_lng, website: submission.cafe_website.filter(|s| !s.is_empty()), + created_at: None, } .normalize(); @@ -79,6 +80,7 @@ pub(crate) async fn submit_checkin( let new_cup = NewCup { roast_id: RoastId::from(roast_id), cafe_id, + created_at: None, }; let cup = state diff --git a/src/application/routes/api/gear.rs b/src/application/routes/api/gear.rs index 50f22cc..e8c917f 100644 --- a/src/application/routes/api/gear.rs +++ b/src/application/routes/api/gear.rs @@ -4,6 +4,7 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use chrono::{DateTime, Utc}; use serde::Deserialize; use tracing::info; @@ -148,6 +149,8 @@ pub(crate) struct NewGearSubmission { category: String, make: String, model: String, + #[serde(default)] + created_at: Option>, } impl NewGearSubmission { @@ -167,6 +170,7 @@ impl NewGearSubmission { category, make: self.make, model: self.model, + created_at: self.created_at, }) } } diff --git a/src/application/routes/api/roasters.rs b/src/application/routes/api/roasters.rs index 62c4a88..357e0b9 100644 --- a/src/application/routes/api/roasters.rs +++ b/src/application/routes/api/roasters.rs @@ -100,7 +100,8 @@ pub(crate) async fn update_roaster( let has_changes = payload.name.is_some() || payload.country.is_some() || payload.city.is_some() - || payload.homepage.is_some(); + || payload.homepage.is_some() + || payload.created_at.is_some(); if !has_changes { return Err(AppError::validation("no changes provided").into()); diff --git a/src/application/routes/api/roasts.rs b/src/application/routes/api/roasts.rs index 680bafb..3dac3e3 100644 --- a/src/application/routes/api/roasts.rs +++ b/src/application/routes/api/roasts.rs @@ -2,6 +2,7 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use chrono::{DateTime, Utc}; use serde::Deserialize; use super::macros::{ @@ -160,7 +161,8 @@ pub(crate) async fn update_roast( || payload.region.is_some() || payload.producer.is_some() || payload.tasting_notes.is_some() - || payload.process.is_some(); + || payload.process.is_some() + || payload.created_at.is_some(); if !has_changes { return Err(AppError::validation("no changes provided").into()); @@ -197,6 +199,8 @@ pub(crate) struct NewRoastSubmission { producer: String, tasting_notes: TastingNotesInput, process: String, + #[serde(default)] + created_at: Option>, } impl NewRoastSubmission { @@ -234,6 +238,7 @@ impl NewRoastSubmission { producer, tasting_notes, process, + created_at: self.created_at, }) } } diff --git a/src/application/routes/api/scan.rs b/src/application/routes/api/scan.rs index 871a4c4..1a10d46 100644 --- a/src/application/routes/api/scan.rs +++ b/src/application/routes/api/scan.rs @@ -121,6 +121,7 @@ async fn match_existing_entities( country: roaster_country.to_string(), city: result.roaster.city.clone(), homepage: None, + created_at: None, } .normalize(); let roaster_slug = temp_roaster.slug(); @@ -281,6 +282,7 @@ pub(crate) async fn submit_scan( country: submission.roaster_country, city: submission.roaster_city, homepage: submission.roaster_homepage, + created_at: None, } .normalize(); @@ -317,6 +319,7 @@ pub(crate) async fn submit_scan( producer: submission.producer.trim().to_string(), process: submission.process.trim().to_string(), tasting_notes, + created_at: None, } } else { fn require(field: &str, value: &str) -> Result { @@ -336,6 +339,7 @@ pub(crate) async fn submit_scan( producer: require("producer", &submission.producer)?, process: require("process", &submission.process)?, tasting_notes, + created_at: None, } }; @@ -358,6 +362,7 @@ pub(crate) async fn submit_scan( roast_id: roast.id, roast_date: None, amount, + created_at: None, }; state .bag_service @@ -416,6 +421,7 @@ async fn submit_existing_roast( roast_id: roast.id, roast_date: None, amount, + created_at: None, }; state .bag_service diff --git a/src/domain/bags.rs b/src/domain/bags.rs index f35457f..334ab48 100644 --- a/src/domain/bags.rs +++ b/src/domain/bags.rs @@ -35,6 +35,8 @@ pub struct NewBag { pub roast_id: RoastId, pub roast_date: Option, pub amount: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -42,6 +44,8 @@ pub struct UpdateBag { pub remaining: Option, pub closed: Option, pub finished_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } /// Filter criteria for bag queries. @@ -141,7 +145,7 @@ pub fn bag_timeline_event( entity_type: "bag".to_string(), entity_id: bag.id.into_inner(), action: action.to_string(), - occurred_at: Utc::now(), + occurred_at: bag.created_at, title: roast.name.clone(), details: vec![ TimelineEventDetail { diff --git a/src/domain/brews.rs b/src/domain/brews.rs index e40b241..1f7a47f 100644 --- a/src/domain/brews.rs +++ b/src/domain/brews.rs @@ -156,7 +156,7 @@ impl BrewWithDetails { entity_type: "brew".to_string(), entity_id: self.brew.id.into_inner(), action: "brewed".to_string(), - occurred_at: Utc::now(), + occurred_at: self.brew.created_at, title: self.roast_name.clone(), details, tasting_notes: vec![], @@ -187,6 +187,8 @@ pub struct NewBrew { pub water_volume: i32, pub water_temp: f64, pub quick_notes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } /// Filter criteria for brew queries. diff --git a/src/domain/cafes.rs b/src/domain/cafes.rs index fb2b9d5..bb23121 100644 --- a/src/domain/cafes.rs +++ b/src/domain/cafes.rs @@ -25,7 +25,7 @@ impl Cafe { entity_type: "cafe".to_string(), entity_id: self.id.into_inner(), action: "added".to_string(), - occurred_at: Utc::now(), + occurred_at: self.created_at, title: self.name.clone(), details: vec![ TimelineEventDetail { @@ -53,6 +53,8 @@ pub struct NewCafe { pub latitude: f64, pub longitude: f64, pub website: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } impl NewCafe { @@ -88,6 +90,8 @@ pub struct UpdateCafe { pub latitude: Option, pub longitude: Option, pub website: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] diff --git a/src/domain/cups.rs b/src/domain/cups.rs index eee27af..fa63383 100644 --- a/src/domain/cups.rs +++ b/src/domain/cups.rs @@ -33,7 +33,7 @@ impl CupWithDetails { entity_type: "cup".to_string(), entity_id: self.cup.id.into_inner(), action: "added".to_string(), - occurred_at: Utc::now(), + occurred_at: self.cup.created_at, title: self.roast_name.clone(), details: vec![ TimelineEventDetail { @@ -61,6 +61,8 @@ impl CupWithDetails { pub struct NewCup { pub roast_id: RoastId, pub cafe_id: CafeId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } /// Filter criteria for cup queries. diff --git a/src/domain/gear.rs b/src/domain/gear.rs index 882aa33..e18dbbc 100644 --- a/src/domain/gear.rs +++ b/src/domain/gear.rs @@ -63,7 +63,7 @@ impl Gear { entity_type: "gear".to_string(), entity_id: self.id.into_inner(), action: "added".to_string(), - occurred_at: Utc::now(), + occurred_at: self.created_at, title: format!("{} {}", self.make, self.model), details: vec![ TimelineEventDetail { @@ -92,12 +92,16 @@ pub struct NewGear { pub category: GearCategory, pub make: String, pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateGear { pub make: Option, pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Debug, Default, Clone)] diff --git a/src/domain/roasters.rs b/src/domain/roasters.rs index 289999b..7a3f4ee 100644 --- a/src/domain/roasters.rs +++ b/src/domain/roasters.rs @@ -22,6 +22,8 @@ pub struct NewRoaster { pub country: String, pub city: Option, pub homepage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } impl NewRoaster { @@ -77,7 +79,7 @@ impl Roaster { entity_type: "roaster".to_string(), entity_id: self.id.into_inner(), action: "added".to_string(), - occurred_at: Utc::now(), + occurred_at: self.created_at, title: self.name.clone(), details, tasting_notes: vec![], @@ -94,6 +96,8 @@ pub struct UpdateRoaster { pub country: Option, pub city: Option, pub homepage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } impl UpdateRoaster { diff --git a/src/domain/roasts.rs b/src/domain/roasts.rs index 336451f..92d4ffe 100644 --- a/src/domain/roasts.rs +++ b/src/domain/roasts.rs @@ -37,6 +37,8 @@ pub struct NewRoast { pub producer: String, pub tasting_notes: Vec, pub process: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } impl NewRoast { @@ -54,6 +56,8 @@ pub struct UpdateRoast { pub producer: Option, pub tasting_notes: Option>, pub process: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -120,7 +124,7 @@ pub fn roast_timeline_event(roast: &Roast, roaster: &Roaster) -> NewTimelineEven entity_type: "roast".to_string(), entity_id: roast.id.into_inner(), action: "added".to_string(), - occurred_at: Utc::now(), + occurred_at: roast.created_at, title: roast.name.clone(), details, tasting_notes: roast.tasting_notes.clone(), diff --git a/src/infrastructure/client/bags.rs b/src/infrastructure/client/bags.rs index 22b6ed7..826f460 100644 --- a/src/infrastructure/client/bags.rs +++ b/src/infrastructure/client/bags.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use chrono::NaiveDate; +use chrono::{DateTime, NaiveDate, Utc}; use crate::domain::bags::{BagWithRoast, UpdateBag}; use crate::domain::ids::{BagId, RoastId}; @@ -20,13 +20,17 @@ impl<'a> BagsClient<'a> { roast_id: RoastId, roast_date: Option, amount: f64, + created_at: Option>, ) -> Result { let url = self.inner.endpoint("api/v1/bags")?; - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "roast_id": roast_id, "roast_date": roast_date.map(|d| d.to_string()), "amount": amount, }); + if let Some(ts) = created_at { + payload["created_at"] = serde_json::json!(ts); + } let response = self .inner @@ -74,12 +78,14 @@ impl<'a> BagsClient<'a> { remaining: Option, closed: Option, finished_at: Option, + created_at: Option>, ) -> Result { let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?; let payload = UpdateBag { remaining, closed, finished_at, + created_at, }; let response = self diff --git a/src/infrastructure/client/brews.rs b/src/infrastructure/client/brews.rs index d0bb267..88dbe9a 100644 --- a/src/infrastructure/client/brews.rs +++ b/src/infrastructure/client/brews.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use crate::domain::brews::{BrewWithDetails, QuickNote}; use crate::domain::ids::{BagId, BrewId, GearId}; @@ -26,6 +27,7 @@ impl<'a> BrewsClient<'a> { water_volume: i32, water_temp: f64, quick_notes: Vec, + created_at: Option>, ) -> Result { let url = self.inner.endpoint("api/v1/brews")?; let mut payload = serde_json::json!({ @@ -44,6 +46,9 @@ impl<'a> BrewsClient<'a> { let labels: Vec<&str> = quick_notes.iter().map(|n| n.label()).collect(); payload["quick_notes"] = serde_json::json!(labels); } + if let Some(ts) = created_at { + payload["created_at"] = serde_json::json!(ts); + } let response = self .inner diff --git a/src/infrastructure/client/gear.rs b/src/infrastructure/client/gear.rs index 99c23f5..550146e 100644 --- a/src/infrastructure/client/gear.rs +++ b/src/infrastructure/client/gear.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use crate::domain::gear::{Gear, UpdateGear}; use crate::domain::ids::GearId; @@ -14,13 +15,22 @@ impl<'a> GearClient<'a> { Self { inner } } - pub async fn create(&self, category: &str, make: String, model: String) -> Result { + pub async fn create( + &self, + category: &str, + make: String, + model: String, + created_at: Option>, + ) -> Result { let url = self.inner.endpoint("api/v1/gear")?; - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "category": category, "make": make, "model": model, }); + if let Some(ts) = created_at { + payload["created_at"] = serde_json::json!(ts); + } let response = self .inner @@ -66,9 +76,14 @@ impl<'a> GearClient<'a> { id: GearId, make: Option, model: Option, + created_at: Option>, ) -> Result { let url = self.inner.endpoint(&format!("api/v1/gear/{id}"))?; - let payload = UpdateGear { make, model }; + let payload = UpdateGear { + make, + model, + created_at, + }; let response = self .inner diff --git a/src/infrastructure/repositories/bags.rs b/src/infrastructure/repositories/bags.rs index f7b58ab..2b12c4d 100644 --- a/src/infrastructure/repositories/bags.rs +++ b/src/infrastructure/repositories/bags.rs @@ -112,9 +112,10 @@ impl SqlBagRepository { #[async_trait] impl BagRepository for SqlBagRepository { async fn insert(&self, bag: NewBag) -> Result { + let created_at = bag.created_at.unwrap_or_else(Utc::now); let query = r" - INSERT INTO bags (roast_id, roast_date, amount, remaining) - VALUES (?, ?, ?, ?) + INSERT INTO bags (roast_id, roast_date, amount, remaining, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at "; @@ -123,6 +124,8 @@ impl BagRepository for SqlBagRepository { .bind(bag.roast_date) .bind(bag.amount) .bind(bag.amount) // remaining starts as amount + .bind(created_at) + .bind(created_at) .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -206,6 +209,7 @@ impl BagRepository for SqlBagRepository { push_update_field!(builder, sep, "remaining", changes.remaining); push_update_field!(builder, sep, "closed", changes.closed); push_update_field!(builder, sep, "finished_at", changes.finished_at); + push_update_field!(builder, sep, "created_at", changes.created_at); let _ = sep; // Suppress unused_assignments warning from macro builder.push(" WHERE id = "); diff --git a/src/infrastructure/repositories/brews.rs b/src/infrastructure/repositories/brews.rs index 328a90b..a05f4ff 100644 --- a/src/infrastructure/repositories/brews.rs +++ b/src/infrastructure/repositories/brews.rs @@ -163,9 +163,10 @@ impl BrewRepository for SqlBrewRepository { } // Insert the brew + let created_at = brew.created_at.unwrap_or_else(Utc::now); let insert_query = r" - INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at "; @@ -182,6 +183,8 @@ impl BrewRepository for SqlBrewRepository { .bind(brew.water_volume) .bind(brew.water_temp) .bind(Self::encode_quick_notes(&brew.quick_notes)) + .bind(created_at) + .bind(created_at) .fetch_one(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; diff --git a/src/infrastructure/repositories/cafes.rs b/src/infrastructure/repositories/cafes.rs index 200f605..34d999b 100644 --- a/src/infrastructure/repositories/cafes.rs +++ b/src/infrastructure/repositories/cafes.rs @@ -68,7 +68,7 @@ impl CafeRepository for SqlCafeRepository { async fn insert(&self, new_cafe: NewCafe) -> Result { let new_cafe = new_cafe.normalize(); let slug = new_cafe.slug(); - let now = Utc::now(); + let now = new_cafe.created_at.unwrap_or_else(Utc::now); let record = query_as::<_, CafeRecord>( "INSERT INTO cafes (name, slug, city, country, latitude, longitude, website, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\ @@ -163,6 +163,7 @@ impl CafeRepository for SqlCafeRepository { push_update_field!(builder, sep, "latitude", changes.latitude); push_update_field!(builder, sep, "longitude", changes.longitude); push_update_field!(builder, sep, "website", changes.website); + push_update_field!(builder, sep, "created_at", changes.created_at); let _ = sep; builder.push(" WHERE id = "); diff --git a/src/infrastructure/repositories/cups.rs b/src/infrastructure/repositories/cups.rs index d6695ca..69c81fb 100644 --- a/src/infrastructure/repositories/cups.rs +++ b/src/infrastructure/repositories/cups.rs @@ -107,12 +107,15 @@ impl SqlCupRepository { #[async_trait] impl CupRepository for SqlCupRepository { async fn insert(&self, new_cup: NewCup) -> Result { + let created_at = new_cup.created_at.unwrap_or_else(Utc::now); let record = query_as::<_, CupRecord>( - "INSERT INTO cups (roast_id, cafe_id) VALUES (?, ?) \ + "INSERT INTO cups (roast_id, cafe_id, created_at, updated_at) VALUES (?, ?, ?, ?) \ RETURNING id, roast_id, cafe_id, created_at, updated_at", ) .bind(new_cup.roast_id.into_inner()) .bind(new_cup.cafe_id.into_inner()) + .bind(created_at) + .bind(created_at) .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; diff --git a/src/infrastructure/repositories/gear.rs b/src/infrastructure/repositories/gear.rs index e2d0a78..7e9d364 100644 --- a/src/infrastructure/repositories/gear.rs +++ b/src/infrastructure/repositories/gear.rs @@ -63,9 +63,10 @@ impl SqlGearRepository { #[async_trait] impl GearRepository for SqlGearRepository { async fn insert(&self, gear: NewGear) -> Result { + let created_at = gear.created_at.unwrap_or_else(Utc::now); let query = r" - INSERT INTO gear (category, make, model) - VALUES (?, ?, ?) + INSERT INTO gear (category, make, model, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) RETURNING id, category, make, model, created_at, updated_at "; @@ -73,6 +74,8 @@ impl GearRepository for SqlGearRepository { .bind(gear.category.as_str()) .bind(&gear.make) .bind(&gear.model) + .bind(created_at) + .bind(created_at) .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -142,6 +145,7 @@ impl GearRepository for SqlGearRepository { push_update_field!(builder, sep, "make", changes.make); push_update_field!(builder, sep, "model", changes.model); + push_update_field!(builder, sep, "created_at", changes.created_at); let _ = sep; // Suppress unused_assignments warning builder.push(" WHERE id = "); diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index 35db125..471ed51 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -62,7 +62,7 @@ impl RoasterRepository for SqlRoasterRepository { async fn insert(&self, new_roaster: NewRoaster) -> Result { let new_roaster = new_roaster.normalize(); let slug = new_roaster.slug(); - let created_at = Utc::now(); + let created_at = new_roaster.created_at.unwrap_or_else(Utc::now); let record = query_as::<_, RoasterRecord>( "INSERT INTO roasters (name, slug, country, city, homepage, created_at) VALUES (?, ?, ?, ?, ?, ?)\ @@ -157,6 +157,7 @@ impl RoasterRepository for SqlRoasterRepository { push_update_field!(builder, sep, "country", changes.country); push_update_field!(builder, sep, "city", changes.city); push_update_field!(builder, sep, "homepage", changes.homepage); + push_update_field!(builder, sep, "created_at", changes.created_at); if !sep { return Err(RepositoryError::unexpected( diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index b0e705f..2235d3e 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -67,6 +67,7 @@ impl RoastRepository for SqlRoastRepository { producer, tasting_notes, process, + created_at, } = new_roast; let origin_value = empty_to_none(origin); @@ -74,7 +75,7 @@ impl RoastRepository for SqlRoastRepository { let producer_value = empty_to_none(producer); let process_value = empty_to_none(process); - let created_at = Utc::now(); + let created_at = created_at.unwrap_or_else(Utc::now); let notes_json = Self::encode_notes(&tasting_notes)?; let record = query_as::<_, RoastRecord>( @@ -228,6 +229,7 @@ impl RoastRepository for SqlRoastRepository { push_update_field!(builder, sep, "region", changes.region); push_update_field!(builder, sep, "producer", changes.producer); push_update_field!(builder, sep, "process", changes.process); + push_update_field!(builder, sep, "created_at", changes.created_at); // Handle tasting_notes specially due to JSON encoding if let Some(tasting_notes) = changes.tasting_notes { diff --git a/src/presentation/cli/bags.rs b/src/presentation/cli/bags.rs index 0cab55e..f6e4b42 100644 --- a/src/presentation/cli/bags.rs +++ b/src/presentation/cli/bags.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::ids::{BagId, RoastId}; use crate::infrastructure::client::BrewlogClient; @@ -38,6 +39,9 @@ pub struct AddBagCommand { pub roast_date: Option, #[arg(long)] pub amount: f64, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_bag(client: &BrewlogClient, command: AddBagCommand) -> Result<()> { @@ -45,9 +49,18 @@ pub async fn add_bag(client: &BrewlogClient, command: AddBagCommand) -> Result<( .roast_date .map(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d")) .transpose()?; + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let bag = client .bags() - .create(RoastId::new(command.roast_id), roast_date, command.amount) + .create( + RoastId::new(command.roast_id), + roast_date, + command.amount, + created_at, + ) .await?; print_json(&bag) } @@ -78,6 +91,9 @@ pub struct UpdateBagCommand { pub closed: Option, #[arg(long)] pub finished_at: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn update_bag(client: &BrewlogClient, command: UpdateBagCommand) -> Result<()> { @@ -85,6 +101,10 @@ pub async fn update_bag(client: &BrewlogClient, command: UpdateBagCommand) -> Re .finished_at .map(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d")) .transpose()?; + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let bag = client .bags() @@ -93,6 +113,7 @@ pub async fn update_bag(client: &BrewlogClient, command: UpdateBagCommand) -> Re command.remaining, command.closed, finished_at, + created_at, ) .await?; print_json(&bag) diff --git a/src/presentation/cli/brews.rs b/src/presentation/cli/brews.rs index 2d88827..f5dac88 100644 --- a/src/presentation/cli/brews.rs +++ b/src/presentation/cli/brews.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::brews::QuickNote; use crate::domain::ids::{BagId, BrewId, GearId}; @@ -65,6 +66,10 @@ pub struct AddBrewCommand { /// Quick notes (comma-separated: good,too-fast,too-slow,too-hot,under-extracted,over-extracted) #[arg(long, value_delimiter = ',')] pub quick_notes: Vec, + + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result<()> { @@ -73,6 +78,10 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result .iter() .filter_map(|s| QuickNote::from_str_value(s)) .collect(); + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let brew = client .brews() @@ -86,6 +95,7 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result command.water_volume, command.water_temp, quick_notes, + created_at, ) .await?; print_json(&brew) diff --git a/src/presentation/cli/cafes.rs b/src/presentation/cli/cafes.rs index 18f7eec..d41c6c1 100644 --- a/src/presentation/cli/cafes.rs +++ b/src/presentation/cli/cafes.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::cafes::{NewCafe, UpdateCafe}; use crate::domain::ids::CafeId; @@ -45,9 +46,16 @@ pub struct AddCafeCommand { pub longitude: f64, #[arg(long)] pub website: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_cafe(client: &BrewlogClient, command: AddCafeCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = NewCafe { name: command.name, city: command.city, @@ -55,6 +63,7 @@ pub async fn add_cafe(client: &BrewlogClient, command: AddCafeCommand) -> Result latitude: command.latitude, longitude: command.longitude, website: command.website, + created_at, }; let cafe = client.cafes().create(&payload).await?; @@ -84,9 +93,16 @@ pub struct UpdateCafeCommand { pub longitude: Option, #[arg(long)] pub website: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn update_cafe(client: &BrewlogClient, command: UpdateCafeCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = UpdateCafe { name: command.name, city: command.city, @@ -94,6 +110,7 @@ pub async fn update_cafe(client: &BrewlogClient, command: UpdateCafeCommand) -> latitude: command.latitude, longitude: command.longitude, website: command.website, + created_at, }; let cafe = client diff --git a/src/presentation/cli/cups.rs b/src/presentation/cli/cups.rs index f9a8103..29f2a33 100644 --- a/src/presentation/cli/cups.rs +++ b/src/presentation/cli/cups.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::cups::NewCup; use crate::domain::ids::{CafeId, CupId, RoastId}; @@ -34,12 +35,20 @@ pub struct AddCupCommand { pub roast_id: i64, #[arg(long)] pub cafe_id: i64, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_cup(client: &BrewlogClient, command: AddCupCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = NewCup { roast_id: RoastId::new(command.roast_id), cafe_id: CafeId::new(command.cafe_id), + created_at, }; let cup = client.cups().create(&payload).await?; diff --git a/src/presentation/cli/gear.rs b/src/presentation/cli/gear.rs index 9d2fd0b..b2f6642 100644 --- a/src/presentation/cli/gear.rs +++ b/src/presentation/cli/gear.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::ids::GearId; use crate::infrastructure::client::BrewlogClient; @@ -38,12 +39,19 @@ pub struct AddGearCommand { pub make: String, #[arg(long)] pub model: String, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_gear(client: &BrewlogClient, command: AddGearCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let gear = client .gear() - .create(&command.category, command.make, command.model) + .create(&command.category, command.make, command.model, created_at) .await?; print_json(&gear) } @@ -69,12 +77,24 @@ pub struct UpdateGearCommand { pub make: Option, #[arg(long)] pub model: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn update_gear(client: &BrewlogClient, command: UpdateGearCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let gear = client .gear() - .update(GearId::new(command.id), command.make, command.model) + .update( + GearId::new(command.id), + command.make, + command.model, + created_at, + ) .await?; print_json(&gear) } diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 684f76a..0207514 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -11,6 +11,8 @@ pub mod tokens; use std::net::SocketAddr; +use chrono::{DateTime, NaiveDate, Utc}; + use backup::{BackupCommand, RestoreCommand}; use bags::BagCommands; use brews::BrewCommands; @@ -129,6 +131,18 @@ pub struct ServeCommand { pub foursquare_api_key: Option, } +pub fn parse_created_at(value: &str) -> anyhow::Result> { + if let Ok(dt) = DateTime::parse_from_rfc3339(value) { + return Ok(dt.with_timezone(&Utc)); + } + if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { + return Ok(date.and_time(chrono::NaiveTime::MIN).and_utc()); + } + anyhow::bail!( + "invalid date format: expected RFC 3339 (e.g. 2025-08-05T10:00:00Z) or YYYY-MM-DD" + ) +} + pub(crate) fn print_json(value: &T) -> anyhow::Result<()> where T: serde::Serialize, diff --git a/src/presentation/cli/roasters.rs b/src/presentation/cli/roasters.rs index f532546..873b53e 100644 --- a/src/presentation/cli/roasters.rs +++ b/src/presentation/cli/roasters.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::ids::RoasterId; use crate::domain::roasters::{NewRoaster, UpdateRoaster}; @@ -41,14 +42,22 @@ pub struct AddRoasterCommand { pub city: Option, #[arg(long)] pub homepage: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_roaster(client: &BrewlogClient, command: AddRoasterCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = NewRoaster { name: command.name, country: command.country, city: command.city, homepage: command.homepage, + created_at, }; let roaster = client.roasters().create(&payload).await?; @@ -74,14 +83,22 @@ pub struct UpdateRoasterCommand { pub city: Option, #[arg(long)] pub homepage: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = UpdateRoaster { name: command.name, country: command.country, city: command.city, homepage: command.homepage, + created_at, }; let roaster = client diff --git a/src/presentation/cli/roasts.rs b/src/presentation/cli/roasts.rs index 9428189..3a98476 100644 --- a/src/presentation/cli/roasts.rs +++ b/src/presentation/cli/roasts.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Args, Subcommand}; use super::macros::{define_delete_command, define_get_command}; +use super::parse_created_at; use super::print_json; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::roasts::{NewRoast, UpdateRoast}; @@ -47,9 +48,16 @@ pub struct AddRoastCommand { pub process: String, #[arg(long = "tasting-notes", required = true)] pub tasting_notes: Vec, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = NewRoast { roaster_id: RoasterId::new(command.roaster_id), name: command.name, @@ -58,6 +66,7 @@ pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Resu producer: command.producer, tasting_notes: command.tasting_notes, process: command.process, + created_at, }; let roast = client.roasts().create(&payload).await?; @@ -98,9 +107,16 @@ pub struct UpdateRoastCommand { pub process: Option, #[arg(long = "tasting-notes")] pub tasting_notes: Option>, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) + #[arg(long)] + pub created_at: Option, } pub async fn update_roast(client: &BrewlogClient, command: UpdateRoastCommand) -> Result<()> { + let created_at = command + .created_at + .map(|s| parse_created_at(&s)) + .transpose()?; let payload = UpdateRoast { roaster_id: command.roaster_id.map(RoasterId::new), name: command.name, @@ -109,6 +125,7 @@ pub async fn update_roast(client: &BrewlogClient, command: UpdateRoastCommand) - producer: command.producer, tasting_notes: command.tasting_notes, process: command.process, + created_at, }; let roast = client