diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dc5fd11..ae1167f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -959,6 +959,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8554498..0104282 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -13,3 +13,4 @@ thiserror = "2.0.21" tokio = { version = "1.54.1", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.29.2", features = ["v7"] } diff --git a/backend/src/db.rs b/backend/src/db.rs deleted file mode 100644 index 3616720..0000000 --- a/backend/src/db.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! MongoDB bağlantısı. - -use std::time::Duration; - -use mongodb::{Client, Database, bson::doc, options::ClientOptions}; - -/// URI'de veritabanı adı yoksa kullanılacak ad. -const DEFAULT_DB_NAME: &str = "kakeibo"; - -/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular. -pub async fn connect(uri: &str) -> mongodb::error::Result { - let mut options = ClientOptions::parse(uri).await?; - options.app_name = Some("kakeibo".into()); - // Sunucu yoksa 30 sn yerine 5 sn içinde hata ver. - options.server_selection_timeout = Some(Duration::from_secs(5)); - - let db_name = options - .default_database - .clone() - .unwrap_or_else(|| DEFAULT_DB_NAME.into()); - - let client = Client::with_options(options)?; - let db = client.database(&db_name); - ping(&db).await?; - Ok(db) -} - -pub async fn ping(db: &Database) -> mongodb::error::Result<()> { - db.run_command(doc! { "ping": 1 }).await.map(|_| ()) -} diff --git a/backend/src/db/indexes.rs b/backend/src/db/indexes.rs new file mode 100644 index 0000000..d788ee0 --- /dev/null +++ b/backend/src/db/indexes.rs @@ -0,0 +1,116 @@ +//! İndeksler (DATA_MODEL.md §4). Açılışta her seferinde çağrılır; +//! MongoDB zaten var olan aynı indeksi yeniden oluşturmaz. + +use mongodb::{ + Database, IndexModel, + bson::{Document, doc}, + error::Result, + options::IndexOptions, +}; + +use super::col; + +pub async fn ensure(db: &Database) -> Result<()> { + for (collection, indexes) in definitions() { + db.collection::(collection) + .create_indexes(indexes) + .await?; + } + tracing::info!("İndeksler hazır"); + Ok(()) +} + +fn idx(keys: Document) -> IndexModel { + IndexModel::builder().keys(keys).build() +} + +fn idx_opts(keys: Document, options: IndexOptions) -> IndexModel { + IndexModel::builder().keys(keys).options(options).build() +} + +fn unique() -> IndexOptions { + IndexOptions::builder().unique(true).build() +} + +fn definitions() -> Vec<(&'static str, Vec)> { + let hp = || idx(doc! { "household_id": 1, "period_id": 1 }); + + let mut defs = vec![ + (col::USERS, vec![idx_opts(doc! { "email": 1 }, unique())]), + (col::INVITES, vec![idx_opts(doc! { "code": 1 }, unique())]), + ( + col::PERIODS, + vec![ + idx(doc! { "household_id": 1, "start_date": 1 }), + // Hanede en fazla bir açık dönem + idx_opts( + doc! { "household_id": 1 }, + IndexOptions::builder() + .name("one_open_period_per_household".to_string()) + .unique(true) + .partial_filter_expression(doc! { "status": "open" }) + .build(), + ), + ], + ), + ( + col::CATEGORIES, + vec![idx(doc! { "household_id": 1, "sort_order": 1 })], + ), + ( + col::PAYMENT_METHODS, + vec![idx(doc! { "household_id": 1, "sort_order": 1 })], + ), + (col::PLAN_ITEMS, vec![hp()]), + (col::BUDGETS, vec![hp()]), + (col::NOTES, vec![hp()]), + ( + col::ENTRIES, + vec![ + idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }), + idx(doc! { "household_id": 1, "date": 1 }), + ], + ), + ( + col::CARRYOVERS, + vec![idx(doc! { "household_id": 1, "to_period_id": 1 })], + ), + ( + col::ASSET_TRANSACTIONS, + vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })], + ), + ( + col::EXCHANGE_RATES, + vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })], + ), + ]; + + // Senkronizasyon (Faz 11): "şu tarihten sonra değişenler" sorgusu. + for &name in col::HOUSEHOLD_SCOPED { + let sync = idx(doc! { "household_id": 1, "updated_at": 1 }); + match defs.iter_mut().find(|(c, _)| *c == name) { + Some((_, list)) => list.push(sync), + None => defs.push((name, vec![sync])), + } + } + + defs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_household_collection_has_sync_index() { + let defs = definitions(); + for &name in col::HOUSEHOLD_SCOPED { + let (_, list) = defs.iter().find(|(c, _)| *c == name).unwrap(); + assert!( + list.iter() + .any(|i| i.keys == doc! { "household_id": 1, "updated_at": 1 }), + "{name} koleksiyonunda senkronizasyon indeksi yok" + ); + } + } +} diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs new file mode 100644 index 0000000..a7d2492 --- /dev/null +++ b/backend/src/db/migrations.rs @@ -0,0 +1,83 @@ +//! Basit şema migration mekanizması (DATA_MODEL.md §3.15). +//! +//! Her migration bir kez çalışır; `schema_migrations` koleksiyonuna +//! `{ _id: sürüm, name, applied_at }` olarak kaydedilir. +//! Yeni migration eklemek: `MIGRATIONS` listesine bir sonraki sürüm numarasıyla ekle. + +use std::{future::Future, pin::Pin}; + +use mongodb::{ + Database, + bson::{DateTime, Document, doc}, + error::Result, +}; + +use super::col; + +type MigrationFn = + for<'a> fn(&'a Database) -> Pin> + Send + 'a>>; + +struct Migration { + version: i32, + name: &'static str, + run: MigrationFn, +} + +/// Sıralı migration listesi. Sürümler 1'den başlar ve atlamadan artar. +const MIGRATIONS: &[Migration] = &[Migration { + version: 1, + name: "initial", + run: |db| Box::pin(m001_initial(db)), +}]; + +/// Uygulanmamış migration'ları sırayla çalıştırır. +pub async fn run(db: &Database) -> Result<()> { + let coll = db.collection::(col::SCHEMA_MIGRATIONS); + + for m in MIGRATIONS { + if coll.find_one(doc! { "_id": m.version }).await?.is_some() { + continue; + } + tracing::info!("Migration çalışıyor: v{} ({})", m.version, m.name); + (m.run)(db).await?; + coll.insert_one(doc! { + "_id": m.version, + "name": m.name, + "applied_at": DateTime::now(), + }) + .await?; + } + Ok(()) +} + +/// v1: Tüm koleksiyonları açıkça oluşturur. +/// (Transaction içinde ilk kez yazılan koleksiyonlar sorun çıkarmasın diye önceden var olsunlar.) +async fn m001_initial(db: &Database) -> Result<()> { + let existing = db.list_collection_names().await?; + let all = col::HOUSEHOLD_SCOPED + .iter() + .chain(&[col::EXCHANGE_RATES, col::SCHEMA_MIGRATIONS]); + + for name in all { + if !existing.iter().any(|e| e == name) { + db.create_collection(*name).await?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_start_at_1_and_are_sequential() { + for (i, m) in MIGRATIONS.iter().enumerate() { + assert_eq!( + m.version, + i as i32 + 1, + "migration sürümleri ardışık olmalı" + ); + } + } +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs new file mode 100644 index 0000000..f13eab0 --- /dev/null +++ b/backend/src/db/mod.rs @@ -0,0 +1,78 @@ +//! MongoDB bağlantısı, koleksiyon adları, migration ve indeksler. + +pub mod indexes; +pub mod migrations; + +use std::time::Duration; + +use mongodb::{Client, Database, bson::doc, options::ClientOptions}; + +/// URI'de veritabanı adı yoksa kullanılacak ad. +const DEFAULT_DB_NAME: &str = "kakeibo"; + +/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular. +pub async fn connect(uri: &str) -> mongodb::error::Result { + let mut options = ClientOptions::parse(uri).await?; + options.app_name = Some("kakeibo".into()); + // Sunucu yoksa 30 sn yerine 5 sn içinde hata ver. + options.server_selection_timeout = Some(Duration::from_secs(5)); + + let db_name = options + .default_database + .clone() + .unwrap_or_else(|| DEFAULT_DB_NAME.into()); + + let client = Client::with_options(options)?; + let db = client.database(&db_name); + ping(&db).await?; + Ok(db) +} + +pub async fn ping(db: &Database) -> mongodb::error::Result<()> { + db.run_command(doc! { "ping": 1 }).await.map(|_| ()) +} + +/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler. +/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar. +pub async fn prepare(db: &Database) -> mongodb::error::Result<()> { + migrations::run(db).await?; + indexes::ensure(db).await?; + Ok(()) +} + +/// Koleksiyon adları (DATA_MODEL.md §3). Yazım hatasını derleyici yakalasın diye sabit. +#[allow(dead_code)] // Bazıları sonraki fazlarda kullanılacak +pub mod col { + pub const USERS: &str = "users"; + pub const HOUSEHOLDS: &str = "households"; + pub const INVITES: &str = "invites"; + pub const PERIODS: &str = "periods"; + pub const CATEGORIES: &str = "categories"; + pub const PAYMENT_METHODS: &str = "payment_methods"; + pub const PLAN_ITEMS: &str = "plan_items"; + pub const BUDGETS: &str = "budgets"; + pub const ENTRIES: &str = "entries"; + pub const CARRYOVERS: &str = "carryovers"; + pub const NOTES: &str = "notes"; + pub const ASSETS: &str = "assets"; + pub const ASSET_TRANSACTIONS: &str = "asset_transactions"; + pub const EXCHANGE_RATES: &str = "exchange_rates"; + pub const SCHEMA_MIGRATIONS: &str = "schema_migrations"; + + /// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var). + pub const HOUSEHOLD_SCOPED: &[&str] = &[ + USERS, + HOUSEHOLDS, + INVITES, + PERIODS, + CATEGORIES, + PAYMENT_METHODS, + PLAN_ITEMS, + BUDGETS, + ENTRIES, + CARRYOVERS, + NOTES, + ASSETS, + ASSET_TRANSACTIONS, + ]; +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 53dae15..b5c5e55 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,6 +2,7 @@ mod config; mod db; #[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak mod error; +mod models; mod routes; mod state; @@ -44,6 +45,11 @@ async fn main() -> ExitCode { } }; + if let Err(e) = db::prepare(&db).await { + tracing::error!("Veritabanı hazırlanamadı (migration/indeks): {e}"); + return ExitCode::FAILURE; + } + let port = config.port; let state = AppState { db, diff --git a/backend/src/models/.gitkeep b/backend/src/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/models/common.rs b/backend/src/models/common.rs new file mode 100644 index 0000000..78d6e8f --- /dev/null +++ b/backend/src/models/common.rs @@ -0,0 +1,70 @@ +//! Tüm koleksiyonlarda ortak alanlar için yardımcılar (DATA_MODEL.md §1.1). +//! +//! Her kayıt şu alanları taşır: +//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version` + +#![allow(dead_code)] // Faz 1 – Adım 3 itibarıyla kullanılacak + +use mongodb::bson::{DateTime, Document, Uuid, doc}; + +/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için +/// sonradan üretilen kimlik her zaman daha büyüktür (zamana göre sıralanabilir). +pub fn new_id() -> Uuid { + Uuid::from_bytes(uuid::Uuid::now_v7().into_bytes()) +} + +/// Şu anki zaman (UTC, BSON DateTime). +pub fn now() -> DateTime { + DateTime::now() +} + +/// Yeni kayıt için ortak alanlar. Modele özgü alanlarla birleştirilir: +/// `let mut d = new_meta(hid); d.insert("name", "Market");` +pub fn new_meta(household_id: Uuid) -> Document { + let ts = now(); + doc! { + "_id": new_id(), + "household_id": household_id, + "created_at": ts, + "updated_at": ts, + "deleted_at": null, + "version": 1_i32, + } +} + +/// Hane kapsamlı, silinmemiş kayıtlar için temel filtre. +/// Tüm okuma sorguları bununla başlar → başka hanenin verisi asla dönmez. +pub fn scope(household_id: Uuid) -> Document { + doc! { "household_id": household_id, "deleted_at": null } +} + +/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1. +pub fn touch() -> Document { + doc! { + "$set": { "updated_at": now() }, + "$inc": { "version": 1_i32 }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_id_is_v7_and_sortable() { + let a = uuid::Uuid::from_bytes(new_id().bytes()); + std::thread::sleep(std::time::Duration::from_millis(2)); + let b = uuid::Uuid::from_bytes(new_id().bytes()); + + assert_eq!(a.get_version_num(), 7); + assert!(b > a, "sonra üretilen UUID daha büyük olmalı"); + } + + #[test] + fn scope_always_filters_household_and_deleted() { + let hid = new_id(); + let f = scope(hid); + assert_eq!(f.get("household_id"), Some(&hid.into())); + assert!(f.contains_key("deleted_at")); + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..f4afdd2 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,4 @@ +//! Veritabanı modelleri. Her koleksiyonun yapısı, onu kullanan adımda eklenir. +//! Şema: `docs/DATA_MODEL.md` + +pub mod common; diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index d897b2a..901ece4 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -60,7 +60,7 @@ Renkler ve ikonlar frontend tasarım token'larında. ### 3.2 `households` ```js -{ _id, name, owner_id, +{ _id, household_id /* = _id */, name, owner_id, settings: { currency: "TRY", budget_split_pct: { needs: 50, wants: 30, culture: 10, unexpected: 10 }, @@ -68,6 +68,7 @@ Renkler ve ikonlar frontend tasarım token'larında. }, created_at, updated_at, deleted_at, version } ``` +- `household_id` = `_id` (ortak `scope()` filtresi tüm koleksiyonlarda aynı çalışsın diye). - Prompttaki `settings` koleksiyonu yerine **haneye gömülü** (her hanede tek kayıt olduğu için ayrı koleksiyona gerek yok). ### 3.3 `invites` diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 7645dbe..b76bfe0 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 1 – Adım 2:** Ortak model alanları (UUID v7, zaman damgaları, version), `schema_migrations` ile migration mekanizması, `DATA_MODEL.md` §4 indeksleri (açılışta otomatik). +**Faz 1 – Adım 3:** Kimlik doğrulama: `POST /api/auth/register` (kullanıcı + hane + örnek kategori/ödeme türü, transaction), `login`, `logout`, `GET /api/auth/me`; argon2, JWT httpOnly cookie, giriş rate limit. ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -11,6 +11,8 @@ - Yerel MongoDB host portu 27020 (27017 sistem mongod, 27018/27019 başka projeler). - Frontend lint aracı: oxlint (Vite şablonu varsayılanı), ESLint yerine. - Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır. +- Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir. +- `households.household_id = _id` (scope filtresi her yerde aynı). - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7. ## ❓ Açık Konular @@ -30,3 +32,4 @@ ### 2026-11-11 — Faz 1 - **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti. +- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi. diff --git a/docs/PHASES.md b/docs/PHASES.md index 42a9c45..7d48778 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -26,7 +26,7 @@ ## Faz 1 Adımları - ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu -- 🔄 2. Modeller, migration mekanizması ve indeksler -- ⏳ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit +- ✅ 2. Modeller, migration mekanizması ve indeksler +- 🔄 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit - ⏳ 4. Hane izolasyonu (middleware), üye davet kodu - ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`