//! 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)>, } 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) -> AppResult> { 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 { let h = db .collection::(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::(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 { 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::>()?), 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 { 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 { 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::>()?, ), 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) -> Option { 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 { 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::, _>>() .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 { 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, /// Yedekteki dönemlerin ilk ve son günü (`"YYYY-MM-DD"`) pub first_date: Option, pub last_date: Option, /// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt sayısı pub changed_since: i64, pub warnings: Vec, } pub async fn preview(db: &Database, household_id: Uuid, snap: &Snapshot) -> AppResult { 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::(kind) .count_documents(doc! { "household_id": household_id, "deleted_at": null }) .await? as i64; if same { changed_since += db .collection::(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 { 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, ) -> Vec<(&'static str, Vec)> { let same = snap.household_id == target; let mut map: HashMap = 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, map: &'a HashMap, } 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> { 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> { 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 { // 1) Şu anki durumun kopyası (sıkıştırılmış) let current = collect(db, household_id).await?; let packed = gzip(&to_json(¤t)?)?; 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 = { let cursor = db .collection::(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::(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::(kind) .delete_many(doc! { "household_id": household_id }) .session(&mut session) .await?; if !list.is_empty() { db.collection::(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::(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::(col::ASSETS) .find_one(doc! { "household_id": household_id }) .await? .is_none() { db.collection::(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> { let cursor = db .collection::(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 { let d = db .collection::(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 = 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::(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::(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> { Ok(db .collection::(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::>() .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::(&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"); } }