feat(yedek): JSON tam hane yedeği backend (önizleme, geri yükleme, geri dönüş noktaları)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
//! JSON tam hane yedeği (`services::backup`).
|
||||
//!
|
||||
//! | Yöntem | Yol | Kim | İş |
|
||||
//! |---|---|---|---|
|
||||
//! | GET | `/api/backup/download` | üye | yedek dosyası (indirme) |
|
||||
//! | GET | `/api/backup/status` | üye | son yedek zamanı + geri dönüş noktaları (yalnız sahip görür) |
|
||||
//! | POST | `/api/backup/preview` | sahip | gövde: yedek dosyası → ne değişecek (hiçbir şey yazılmaz) |
|
||||
//! | POST | `/api/backup/restore` | sahip | gövde: yedek dosyası → hane verisi değiştirilir |
|
||||
//! | POST | `/api/backup/restore-points/{id}/restore` | sahip | geri dönüş noktasına dön ("geri al") |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use mongodb::bson::DateTime;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::user::Role,
|
||||
services::{
|
||||
backup::{self, Reason},
|
||||
calendar::{format_date, today_tr},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
fn iso(d: DateTime) -> String {
|
||||
d.try_to_rfc3339_string().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<mongodb::bson::Uuid> {
|
||||
mongodb::bson::Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn download(State(s): State<AppState>, auth: AuthUser) -> AppResult<impl IntoResponse> {
|
||||
let snap = backup::collect(&s.db, auth.household_id).await?;
|
||||
let body = backup::to_json(&snap)?;
|
||||
backup::mark_exported(&s.db, auth.household_id).await?;
|
||||
let file = format!(
|
||||
"kakeibo-yedek-{}-{}.json",
|
||||
backup::slug(&snap.household_name),
|
||||
format_date(today_tr())
|
||||
);
|
||||
Ok((
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
"application/json; charset=utf-8".to_string(),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{file}\""),
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
body,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct RestorePoint {
|
||||
id: String,
|
||||
/// RFC 3339
|
||||
created_at: String,
|
||||
/// `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
|
||||
#[ts(type = "\"before_restore\" | \"before_undo\"")]
|
||||
reason: String,
|
||||
#[ts(type = "number")]
|
||||
periods: i64,
|
||||
#[ts(type = "number")]
|
||||
entries: i64,
|
||||
/// Sıkıştırılmış boyut (bayt)
|
||||
#[ts(type = "number")]
|
||||
size: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct BackupStatus {
|
||||
/// Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
|
||||
last_backup_at: Option<String>,
|
||||
/// En yeniden eskiye; sahip olmayana boş
|
||||
restore_points: Vec<RestorePoint>,
|
||||
keep_points: u32,
|
||||
keep_days: u32,
|
||||
}
|
||||
|
||||
pub async fn status(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<BackupStatus>> {
|
||||
let points = if auth.role == Role::Owner {
|
||||
backup::points(&s.db, auth.household_id).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(Json(BackupStatus {
|
||||
last_backup_at: backup::last_exported(&s.db, auth.household_id)
|
||||
.await?
|
||||
.map(iso),
|
||||
restore_points: points
|
||||
.into_iter()
|
||||
.map(|p| RestorePoint {
|
||||
id: p.id.to_string(),
|
||||
created_at: iso(p.created_at),
|
||||
reason: p.reason,
|
||||
periods: p.periods,
|
||||
entries: p.entries,
|
||||
size: p.size,
|
||||
})
|
||||
.collect(),
|
||||
keep_points: backup::KEEP_POINTS as u32,
|
||||
keep_days: backup::KEEP_DAYS as u32,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct BackupCount {
|
||||
/// Koleksiyon adı (`entries`, `periods`, …)
|
||||
kind: String,
|
||||
/// Türkçe ad
|
||||
label: String,
|
||||
#[ts(type = "number")]
|
||||
backup: i64,
|
||||
#[ts(type = "number")]
|
||||
current: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct BackupPreview {
|
||||
source_name: String,
|
||||
same_household: bool,
|
||||
/// RFC 3339
|
||||
exported_at: String,
|
||||
app_version: String,
|
||||
counts: Vec<BackupCount>,
|
||||
/// Yedekteki dönemlerin ilk ve son günü
|
||||
first_date: Option<String>,
|
||||
last_date: Option<String>,
|
||||
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
|
||||
#[ts(type = "number")]
|
||||
changed_since: i64,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<BackupPreview>> {
|
||||
auth.require_owner()?;
|
||||
let snap = backup::parse(&body)?;
|
||||
let p = backup::preview(&s.db, auth.household_id, &snap).await?;
|
||||
Ok(Json(BackupPreview {
|
||||
source_name: p.source_name,
|
||||
same_household: p.same_household,
|
||||
exported_at: iso(p.exported_at),
|
||||
app_version: p.app_version,
|
||||
counts: p
|
||||
.counts
|
||||
.into_iter()
|
||||
.map(|c| BackupCount {
|
||||
kind: c.kind.into(),
|
||||
label: c.label.into(),
|
||||
backup: c.backup,
|
||||
current: c.current,
|
||||
})
|
||||
.collect(),
|
||||
first_date: p.first_date,
|
||||
last_date: p.last_date,
|
||||
changed_since: p.changed_since,
|
||||
warnings: p.warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct RestoreResult {
|
||||
/// İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
|
||||
restore_point_id: String,
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<RestoreResult>> {
|
||||
auth.require_owner()?;
|
||||
let snap = backup::parse(&body)?;
|
||||
let id = backup::restore(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
&snap,
|
||||
Reason::BeforeRestore,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(RestoreResult {
|
||||
restore_point_id: id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn undo(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<RestoreResult>> {
|
||||
auth.require_owner()?;
|
||||
let id = backup::undo(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||||
Ok(Json(RestoreResult {
|
||||
restore_point_id: id.to_string(),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user