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)
This commit is contained in:
Jon Seager 2026-02-07 09:48:20 +00:00
parent e58ea5758f
commit e01b6d0a1c
No known key found for this signature in database
33 changed files with 250 additions and 28 deletions

View file

@ -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<String>,
amount: f64,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
}
impl NewBagSubmission {
@ -208,6 +213,7 @@ impl NewBagSubmission {
roast_id,
roast_date,
amount: self.amount,
created_at: self.created_at,
})
}
}

View file

@ -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<QuickNote>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
}
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,
})
}
}

View file

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

View file

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

View file

@ -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<DateTime<Utc>>,
}
impl NewGearSubmission {
@ -167,6 +170,7 @@ impl NewGearSubmission {
category,
make: self.make,
model: self.model,
created_at: self.created_at,
})
}
}

View file

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

View file

@ -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<DateTime<Utc>>,
}
impl NewRoastSubmission {
@ -234,6 +238,7 @@ impl NewRoastSubmission {
producer,
tasting_notes,
process,
created_at: self.created_at,
})
}
}

View file

@ -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<String, AppError> {
@ -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

View file

@ -35,6 +35,8 @@ pub struct NewBag {
pub roast_id: RoastId,
pub roast_date: Option<NaiveDate>,
pub amount: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -42,6 +44,8 @@ pub struct UpdateBag {
pub remaining: Option<f64>,
pub closed: Option<bool>,
pub finished_at: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
/// 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 {

View file

@ -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<QuickNote>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
/// Filter criteria for brew queries.

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
impl NewCafe {
@ -88,6 +90,8 @@ pub struct UpdateCafe {
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)]

View file

@ -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<DateTime<Utc>>,
}
/// Filter criteria for cup queries.

View file

@ -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<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateGear {
pub make: Option<String>,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default, Clone)]

View file

@ -22,6 +22,8 @@ pub struct NewRoaster {
pub country: String,
pub city: Option<String>,
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
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<String>,
pub city: Option<String>,
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
impl UpdateRoaster {

View file

@ -37,6 +37,8 @@ pub struct NewRoast {
pub producer: String,
pub tasting_notes: Vec<String>,
pub process: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
impl NewRoast {
@ -54,6 +56,8 @@ pub struct UpdateRoast {
pub producer: Option<String>,
pub tasting_notes: Option<Vec<String>>,
pub process: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
}
#[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(),

View file

@ -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<NaiveDate>,
amount: f64,
created_at: Option<DateTime<Utc>>,
) -> Result<BagWithRoast> {
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<f64>,
closed: Option<bool>,
finished_at: Option<NaiveDate>,
created_at: Option<DateTime<Utc>>,
) -> Result<BagWithRoast> {
let url = self.inner.endpoint(&format!("api/v1/bags/{id}"))?;
let payload = UpdateBag {
remaining,
closed,
finished_at,
created_at,
};
let response = self

View file

@ -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<QuickNote>,
created_at: Option<DateTime<Utc>>,
) -> Result<BrewWithDetails> {
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

View file

@ -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<Gear> {
pub async fn create(
&self,
category: &str,
make: String,
model: String,
created_at: Option<DateTime<Utc>>,
) -> Result<Gear> {
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<String>,
model: Option<String>,
created_at: Option<DateTime<Utc>>,
) -> Result<Gear> {
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

View file

@ -112,9 +112,10 @@ impl SqlBagRepository {
#[async_trait]
impl BagRepository for SqlBagRepository {
async fn insert(&self, bag: NewBag) -> Result<Bag, RepositoryError> {
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 = ");

View file

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

View file

@ -68,7 +68,7 @@ impl CafeRepository for SqlCafeRepository {
async fn insert(&self, new_cafe: NewCafe) -> Result<Cafe, RepositoryError> {
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 = ");

View file

@ -107,12 +107,15 @@ impl SqlCupRepository {
#[async_trait]
impl CupRepository for SqlCupRepository {
async fn insert(&self, new_cup: NewCup) -> Result<Cup, RepositoryError> {
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()))?;

View file

@ -63,9 +63,10 @@ impl SqlGearRepository {
#[async_trait]
impl GearRepository for SqlGearRepository {
async fn insert(&self, gear: NewGear) -> Result<Gear, RepositoryError> {
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 = ");

View file

@ -62,7 +62,7 @@ impl RoasterRepository for SqlRoasterRepository {
async fn insert(&self, new_roaster: NewRoaster) -> Result<Roaster, RepositoryError> {
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(

View file

@ -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 {

View file

@ -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<String>,
#[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<String>,
}
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<bool>,
#[arg(long)]
pub finished_at: Option<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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)

View file

@ -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<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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)

View file

@ -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<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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<f64>,
#[arg(long)]
pub website: Option<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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

View file

@ -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<String>,
}
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?;

View file

@ -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<String>,
}
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<String>,
#[arg(long)]
pub model: Option<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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)
}

View file

@ -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<String>,
}
pub fn parse_created_at(value: &str) -> anyhow::Result<DateTime<Utc>> {
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<T>(value: &T) -> anyhow::Result<()>
where
T: serde::Serialize,

View file

@ -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<String>,
#[arg(long)]
pub homepage: Option<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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<String>,
#[arg(long)]
pub homepage: Option<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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

View file

@ -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<String>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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<String>,
#[arg(long = "tasting-notes")]
pub tasting_notes: Option<Vec<String>>,
/// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05)
#[arg(long)]
pub created_at: Option<String>,
}
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