From 0d70576df7a3eea149ef45a65fc8ed45ab1d585a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 20:49:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(yedek):=20JSON=20tam=20hane=20yede=C4=9Fi?= =?UTF-8?q?=20backend=20(=C3=B6nizleme,=20geri=20y=C3=BCkleme,=20geri=20d?= =?UTF-8?q?=C3=B6n=C3=BC=C5=9F=20noktalar=C4=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + backend/Cargo.lock | 2 + backend/Cargo.toml | 2 + backend/api.http | 23 + backend/src/db/indexes.rs | 4 + backend/src/db/migrations.rs | 23 + backend/src/db/mod.rs | 2 + backend/src/routes/backup.rs | 219 ++++++ backend/src/routes/mod.rs | 17 + backend/src/services/backup.rs | 997 ++++++++++++++++++++++++++++ backend/src/services/mod.rs | 1 + backend/tests/backup.rs | 388 +++++++++++ docs/DATA_MODEL.md | 25 + docs/MEMORY.md | 3 +- docs/PHASES.md | 2 +- frontend/src/types/BackupCount.ts | 11 + frontend/src/types/BackupPreview.ts | 16 + frontend/src/types/BackupStatus.ts | 12 + frontend/src/types/RestorePoint.ts | 15 + frontend/src/types/RestoreResult.ts | 7 + 20 files changed, 1768 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/backup.rs create mode 100644 backend/src/services/backup.rs create mode 100644 backend/tests/backup.rs create mode 100644 frontend/src/types/BackupCount.ts create mode 100644 frontend/src/types/BackupPreview.ts create mode 100644 frontend/src/types/BackupStatus.ts create mode 100644 frontend/src/types/RestorePoint.ts create mode 100644 frontend/src/types/RestoreResult.ts diff --git a/CLAUDE.md b/CLAUDE.md index aac47a4..2592049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ npm run db:restore -- [--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 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f5f236e..dc4302e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 797871a..79f0c72 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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"] } diff --git a/backend/api.http b/backend/api.http index cb86d82..7c6b7df 100644 --- a/backend/api.http +++ b/backend/api.http @@ -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 diff --git a/backend/src/db/indexes.rs b/backend/src/db/indexes.rs index 4b5f6a2..9c92235 100644 --- a/backend/src/db/indexes.rs +++ b/backend/src/db/indexes.rs @@ -89,6 +89,10 @@ fn definitions() -> Vec<(&'static str, Vec)> { 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 })], diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index 0639494..4d0feed 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -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::(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::*; diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 4b307af..1a3f52d 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -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] = &[ diff --git a/backend/src/routes/backup.rs b/backend/src/routes/backup.rs new file mode 100644 index 0000000..801fbe2 --- /dev/null +++ b/backend/src/routes/backup.rs @@ -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::parse_str(id).map_err(|_| AppError::NotFound) +} + +pub async fn download(State(s): State, auth: AuthUser) -> AppResult { + 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, + /// En yeniden eskiye; sahip olmayana boş + restore_points: Vec, + keep_points: u32, + keep_days: u32, +} + +pub async fn status(State(s): State, auth: AuthUser) -> AppResult> { + 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, + /// Yedekteki dönemlerin ilk ve son günü + first_date: Option, + last_date: Option, + /// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt + #[ts(type = "number")] + changed_since: i64, + warnings: Vec, +} + +pub async fn preview( + State(s): State, + auth: AuthUser, + body: Bytes, +) -> AppResult> { + 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, + auth: AuthUser, + body: Bytes, +) -> AppResult> { + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult> { + 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(), + })) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index d66bbbb..6f319d4 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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 +} diff --git a/backend/src/services/backup.rs b/backend/src/services/backup.rs new file mode 100644 index 0000000..1086175 --- /dev/null +++ b/backend/src/services/backup.rs @@ -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)>, +} + +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"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 0aa04f9..928ea2d 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod asset; +pub mod backup; pub mod bank_import; pub mod budget; pub mod calendar; diff --git a/backend/tests/backup.rs b/backend/tests/backup.rs new file mode 100644 index 0000000..dc25f81 --- /dev/null +++ b/backend/tests/backup.rs @@ -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::(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 { + 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::("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![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; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index b00264f..21c1a0a 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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 } diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 2b34ca4..e91069d 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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--.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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 0116c40..eaaeef5 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -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 diff --git a/frontend/src/types/BackupCount.ts b/frontend/src/types/BackupCount.ts new file mode 100644 index 0000000..53f8842 --- /dev/null +++ b/frontend/src/types/BackupCount.ts @@ -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, }; diff --git a/frontend/src/types/BackupPreview.ts b/frontend/src/types/BackupPreview.ts new file mode 100644 index 0000000..10970b2 --- /dev/null +++ b/frontend/src/types/BackupPreview.ts @@ -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, +/** + * 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, }; diff --git a/frontend/src/types/BackupStatus.ts b/frontend/src/types/BackupStatus.ts new file mode 100644 index 0000000..d8a4527 --- /dev/null +++ b/frontend/src/types/BackupStatus.ts @@ -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, keep_points: number, keep_days: number, }; diff --git a/frontend/src/types/RestorePoint.ts b/frontend/src/types/RestorePoint.ts new file mode 100644 index 0000000..a1eb59f --- /dev/null +++ b/frontend/src/types/RestorePoint.ts @@ -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, }; diff --git a/frontend/src/types/RestoreResult.ts b/frontend/src/types/RestoreResult.ts new file mode 100644 index 0000000..7c22f1e --- /dev/null +++ b/frontend/src/types/RestoreResult.ts @@ -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, };