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:
2026-11-13 20:49:13 +03:00
parent 6e1000afd2
commit 0d70576df7
20 changed files with 1768 additions and 2 deletions
+1
View File
@@ -81,6 +81,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
+2
View File
@@ -1425,6 +1425,7 @@ dependencies = [
"axum",
"axum-extra",
"dotenvy",
"flate2",
"http-body-util",
"jsonwebtoken",
"mongodb",
@@ -1432,6 +1433,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror",
"time",
"tokio",
+2
View File
@@ -8,12 +8,14 @@ argon2 = "0.6.0"
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenvy = "0.15.7"
flate2 = "1"
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
mongodb = "3.10.0"
quick-xml = { version = "0.42.0", features = ["serialize"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.10"
thiserror = "2.0.21"
time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] }
+23
View File
@@ -397,3 +397,26 @@ Content-Type: text/csv; charset=utf-8
Tarih;Tutar;Kategori;Açıklama
13.11.2026;45,90;Market;Ekmek
# ─────────────────────────── JSON tam hane yedeği ───────────────────────────
### Yedek dosyasını indir (her üye)
GET {{baseUrl}}/backup/download
### Son yedek zamanı + geri dönüş noktaları (noktaları yalnız sahip görür)
GET {{baseUrl}}/backup/status
### Önizleme (sahip; hiçbir şey yazılmaz) — gövde: indirilen dosyanın içeriği
POST {{baseUrl}}/backup/preview
Content-Type: application/json
< ./kakeibo-yedek.json
### Geri yükle (sahip): önce geri dönüş noktası, sonra tam değiştirme
POST {{baseUrl}}/backup/restore
Content-Type: application/json
< ./kakeibo-yedek.json
### Geri al: geri dönüş noktasına dön (bu da yeni bir nokta bırakır)
POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
+4
View File
@@ -89,6 +89,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
col::IMPORT_RULES,
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
),
(
col::RESTORE_POINTS,
vec![idx(doc! { "household_id": 1, "created_at": -1 })],
),
(
col::EXCHANGE_RATES,
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
+23
View File
@@ -35,8 +35,18 @@ const MIGRATIONS: &[Migration] = &[
name: "default_assets",
run: |db| Box::pin(m002_default_assets(db)),
},
Migration {
version: 3,
name: "restore_points",
run: |db| Box::pin(m003_restore_points(db)),
},
];
/// En son şema sürümü (JSON yedeğine yazılır; daha yeni yedek reddedilir).
pub fn latest_version() -> i32 {
MIGRATIONS.last().map_or(0, |m| m.version)
}
/// Uygulanmamış migration'ları sırayla çalıştırır.
pub async fn run(db: &Database) -> Result<()> {
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
@@ -103,6 +113,19 @@ async fn m002_default_assets(db: &Database) -> Result<()> {
Ok(())
}
/// v3: geri dönüş noktaları koleksiyonu (transaction içinde ilk yazımdan önce var olmalı).
async fn m003_restore_points(db: &Database) -> Result<()> {
if !db
.list_collection_names()
.await?
.iter()
.any(|n| n == col::RESTORE_POINTS)
{
db.create_collection(col::RESTORE_POINTS).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -73,6 +73,8 @@ pub mod col {
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
pub const IMPORT_RULES: &str = "import_rules";
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
/// JSON geri yüklemeden önce alınan, sıkıştırılmış hane kopyaları ("geri al")
pub const RESTORE_POINTS: &str = "restore_points";
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
pub const HOUSEHOLD_SCOPED: &[&str] = &[
+219
View File
@@ -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(),
}))
}
+17
View File
@@ -2,6 +2,7 @@
mod asset;
mod auth;
mod backup;
mod bank_import;
mod budget;
mod catalog;
@@ -92,6 +93,17 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route("/backup/download", get(backup::download))
.route("/backup/status", get(backup::status))
.route(
"/backup/preview",
post(backup::preview).layer(DefaultBodyLimit::max(backup_limit())),
)
.route(
"/backup/restore",
post(backup::restore).layer(DefaultBodyLimit::max(backup_limit())),
)
.route("/backup/restore-points/{id}/restore", post(backup::undo))
.route("/export/entries.csv", get(transfer::export_entries))
.route("/export/plan-items.csv", get(transfer::export_plan_items))
.route(
@@ -144,3 +156,8 @@ pub fn router(state: AppState) -> Router {
Router::new().nest("/api", api).with_state(state)
}
/// JSON yedeği gövde sınırı (`services::backup::MAX_BYTES`)
fn backup_limit() -> usize {
crate::services::backup::MAX_BYTES
}
+997
View File
@@ -0,0 +1,997 @@
//! JSON tam hane yedeği (Faz 9 Adım 5; tasarım docs/DATA_MODEL.md "JSON yedeği").
//!
//! - **Dışa aktarma:** hanenin tüm kayıtları (silinmiş işaretliler dahil → iç bağlantılar
//! kopmaz, "geri al" birebir döner). Kullanıcılar, şifreler, davetler, otomatik kurlar yok.
//! - **Biçim:** okunabilir JSON. UUID `{"$uuid": "…"}`, zaman `{"$date": "…"}` (MongoDB
//! Extended JSON'daki adlarla), tutarlar tam sayı (kuruş / `_e6`). `checksum` = SHA-256
//! (`household` + `data`, anahtarlar sıralı) → yarım inmiş ya da değiştirilmiş dosya reddedilir.
//! - **Geri yükleme (yalnız sahip):** mevcut veri yedektekiyle **tamamen değiştirilir** (tek
//! transaction). Öncesinde mevcut durum sıkıştırılıp `restore_points`'e yazılır (son 3, 30 gün).
//! - **Kimlikler:** aynı haneye → korunur; başka haneye → hepsi yeniden üretilir ve tüm
//! referanslar (`*_id`) çevrilir; `*_by` (kişi) bu hanede yoksa geri yükleyen kişi olur.
use std::{
collections::{HashMap, HashSet},
io::{Read, Write},
};
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
use mongodb::{
Database,
bson::{Binary, Bson, DateTime, Document, Uuid, doc, spec::BinarySubtype},
};
use serde_json::{Map, Number, Value};
use sha2::{Digest, Sha256};
use crate::{
db::{col, migrations},
error::{AppError, AppResult},
models::common::{new_id, now},
services::{
calendar::{format_date, parse_date},
seed,
},
};
pub const FORMAT: &str = "kakeibo-backup";
pub const FORMAT_VERSION: i64 = 1;
/// Yüklenebilecek en büyük yedek dosyası
pub const MAX_BYTES: usize = 20 * 1024 * 1024;
/// Saklanan geri dönüş noktası sayısı ve süresi
pub const KEEP_POINTS: usize = 3;
pub const KEEP_DAYS: i64 = 30;
/// Sıkıştırılmış geri dönüş noktası üst sınırı (MongoDB belge sınırı 16 MB)
const MAX_POINT_BYTES: usize = 15 * 1024 * 1024;
/// Yedekteki koleksiyonlar ve Türkçe adları (sıra dosyadaki sıradır).
pub const KINDS: &[(&str, &str)] = &[
(col::PERIODS, "Dönem"),
(col::PLAN_ITEMS, "Plan kalemi"),
(col::BUDGETS, "Bütçe"),
(col::ENTRIES, "Harcama"),
(col::CARRYOVERS, "Devir"),
(col::NOTES, "Not"),
(col::CATEGORIES, "Kategori"),
(col::PAYMENT_METHODS, "Ödeme türü"),
(col::ASSETS, "Varlık"),
(col::ASSET_TRANSACTIONS, "Varlık işlemi"),
(col::EXCHANGE_RATES, "Elle girilen kur"),
(col::IMPORT_RULES, "Ekstre kuralı"),
];
/// Uygulamanın `i32` yazdığı alanlar (geri kalan tam sayılar `i64`: tutarlar, `_e6`).
const INT32_KEYS: &[&str] = &["version", "sort_order"];
/// Bir hanenin bellekteki tam kopyası.
#[derive(Debug, Clone)]
pub struct Snapshot {
pub household_id: Uuid,
pub household_name: String,
pub settings: Document,
pub exported_at: DateTime,
pub schema_version: i64,
pub app_version: String,
/// `KINDS` sırasıyla
pub data: Vec<(&'static str, Vec<Document>)>,
}
impl Snapshot {
/// Silinmemiş kayıt sayısı (kullanıcıya gösterilen)
pub fn active(&self, kind: &str) -> i64 {
self.data
.iter()
.find(|(k, _)| *k == kind)
.map_or(0, |(_, docs)| {
docs.iter()
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)))
.count() as i64
})
}
}
fn corrupt(detail: &str) -> AppError {
tracing::warn!("Bozuk yedek: {detail}");
AppError::BadRequest(
"Yedek dosyası bozuk ya da değiştirilmiş; doğrulama tutmadı. Başka bir yedek deneyin."
.into(),
)
}
fn not_backup() -> AppError {
AppError::BadRequest(
"Bu dosya bir Kakeibo yedeği değil. Ayarlar → Veri'den alınan .json yedek dosyasını seçin."
.into(),
)
}
// ---------- Toplama ----------
async fn drain(mut cursor: mongodb::Cursor<Document>) -> AppResult<Vec<Document>> {
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
/// Hanenin tüm kayıtlarını okur (silinmiş işaretliler dahil).
pub async fn collect(db: &Database, household_id: Uuid) -> AppResult<Snapshot> {
let h = db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(doc! { "_id": household_id })
.await?
.ok_or(AppError::NotFound)?;
let mut data = Vec::with_capacity(KINDS.len());
for &(kind, _) in KINDS {
let cursor = db
.collection::<Document>(kind)
.find(doc! { "household_id": household_id })
.sort(doc! { "_id": 1 })
.await?;
data.push((kind, drain(cursor).await?));
}
Ok(Snapshot {
household_id,
household_name: h.get_str("name").unwrap_or_default().to_string(),
settings: h.get_document("settings").cloned().unwrap_or_default(),
exported_at: now(),
schema_version: i64::from(migrations::latest_version()),
app_version: env!("CARGO_PKG_VERSION").to_string(),
data,
})
}
// ---------- BSON ↔ JSON ----------
fn bson_to_json(b: &Bson) -> Result<Value, String> {
Ok(match b {
Bson::Null => Value::Null,
Bson::Boolean(v) => Value::Bool(*v),
Bson::Int32(v) => Value::from(*v),
Bson::Int64(v) => Value::from(*v),
Bson::Double(v) => Value::Number(Number::from_f64(*v).ok_or("NaN")?),
Bson::String(s) => Value::String(s.clone()),
Bson::Array(a) => Value::Array(a.iter().map(bson_to_json).collect::<Result<_, _>>()?),
Bson::Document(d) => doc_to_json(d)?,
Bson::DateTime(dt) => {
let s = dt.try_to_rfc3339_string().map_err(|e| e.to_string())?;
Value::Object(Map::from_iter([("$date".into(), Value::String(s))]))
}
Bson::Binary(Binary {
subtype: BinarySubtype::Uuid,
bytes,
}) => {
let arr: [u8; 16] = bytes.as_slice().try_into().map_err(|_| "uuid")?;
Value::Object(Map::from_iter([(
"$uuid".into(),
Value::String(Uuid::from_bytes(arr).to_string()),
)]))
}
other => return Err(format!("desteklenmeyen tür: {:?}", other.element_type())),
})
}
fn doc_to_json(d: &Document) -> Result<Value, String> {
let mut m = Map::new();
for (k, v) in d {
m.insert(k.clone(), bson_to_json(v)?);
}
Ok(Value::Object(m))
}
fn json_to_bson(v: &Value, key: &str) -> Option<Bson> {
Some(match v {
Value::Null => Bson::Null,
Value::Bool(b) => Bson::Boolean(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
if INT32_KEYS.contains(&key) {
Bson::Int32(i32::try_from(i).ok()?)
} else {
Bson::Int64(i)
}
} else if n.is_u64() {
return None;
} else {
Bson::Double(n.as_f64()?)
}
}
Value::String(s) => Bson::String(s.clone()),
Value::Array(a) => Bson::Array(
a.iter()
.map(|x| json_to_bson(x, key))
.collect::<Option<_>>()?,
),
Value::Object(m) => {
if m.len() == 1 {
if let Some(Value::String(s)) = m.get("$uuid") {
return Uuid::parse_str(s).ok().map(Bson::from);
}
if let Some(Value::String(s)) = m.get("$date") {
return DateTime::parse_rfc3339_str(s).ok().map(Bson::DateTime);
}
}
Bson::Document(json_to_doc(m)?)
}
})
}
fn json_to_doc(m: &Map<String, Value>) -> Option<Document> {
let mut d = Document::new();
for (k, v) in m {
// Operatör ya da yol gibi görünen anahtarlar asla veritabanına gitmez
if k.starts_with('$') || k.contains('.') || k.contains('\0') {
return None;
}
d.insert(k.clone(), json_to_bson(v, k)?);
}
Some(d)
}
/// `i32`'ye sığan tam sayıları `Int32` yapar (iç içe belgeler dahil)
fn small_ints(d: Document) -> Document {
fn conv(b: Bson) -> Bson {
match b {
Bson::Int64(v) => i32::try_from(v).map_or(Bson::Int64(v), Bson::Int32),
Bson::Document(d) => Bson::Document(small_ints(d)),
Bson::Array(a) => Bson::Array(a.into_iter().map(conv).collect()),
other => other,
}
}
d.into_iter().map(|(k, v)| (k, conv(v))).collect()
}
/// Anahtarları her düzeyde sıralı kopya (dosya biçimlendirmesinden bağımsız özet için)
fn canonical(v: &Value) -> Value {
match v {
Value::Object(m) => {
let mut keys: Vec<&String> = m.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|k| (k.clone(), canonical(&m[k])))
.collect(),
)
}
Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
other => other.clone(),
}
}
fn checksum(household: &Value, data: &Value) -> String {
let body = canonical(&Value::Object(Map::from_iter([
("household".to_string(), household.clone()),
("data".to_string(), data.clone()),
])));
let hash = Sha256::digest(serde_json::to_string(&body).unwrap_or_default().as_bytes());
format!("sha256:{hash:x}")
}
// ---------- Yazma (dosya) ----------
/// Yedek dosyasının metni. Başlık önce, sonra `data` (her kayıt bir satır).
pub fn to_json(s: &Snapshot) -> AppResult<String> {
let internal = |e: String| AppError::Internal(format!("yedek dönüştürme: {e}"));
let household = serde_json::json!({
"id": s.household_id.to_string(),
"name": s.household_name,
"settings": doc_to_json(&s.settings).map_err(internal)?,
});
let mut data = Map::new();
let mut counts = Map::new();
for (kind, docs) in &s.data {
let arr = docs
.iter()
.map(doc_to_json)
.collect::<Result<Vec<_>, _>>()
.map_err(internal)?;
data.insert(kind.to_string(), Value::Array(arr));
counts.insert(kind.to_string(), Value::from(s.active(kind)));
}
let data = Value::Object(data);
let header: [(&str, Value); 8] = [
("format", FORMAT.into()),
("format_version", FORMAT_VERSION.into()),
("schema_version", s.schema_version.into()),
("app_version", s.app_version.clone().into()),
(
"exported_at",
s.exported_at
.try_to_rfc3339_string()
.map_err(|e| internal(e.to_string()))?
.into(),
),
("household", household.clone()),
("counts", Value::Object(counts)),
("checksum", checksum(&household, &data).into()),
];
let j = |v: &Value| serde_json::to_string(v).unwrap_or_default();
let mut out = String::from("{\n");
for (k, v) in &header {
out.push_str(&format!(" {}: {},\n", j(&(*k).into()), j(v)));
}
out.push_str(" \"data\": {");
let Value::Object(map) = &data else {
unreachable!()
};
for (i, (kind, arr)) in map.iter().enumerate() {
out.push_str(if i == 0 { "\n" } else { ",\n" });
out.push_str(&format!(" {}: [", j(&kind.as_str().into())));
let items = arr.as_array().map(Vec::as_slice).unwrap_or_default();
for (n, item) in items.iter().enumerate() {
out.push_str(if n == 0 { "\n " } else { ",\n " });
out.push_str(&j(item));
}
out.push_str(if items.is_empty() { "]" } else { "\n ]" });
}
out.push_str("\n }\n}\n");
Ok(out)
}
// ---------- Okuma (dosya) ----------
/// Dosyayı okur ve doğrular: biçim, sürüm, özet, kayıt yapısı.
pub fn parse(bytes: &[u8]) -> AppResult<Snapshot> {
let v: Value = serde_json::from_slice(bytes).map_err(|_| not_backup())?;
let o = v.as_object().ok_or_else(not_backup)?;
if o.get("format").and_then(Value::as_str) != Some(FORMAT) {
return Err(not_backup());
}
let newer = || {
AppError::BadRequest(
"Bu yedek uygulamanın daha yeni bir sürümüyle alınmış. Önce uygulamayı güncelleyin."
.into(),
)
};
let format_version = o.get("format_version").and_then(Value::as_i64).unwrap_or(0);
let schema_version = o.get("schema_version").and_then(Value::as_i64).unwrap_or(0);
if format_version < 1 || schema_version < 1 {
return Err(corrupt("sürüm alanı yok"));
}
if format_version > FORMAT_VERSION || schema_version > i64::from(migrations::latest_version()) {
return Err(newer());
}
let household = o.get("household").ok_or_else(|| corrupt("household yok"))?;
let data = o.get("data").ok_or_else(|| corrupt("data yok"))?;
if o.get("checksum").and_then(Value::as_str) != Some(checksum(household, data).as_str()) {
return Err(corrupt("checksum"));
}
let household_id = household
.get("id")
.and_then(Value::as_str)
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| corrupt("household.id"))?;
let settings = match household.get("settings") {
// Ayarlar uygulamada i32 yazılır (ör. yüzdeler)
Some(Value::Object(m)) => small_ints(json_to_doc(m).ok_or_else(|| corrupt("settings"))?),
_ => Document::new(),
};
let data = data
.as_object()
.ok_or_else(|| corrupt("data nesne değil"))?;
if let Some(k) = data.keys().find(|k| !KINDS.iter().any(|(c, _)| c == k)) {
return Err(corrupt(&format!("bilinmeyen koleksiyon {k}")));
}
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(KINDS.len());
for &(kind, _) in KINDS {
let items = match data.get(kind) {
None => &Vec::new(),
Some(Value::Array(a)) => a,
Some(_) => return Err(corrupt(kind)),
};
let mut docs = Vec::with_capacity(items.len());
for item in items {
let d = item
.as_object()
.and_then(json_to_doc)
.ok_or_else(|| corrupt(kind))?;
let id = match d.get("_id") {
Some(Bson::Binary(b)) if b.subtype == BinarySubtype::Uuid => b.bytes.clone(),
_ => return Err(corrupt(&format!("{kind}: _id"))),
};
if d.get("household_id") != Some(&Bson::from(household_id)) || !seen.insert(id) {
return Err(corrupt(&format!("{kind}: household_id / tekrar eden _id")));
}
docs.push(d);
}
out.push((kind, docs));
}
Ok(Snapshot {
household_id,
household_name: household
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
settings,
exported_at: o
.get("exported_at")
.and_then(Value::as_str)
.and_then(|s| DateTime::parse_rfc3339_str(s).ok())
.ok_or_else(|| corrupt("exported_at"))?,
schema_version,
app_version: o
.get("app_version")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
data: out,
})
}
// ---------- Önizleme ----------
#[derive(Debug)]
pub struct KindCount {
pub kind: &'static str,
pub label: &'static str,
pub backup: i64,
pub current: i64,
}
#[derive(Debug)]
pub struct Preview {
pub source_name: String,
pub same_household: bool,
pub exported_at: DateTime,
pub app_version: String,
pub counts: Vec<KindCount>,
/// Yedekteki dönemlerin ilk ve son günü (`"YYYY-MM-DD"`)
pub first_date: Option<String>,
pub last_date: Option<String>,
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt sayısı
pub changed_since: i64,
pub warnings: Vec<String>,
}
pub async fn preview(db: &Database, household_id: Uuid, snap: &Snapshot) -> AppResult<Preview> {
let same = snap.household_id == household_id;
let mut counts = Vec::with_capacity(KINDS.len());
let mut changed_since = 0;
for &(kind, label) in KINDS {
let current = db
.collection::<Document>(kind)
.count_documents(doc! { "household_id": household_id, "deleted_at": null })
.await? as i64;
if same {
changed_since += db
.collection::<Document>(kind)
.count_documents(
doc! { "household_id": household_id, "updated_at": { "$gt": snap.exported_at } },
)
.await? as i64;
}
counts.push(KindCount {
kind,
label,
backup: snap.active(kind),
current,
});
}
let periods = snap
.data
.iter()
.find(|(k, _)| *k == col::PERIODS)
.map(|(_, d)| d.as_slice())
.unwrap_or_default();
let live = periods
.iter()
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)));
let first_date = live
.clone()
.filter_map(|d| d.get_str("start_date").ok())
.min()
.map(str::to_string);
let last_date = live
.filter_map(|d| d.get_str("end_date").ok())
.max()
.and_then(parse_date)
.map(|d| format_date(d - time::Duration::days(1)));
let mut warnings = Vec::new();
if !same {
warnings.push(format!(
"Bu yedek başka bir haneden alınmış (“{}”). Kayıtlar bu haneye kopyalanır; kaydı giren kişi bu hanede yoksa kayıt sizin adınıza görünür.",
snap.household_name
));
}
if changed_since > 0 {
warnings.push(format!(
"Yedek alındıktan sonra eklediğiniz ya da değiştirdiğiniz {changed_since} kayıt bu işlemle kaybolur. Gerekirse sonradan “Geri al” ile dönebilirsiniz."
));
}
if snap.schema_version < i64::from(migrations::latest_version()) {
warnings.push(
"Bu yedek uygulamanın eski bir sürümüyle alınmış; geri yüklenirken güncellenir.".into(),
);
}
if snap.active(col::PERIODS) == 0 {
warnings.push("Yedekte hiç dönem yok; geri yüklenirse hane boş kalır.".into());
}
Ok(Preview {
source_name: snap.household_name.clone(),
same_household: same,
exported_at: snap.exported_at,
app_version: snap.app_version.clone(),
counts,
first_date,
last_date,
changed_since,
warnings,
})
}
// ---------- Kimlik çevirme ----------
fn uuid_of(b: Option<&Bson>) -> Option<Uuid> {
match b {
Some(Bson::Binary(bin)) if bin.subtype == BinarySubtype::Uuid => {
<[u8; 16]>::try_from(bin.bytes.as_slice())
.ok()
.map(Uuid::from_bytes)
}
_ => None,
}
}
/// Yedeği hedef haneye uyarlar. Aynı hane → kimlikler aynı; başka hane → yeni kimlikler,
/// tüm referanslar çevrilir. `*_by` alanı hanede olmayan biriyse geri yükleyen kişi olur.
fn retarget(
snap: &Snapshot,
target: Uuid,
restorer: Uuid,
members: &HashSet<Uuid>,
) -> Vec<(&'static str, Vec<Document>)> {
let same = snap.household_id == target;
let mut map: HashMap<Uuid, Uuid> = HashMap::new();
for (_, docs) in &snap.data {
for d in docs {
if let Some(old) = uuid_of(d.get("_id")) {
map.insert(old, if same { old } else { new_id() });
}
}
}
fn fix(b: &mut Bson, key: &str, ctx: &Ctx) {
match b {
Bson::Binary(bin) if bin.subtype == BinarySubtype::Uuid => {
let Ok(arr) = <[u8; 16]>::try_from(bin.bytes.as_slice()) else {
return;
};
let old = Uuid::from_bytes(arr);
let new = if key == "household_id" {
ctx.target
} else if key.ends_with("_by") {
if ctx.members.contains(&old) {
old
} else {
ctx.restorer
}
} else {
// Yedekte olmayan (zaten kopuk) referans olduğu gibi kalır
*ctx.map.get(&old).unwrap_or(&old)
};
*b = Bson::from(new);
}
Bson::Document(d) => {
for (k, v) in d.iter_mut() {
fix(v, k, ctx);
}
}
Bson::Array(a) => {
for v in a.iter_mut() {
fix(v, key, ctx);
}
}
_ => {}
}
}
struct Ctx<'a> {
target: Uuid,
restorer: Uuid,
members: &'a HashSet<Uuid>,
map: &'a HashMap<Uuid, Uuid>,
}
let ctx = Ctx {
target,
restorer,
members,
map: &map,
};
snap.data
.iter()
.map(|(kind, docs)| {
let docs = docs
.iter()
.map(|d| {
let mut b = Bson::Document(d.clone());
fix(&mut b, "", &ctx);
match b {
Bson::Document(d) => d,
_ => unreachable!(),
}
})
.collect();
(*kind, docs)
})
.collect()
}
// ---------- Geri yükleme ----------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
/// JSON dosyasından geri yüklemeden önce
BeforeRestore,
/// Geri dönüş noktasına dönmeden önce
BeforeUndo,
}
impl Reason {
pub fn as_str(self) -> &'static str {
match self {
Reason::BeforeRestore => "before_restore",
Reason::BeforeUndo => "before_undo",
}
}
}
fn gzip(text: &str) -> AppResult<Vec<u8>> {
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(text.as_bytes())
.and_then(|_| enc.finish())
.map_err(|e| AppError::Internal(format!("sıkıştırma: {e}")))
}
fn gunzip(bytes: &[u8]) -> AppResult<Vec<u8>> {
let mut out = Vec::new();
GzDecoder::new(bytes)
.take(MAX_BYTES as u64 * 4)
.read_to_end(&mut out)
.map_err(|e| AppError::Internal(format!("geri dönüş noktası açılamadı: {e}")))?;
Ok(out)
}
/// Mevcut veriyi geri dönüş noktasına alır ve hanenin verisini `snap` ile değiştirir.
/// Hepsi tek transaction: yarıda kalırsa hiçbir şey değişmez. Dönen: geri dönüş noktası kimliği.
pub async fn restore(
db: &Database,
household_id: Uuid,
restorer: Uuid,
snap: &Snapshot,
reason: Reason,
) -> AppResult<Uuid> {
// 1) Şu anki durumun kopyası (sıkıştırılmış)
let current = collect(db, household_id).await?;
let packed = gzip(&to_json(&current)?)?;
if packed.len() > MAX_POINT_BYTES {
return Err(AppError::BadRequest(
"Mevcut veriniz geri dönüş noktası için çok büyük; güvenlik için geri yükleme yapılmadı."
.into(),
));
}
// 2) Yedeği bu haneye uyarla
let members: HashSet<Uuid> = {
let cursor = db
.collection::<Document>(col::USERS)
.find(doc! { "household_id": household_id })
.projection(doc! { "_id": 1 })
.await?;
drain(cursor)
.await?
.iter()
.filter_map(|d| uuid_of(d.get("_id")))
.collect()
};
let docs = retarget(snap, household_id, restorer, &members);
// 3) Tek transaction: nokta yaz → sil → ekle → ayarlar
let point_id = new_id();
let ts = now();
let mut counts = Document::new();
for &(kind, _) in KINDS {
counts.insert(kind, current.active(kind));
}
let mut session = db.client().start_session().await?;
session.start_transaction().await?;
let result = async {
db.collection::<Document>(col::RESTORE_POINTS)
.insert_one(doc! {
"_id": point_id,
"household_id": household_id,
"created_at": ts,
"created_by": restorer,
"reason": reason.as_str(),
"counts": counts,
"size": packed.len() as i64,
"data": Binary { subtype: BinarySubtype::Generic, bytes: packed },
})
.session(&mut session)
.await?;
for (kind, list) in &docs {
db.collection::<Document>(kind)
.delete_many(doc! { "household_id": household_id })
.session(&mut session)
.await?;
if !list.is_empty() {
db.collection::<Document>(kind)
.insert_many(list)
.session(&mut session)
.await?;
}
}
let mut set = doc! { "updated_at": ts };
if !snap.settings.is_empty() {
set.insert("settings", snap.settings.clone());
}
if snap.household_id == household_id && !snap.household_name.is_empty() {
set.insert("name", snap.household_name.clone());
}
db.collection::<Document>(col::HOUSEHOLDS)
.update_one(
doc! { "_id": household_id },
doc! { "$set": set, "$inc": { "version": 1 } },
)
.session(&mut session)
.await?;
Ok::<(), AppError>(())
}
.await;
match result {
Ok(()) => session.commit_transaction().await?,
Err(e) => {
let _ = session.abort_transaction().await;
return Err(e);
}
}
// 4) Eski sürüm yedeği → o sürümden sonra gelen hane düzeyi migration'lar
if snap.schema_version < 2
&& db
.collection::<Document>(col::ASSETS)
.find_one(doc! { "household_id": household_id })
.await?
.is_none()
{
db.collection::<Document>(col::ASSETS)
.insert_many(seed::assets(household_id))
.await?;
}
prune(db, household_id).await?;
tracing::info!(
"Hane {household_id} geri yüklendi ({}); geri dönüş noktası {point_id}",
reason.as_str()
);
Ok(point_id)
}
// ---------- Geri dönüş noktaları ----------
#[derive(Debug)]
pub struct PointInfo {
pub id: Uuid,
pub created_at: DateTime,
pub reason: String,
pub periods: i64,
pub entries: i64,
pub size: i64,
}
/// En yeniden eskiye (veri olmadan)
pub async fn points(db: &Database, household_id: Uuid) -> AppResult<Vec<PointInfo>> {
let cursor = db
.collection::<Document>(col::RESTORE_POINTS)
.find(doc! { "household_id": household_id })
.projection(doc! { "data": 0 })
.sort(doc! { "created_at": -1 })
.await?;
let docs = drain(cursor).await?;
let int = |d: &Document, k: &str| match d.get(k) {
Some(Bson::Int64(v)) => *v,
Some(Bson::Int32(v)) => i64::from(*v),
_ => 0,
};
Ok(docs
.iter()
.filter_map(|d| {
let id = uuid_of(d.get("_id"))?;
let counts = d.get_document("counts").cloned().unwrap_or_default();
Some(PointInfo {
id,
created_at: d.get_datetime("created_at").ok().copied()?,
reason: d.get_str("reason").unwrap_or_default().to_string(),
periods: int(&counts, col::PERIODS),
entries: int(&counts, col::ENTRIES),
size: int(d, "size"),
})
})
.collect())
}
/// Geri dönüş noktasına döner (öncesinde şu anki durum da nokta olur → bu da geri alınabilir).
pub async fn undo(
db: &Database,
household_id: Uuid,
restorer: Uuid,
point_id: Uuid,
) -> AppResult<Uuid> {
let d = db
.collection::<Document>(col::RESTORE_POINTS)
.find_one(doc! { "_id": point_id, "household_id": household_id })
.await?
.ok_or(AppError::NotFound)?;
let bytes = match d.get("data") {
Some(Bson::Binary(b)) => gunzip(&b.bytes)?,
_ => return Err(AppError::Internal("geri dönüş noktasında veri yok".into())),
};
let snap = parse(&bytes)?;
restore(db, household_id, restorer, &snap, Reason::BeforeUndo).await
}
/// Son `KEEP_POINTS` nokta kalır; `KEEP_DAYS` günden eskiler silinir.
pub async fn prune(db: &Database, household_id: Uuid) -> AppResult<()> {
let list = points(db, household_id).await?;
let cutoff = now().timestamp_millis() - KEEP_DAYS * 86_400_000;
let old: Vec<Uuid> = list
.iter()
.enumerate()
.filter(|(i, p)| *i >= KEEP_POINTS || p.created_at.timestamp_millis() < cutoff)
.map(|(_, p)| p.id)
.collect();
if !old.is_empty() {
db.collection::<Document>(col::RESTORE_POINTS)
.delete_many(doc! { "household_id": household_id, "_id": { "$in": old } })
.await?;
}
Ok(())
}
/// Dışa aktarma zamanı ("son yedek X gün önce" hatırlatması için)
pub async fn mark_exported(db: &Database, household_id: Uuid) -> AppResult<()> {
db.collection::<Document>(col::HOUSEHOLDS)
.update_one(
doc! { "_id": household_id },
doc! { "$set": { "last_backup_at": now() } },
)
.await?;
Ok(())
}
pub async fn last_exported(db: &Database, household_id: Uuid) -> AppResult<Option<DateTime>> {
Ok(db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(doc! { "_id": household_id })
.await?
.and_then(|h| h.get_datetime("last_backup_at").ok().copied()))
}
/// Dosya adı için hane adı: `Çakır Hanesi` → `cakir-hanesi`
pub fn slug(name: &str) -> String {
let folded = crate::services::bank_import::fold(name);
let s: String = folded
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let s = s
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-");
if s.is_empty() { "hane".into() } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Snapshot {
let hid = new_id();
let pid = new_id();
let ts = DateTime::from_millis(1_763_000_000_123);
Snapshot {
household_id: hid,
household_name: "Çakır Hanesi".into(),
settings: doc! { "currency": "TRY", "budget_split_pct": { "needs": 50 } },
exported_at: ts,
schema_version: i64::from(migrations::latest_version()),
app_version: "0.1.0".into(),
data: KINDS
.iter()
.map(|&(k, _)| {
let docs = if k == col::PERIODS {
vec![doc! {
"_id": pid, "household_id": hid, "start_date": "2026-11-01",
"end_date": "2026-12-01", "created_at": ts, "deleted_at": null,
"version": 3_i32, "to_savings": 1_250_i64, "rate": 1.5_f64,
"tags": ["a", "b"], "review": { "note": "İyi", "reviewed_by": hid },
}]
} else {
Vec::new()
};
(k, docs)
})
.collect(),
}
}
#[test]
fn json_round_trip_keeps_types() {
let s = sample();
let text = to_json(&s).unwrap();
assert!(text.starts_with("{\n \"format\": \"kakeibo-backup\""));
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back.household_id, s.household_id);
assert_eq!(back.household_name, "Çakır Hanesi");
assert_eq!(back.settings, s.settings.clone());
assert_eq!(back.exported_at, s.exported_at);
assert_eq!(
back.data[0].1, s.data[0].1,
"belge birebir aynı (türler dahil)"
);
// Yeniden biçimlendirilmiş (ör. düzenleyicide açılıp kaydedilmiş) dosya da geçerli
let pretty =
serde_json::to_string_pretty(&serde_json::from_str::<Value>(&text).unwrap()).unwrap();
assert!(parse(pretty.as_bytes()).is_ok());
}
#[test]
fn tampered_or_foreign_files_are_rejected() {
let text = to_json(&sample()).unwrap();
let msg = |b: &[u8]| parse(b).unwrap_err().to_string();
assert!(msg(text.replace("1250", "9250").as_bytes()).contains("bozuk"));
assert!(msg(&text.as_bytes()[..text.len() / 2]).contains("Kakeibo yedeği değil"));
assert!(msg(b"{\"a\": 1}").contains("Kakeibo yedeği değil"));
let newer = text.replace("\"format_version\": 1", "\"format_version\": 2");
assert!(msg(newer.as_bytes()).contains("daha yeni"));
}
#[test]
fn retarget_maps_ids_and_people() {
let s = sample();
let pid = uuid_of(s.data[0].1[0].get("_id")).unwrap();
let target = new_id();
let me = new_id();
let mut plan = s.clone();
plan.data[1].1.push(doc! {
"_id": new_id(), "household_id": s.household_id, "period_id": pid,
"created_by": new_id(), "missing_id": new_id(),
});
// Başka hane: yeni kimlikler, referans çevrilir, kişi → geri yükleyen
let out = retarget(&plan, target, me, &HashSet::new());
let p = &out[0].1[0];
let item = &out[1].1[0];
assert_ne!(p.get("_id"), Some(&Bson::from(pid)));
assert_eq!(item.get("period_id"), p.get("_id"));
assert_eq!(item.get("household_id"), Some(&Bson::from(target)));
assert_eq!(item.get("created_by"), Some(&Bson::from(me)));
assert_eq!(
p.get_document("review").unwrap().get("reviewed_by"),
Some(&Bson::from(me)),
"iç içe *_by"
);
assert_eq!(item.get("missing_id"), plan.data[1].1[0].get("missing_id"));
// Aynı hane: kimlikler aynı
let out = retarget(&plan, s.household_id, me, &HashSet::new());
assert_eq!(out[0].1[0].get("_id"), Some(&Bson::from(pid)));
}
#[test]
fn file_name_slug() {
assert_eq!(slug("Çakır Hanesi"), "cakir-hanesi");
assert_eq!(slug(" İZMİR / Ev "), "izmir-ev");
assert_eq!(slug("***"), "hane");
}
}
+1
View File
@@ -2,6 +2,7 @@
pub mod account;
pub mod asset;
pub mod backup;
pub mod bank_import;
pub mod budget;
pub mod calendar;
+388
View File
@@ -0,0 +1,388 @@
//! JSON tam hane yedeği: dışa aktarma, önizleme, aynı / başka haneye geri yükleme,
//! geri dönüş noktaları ("geri al"), bozuk dosya, yetki.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Bson, Document, doc};
use serde_json::{Value, json};
async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
let (_, list) = app.get(path, s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == name)
.unwrap_or_else(|| panic!("{name} yok"))["id"]
.as_str()
.unwrap()
.to_string()
}
fn id(v: &Value) -> String {
v["id"].as_str().unwrap().to_string()
}
/// Ekim (kapalı, birikim TL'ye aktarılmış, devirli) + Kasım (açık, harcama, not, bütçe)
async fn build(app: &TestApp, s: &Session) -> (String, String) {
let market = id_by(app, s, "/categories", "Market").await;
let (_, oct) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
)
.await;
let oct = id(&oct);
app.post(
&format!("/periods/{oct}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
for (date, amount) in [("2026-10-05", 200_000), ("2026-10-06", 1)] {
let (st, e) = app
.post(
&format!("/periods/{oct}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": market, "description": "Ekim" }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
if amount == 1 {
app.delete(&format!("/entries/{}", id(&e)), s).await; // silinmiş kayıt da taşınır
}
}
let (st, b) = app
.post(
&format!("/periods/{oct}/close"),
s,
json!({ "to_savings": 500_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let (st, b) = app
.post(&format!("/periods/{oct}/to-savings"), s, json!({}))
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
let (_, nov) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
)
.await;
let nov = id(&nov);
app.put(
&format!("/periods/{nov}/budget"),
s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }),
)
.await;
app.post(
&format!("/periods/{nov}/entries"),
s,
json!({ "date": "2026-11-03", "amount": 45_090, "category_id": market, "description": "Migros" }),
)
.await;
app.post(
&format!("/periods/{nov}/notes"),
s,
json!({ "context": "planning", "title": "Hedef" }),
)
.await;
(oct, nov)
}
async fn download(app: &TestApp, s: &Session) -> String {
let (st, ct, body) = app.get_text("/backup/download", s).await;
assert_eq!(st, StatusCode::OK, "{body}");
assert!(ct.starts_with("application/json"), "{ct}");
body
}
fn data(file: &str) -> Value {
serde_json::from_str::<Value>(file).unwrap()["data"].clone()
}
/// Bir dönemin özet + harcama listesi (karşılaştırma için)
async fn view(app: &TestApp, s: &Session, pid: &str) -> (Value, Value) {
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), s).await;
let (_, list) = app.get(&format!("/periods/{pid}/entries"), s).await;
(sum, list)
}
async fn points(app: &TestApp, s: &Session) -> Vec<Value> {
let (_, st) = app.get("/backup/status", s).await;
st["restore_points"].as_array().unwrap().clone()
}
#[tokio::test]
async fn same_household_restore_and_undo_chain() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let (oct, nov) = build(&app, &s).await;
let before = view(&app, &s, &nov).await;
let (_, st) = app.get("/backup/status", &s).await;
assert!(st["last_backup_at"].is_null(), "henüz yedek yok");
let file = download(&app, &s).await;
let v: Value = serde_json::from_str(&file).unwrap();
assert_eq!(v["format"], "kakeibo-backup");
assert_eq!(v["counts"]["entries"], 2, "silinmiş sayılmaz");
assert_eq!(
v["data"]["entries"].as_array().unwrap().len(),
3,
"ama dosyada var"
);
assert!(v["data"].get("users").is_none() && !file.contains("password"));
let (_, st) = app.get("/backup/status", &s).await;
assert!(st["last_backup_at"].is_string());
// Yedekten sonra değişiklikler
let market = id_by(&app, &s, "/categories", "Market").await;
app.post(
&format!("/periods/{nov}/entries"),
&s,
json!({ "date": "2026-11-10", "amount": 999, "category_id": market }),
)
.await;
app.patch(
&format!("/categories/{market}"),
&s,
json!({ "name": "Market (yeni)" }),
)
.await;
// Önizleme: hiçbir şey yazmaz
let (st, p) = app.post_text("/backup/preview", &s, &file).await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["same_household"], true);
assert_eq!(
(p["first_date"].as_str(), p["last_date"].as_str()),
(Some("2026-10-01"), Some("2026-11-30"))
);
let entries = p["counts"]
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "entries")
.unwrap();
assert_eq!(
(entries["backup"].as_i64(), entries["current"].as_i64()),
(Some(2), Some(3))
);
assert_eq!(p["changed_since"], 2, "{p}");
assert!(p["warnings"][0].as_str().unwrap().contains("2 kayıt"));
assert!(points(&app, &s).await.is_empty());
// Geri yükle → yedekteki durum birebir
let (st, r) = app.post_text("/backup/restore", &s, &file).await;
assert_eq!(st, StatusCode::OK, "{r}");
assert_eq!(view(&app, &s, &nov).await, before);
assert_eq!(
data(&download(&app, &s).await),
data(&file),
"kayıtlar birebir"
);
let (_, oct_p) = app.get(&format!("/periods/{oct}"), &s).await;
assert_eq!(oct_p["status"], "closed");
// Tür korunur: version i32, tutar i64
let e: Document = app
.db
.collection::<Document>("entries")
.find_one(doc! { "amount": 45_090_i64 })
.await
.unwrap()
.unwrap();
assert!(matches!(e.get("version"), Some(Bson::Int32(_))));
assert!(matches!(e.get("amount"), Some(Bson::Int64(_))));
let list = points(&app, &s).await;
assert_eq!(list.len(), 1);
assert_eq!(list[0]["reason"], "before_restore");
assert_eq!(list[0]["entries"], 3, "geri yüklemeden önceki durum");
assert_eq!(list[0]["id"], r["restore_point_id"]);
// Geri al → değişiklikler geri gelir; bu da bir nokta bırakır
let (st, u) = app
.post(
&format!(
"/backup/restore-points/{}/restore",
r["restore_point_id"].as_str().unwrap()
),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::OK, "{u}");
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
assert_eq!(list.as_array().unwrap().len(), 2);
assert!(
app.get("/categories", &s)
.await
.1
.as_array()
.unwrap()
.iter()
.any(|c| c["name"] == "Market (yeni)")
);
let list = points(&app, &s).await;
assert_eq!(
list.iter().map(|p| p["reason"].clone()).collect::<Vec<_>>(),
vec![json!("before_undo"), json!("before_restore")]
);
// "Geri al"ı geri al → yine yedekteki durum
let (st, _) = app
.post(
&format!(
"/backup/restore-points/{}/restore",
u["restore_point_id"].as_str().unwrap()
),
&s,
json!({}),
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(view(&app, &s, &nov).await, before);
// En fazla 3 nokta saklanır
app.post_text("/backup/restore", &s, &file).await;
assert_eq!(points(&app, &s).await.len(), 3);
app.cleanup().await;
}
#[tokio::test]
async fn copy_to_another_household_gets_new_ids() {
let app = TestApp::new().await;
let a = app.register("Kaynak", None).await;
let (oct, nov) = build(&app, &a).await;
let file = download(&app, &a).await;
let a_before = view(&app, &a, &nov).await;
let b = app.register("Hedef", None).await;
let (st, p) = app.post_text("/backup/preview", &b, &file).await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["same_household"], false);
assert_eq!(p["source_name"], "Kaynak Hanesi");
assert!(
p["warnings"][0]
.as_str()
.unwrap()
.contains("başka bir haneden")
);
for round in 0..2 {
// İki kez yüklense de kimlik çakışmaz
let (st, r) = app.post_text("/backup/restore", &b, &file).await;
assert_eq!(st, StatusCode::OK, "tur {round}: {r}");
}
let (_, periods) = app.get("/periods", &b).await;
let periods = periods.as_array().unwrap();
assert_eq!(periods.len(), 2);
assert!(
periods
.iter()
.all(|p| p["id"] != oct.as_str() && p["id"] != nov.as_str())
);
let b_nov = periods.iter().find(|p| p["status"] == "open").unwrap();
let b_nov_id = id(b_nov);
// Aynı rakamlar, yeni kimlikler; referanslar bu hanenin kayıtlarına
let (sum, list) = view(&app, &b, &b_nov_id).await;
assert_eq!(sum, a_before.0);
let entry = &list[0];
assert_eq!(entry["amount"], 45_090);
assert_ne!(entry["id"], a_before.1[0]["id"]);
let (_, cats) = app.get("/categories", &b).await;
assert!(
cats.as_array()
.unwrap()
.iter()
.any(|c| c["id"] == entry["category_id"])
);
let (_, me) = app.get("/auth/me", &b).await;
assert_eq!(
entry["created_by"], me["user"]["id"],
"kaynak kişi → geri yükleyen"
);
let (_, pf) = app.get("/portfolio", &b).await;
assert!(
pf.to_string().contains("500000"),
"birikim aktarımı da taşındı: {pf}"
);
// Kaynak hane değişmedi; hedef kaynağın kayıtlarını göremez
assert_eq!(view(&app, &a, &nov).await, a_before);
let (st, _) = app.get(&format!("/periods/{nov}"), &b).await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
#[tokio::test]
async fn bad_files_and_permissions() {
let app = TestApp::new().await;
let owner = app.register("Sahip", None).await;
let (_, nov) = build(&app, &owner).await;
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let member = app
.register("Uye", Some(inv["code"].as_str().unwrap()))
.await;
// Üye yedek alabilir, geri yükleyemez
let file = download(&app, &member).await;
for path in ["/backup/preview", "/backup/restore"] {
let (st, _) = app.post_text(path, &member, &file).await;
assert_eq!(st, StatusCode::FORBIDDEN, "{path}");
}
// Bozuk / değiştirilmiş / yabancı dosya → anlaşılır hata, hiçbir şey yazılmaz
let before = view(&app, &owner, &nov).await;
let tampered = file.replacen("45090", "45091", 1);
let cases = [
(tampered.as_str(), "bozuk"),
(&file[..file.len() / 2], "Kakeibo yedeği değil"),
("Tarih;Tutar\n01.11.2026;5", "Kakeibo yedeği değil"),
];
for (body, msg) in cases {
let (st, b) = app.post_text("/backup/restore", &owner, body).await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
assert!(b["error"]["message"].as_str().unwrap().contains(msg), "{b}");
}
let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1);
let (st, b) = app.post_text("/backup/preview", &owner, &newer).await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert!(b["error"]["message"].as_str().unwrap().contains("daha yeni"), "{b}");
assert_eq!(view(&app, &owner, &nov).await, before);
assert!(points(&app, &owner).await.is_empty());
// Geri dönüş noktası: üye göremez; başka hane kullanamaz
let (_, r) = app.post_text("/backup/restore", &owner, &file).await;
let pid = r["restore_point_id"].as_str().unwrap();
assert!(points(&app, &member).await.is_empty());
let (st, _) = app
.post(
&format!("/backup/restore-points/{pid}/restore"),
&member,
json!({}),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
let other = app.register("Diger", None).await;
let (st, _) = app
.post(
&format!("/backup/restore-points/{pid}/restore"),
&other,
json!({}),
)
.await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}
+25
View File
@@ -66,6 +66,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
budget_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 },
budget_suggestion: "previous" | "avg3" | "default" // varsayılan öneri yöntemi
},
last_backup_at, // son JSON dışa aktarma ("son yedek X gün önce")
created_at, updated_at, deleted_at, version }
```
- `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye).
@@ -202,6 +203,30 @@ Renkler ve ikonlar frontend tasarım token'larında.
- Otomatik kurlar gün başına tek kayıt (kod × kaynak × gün; aynı gün yeniden çekilince güncellenir) → günlük geçmiş birikir. TCMB `rate_date` = bülten tarihi; altın = Türkiye'de bugün.
- Altın: xaus.com gram (saf) USD × TCMB USD alış; çeyrek/yarım/tam = has gram (1,603 / 3,206 / 6,412) × gram, ons = 31,1034768 gr. İşçilik payı yok → gerekirse manuel.
### 3.14a `restore_points` — JSON geri yüklemeden önceki durum ("geri al")
```js
{ _id, household_id, created_at, created_by,
reason: "before_restore" | "before_undo",
counts: { periods: 3, entries: 412, ... }, // o anki silinmemiş kayıt sayıları
size, // sıkıştırılmış bayt
data: BinData } // gzip(JSON yedek dosyası)
```
- Her geri yükleme / geri alma öncesinde aynı transaction içinde yazılır. Son **3** nokta, en fazla **30 gün** (`backup::prune`). Hane verisi değil → yedeğe girmez.
### 3.14b JSON yedek dosyası (`services/backup.rs`)
```js
{ "format": "kakeibo-backup", "format_version": 1, "schema_version": 3, "app_version": "0.1.0",
"exported_at": "2026-11-13T17:22:00Z",
"household": { "id", "name", "settings" },
"counts": { "entries": 412, ... }, // silinmemiş
"checksum": "sha256:…", // household + data, anahtarlar sıralı
"data": { "periods": [ {…}, … ], "entries": [ … ], … } } // her kayıt bir satır
```
- İçerik: dönem, plan kalemi, bütçe, harcama, devir, not, kategori, ödeme türü, varlık, varlık işlemi, elle girilen kur, ekstre kuralı — **silinmiş işaretliler dahil** (referanslar kopmasın, "geri al" birebir olsun). Kullanıcı, şifre, oturum, davet, otomatik kur **yok**.
- Değerler: UUID `{"$uuid": "…"}`, zaman `{"$date": "…"}`, tam sayılar `i64` (`version`, `sort_order` ve ayarlar `i32`).
- Geri yükleme = **tam değiştirme** (birleştirme yok), tek transaction. Aynı hane → kimlikler korunur; başka hane → tüm `_id`'ler yeniden üretilir, `*_id` referansları çevrilir, `household_id` hedef hane, `*_by` hanede yoksa geri yükleyen kişi. Hane adı yalnız aynı hanede geri gelir; ayarlar her zaman.
- Sürüm: `format_version`/`schema_version` daha yeniyse reddedilir; eskiyse hane düzeyi yükseltmeler uygulanır (v<2 → varsayılan varlıklar).
### 3.15 `schema_migrations`
```js
{ _id: 1 /* sürüm numarası */, name: "initial", applied_at }
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 9 – Adım 5a:** JSON yedeği backend. Tasarım kullanıcıya sunuldu ve onaylandı ("5a'dan devam"): `kakeibo-yedek-<hane>-<tarih>.json` (format/şema sürümü, zaman, hane, sayılar, SHA-256), içerik hane verisi (kullanıcı/şifre/davet/otomatik kur/silinmiş yok), dışa aktarma her üye, geri yükleme yalnız sahip; önizleme → tam değiştirme (birleştirme yok) tek transaction; geri yüklemeden önce sıkıştırılmış geri dönüş noktası (`flate2`), son 3 / 30 gün, "geri al" da nokta oluşturur; aynı hane → kimlikler korunur, başka hane → yeniden üretilir + iç referanslar çevrilir, `created_by` yoksa geri yükleyen; eski biçim yükseltilir, yeni biçim reddedilir; 20 MB. 5b: arayüz + "son yedek X gün önce" hatırlatması.
**Faz 9 – Adım 5b:** JSON yedeği arayüzü (Ayarlar → Veri): "Yedeği indir" (son yedek X gün önce / hiç alınmadı hatırlatması), sahip için "Yedekten geri yükle" → dosya seç → önizleme (kaynak hane, tarih, kayıt sayıları yedek/şimdi, tarih aralığı, uyarılar) → onay kutusu → geri yükle → sonuç + "Geri al"; geri dönüş noktaları listesi (tarih, neden, kayıt sayısı, "Bu duruma dön" onaylı). Tipler hazır: `BackupStatus`, `BackupPreview`, `BackupCount`, `RestorePoint`, `RestoreResult`. Gövde: dosya metni (`api.postCsv` benzeri, `application/json`). Sonra Adım 6 aylık rapor.
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar
@@ -142,3 +142,4 @@
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur).
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
+1 -1
View File
@@ -83,5 +83,5 @@
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
- 🔄 5. JSON tam hane yedeği — ✅ 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · ⏳ 5b arayüz (Ayarlar → Veri)
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type BackupCount = {
/**
* Koleksiyon adı (`entries`, `periods`, …)
*/
kind: string,
/**
* Türkçe ad
*/
label: string, backup: number, current: number, };
+16
View File
@@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BackupCount } from "./BackupCount";
export type BackupPreview = { source_name: string, same_household: boolean,
/**
* RFC 3339
*/
exported_at: string, app_version: string, counts: Array<BackupCount>,
/**
* Yedekteki dönemlerin ilk ve son günü
*/
first_date: string | null, last_date: string | null,
/**
* Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
*/
changed_since: number, warnings: Array<string>, };
+12
View File
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RestorePoint } from "./RestorePoint";
export type BackupStatus = {
/**
* Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
*/
last_backup_at: string | null,
/**
* En yeniden eskiye; sahip olmayana boş
*/
restore_points: Array<RestorePoint>, keep_points: number, keep_days: number, };
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RestorePoint = { id: string,
/**
* RFC 3339
*/
created_at: string,
/**
* `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
*/
reason: "before_restore" | "before_undo", periods: number, entries: number,
/**
* Sıkıştırılmış boyut (bayt)
*/
size: number, };
+7
View File
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RestoreResult = {
/**
* İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
*/
restore_point_id: string, };