feat(yedek): JSON tam hane yedeği backend (önizleme, geri yükleme, geri dönüş noktaları)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
|
|||||||
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
- 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.
|
- 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ç.
|
- 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.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
Generated
+2
@@ -1425,6 +1425,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"flate2",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
@@ -1432,6 +1433,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ argon2 = "0.6.0"
|
|||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
|
flate2 = "1"
|
||||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||||
mongodb = "3.10.0"
|
mongodb = "3.10.0"
|
||||||
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
serde_json = "1.0.154"
|
||||||
|
sha2 = "0.10"
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
time = "0.3.55"
|
time = "0.3.55"
|
||||||
tokio = { version = "1.54.1", features = ["full"] }
|
tokio = { version = "1.54.1", features = ["full"] }
|
||||||
|
|||||||
@@ -397,3 +397,26 @@ Content-Type: text/csv; charset=utf-8
|
|||||||
|
|
||||||
Tarih;Tutar;Kategori;Açıklama
|
Tarih;Tutar;Kategori;Açıklama
|
||||||
13.11.2026;45,90;Market;Ekmek
|
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
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
|||||||
col::IMPORT_RULES,
|
col::IMPORT_RULES,
|
||||||
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
col::RESTORE_POINTS,
|
||||||
|
vec![idx(doc! { "household_id": 1, "created_at": -1 })],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
col::EXCHANGE_RATES,
|
col::EXCHANGE_RATES,
|
||||||
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
||||||
|
|||||||
@@ -35,8 +35,18 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "default_assets",
|
name: "default_assets",
|
||||||
run: |db| Box::pin(m002_default_assets(db)),
|
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.
|
/// Uygulanmamış migration'ları sırayla çalıştırır.
|
||||||
pub async fn run(db: &Database) -> Result<()> {
|
pub async fn run(db: &Database) -> Result<()> {
|
||||||
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
|
let coll = db.collection::<Document>(col::SCHEMA_MIGRATIONS);
|
||||||
@@ -103,6 +113,19 @@ async fn m002_default_assets(db: &Database) -> Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub mod col {
|
|||||||
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
||||||
pub const IMPORT_RULES: &str = "import_rules";
|
pub const IMPORT_RULES: &str = "import_rules";
|
||||||
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
|
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).
|
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
|
||||||
pub const HOUSEHOLD_SCOPED: &[&str] = &[
|
pub const HOUSEHOLD_SCOPED: &[&str] = &[
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! JSON tam hane yedeği (`services::backup`).
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | Kim | İş |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | GET | `/api/backup/download` | üye | yedek dosyası (indirme) |
|
||||||
|
//! | GET | `/api/backup/status` | üye | son yedek zamanı + geri dönüş noktaları (yalnız sahip görür) |
|
||||||
|
//! | POST | `/api/backup/preview` | sahip | gövde: yedek dosyası → ne değişecek (hiçbir şey yazılmaz) |
|
||||||
|
//! | POST | `/api/backup/restore` | sahip | gövde: yedek dosyası → hane verisi değiştirilir |
|
||||||
|
//! | POST | `/api/backup/restore-points/{id}/restore` | sahip | geri dönüş noktasına dön ("geri al") |
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::header,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use mongodb::bson::DateTime;
|
||||||
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::user::Role,
|
||||||
|
services::{
|
||||||
|
backup::{self, Reason},
|
||||||
|
calendar::{format_date, today_tr},
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn iso(d: DateTime) -> String {
|
||||||
|
d.try_to_rfc3339_string().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_id(id: &str) -> AppResult<mongodb::bson::Uuid> {
|
||||||
|
mongodb::bson::Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download(State(s): State<AppState>, auth: AuthUser) -> AppResult<impl IntoResponse> {
|
||||||
|
let snap = backup::collect(&s.db, auth.household_id).await?;
|
||||||
|
let body = backup::to_json(&snap)?;
|
||||||
|
backup::mark_exported(&s.db, auth.household_id).await?;
|
||||||
|
let file = format!(
|
||||||
|
"kakeibo-yedek-{}-{}.json",
|
||||||
|
backup::slug(&snap.household_name),
|
||||||
|
format_date(today_tr())
|
||||||
|
);
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/json; charset=utf-8".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{file}\""),
|
||||||
|
),
|
||||||
|
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||||
|
],
|
||||||
|
body,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct RestorePoint {
|
||||||
|
id: String,
|
||||||
|
/// RFC 3339
|
||||||
|
created_at: String,
|
||||||
|
/// `before_restore`: dosyadan geri yüklemeden önce · `before_undo`: geri almadan önce
|
||||||
|
#[ts(type = "\"before_restore\" | \"before_undo\"")]
|
||||||
|
reason: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
periods: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
entries: i64,
|
||||||
|
/// Sıkıştırılmış boyut (bayt)
|
||||||
|
#[ts(type = "number")]
|
||||||
|
size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupStatus {
|
||||||
|
/// Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
|
||||||
|
last_backup_at: Option<String>,
|
||||||
|
/// En yeniden eskiye; sahip olmayana boş
|
||||||
|
restore_points: Vec<RestorePoint>,
|
||||||
|
keep_points: u32,
|
||||||
|
keep_days: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<BackupStatus>> {
|
||||||
|
let points = if auth.role == Role::Owner {
|
||||||
|
backup::points(&s.db, auth.household_id).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
Ok(Json(BackupStatus {
|
||||||
|
last_backup_at: backup::last_exported(&s.db, auth.household_id)
|
||||||
|
.await?
|
||||||
|
.map(iso),
|
||||||
|
restore_points: points
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| RestorePoint {
|
||||||
|
id: p.id.to_string(),
|
||||||
|
created_at: iso(p.created_at),
|
||||||
|
reason: p.reason,
|
||||||
|
periods: p.periods,
|
||||||
|
entries: p.entries,
|
||||||
|
size: p.size,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
keep_points: backup::KEEP_POINTS as u32,
|
||||||
|
keep_days: backup::KEEP_DAYS as u32,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupCount {
|
||||||
|
/// Koleksiyon adı (`entries`, `periods`, …)
|
||||||
|
kind: String,
|
||||||
|
/// Türkçe ad
|
||||||
|
label: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
backup: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
current: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct BackupPreview {
|
||||||
|
source_name: String,
|
||||||
|
same_household: bool,
|
||||||
|
/// RFC 3339
|
||||||
|
exported_at: String,
|
||||||
|
app_version: String,
|
||||||
|
counts: Vec<BackupCount>,
|
||||||
|
/// Yedekteki dönemlerin ilk ve son günü
|
||||||
|
first_date: Option<String>,
|
||||||
|
last_date: Option<String>,
|
||||||
|
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
|
||||||
|
#[ts(type = "number")]
|
||||||
|
changed_since: i64,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<Json<BackupPreview>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let snap = backup::parse(&body)?;
|
||||||
|
let p = backup::preview(&s.db, auth.household_id, &snap).await?;
|
||||||
|
Ok(Json(BackupPreview {
|
||||||
|
source_name: p.source_name,
|
||||||
|
same_household: p.same_household,
|
||||||
|
exported_at: iso(p.exported_at),
|
||||||
|
app_version: p.app_version,
|
||||||
|
counts: p
|
||||||
|
.counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| BackupCount {
|
||||||
|
kind: c.kind.into(),
|
||||||
|
label: c.label.into(),
|
||||||
|
backup: c.backup,
|
||||||
|
current: c.current,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
first_date: p.first_date,
|
||||||
|
last_date: p.last_date,
|
||||||
|
changed_since: p.changed_since,
|
||||||
|
warnings: p.warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct RestoreResult {
|
||||||
|
/// İşlemden önceki durumun geri dönüş noktası ("Geri al" bununla)
|
||||||
|
restore_point_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<Json<RestoreResult>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let snap = backup::parse(&body)?;
|
||||||
|
let id = backup::restore(
|
||||||
|
&s.db,
|
||||||
|
auth.household_id,
|
||||||
|
auth.user_id,
|
||||||
|
&snap,
|
||||||
|
Reason::BeforeRestore,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(RestoreResult {
|
||||||
|
restore_point_id: id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn undo(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<RestoreResult>> {
|
||||||
|
auth.require_owner()?;
|
||||||
|
let id = backup::undo(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||||||
|
Ok(Json(RestoreResult {
|
||||||
|
restore_point_id: id.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod asset;
|
mod asset;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod backup;
|
||||||
mod bank_import;
|
mod bank_import;
|
||||||
mod budget;
|
mod budget;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
@@ -92,6 +93,17 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/periods/{id}/daily", get(stats::daily))
|
.route("/periods/{id}/daily", get(stats::daily))
|
||||||
.route("/stats/periods", get(stats::periods))
|
.route("/stats/periods", get(stats::periods))
|
||||||
.route("/reports/breakdown", get(reports::breakdown))
|
.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/entries.csv", get(transfer::export_entries))
|
||||||
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||||
.route(
|
.route(
|
||||||
@@ -144,3 +156,8 @@ pub fn router(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new().nest("/api", api).with_state(state)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,997 @@
|
|||||||
|
//! JSON tam hane yedeği (Faz 9 Adım 5; tasarım docs/DATA_MODEL.md "JSON yedeği").
|
||||||
|
//!
|
||||||
|
//! - **Dışa aktarma:** hanenin tüm kayıtları (silinmiş işaretliler dahil → iç bağlantılar
|
||||||
|
//! kopmaz, "geri al" birebir döner). Kullanıcılar, şifreler, davetler, otomatik kurlar yok.
|
||||||
|
//! - **Biçim:** okunabilir JSON. UUID `{"$uuid": "…"}`, zaman `{"$date": "…"}` (MongoDB
|
||||||
|
//! Extended JSON'daki adlarla), tutarlar tam sayı (kuruş / `_e6`). `checksum` = SHA-256
|
||||||
|
//! (`household` + `data`, anahtarlar sıralı) → yarım inmiş ya da değiştirilmiş dosya reddedilir.
|
||||||
|
//! - **Geri yükleme (yalnız sahip):** mevcut veri yedektekiyle **tamamen değiştirilir** (tek
|
||||||
|
//! transaction). Öncesinde mevcut durum sıkıştırılıp `restore_points`'e yazılır (son 3, 30 gün).
|
||||||
|
//! - **Kimlikler:** aynı haneye → korunur; başka haneye → hepsi yeniden üretilir ve tüm
|
||||||
|
//! referanslar (`*_id`) çevrilir; `*_by` (kişi) bu hanede yoksa geri yükleyen kişi olur.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
io::{Read, Write},
|
||||||
|
};
|
||||||
|
|
||||||
|
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Binary, Bson, DateTime, Document, Uuid, doc, spec::BinarySubtype},
|
||||||
|
};
|
||||||
|
use serde_json::{Map, Number, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{col, migrations},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::common::{new_id, now},
|
||||||
|
services::{
|
||||||
|
calendar::{format_date, parse_date},
|
||||||
|
seed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const FORMAT: &str = "kakeibo-backup";
|
||||||
|
pub const FORMAT_VERSION: i64 = 1;
|
||||||
|
/// Yüklenebilecek en büyük yedek dosyası
|
||||||
|
pub const MAX_BYTES: usize = 20 * 1024 * 1024;
|
||||||
|
/// Saklanan geri dönüş noktası sayısı ve süresi
|
||||||
|
pub const KEEP_POINTS: usize = 3;
|
||||||
|
pub const KEEP_DAYS: i64 = 30;
|
||||||
|
/// Sıkıştırılmış geri dönüş noktası üst sınırı (MongoDB belge sınırı 16 MB)
|
||||||
|
const MAX_POINT_BYTES: usize = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Yedekteki koleksiyonlar ve Türkçe adları (sıra dosyadaki sıradır).
|
||||||
|
pub const KINDS: &[(&str, &str)] = &[
|
||||||
|
(col::PERIODS, "Dönem"),
|
||||||
|
(col::PLAN_ITEMS, "Plan kalemi"),
|
||||||
|
(col::BUDGETS, "Bütçe"),
|
||||||
|
(col::ENTRIES, "Harcama"),
|
||||||
|
(col::CARRYOVERS, "Devir"),
|
||||||
|
(col::NOTES, "Not"),
|
||||||
|
(col::CATEGORIES, "Kategori"),
|
||||||
|
(col::PAYMENT_METHODS, "Ödeme türü"),
|
||||||
|
(col::ASSETS, "Varlık"),
|
||||||
|
(col::ASSET_TRANSACTIONS, "Varlık işlemi"),
|
||||||
|
(col::EXCHANGE_RATES, "Elle girilen kur"),
|
||||||
|
(col::IMPORT_RULES, "Ekstre kuralı"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Uygulamanın `i32` yazdığı alanlar (geri kalan tam sayılar `i64`: tutarlar, `_e6`).
|
||||||
|
const INT32_KEYS: &[&str] = &["version", "sort_order"];
|
||||||
|
|
||||||
|
/// Bir hanenin bellekteki tam kopyası.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub household_id: Uuid,
|
||||||
|
pub household_name: String,
|
||||||
|
pub settings: Document,
|
||||||
|
pub exported_at: DateTime,
|
||||||
|
pub schema_version: i64,
|
||||||
|
pub app_version: String,
|
||||||
|
/// `KINDS` sırasıyla
|
||||||
|
pub data: Vec<(&'static str, Vec<Document>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Snapshot {
|
||||||
|
/// Silinmemiş kayıt sayısı (kullanıcıya gösterilen)
|
||||||
|
pub fn active(&self, kind: &str) -> i64 {
|
||||||
|
self.data
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| *k == kind)
|
||||||
|
.map_or(0, |(_, docs)| {
|
||||||
|
docs.iter()
|
||||||
|
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)))
|
||||||
|
.count() as i64
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt(detail: &str) -> AppError {
|
||||||
|
tracing::warn!("Bozuk yedek: {detail}");
|
||||||
|
AppError::BadRequest(
|
||||||
|
"Yedek dosyası bozuk ya da değiştirilmiş; doğrulama tutmadı. Başka bir yedek deneyin."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_backup() -> AppError {
|
||||||
|
AppError::BadRequest(
|
||||||
|
"Bu dosya bir Kakeibo yedeği değil. Ayarlar → Veri'den alınan .json yedek dosyasını seçin."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Toplama ----------
|
||||||
|
|
||||||
|
async fn drain(mut cursor: mongodb::Cursor<Document>) -> AppResult<Vec<Document>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while cursor.advance().await? {
|
||||||
|
out.push(cursor.deserialize_current()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hanenin tüm kayıtlarını okur (silinmiş işaretliler dahil).
|
||||||
|
pub async fn collect(db: &Database, household_id: Uuid) -> AppResult<Snapshot> {
|
||||||
|
let h = db
|
||||||
|
.collection::<Document>(col::HOUSEHOLDS)
|
||||||
|
.find_one(doc! { "_id": household_id })
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
let mut data = Vec::with_capacity(KINDS.len());
|
||||||
|
for &(kind, _) in KINDS {
|
||||||
|
let cursor = db
|
||||||
|
.collection::<Document>(kind)
|
||||||
|
.find(doc! { "household_id": household_id })
|
||||||
|
.sort(doc! { "_id": 1 })
|
||||||
|
.await?;
|
||||||
|
data.push((kind, drain(cursor).await?));
|
||||||
|
}
|
||||||
|
Ok(Snapshot {
|
||||||
|
household_id,
|
||||||
|
household_name: h.get_str("name").unwrap_or_default().to_string(),
|
||||||
|
settings: h.get_document("settings").cloned().unwrap_or_default(),
|
||||||
|
exported_at: now(),
|
||||||
|
schema_version: i64::from(migrations::latest_version()),
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- BSON ↔ JSON ----------
|
||||||
|
|
||||||
|
fn bson_to_json(b: &Bson) -> Result<Value, String> {
|
||||||
|
Ok(match b {
|
||||||
|
Bson::Null => Value::Null,
|
||||||
|
Bson::Boolean(v) => Value::Bool(*v),
|
||||||
|
Bson::Int32(v) => Value::from(*v),
|
||||||
|
Bson::Int64(v) => Value::from(*v),
|
||||||
|
Bson::Double(v) => Value::Number(Number::from_f64(*v).ok_or("NaN")?),
|
||||||
|
Bson::String(s) => Value::String(s.clone()),
|
||||||
|
Bson::Array(a) => Value::Array(a.iter().map(bson_to_json).collect::<Result<_, _>>()?),
|
||||||
|
Bson::Document(d) => doc_to_json(d)?,
|
||||||
|
Bson::DateTime(dt) => {
|
||||||
|
let s = dt.try_to_rfc3339_string().map_err(|e| e.to_string())?;
|
||||||
|
Value::Object(Map::from_iter([("$date".into(), Value::String(s))]))
|
||||||
|
}
|
||||||
|
Bson::Binary(Binary {
|
||||||
|
subtype: BinarySubtype::Uuid,
|
||||||
|
bytes,
|
||||||
|
}) => {
|
||||||
|
let arr: [u8; 16] = bytes.as_slice().try_into().map_err(|_| "uuid")?;
|
||||||
|
Value::Object(Map::from_iter([(
|
||||||
|
"$uuid".into(),
|
||||||
|
Value::String(Uuid::from_bytes(arr).to_string()),
|
||||||
|
)]))
|
||||||
|
}
|
||||||
|
other => return Err(format!("desteklenmeyen tür: {:?}", other.element_type())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doc_to_json(d: &Document) -> Result<Value, String> {
|
||||||
|
let mut m = Map::new();
|
||||||
|
for (k, v) in d {
|
||||||
|
m.insert(k.clone(), bson_to_json(v)?);
|
||||||
|
}
|
||||||
|
Ok(Value::Object(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_bson(v: &Value, key: &str) -> Option<Bson> {
|
||||||
|
Some(match v {
|
||||||
|
Value::Null => Bson::Null,
|
||||||
|
Value::Bool(b) => Bson::Boolean(*b),
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
if INT32_KEYS.contains(&key) {
|
||||||
|
Bson::Int32(i32::try_from(i).ok()?)
|
||||||
|
} else {
|
||||||
|
Bson::Int64(i)
|
||||||
|
}
|
||||||
|
} else if n.is_u64() {
|
||||||
|
return None;
|
||||||
|
} else {
|
||||||
|
Bson::Double(n.as_f64()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => Bson::String(s.clone()),
|
||||||
|
Value::Array(a) => Bson::Array(
|
||||||
|
a.iter()
|
||||||
|
.map(|x| json_to_bson(x, key))
|
||||||
|
.collect::<Option<_>>()?,
|
||||||
|
),
|
||||||
|
Value::Object(m) => {
|
||||||
|
if m.len() == 1 {
|
||||||
|
if let Some(Value::String(s)) = m.get("$uuid") {
|
||||||
|
return Uuid::parse_str(s).ok().map(Bson::from);
|
||||||
|
}
|
||||||
|
if let Some(Value::String(s)) = m.get("$date") {
|
||||||
|
return DateTime::parse_rfc3339_str(s).ok().map(Bson::DateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Bson::Document(json_to_doc(m)?)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_doc(m: &Map<String, Value>) -> Option<Document> {
|
||||||
|
let mut d = Document::new();
|
||||||
|
for (k, v) in m {
|
||||||
|
// Operatör ya da yol gibi görünen anahtarlar asla veritabanına gitmez
|
||||||
|
if k.starts_with('$') || k.contains('.') || k.contains('\0') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
d.insert(k.clone(), json_to_bson(v, k)?);
|
||||||
|
}
|
||||||
|
Some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `i32`'ye sığan tam sayıları `Int32` yapar (iç içe belgeler dahil)
|
||||||
|
fn small_ints(d: Document) -> Document {
|
||||||
|
fn conv(b: Bson) -> Bson {
|
||||||
|
match b {
|
||||||
|
Bson::Int64(v) => i32::try_from(v).map_or(Bson::Int64(v), Bson::Int32),
|
||||||
|
Bson::Document(d) => Bson::Document(small_ints(d)),
|
||||||
|
Bson::Array(a) => Bson::Array(a.into_iter().map(conv).collect()),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.into_iter().map(|(k, v)| (k, conv(v))).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anahtarları her düzeyde sıralı kopya (dosya biçimlendirmesinden bağımsız özet için)
|
||||||
|
fn canonical(v: &Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Object(m) => {
|
||||||
|
let mut keys: Vec<&String> = m.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
Value::Object(
|
||||||
|
keys.into_iter()
|
||||||
|
.map(|k| (k.clone(), canonical(&m[k])))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checksum(household: &Value, data: &Value) -> String {
|
||||||
|
let body = canonical(&Value::Object(Map::from_iter([
|
||||||
|
("household".to_string(), household.clone()),
|
||||||
|
("data".to_string(), data.clone()),
|
||||||
|
])));
|
||||||
|
let hash = Sha256::digest(serde_json::to_string(&body).unwrap_or_default().as_bytes());
|
||||||
|
format!("sha256:{hash:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Yazma (dosya) ----------
|
||||||
|
|
||||||
|
/// Yedek dosyasının metni. Başlık önce, sonra `data` (her kayıt bir satır).
|
||||||
|
pub fn to_json(s: &Snapshot) -> AppResult<String> {
|
||||||
|
let internal = |e: String| AppError::Internal(format!("yedek dönüştürme: {e}"));
|
||||||
|
let household = serde_json::json!({
|
||||||
|
"id": s.household_id.to_string(),
|
||||||
|
"name": s.household_name,
|
||||||
|
"settings": doc_to_json(&s.settings).map_err(internal)?,
|
||||||
|
});
|
||||||
|
let mut data = Map::new();
|
||||||
|
let mut counts = Map::new();
|
||||||
|
for (kind, docs) in &s.data {
|
||||||
|
let arr = docs
|
||||||
|
.iter()
|
||||||
|
.map(doc_to_json)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(internal)?;
|
||||||
|
data.insert(kind.to_string(), Value::Array(arr));
|
||||||
|
counts.insert(kind.to_string(), Value::from(s.active(kind)));
|
||||||
|
}
|
||||||
|
let data = Value::Object(data);
|
||||||
|
let header: [(&str, Value); 8] = [
|
||||||
|
("format", FORMAT.into()),
|
||||||
|
("format_version", FORMAT_VERSION.into()),
|
||||||
|
("schema_version", s.schema_version.into()),
|
||||||
|
("app_version", s.app_version.clone().into()),
|
||||||
|
(
|
||||||
|
"exported_at",
|
||||||
|
s.exported_at
|
||||||
|
.try_to_rfc3339_string()
|
||||||
|
.map_err(|e| internal(e.to_string()))?
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
("household", household.clone()),
|
||||||
|
("counts", Value::Object(counts)),
|
||||||
|
("checksum", checksum(&household, &data).into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let j = |v: &Value| serde_json::to_string(v).unwrap_or_default();
|
||||||
|
let mut out = String::from("{\n");
|
||||||
|
for (k, v) in &header {
|
||||||
|
out.push_str(&format!(" {}: {},\n", j(&(*k).into()), j(v)));
|
||||||
|
}
|
||||||
|
out.push_str(" \"data\": {");
|
||||||
|
let Value::Object(map) = &data else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
for (i, (kind, arr)) in map.iter().enumerate() {
|
||||||
|
out.push_str(if i == 0 { "\n" } else { ",\n" });
|
||||||
|
out.push_str(&format!(" {}: [", j(&kind.as_str().into())));
|
||||||
|
let items = arr.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||||
|
for (n, item) in items.iter().enumerate() {
|
||||||
|
out.push_str(if n == 0 { "\n " } else { ",\n " });
|
||||||
|
out.push_str(&j(item));
|
||||||
|
}
|
||||||
|
out.push_str(if items.is_empty() { "]" } else { "\n ]" });
|
||||||
|
}
|
||||||
|
out.push_str("\n }\n}\n");
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Okuma (dosya) ----------
|
||||||
|
|
||||||
|
/// Dosyayı okur ve doğrular: biçim, sürüm, özet, kayıt yapısı.
|
||||||
|
pub fn parse(bytes: &[u8]) -> AppResult<Snapshot> {
|
||||||
|
let v: Value = serde_json::from_slice(bytes).map_err(|_| not_backup())?;
|
||||||
|
let o = v.as_object().ok_or_else(not_backup)?;
|
||||||
|
if o.get("format").and_then(Value::as_str) != Some(FORMAT) {
|
||||||
|
return Err(not_backup());
|
||||||
|
}
|
||||||
|
let newer = || {
|
||||||
|
AppError::BadRequest(
|
||||||
|
"Bu yedek uygulamanın daha yeni bir sürümüyle alınmış. Önce uygulamayı güncelleyin."
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let format_version = o.get("format_version").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let schema_version = o.get("schema_version").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
if format_version < 1 || schema_version < 1 {
|
||||||
|
return Err(corrupt("sürüm alanı yok"));
|
||||||
|
}
|
||||||
|
if format_version > FORMAT_VERSION || schema_version > i64::from(migrations::latest_version()) {
|
||||||
|
return Err(newer());
|
||||||
|
}
|
||||||
|
|
||||||
|
let household = o.get("household").ok_or_else(|| corrupt("household yok"))?;
|
||||||
|
let data = o.get("data").ok_or_else(|| corrupt("data yok"))?;
|
||||||
|
if o.get("checksum").and_then(Value::as_str) != Some(checksum(household, data).as_str()) {
|
||||||
|
return Err(corrupt("checksum"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let household_id = household
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
|
.ok_or_else(|| corrupt("household.id"))?;
|
||||||
|
let settings = match household.get("settings") {
|
||||||
|
// Ayarlar uygulamada i32 yazılır (ör. yüzdeler)
|
||||||
|
Some(Value::Object(m)) => small_ints(json_to_doc(m).ok_or_else(|| corrupt("settings"))?),
|
||||||
|
_ => Document::new(),
|
||||||
|
};
|
||||||
|
let data = data
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| corrupt("data nesne değil"))?;
|
||||||
|
if let Some(k) = data.keys().find(|k| !KINDS.iter().any(|(c, _)| c == k)) {
|
||||||
|
return Err(corrupt(&format!("bilinmeyen koleksiyon {k}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut out = Vec::with_capacity(KINDS.len());
|
||||||
|
for &(kind, _) in KINDS {
|
||||||
|
let items = match data.get(kind) {
|
||||||
|
None => &Vec::new(),
|
||||||
|
Some(Value::Array(a)) => a,
|
||||||
|
Some(_) => return Err(corrupt(kind)),
|
||||||
|
};
|
||||||
|
let mut docs = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
let d = item
|
||||||
|
.as_object()
|
||||||
|
.and_then(json_to_doc)
|
||||||
|
.ok_or_else(|| corrupt(kind))?;
|
||||||
|
let id = match d.get("_id") {
|
||||||
|
Some(Bson::Binary(b)) if b.subtype == BinarySubtype::Uuid => b.bytes.clone(),
|
||||||
|
_ => return Err(corrupt(&format!("{kind}: _id"))),
|
||||||
|
};
|
||||||
|
if d.get("household_id") != Some(&Bson::from(household_id)) || !seen.insert(id) {
|
||||||
|
return Err(corrupt(&format!("{kind}: household_id / tekrar eden _id")));
|
||||||
|
}
|
||||||
|
docs.push(d);
|
||||||
|
}
|
||||||
|
out.push((kind, docs));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Snapshot {
|
||||||
|
household_id,
|
||||||
|
household_name: household
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
settings,
|
||||||
|
exported_at: o
|
||||||
|
.get("exported_at")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|s| DateTime::parse_rfc3339_str(s).ok())
|
||||||
|
.ok_or_else(|| corrupt("exported_at"))?,
|
||||||
|
schema_version,
|
||||||
|
app_version: o
|
||||||
|
.get("app_version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
data: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Önizleme ----------
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KindCount {
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub backup: i64,
|
||||||
|
pub current: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Preview {
|
||||||
|
pub source_name: String,
|
||||||
|
pub same_household: bool,
|
||||||
|
pub exported_at: DateTime,
|
||||||
|
pub app_version: String,
|
||||||
|
pub counts: Vec<KindCount>,
|
||||||
|
/// Yedekteki dönemlerin ilk ve son günü (`"YYYY-MM-DD"`)
|
||||||
|
pub first_date: Option<String>,
|
||||||
|
pub last_date: Option<String>,
|
||||||
|
/// Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt sayısı
|
||||||
|
pub changed_since: i64,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(db: &Database, household_id: Uuid, snap: &Snapshot) -> AppResult<Preview> {
|
||||||
|
let same = snap.household_id == household_id;
|
||||||
|
let mut counts = Vec::with_capacity(KINDS.len());
|
||||||
|
let mut changed_since = 0;
|
||||||
|
for &(kind, label) in KINDS {
|
||||||
|
let current = db
|
||||||
|
.collection::<Document>(kind)
|
||||||
|
.count_documents(doc! { "household_id": household_id, "deleted_at": null })
|
||||||
|
.await? as i64;
|
||||||
|
if same {
|
||||||
|
changed_since += db
|
||||||
|
.collection::<Document>(kind)
|
||||||
|
.count_documents(
|
||||||
|
doc! { "household_id": household_id, "updated_at": { "$gt": snap.exported_at } },
|
||||||
|
)
|
||||||
|
.await? as i64;
|
||||||
|
}
|
||||||
|
counts.push(KindCount {
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
backup: snap.active(kind),
|
||||||
|
current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let periods = snap
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| *k == col::PERIODS)
|
||||||
|
.map(|(_, d)| d.as_slice())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let live = periods
|
||||||
|
.iter()
|
||||||
|
.filter(|d| matches!(d.get("deleted_at"), None | Some(Bson::Null)));
|
||||||
|
let first_date = live
|
||||||
|
.clone()
|
||||||
|
.filter_map(|d| d.get_str("start_date").ok())
|
||||||
|
.min()
|
||||||
|
.map(str::to_string);
|
||||||
|
let last_date = live
|
||||||
|
.filter_map(|d| d.get_str("end_date").ok())
|
||||||
|
.max()
|
||||||
|
.and_then(parse_date)
|
||||||
|
.map(|d| format_date(d - time::Duration::days(1)));
|
||||||
|
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if !same {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Bu yedek başka bir haneden alınmış (“{}”). Kayıtlar bu haneye kopyalanır; kaydı giren kişi bu hanede yoksa kayıt sizin adınıza görünür.",
|
||||||
|
snap.household_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if changed_since > 0 {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Yedek alındıktan sonra eklediğiniz ya da değiştirdiğiniz {changed_since} kayıt bu işlemle kaybolur. Gerekirse sonradan “Geri al” ile dönebilirsiniz."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if snap.schema_version < i64::from(migrations::latest_version()) {
|
||||||
|
warnings.push(
|
||||||
|
"Bu yedek uygulamanın eski bir sürümüyle alınmış; geri yüklenirken güncellenir.".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if snap.active(col::PERIODS) == 0 {
|
||||||
|
warnings.push("Yedekte hiç dönem yok; geri yüklenirse hane boş kalır.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Preview {
|
||||||
|
source_name: snap.household_name.clone(),
|
||||||
|
same_household: same,
|
||||||
|
exported_at: snap.exported_at,
|
||||||
|
app_version: snap.app_version.clone(),
|
||||||
|
counts,
|
||||||
|
first_date,
|
||||||
|
last_date,
|
||||||
|
changed_since,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Kimlik çevirme ----------
|
||||||
|
|
||||||
|
fn uuid_of(b: Option<&Bson>) -> Option<Uuid> {
|
||||||
|
match b {
|
||||||
|
Some(Bson::Binary(bin)) if bin.subtype == BinarySubtype::Uuid => {
|
||||||
|
<[u8; 16]>::try_from(bin.bytes.as_slice())
|
||||||
|
.ok()
|
||||||
|
.map(Uuid::from_bytes)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yedeği hedef haneye uyarlar. Aynı hane → kimlikler aynı; başka hane → yeni kimlikler,
|
||||||
|
/// tüm referanslar çevrilir. `*_by` alanı hanede olmayan biriyse geri yükleyen kişi olur.
|
||||||
|
fn retarget(
|
||||||
|
snap: &Snapshot,
|
||||||
|
target: Uuid,
|
||||||
|
restorer: Uuid,
|
||||||
|
members: &HashSet<Uuid>,
|
||||||
|
) -> Vec<(&'static str, Vec<Document>)> {
|
||||||
|
let same = snap.household_id == target;
|
||||||
|
let mut map: HashMap<Uuid, Uuid> = HashMap::new();
|
||||||
|
for (_, docs) in &snap.data {
|
||||||
|
for d in docs {
|
||||||
|
if let Some(old) = uuid_of(d.get("_id")) {
|
||||||
|
map.insert(old, if same { old } else { new_id() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fix(b: &mut Bson, key: &str, ctx: &Ctx) {
|
||||||
|
match b {
|
||||||
|
Bson::Binary(bin) if bin.subtype == BinarySubtype::Uuid => {
|
||||||
|
let Ok(arr) = <[u8; 16]>::try_from(bin.bytes.as_slice()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let old = Uuid::from_bytes(arr);
|
||||||
|
let new = if key == "household_id" {
|
||||||
|
ctx.target
|
||||||
|
} else if key.ends_with("_by") {
|
||||||
|
if ctx.members.contains(&old) {
|
||||||
|
old
|
||||||
|
} else {
|
||||||
|
ctx.restorer
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Yedekte olmayan (zaten kopuk) referans olduğu gibi kalır
|
||||||
|
*ctx.map.get(&old).unwrap_or(&old)
|
||||||
|
};
|
||||||
|
*b = Bson::from(new);
|
||||||
|
}
|
||||||
|
Bson::Document(d) => {
|
||||||
|
for (k, v) in d.iter_mut() {
|
||||||
|
fix(v, k, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Bson::Array(a) => {
|
||||||
|
for v in a.iter_mut() {
|
||||||
|
fix(v, key, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Ctx<'a> {
|
||||||
|
target: Uuid,
|
||||||
|
restorer: Uuid,
|
||||||
|
members: &'a HashSet<Uuid>,
|
||||||
|
map: &'a HashMap<Uuid, Uuid>,
|
||||||
|
}
|
||||||
|
let ctx = Ctx {
|
||||||
|
target,
|
||||||
|
restorer,
|
||||||
|
members,
|
||||||
|
map: &map,
|
||||||
|
};
|
||||||
|
|
||||||
|
snap.data
|
||||||
|
.iter()
|
||||||
|
.map(|(kind, docs)| {
|
||||||
|
let docs = docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let mut b = Bson::Document(d.clone());
|
||||||
|
fix(&mut b, "", &ctx);
|
||||||
|
match b {
|
||||||
|
Bson::Document(d) => d,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(*kind, docs)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Geri yükleme ----------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Reason {
|
||||||
|
/// JSON dosyasından geri yüklemeden önce
|
||||||
|
BeforeRestore,
|
||||||
|
/// Geri dönüş noktasına dönmeden önce
|
||||||
|
BeforeUndo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reason {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Reason::BeforeRestore => "before_restore",
|
||||||
|
Reason::BeforeUndo => "before_undo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gzip(text: &str) -> AppResult<Vec<u8>> {
|
||||||
|
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
|
||||||
|
enc.write_all(text.as_bytes())
|
||||||
|
.and_then(|_| enc.finish())
|
||||||
|
.map_err(|e| AppError::Internal(format!("sıkıştırma: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gunzip(bytes: &[u8]) -> AppResult<Vec<u8>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
GzDecoder::new(bytes)
|
||||||
|
.take(MAX_BYTES as u64 * 4)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.map_err(|e| AppError::Internal(format!("geri dönüş noktası açılamadı: {e}")))?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mevcut veriyi geri dönüş noktasına alır ve hanenin verisini `snap` ile değiştirir.
|
||||||
|
/// Hepsi tek transaction: yarıda kalırsa hiçbir şey değişmez. Dönen: geri dönüş noktası kimliği.
|
||||||
|
pub async fn restore(
|
||||||
|
db: &Database,
|
||||||
|
household_id: Uuid,
|
||||||
|
restorer: Uuid,
|
||||||
|
snap: &Snapshot,
|
||||||
|
reason: Reason,
|
||||||
|
) -> AppResult<Uuid> {
|
||||||
|
// 1) Şu anki durumun kopyası (sıkıştırılmış)
|
||||||
|
let current = collect(db, household_id).await?;
|
||||||
|
let packed = gzip(&to_json(¤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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod asset;
|
pub mod asset;
|
||||||
|
pub mod backup;
|
||||||
pub mod bank_import;
|
pub mod bank_import;
|
||||||
pub mod budget;
|
pub mod budget;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
//! JSON tam hane yedeği: dışa aktarma, önizleme, aynı / başka haneye geri yükleme,
|
||||||
|
//! geri dönüş noktaları ("geri al"), bozuk dosya, yetki.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use mongodb::bson::{Bson, Document, doc};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||||
|
let (_, list) = app.get(path, s).await;
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["name"] == name)
|
||||||
|
.unwrap_or_else(|| panic!("{name} yok"))["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(v: &Value) -> String {
|
||||||
|
v["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ekim (kapalı, birikim TL'ye aktarılmış, devirli) + Kasım (açık, harcama, not, bütçe)
|
||||||
|
async fn build(app: &TestApp, s: &Session) -> (String, String) {
|
||||||
|
let market = id_by(app, s, "/categories", "Market").await;
|
||||||
|
let (_, oct) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oct = id(&oct);
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{oct}/plan-items"),
|
||||||
|
s,
|
||||||
|
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for (date, amount) in [("2026-10-05", 200_000), ("2026-10-06", 1)] {
|
||||||
|
let (st, e) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{oct}/entries"),
|
||||||
|
s,
|
||||||
|
json!({ "date": date, "amount": amount, "category_id": market, "description": "Ekim" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||||
|
if amount == 1 {
|
||||||
|
app.delete(&format!("/entries/{}", id(&e)), s).await; // silinmiş kayıt da taşınır
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (st, b) = app
|
||||||
|
.post(
|
||||||
|
&format!("/periods/{oct}/close"),
|
||||||
|
s,
|
||||||
|
json!({ "to_savings": 500_000 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{b}");
|
||||||
|
let (st, b) = app
|
||||||
|
.post(&format!("/periods/{oct}/to-savings"), s, json!({}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||||
|
|
||||||
|
let (_, nov) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
s,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let nov = id(&nov);
|
||||||
|
app.put(
|
||||||
|
&format!("/periods/{nov}/budget"),
|
||||||
|
s,
|
||||||
|
json!({ "needs": 100_000, "wants": 50_000, "culture": 0, "unexpected": 0 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/entries"),
|
||||||
|
s,
|
||||||
|
json!({ "date": "2026-11-03", "amount": 45_090, "category_id": market, "description": "Migros" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/notes"),
|
||||||
|
s,
|
||||||
|
json!({ "context": "planning", "title": "Hedef" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(oct, nov)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(app: &TestApp, s: &Session) -> String {
|
||||||
|
let (st, ct, body) = app.get_text("/backup/download", s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{body}");
|
||||||
|
assert!(ct.starts_with("application/json"), "{ct}");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(file: &str) -> Value {
|
||||||
|
serde_json::from_str::<Value>(file).unwrap()["data"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bir dönemin özet + harcama listesi (karşılaştırma için)
|
||||||
|
async fn view(app: &TestApp, s: &Session, pid: &str) -> (Value, Value) {
|
||||||
|
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), s).await;
|
||||||
|
let (_, list) = app.get(&format!("/periods/{pid}/entries"), s).await;
|
||||||
|
(sum, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn points(app: &TestApp, s: &Session) -> Vec<Value> {
|
||||||
|
let (_, st) = app.get("/backup/status", s).await;
|
||||||
|
st["restore_points"].as_array().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn same_household_restore_and_undo_chain() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Sahip", None).await;
|
||||||
|
let (oct, nov) = build(&app, &s).await;
|
||||||
|
let before = view(&app, &s, &nov).await;
|
||||||
|
|
||||||
|
let (_, st) = app.get("/backup/status", &s).await;
|
||||||
|
assert!(st["last_backup_at"].is_null(), "henüz yedek yok");
|
||||||
|
let file = download(&app, &s).await;
|
||||||
|
let v: Value = serde_json::from_str(&file).unwrap();
|
||||||
|
assert_eq!(v["format"], "kakeibo-backup");
|
||||||
|
assert_eq!(v["counts"]["entries"], 2, "silinmiş sayılmaz");
|
||||||
|
assert_eq!(
|
||||||
|
v["data"]["entries"].as_array().unwrap().len(),
|
||||||
|
3,
|
||||||
|
"ama dosyada var"
|
||||||
|
);
|
||||||
|
assert!(v["data"].get("users").is_none() && !file.contains("password"));
|
||||||
|
let (_, st) = app.get("/backup/status", &s).await;
|
||||||
|
assert!(st["last_backup_at"].is_string());
|
||||||
|
|
||||||
|
// Yedekten sonra değişiklikler
|
||||||
|
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||||
|
app.post(
|
||||||
|
&format!("/periods/{nov}/entries"),
|
||||||
|
&s,
|
||||||
|
json!({ "date": "2026-11-10", "amount": 999, "category_id": market }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.patch(
|
||||||
|
&format!("/categories/{market}"),
|
||||||
|
&s,
|
||||||
|
json!({ "name": "Market (yeni)" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Önizleme: hiçbir şey yazmaz
|
||||||
|
let (st, p) = app.post_text("/backup/preview", &s, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{p}");
|
||||||
|
assert_eq!(p["same_household"], true);
|
||||||
|
assert_eq!(
|
||||||
|
(p["first_date"].as_str(), p["last_date"].as_str()),
|
||||||
|
(Some("2026-10-01"), Some("2026-11-30"))
|
||||||
|
);
|
||||||
|
let entries = p["counts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["kind"] == "entries")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(entries["backup"].as_i64(), entries["current"].as_i64()),
|
||||||
|
(Some(2), Some(3))
|
||||||
|
);
|
||||||
|
assert_eq!(p["changed_since"], 2, "{p}");
|
||||||
|
assert!(p["warnings"][0].as_str().unwrap().contains("2 kayıt"));
|
||||||
|
assert!(points(&app, &s).await.is_empty());
|
||||||
|
|
||||||
|
// Geri yükle → yedekteki durum birebir
|
||||||
|
let (st, r) = app.post_text("/backup/restore", &s, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(view(&app, &s, &nov).await, before);
|
||||||
|
assert_eq!(
|
||||||
|
data(&download(&app, &s).await),
|
||||||
|
data(&file),
|
||||||
|
"kayıtlar birebir"
|
||||||
|
);
|
||||||
|
let (_, oct_p) = app.get(&format!("/periods/{oct}"), &s).await;
|
||||||
|
assert_eq!(oct_p["status"], "closed");
|
||||||
|
// Tür korunur: version i32, tutar i64
|
||||||
|
let e: Document = app
|
||||||
|
.db
|
||||||
|
.collection::<Document>("entries")
|
||||||
|
.find_one(doc! { "amount": 45_090_i64 })
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(e.get("version"), Some(Bson::Int32(_))));
|
||||||
|
assert!(matches!(e.get("amount"), Some(Bson::Int64(_))));
|
||||||
|
|
||||||
|
let list = points(&app, &s).await;
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0]["reason"], "before_restore");
|
||||||
|
assert_eq!(list[0]["entries"], 3, "geri yüklemeden önceki durum");
|
||||||
|
assert_eq!(list[0]["id"], r["restore_point_id"]);
|
||||||
|
|
||||||
|
// Geri al → değişiklikler geri gelir; bu da bir nokta bırakır
|
||||||
|
let (st, u) = app
|
||||||
|
.post(
|
||||||
|
&format!(
|
||||||
|
"/backup/restore-points/{}/restore",
|
||||||
|
r["restore_point_id"].as_str().unwrap()
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{u}");
|
||||||
|
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||||
|
assert_eq!(list.as_array().unwrap().len(), 2);
|
||||||
|
assert!(
|
||||||
|
app.get("/categories", &s)
|
||||||
|
.await
|
||||||
|
.1
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c["name"] == "Market (yeni)")
|
||||||
|
);
|
||||||
|
let list = points(&app, &s).await;
|
||||||
|
assert_eq!(
|
||||||
|
list.iter().map(|p| p["reason"].clone()).collect::<Vec<_>>(),
|
||||||
|
vec![json!("before_undo"), json!("before_restore")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Geri al"ı geri al → yine yedekteki durum
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!(
|
||||||
|
"/backup/restore-points/{}/restore",
|
||||||
|
u["restore_point_id"].as_str().unwrap()
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::OK);
|
||||||
|
assert_eq!(view(&app, &s, &nov).await, before);
|
||||||
|
|
||||||
|
// En fazla 3 nokta saklanır
|
||||||
|
app.post_text("/backup/restore", &s, &file).await;
|
||||||
|
assert_eq!(points(&app, &s).await.len(), 3);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn copy_to_another_household_gets_new_ids() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let a = app.register("Kaynak", None).await;
|
||||||
|
let (oct, nov) = build(&app, &a).await;
|
||||||
|
let file = download(&app, &a).await;
|
||||||
|
let a_before = view(&app, &a, &nov).await;
|
||||||
|
|
||||||
|
let b = app.register("Hedef", None).await;
|
||||||
|
let (st, p) = app.post_text("/backup/preview", &b, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{p}");
|
||||||
|
assert_eq!(p["same_household"], false);
|
||||||
|
assert_eq!(p["source_name"], "Kaynak Hanesi");
|
||||||
|
assert!(
|
||||||
|
p["warnings"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("başka bir haneden")
|
||||||
|
);
|
||||||
|
|
||||||
|
for round in 0..2 {
|
||||||
|
// İki kez yüklense de kimlik çakışmaz
|
||||||
|
let (st, r) = app.post_text("/backup/restore", &b, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "tur {round}: {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, periods) = app.get("/periods", &b).await;
|
||||||
|
let periods = periods.as_array().unwrap();
|
||||||
|
assert_eq!(periods.len(), 2);
|
||||||
|
assert!(
|
||||||
|
periods
|
||||||
|
.iter()
|
||||||
|
.all(|p| p["id"] != oct.as_str() && p["id"] != nov.as_str())
|
||||||
|
);
|
||||||
|
let b_nov = periods.iter().find(|p| p["status"] == "open").unwrap();
|
||||||
|
let b_nov_id = id(b_nov);
|
||||||
|
|
||||||
|
// Aynı rakamlar, yeni kimlikler; referanslar bu hanenin kayıtlarına
|
||||||
|
let (sum, list) = view(&app, &b, &b_nov_id).await;
|
||||||
|
assert_eq!(sum, a_before.0);
|
||||||
|
let entry = &list[0];
|
||||||
|
assert_eq!(entry["amount"], 45_090);
|
||||||
|
assert_ne!(entry["id"], a_before.1[0]["id"]);
|
||||||
|
let (_, cats) = app.get("/categories", &b).await;
|
||||||
|
assert!(
|
||||||
|
cats.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c["id"] == entry["category_id"])
|
||||||
|
);
|
||||||
|
let (_, me) = app.get("/auth/me", &b).await;
|
||||||
|
assert_eq!(
|
||||||
|
entry["created_by"], me["user"]["id"],
|
||||||
|
"kaynak kişi → geri yükleyen"
|
||||||
|
);
|
||||||
|
let (_, pf) = app.get("/portfolio", &b).await;
|
||||||
|
assert!(
|
||||||
|
pf.to_string().contains("500000"),
|
||||||
|
"birikim aktarımı da taşındı: {pf}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kaynak hane değişmedi; hedef kaynağın kayıtlarını göremez
|
||||||
|
assert_eq!(view(&app, &a, &nov).await, a_before);
|
||||||
|
let (st, _) = app.get(&format!("/periods/{nov}"), &b).await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bad_files_and_permissions() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let owner = app.register("Sahip", None).await;
|
||||||
|
let (_, nov) = build(&app, &owner).await;
|
||||||
|
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||||
|
let member = app
|
||||||
|
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Üye yedek alabilir, geri yükleyemez
|
||||||
|
let file = download(&app, &member).await;
|
||||||
|
for path in ["/backup/preview", "/backup/restore"] {
|
||||||
|
let (st, _) = app.post_text(path, &member, &file).await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN, "{path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bozuk / değiştirilmiş / yabancı dosya → anlaşılır hata, hiçbir şey yazılmaz
|
||||||
|
let before = view(&app, &owner, &nov).await;
|
||||||
|
let tampered = file.replacen("45090", "45091", 1);
|
||||||
|
let cases = [
|
||||||
|
(tampered.as_str(), "bozuk"),
|
||||||
|
(&file[..file.len() / 2], "Kakeibo yedeği değil"),
|
||||||
|
("Tarih;Tutar\n01.11.2026;5", "Kakeibo yedeği değil"),
|
||||||
|
];
|
||||||
|
for (body, msg) in cases {
|
||||||
|
let (st, b) = app.post_text("/backup/restore", &owner, body).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||||
|
assert!(b["error"]["message"].as_str().unwrap().contains(msg), "{b}");
|
||||||
|
}
|
||||||
|
let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1);
|
||||||
|
let (st, b) = app.post_text("/backup/preview", &owner, &newer).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(b["error"]["message"].as_str().unwrap().contains("daha yeni"), "{b}");
|
||||||
|
assert_eq!(view(&app, &owner, &nov).await, before);
|
||||||
|
assert!(points(&app, &owner).await.is_empty());
|
||||||
|
|
||||||
|
// Geri dönüş noktası: üye göremez; başka hane kullanamaz
|
||||||
|
let (_, r) = app.post_text("/backup/restore", &owner, &file).await;
|
||||||
|
let pid = r["restore_point_id"].as_str().unwrap();
|
||||||
|
assert!(points(&app, &member).await.is_empty());
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!("/backup/restore-points/{pid}/restore"),
|
||||||
|
&member,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||||
|
let other = app.register("Diger", None).await;
|
||||||
|
let (st, _) = app
|
||||||
|
.post(
|
||||||
|
&format!("/backup/restore-points/{pid}/restore"),
|
||||||
|
&other,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -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_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 },
|
||||||
budget_suggestion: "previous" | "avg3" | "default" // varsayılan öneri yöntemi
|
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 }
|
created_at, updated_at, deleted_at, version }
|
||||||
```
|
```
|
||||||
- `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye).
|
- `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.
|
- 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.
|
- 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`
|
### 3.15 `schema_migrations`
|
||||||
```js
|
```js
|
||||||
{ _id: 1 /* sürüm numarası */, name: "initial", applied_at }
|
{ _id: 1 /* sürüm numarası */, name: "initial", applied_at }
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 9 – Adım 5a:** JSON yedeği backend. Tasarım kullanıcıya sunuldu ve onaylandı ("5a'dan devam"): `kakeibo-yedek-<hane>-<tarih>.json` (format/şema sürümü, zaman, hane, sayılar, SHA-256), içerik hane verisi (kullanıcı/şifre/davet/otomatik kur/silinmiş yok), dışa aktarma her üye, geri yükleme yalnız sahip; önizleme → tam değiştirme (birleştirme yok) tek transaction; geri yüklemeden önce sıkıştırılmış geri dönüş noktası (`flate2`), son 3 / 30 gün, "geri al" da nokta oluşturur; aynı hane → kimlikler korunur, başka hane → yeniden üretilir + iç referanslar çevrilir, `created_by` yoksa geri yükleyen; eski biçim yükseltilir, yeni biçim reddedilir; 20 MB. 5b: arayüz + "son yedek X gün önce" hatırlatması.
|
**Faz 9 – Adım 5b:** JSON yedeği arayüzü (Ayarlar → Veri): "Yedeği indir" (son yedek X gün önce / hiç alınmadı hatırlatması), sahip için "Yedekten geri yükle" → dosya seç → önizleme (kaynak hane, tarih, kayıt sayıları yedek/şimdi, tarih aralığı, uyarılar) → onay kutusu → geri yükle → sonuç + "Geri al"; geri dönüş noktaları listesi (tarih, neden, kayıt sayısı, "Bu duruma dön" onaylı). Tipler hazır: `BackupStatus`, `BackupPreview`, `BackupCount`, `RestorePoint`, `RestoreResult`. Gövde: dosya metni (`api.postCsv` benzeri, `application/json`). Sonra Adım 6 aylık rapor.
|
||||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
**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
|
## 📌 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 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.
|
- **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).
|
- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur).
|
||||||
|
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
|
||||||
|
|||||||
+1
-1
@@ -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
|
- ✅ 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
|
- ✅ 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)
|
- ✅ 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
|
- ⏳ 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
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { BackupCount } from "./BackupCount";
|
||||||
|
|
||||||
|
export type BackupPreview = { source_name: string, same_household: boolean,
|
||||||
|
/**
|
||||||
|
* RFC 3339
|
||||||
|
*/
|
||||||
|
exported_at: string, app_version: string, counts: Array<BackupCount>,
|
||||||
|
/**
|
||||||
|
* Yedekteki dönemlerin ilk ve son günü
|
||||||
|
*/
|
||||||
|
first_date: string | null, last_date: string | null,
|
||||||
|
/**
|
||||||
|
* Aynı hane: yedekten sonra eklenen/değişen (kaybolacak) kayıt
|
||||||
|
*/
|
||||||
|
changed_since: number, warnings: Array<string>, };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { RestorePoint } from "./RestorePoint";
|
||||||
|
|
||||||
|
export type BackupStatus = {
|
||||||
|
/**
|
||||||
|
* Son dışa aktarma (RFC 3339); hiç alınmadıysa `null`
|
||||||
|
*/
|
||||||
|
last_backup_at: string | null,
|
||||||
|
/**
|
||||||
|
* En yeniden eskiye; sahip olmayana boş
|
||||||
|
*/
|
||||||
|
restore_points: Array<RestorePoint>, keep_points: number, keep_days: number, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user