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:
parent
47feedb273
commit
e58ea5758f
5 changed files with 143 additions and 2 deletions
|
|
@ -58,3 +58,19 @@ pub(crate) async fn restore_backup(
|
||||||
})?;
|
})?;
|
||||||
Ok(StatusCode::NO_CONTENT.into_response())
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
"/backup/restore",
|
"/backup/restore",
|
||||||
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
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> {
|
pub(super) fn webauthn_router() -> axum::Router<AppState> {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,44 @@ impl BackupService {
|
||||||
Ok(())
|
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 ---
|
// --- Export methods ---
|
||||||
|
|
||||||
async fn export_roasters(&self) -> anyhow::Result<Vec<Roaster>> {
|
async fn export_roasters(&self) -> anyhow::Result<Vec<Roaster>> {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||||
<a
|
<a
|
||||||
href="/api/v1/backup"
|
href="/api/v1/backup"
|
||||||
download
|
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
|
Download Backup
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -631,3 +631,89 @@ async fn backup_round_trip_via_api() {
|
||||||
assert_eq!(roasters.len(), 1);
|
assert_eq!(roasters.len(), 1);
|
||||||
assert_eq!(roasters[0].name, "Test Roasters");
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue