use std::str::FromStr; use anyhow::{Context, bail}; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; 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, CupId, GearId, RoastId, RoasterId, TimelineEventId, }; use crate::domain::roasters::Roaster; use crate::domain::roasts::Roast; use crate::domain::timeline::TimelineEvent; use crate::infrastructure::database::{DatabasePool, DatabaseTransaction}; fn decode_json_vec( raw: Option, label: &str, ) -> anyhow::Result> { match raw { Some(s) if !s.is_empty() => { from_str(&s).with_context(|| format!("failed to decode {label}: {s}")) } _ => Ok(Vec::new()), } } fn decode_json_opt( raw: Option, label: &str, ) -> anyhow::Result> { match raw { Some(s) if !s.is_empty() => from_str(&s) .map(Some) .with_context(|| format!("failed to decode {label}: {s}")), _ => Ok(None), } } #[derive(Debug, Serialize, Deserialize)] pub struct BackupData { pub version: u32, pub created_at: DateTime, pub roasters: Vec, pub gear: Vec, pub roasts: Vec, pub bags: Vec, pub brews: Vec, #[serde(default)] pub cafes: Vec, #[serde(default)] pub cups: Vec, pub timeline_events: Vec, } pub struct BackupService { pool: DatabasePool, } impl BackupService { pub fn new(pool: DatabasePool) -> Self { Self { pool } } pub async fn export(&self) -> anyhow::Result { let roasters = self.export_roasters().await?; let gear = self.export_gear().await?; let roasts = self.export_roasts().await?; 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: 2, created_at: Utc::now(), roasters, gear, roasts, bags, brews, cafes, cups, timeline_events, }) } pub async fn restore(&self, data: BackupData) -> anyhow::Result<()> { self.verify_empty_database().await?; let mut tx = self .pool .begin() .await .context("failed to begin transaction")?; self.restore_roasters(&mut tx, &data.roasters).await?; self.restore_gear(&mut tx, &data.gear).await?; self.restore_roasts(&mut tx, &data.roasts).await?; 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?; tx.commit().await.context("failed to commit transaction")?; Ok(()) } /// Delete all coffee data, leaving auth tables intact. /// /// After a successful reset the database is in the same state that /// `verify_empty_database()` requires, so a subsequent `restore()` will /// succeed. pub async fn reset(&self) -> anyhow::Result<()> { let mut tx = self .pool .begin() .await .context("failed to begin transaction")?; // Delete in FK-safe order: children before parents. // brews has RESTRICT FK → gear; cups has RESTRICT FK → roasts, cafes. let tables = [ "brews", "cups", "bags", "roasts", "timeline_events", "gear", "cafes", "roasters", "stats_cache", ]; for table in tables { let query = format!("DELETE FROM {table}"); sqlx::query(&query) .execute(&mut *tx) .await .with_context(|| format!("failed to delete from {table}"))?; } tx.commit().await.context("failed to commit transaction")?; Ok(()) } // --- Export methods --- async fn export_roasters(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, RoasterRecord>( "SELECT id, name, slug, country, city, homepage, created_at FROM roasters ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export roasters")?; Ok(records .into_iter() .map(RoasterRecord::into_domain) .collect()) } async fn export_gear(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, GearRecord>( "SELECT id, category, make, model, created_at, updated_at FROM gear ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export gear")?; records .into_iter() .map(GearRecord::into_domain) .collect::>>() } async fn export_roasts(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, RoastRecord>( "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export roasts")?; records .into_iter() .map(RoastRecord::into_domain) .collect::>>() } async fn export_bags(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, BagRecord>( "SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at FROM bags ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export bags")?; Ok(records.into_iter().map(BagRecord::into_domain).collect()) } async fn export_brews(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, BrewRecord>( "SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at FROM brews ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export brews")?; Ok(records.into_iter().map(BrewRecord::into_domain).collect()) } async fn export_cafes(&self) -> anyhow::Result> { let records = sqlx::query_as::<_, CafeRecord>( "SELECT id, name, slug, city, country, latitude, longitude, website, created_at, updated_at FROM cafes ORDER BY id", ) .fetch_all(&self.pool) .await .context("failed to export cafes")?; 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", ) .fetch_all(&self.pool) .await .context("failed to export timeline events")?; records .into_iter() .map(TimelineEventRecord::into_domain) .collect::>>() } // --- Restore methods --- async fn verify_empty_database(&self) -> anyhow::Result<()> { let tables = [ "roasters", "roasts", "bags", "gear", "brews", "cafes", "cups", "timeline_events", ]; for table in tables { let query = format!("SELECT COUNT(*) as count FROM {table}"); let row: (i64,) = sqlx::query_as(&query) .fetch_one(&self.pool) .await .with_context(|| format!("failed to check table {table}"))?; if row.0 > 0 { bail!( "Cannot restore: table '{table}' is not empty ({} rows). Restore requires an empty database.", row.0 ); } } Ok(()) } async fn restore_roasters( &self, tx: &mut DatabaseTransaction<'_>, roasters: &[Roaster], ) -> anyhow::Result<()> { for roaster in roasters { sqlx::query( "INSERT INTO roasters (id, name, slug, country, city, homepage, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(roaster.id)) .bind(&roaster.name) .bind(&roaster.slug) .bind(&roaster.country) .bind(roaster.city.as_deref()) .bind(roaster.homepage.as_deref()) .bind(roaster.created_at) .execute(&mut **tx) .await .context("failed to restore roaster")?; } Ok(()) } async fn restore_gear( &self, tx: &mut DatabaseTransaction<'_>, gear: &[Gear], ) -> anyhow::Result<()> { for item in gear { sqlx::query( "INSERT INTO gear (id, category, make, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(i64::from(item.id)) .bind(item.category.as_str()) .bind(&item.make) .bind(&item.model) .bind(item.created_at) .bind(item.updated_at) .execute(&mut **tx) .await .context("failed to restore gear")?; } Ok(()) } async fn restore_roasts( &self, tx: &mut DatabaseTransaction<'_>, roasts: &[Roast], ) -> anyhow::Result<()> { for roast in roasts { let tasting_notes_json = if roast.tasting_notes.is_empty() { None } else { Some( to_string(&roast.tasting_notes) .context("failed to encode tasting notes for restore")?, ) }; sqlx::query( "INSERT INTO roasts (id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(roast.id)) .bind(i64::from(roast.roaster_id)) .bind(&roast.name) .bind(&roast.slug) .bind(roast.origin.as_deref()) .bind(roast.region.as_deref()) .bind(roast.producer.as_deref()) .bind(roast.process.as_deref()) .bind(tasting_notes_json.as_deref()) .bind(roast.created_at) .execute(&mut **tx) .await .context("failed to restore roast")?; } Ok(()) } async fn restore_bags( &self, tx: &mut DatabaseTransaction<'_>, bags: &[Bag], ) -> anyhow::Result<()> { for bag in bags { sqlx::query( "INSERT INTO bags (id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(bag.id)) .bind(i64::from(bag.roast_id)) .bind(bag.roast_date) .bind(bag.amount) .bind(bag.remaining) .bind(bag.closed) .bind(bag.finished_at) .bind(bag.created_at) .bind(bag.updated_at) .execute(&mut **tx) .await .context("failed to restore bag")?; } Ok(()) } async fn restore_brews( &self, tx: &mut DatabaseTransaction<'_>, 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, quick_notes, brew_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(brew.id)) .bind(i64::from(brew.bag_id)) .bind(brew.coffee_weight) .bind(i64::from(brew.grinder_id)) .bind(brew.grind_setting) .bind(i64::from(brew.brewer_id)) .bind(brew.filter_paper_id.map(i64::from)) .bind(brew.water_volume) .bind(brew.water_temp) .bind(quick_notes_json.as_deref()) .bind(brew.brew_time) .bind(brew.created_at) .bind(brew.updated_at) .execute(&mut **tx) .await .context("failed to restore brew")?; } Ok(()) } async fn restore_cafes( &self, tx: &mut DatabaseTransaction<'_>, cafes: &[Cafe], ) -> anyhow::Result<()> { for cafe in cafes { sqlx::query( "INSERT INTO cafes (id, name, slug, city, country, latitude, longitude, website, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(cafe.id)) .bind(&cafe.name) .bind(&cafe.slug) .bind(&cafe.city) .bind(&cafe.country) .bind(cafe.latitude) .bind(cafe.longitude) .bind(cafe.website.as_deref()) .bind(cafe.created_at) .bind(cafe.updated_at) .execute(&mut **tx) .await .context("failed to restore cafe")?; } 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<'_>, events: &[TimelineEvent], ) -> anyhow::Result<()> { for event in events { let details_json = to_string(&event.details) .context("failed to encode timeline event details for restore")?; let tasting_notes_json = to_string(&event.tasting_notes) .context("failed to encode timeline event tasting notes for restore")?; let brew_data_json = event .brew_data .as_ref() .map(to_string) .transpose() .context("failed to encode timeline brew data for restore")?; sqlx::query( "INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(event.id)) .bind(&event.entity_type) .bind(event.entity_id) .bind(&event.action) .bind(event.occurred_at) .bind(&event.title) .bind(&details_json) .bind(&tasting_notes_json) .bind(event.slug.as_deref()) .bind(event.roaster_slug.as_deref()) .bind(brew_data_json.as_deref()) .execute(&mut **tx) .await .context("failed to restore timeline event")?; } Ok(()) } } // --- Record types for export queries --- #[derive(sqlx::FromRow)] struct RoasterRecord { id: i64, name: String, slug: String, country: String, city: Option, homepage: Option, created_at: DateTime, } impl RoasterRecord { fn into_domain(self) -> Roaster { Roaster { id: RoasterId::from(self.id), name: self.name, slug: self.slug, country: self.country, city: self.city, homepage: self.homepage, created_at: self.created_at, } } } #[derive(sqlx::FromRow)] struct GearRecord { id: i64, category: String, make: String, model: String, created_at: DateTime, updated_at: DateTime, } impl GearRecord { fn into_domain(self) -> anyhow::Result { let category = GearCategory::from_str(&self.category) .map_err(|()| anyhow::anyhow!("invalid gear category: {}", self.category))?; Ok(Gear { id: GearId::new(self.id), category, make: self.make, model: self.model, created_at: self.created_at, updated_at: self.updated_at, }) } } #[derive(sqlx::FromRow)] struct RoastRecord { id: i64, roaster_id: i64, name: String, slug: String, origin: Option, region: Option, producer: Option, process: Option, tasting_notes: Option, created_at: DateTime, } impl RoastRecord { fn into_domain(self) -> anyhow::Result { let tasting_notes = decode_json_vec(self.tasting_notes, "tasting notes")?; Ok(Roast { id: RoastId::from(self.id), roaster_id: RoasterId::from(self.roaster_id), name: self.name, slug: self.slug, origin: self.origin, region: self.region, producer: self.producer, process: self.process, tasting_notes, created_at: self.created_at, }) } } #[derive(sqlx::FromRow)] struct BagRecord { id: i64, roast_id: i64, roast_date: Option, amount: f64, remaining: f64, closed: bool, finished_at: Option, created_at: DateTime, updated_at: DateTime, } impl BagRecord { fn into_domain(self) -> Bag { Bag { id: BagId::new(self.id), roast_id: RoastId::new(self.roast_id), roast_date: self.roast_date, amount: self.amount, remaining: self.remaining, closed: self.closed, finished_at: self.finished_at, created_at: self.created_at, updated_at: self.updated_at, } } } #[derive(sqlx::FromRow)] struct BrewRecord { id: i64, bag_id: i64, coffee_weight: f64, grinder_id: i64, grind_setting: f64, brewer_id: i64, filter_paper_id: Option, water_volume: i32, water_temp: f64, quick_notes: Option, brew_time: Option, created_at: DateTime, updated_at: DateTime, } impl BrewRecord { fn into_domain(self) -> Brew { let quick_notes = match self.quick_notes { Some(s) if !s.is_empty() => serde_json::from_str::>(&s) .unwrap_or_default() .iter() .filter_map(|v| QuickNote::from_str_value(v)) .collect(), _ => Vec::new(), }; Brew { id: BrewId::new(self.id), bag_id: BagId::new(self.bag_id), coffee_weight: self.coffee_weight, grinder_id: GearId::new(self.grinder_id), grind_setting: self.grind_setting, brewer_id: GearId::new(self.brewer_id), filter_paper_id: self.filter_paper_id.map(GearId::new), water_volume: self.water_volume, water_temp: self.water_temp, quick_notes, brew_time: self.brew_time, created_at: self.created_at, updated_at: self.updated_at, } } } #[derive(sqlx::FromRow)] struct CafeRecord { id: i64, name: String, slug: String, city: String, country: String, latitude: f64, longitude: f64, website: Option, created_at: DateTime, updated_at: DateTime, } impl CafeRecord { fn into_domain(self) -> Cafe { Cafe { id: CafeId::from(self.id), name: self.name, slug: self.slug, city: self.city, country: self.country, latitude: self.latitude, longitude: self.longitude, website: self.website, created_at: self.created_at, updated_at: self.updated_at, } } } #[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, entity_type: String, entity_id: i64, action: String, occurred_at: DateTime, title: String, details_json: Option, tasting_notes_json: Option, slug: Option, roaster_slug: Option, brew_data_json: Option, } impl TimelineEventRecord { fn into_domain(self) -> anyhow::Result { let details = decode_json_vec(self.details_json, "timeline event details")?; let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?; let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?; Ok(TimelineEvent { id: TimelineEventId::from(self.id), entity_type: self.entity_type, entity_id: self.entity_id, action: self.action, occurred_at: self.occurred_at, title: self.title, details, tasting_notes, slug: self.slug, roaster_slug: self.roaster_slug, brew_data, }) } }