From 60dc9513f4d9391256e94fcbaf80223ae47607e5 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 18:26:28 +0000 Subject: [PATCH] fix(backup): include brew quick_notes and cups in backup/restore - Add missing quick_notes column to restore_brews INSERT - Add cups export/restore with CupRecord and BackupData field - Bump backup version to 2; old v1 backups still restore via serde default --- src/infrastructure/backup.rs | 77 ++++++++++++++++++++++++++++++++++-- tests/cli/backup_cli.rs | 3 +- tests/server/backup.rs | 13 +++--- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index e690048..6f75551 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -8,8 +8,11 @@ use serde_json::{from_str, to_string}; use crate::domain::bags::Bag; use crate::domain::brews::{Brew, QuickNote}; use crate::domain::cafes::Cafe; +use crate::domain::cups::Cup; use crate::domain::gear::{Gear, GearCategory}; -use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId}; +use crate::domain::ids::{ + BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId, +}; use crate::domain::roasters::Roaster; use crate::domain::roasts::Roast; use crate::domain::timeline::TimelineEvent; @@ -50,6 +53,8 @@ pub struct BackupData { pub brews: Vec, #[serde(default)] pub cafes: Vec, + #[serde(default)] + pub cups: Vec, pub timeline_events: Vec, } @@ -69,10 +74,11 @@ impl BackupService { let bags = self.export_bags().await?; let brews = self.export_brews().await?; let cafes = self.export_cafes().await?; + let cups = self.export_cups().await?; let timeline_events = self.export_timeline_events().await?; Ok(BackupData { - version: 1, + version: 2, created_at: Utc::now(), roasters, gear, @@ -80,6 +86,7 @@ impl BackupService { bags, brews, cafes, + cups, timeline_events, }) } @@ -99,6 +106,7 @@ impl BackupService { self.restore_bags(&mut tx, &data.bags).await?; self.restore_brews(&mut tx, &data.brews).await?; self.restore_cafes(&mut tx, &data.cafes).await?; + self.restore_cups(&mut tx, &data.cups).await?; self.restore_timeline_events(&mut tx, &data.timeline_events) .await?; @@ -184,6 +192,17 @@ impl BackupService { Ok(records.into_iter().map(CafeRecord::into_domain).collect()) } + async fn export_cups(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, CupRecord>( + "SELECT id, roast_id, cafe_id, created_at, updated_at FROM cups ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export cups")?; + + Ok(records.into_iter().map(CupRecord::into_domain).collect()) + } + async fn export_timeline_events(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, TimelineEventRecord>( "SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id", @@ -208,6 +227,7 @@ impl BackupService { "gear", "brews", "cafes", + "cups", "timeline_events", ]; @@ -344,8 +364,15 @@ impl BackupService { brews: &[Brew], ) -> anyhow::Result<()> { for brew in brews { + let quick_notes_json = if brew.quick_notes.is_empty() { + None + } else { + let values: Vec<&str> = brew.quick_notes.iter().map(|n| n.form_value()).collect(); + Some(to_string(&values).context("failed to encode quick notes for restore")?) + }; + sqlx::query( - "INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(brew.id)) .bind(i64::from(brew.bag_id)) @@ -356,6 +383,7 @@ impl BackupService { .bind(brew.filter_paper_id.map(i64::from)) .bind(brew.water_volume) .bind(brew.water_temp) + .bind(quick_notes_json.as_deref()) .bind(brew.created_at) .bind(brew.updated_at) .execute(&mut **tx) @@ -393,6 +421,28 @@ impl BackupService { Ok(()) } + async fn restore_cups( + &self, + tx: &mut DatabaseTransaction<'_>, + cups: &[Cup], + ) -> anyhow::Result<()> { + for cup in cups { + sqlx::query( + "INSERT INTO cups (id, roast_id, cafe_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(i64::from(cup.id)) + .bind(i64::from(cup.roast_id)) + .bind(i64::from(cup.cafe_id)) + .bind(cup.created_at) + .bind(cup.updated_at) + .execute(&mut **tx) + .await + .context("failed to restore cup")?; + } + + Ok(()) + } + async fn restore_timeline_events( &self, tx: &mut DatabaseTransaction<'_>, @@ -624,6 +674,27 @@ impl CafeRecord { } } +#[derive(sqlx::FromRow)] +struct CupRecord { + id: i64, + roast_id: i64, + cafe_id: i64, + created_at: DateTime, + updated_at: DateTime, +} + +impl CupRecord { + fn into_domain(self) -> Cup { + Cup { + id: CupId::from(self.id), + roast_id: RoastId::from(self.roast_id), + cafe_id: CafeId::from(self.cafe_id), + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + #[derive(sqlx::FromRow)] struct TimelineEventRecord { id: i64, diff --git a/tests/cli/backup_cli.rs b/tests/cli/backup_cli.rs index e612ad1..0c5e815 100644 --- a/tests/cli/backup_cli.rs +++ b/tests/cli/backup_cli.rs @@ -19,12 +19,13 @@ fn backup_produces_valid_json() { let data: serde_json::Value = serde_json::from_str(&stdout).expect("backup output is not valid JSON"); - assert_eq!(data["version"], 1); + assert_eq!(data["version"], 2); assert!(data["roasters"].is_array()); assert!(data["roasts"].is_array()); assert!(data["bags"].is_array()); assert!(data["gear"].is_array()); assert!(data["brews"].is_array()); assert!(data["cafes"].is_array()); + assert!(data["cups"].is_array()); assert!(data["timeline_events"].is_array()); } diff --git a/tests/server/backup.rs b/tests/server/backup.rs index 253d934..0fd15b6 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -294,7 +294,7 @@ async fn backup_and_restore_round_trip() { .await .expect("failed to export backup"); - assert_eq!(backup_data.version, 1); + assert_eq!(backup_data.version, 2); assert_eq!(backup_data.roasters.len(), 1); assert_eq!(backup_data.roasts.len(), 1); assert_eq!(backup_data.bags.len(), 1); @@ -308,7 +308,7 @@ async fn backup_and_restore_round_trip() { let restored_data: BackupData = serde_json::from_str(&json).expect("failed to deserialize backup"); - assert_eq!(restored_data.version, 1); + assert_eq!(restored_data.version, 2); assert_eq!(restored_data.roasters.len(), 1); assert_eq!(restored_data.roasts.len(), 1); assert_eq!(restored_data.bags.len(), 1); @@ -449,6 +449,7 @@ async fn restore_to_non_empty_database_fails() { bags: vec![], brews: vec![], cafes: vec![], + cups: vec![], timeline_events: vec![], }; @@ -472,7 +473,7 @@ async fn backup_empty_database() { .await .expect("failed to export empty database"); - assert_eq!(backup_data.version, 1); + assert_eq!(backup_data.version, 2); assert!(backup_data.roasters.is_empty()); assert!(backup_data.roasts.is_empty()); assert!(backup_data.bags.is_empty()); @@ -484,7 +485,7 @@ async fn backup_empty_database() { // Should serialize to valid JSON let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize"); let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize"); - assert_eq!(parsed.version, 1); + assert_eq!(parsed.version, 2); } // --- API-level tests --- @@ -521,7 +522,7 @@ async fn backup_export_returns_data() { assert_eq!(response.status(), reqwest::StatusCode::OK); let data: BackupData = response.json().await.expect("failed to parse backup data"); - assert_eq!(data.version, 1); + assert_eq!(data.version, 2); assert_eq!(data.roasters.len(), 1); assert_eq!(data.roasters[0].name, "Test Roasters"); } @@ -540,6 +541,7 @@ async fn backup_restore_requires_auth() { bags: vec![], brews: vec![], cafes: vec![], + cups: vec![], timeline_events: vec![], }; @@ -570,6 +572,7 @@ async fn backup_restore_non_empty_db_returns_conflict() { bags: vec![], brews: vec![], cafes: vec![], + cups: vec![], timeline_events: vec![], };