feat(backup): add database reset action to account page

- Add reset() method to BackupService that deletes all 8 coffee tables
  in a transaction, respecting FK constraint order
- Add POST /api/v1/backup/reset endpoint (requires auth)
- Add "Reset Database" button with double-confirmation to account page
- Stack data buttons vertically on mobile for better layout
This commit is contained in:
Jon Seager 2026-02-07 08:45:30 +00:00
parent 47feedb273
commit e58ea5758f
No known key found for this signature in database
5 changed files with 143 additions and 2 deletions

View file

@ -58,3 +58,19 @@ pub(crate) async fn restore_backup(
})?;
Ok(StatusCode::NO_CONTENT.into_response())
}
/// POST /api/v1/backup/reset — delete all coffee data (requires authentication)
pub(crate) async fn reset_database(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
) -> Result<Response, ApiError> {
state
.backup_service
.reset()
.await
.map_err(|e| AppError::unexpected(e.to_string()))?;
tracing::info!("database reset: all coffee data deleted");
Ok(StatusCode::NO_CONTENT.into_response())
}

View file

@ -90,6 +90,7 @@ pub(super) fn router() -> axum::Router<AppState> {
"/backup/restore",
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
)
.route("/backup/reset", post(backup::reset_database))
}
pub(super) fn webauthn_router() -> axum::Router<AppState> {

View file

@ -115,6 +115,44 @@ impl BackupService {
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",
];
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<Vec<Roaster>> {

View file

@ -224,11 +224,11 @@
<p class="mt-1 text-sm text-text-secondary">Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<a
href="/api/v1/backup"
download
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover inline-block"
class="rounded-md bg-accent px-4 py-2 text-center text-sm font-medium text-accent-text transition hover:bg-accent-hover"
>
Download Backup
</a>

View file

@ -631,3 +631,89 @@ async fn backup_round_trip_via_api() {
assert_eq!(roasters.len(), 1);
assert_eq!(roasters[0].name, "Test Roasters");
}
// --- Reset tests ---
#[tokio::test]
async fn reset_requires_auth() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.post(app.api_url("/backup/reset"))
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn reset_clears_all_data() {
let db = create_test_db().await;
populate_test_data(&db).await;
// Verify data exists before reset
assert!(!list_all_roasters(db.roaster_repo.as_ref()).await.is_empty());
assert!(!list_all_roasts(db.roast_repo.as_ref()).await.is_empty());
assert!(!list_all_bags(db.bag_repo.as_ref()).await.is_empty());
assert!(!list_all_gear(db.gear_repo.as_ref()).await.is_empty());
assert!(!list_all_brews(db.brew_repo.as_ref()).await.is_empty());
assert!(!list_all_cafes(db.cafe_repo.as_ref()).await.is_empty());
assert!(
!list_all_timeline_events(db.timeline_repo.as_ref())
.await
.is_empty()
);
// Reset
db.backup_service
.reset()
.await
.expect("failed to reset database");
// Verify all tables are empty
assert!(list_all_roasters(db.roaster_repo.as_ref()).await.is_empty());
assert!(list_all_roasts(db.roast_repo.as_ref()).await.is_empty());
assert!(list_all_bags(db.bag_repo.as_ref()).await.is_empty());
assert!(list_all_gear(db.gear_repo.as_ref()).await.is_empty());
assert!(list_all_brews(db.brew_repo.as_ref()).await.is_empty());
assert!(list_all_cafes(db.cafe_repo.as_ref()).await.is_empty());
assert!(
list_all_timeline_events(db.timeline_repo.as_ref())
.await
.is_empty()
);
}
#[tokio::test]
async fn reset_then_restore_succeeds() {
let db = create_test_db().await;
populate_test_data(&db).await;
// Export before reset
let backup = db.backup_service.export().await.expect("failed to export");
assert_eq!(backup.roasters.len(), 1);
assert_eq!(backup.gear.len(), 3);
assert_eq!(backup.brews.len(), 1);
// Reset
db.backup_service
.reset()
.await
.expect("failed to reset database");
// Restore should succeed (database is now empty)
db.backup_service
.restore(backup)
.await
.expect("failed to restore after reset");
// Verify data is back
assert_eq!(list_all_roasters(db.roaster_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_roasts(db.roast_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_bags(db.bag_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_gear(db.gear_repo.as_ref()).await.len(), 3);
assert_eq!(list_all_brews(db.brew_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_cafes(db.cafe_repo.as_ref()).await.len(), 1);
}