Files
kakeibo/backend/src/services/backup.rs
T

998 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}