diff --git a/CLAUDE.md b/CLAUDE.md index ea47482..b6e2359 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,14 +53,17 @@ npm run dev # frontend (5173) + backend (8080) birlikte, hot reload npm run dev:host # telefondan test için ağa aç npm run db:up # yerel MongoDB (Docker) npm run db:down -npm run db:seed / db:reset -npm test # backend testleri +npm run db:seed +npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler +npm test # backend: birim + entegrasyon (yerel MongoDB açık olmalı) npm run lint # clippy + oxlint ``` - `npm run dev` öncesinde DB otomatik başlar (`predev`). - Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). - API örnekleri: `backend/api.http` +- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_` DB, `tests/common` yardımcıları). +- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam - Geliştirme makinesi: CachyOS (Arch), fish kabuğu, VS Code diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 629bc07..40de7b8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1287,6 +1287,7 @@ dependencies = [ "axum", "axum-extra", "dotenvy", + "http-body-util", "jsonwebtoken", "mongodb", "serde", @@ -1294,6 +1295,7 @@ dependencies = [ "thiserror", "time", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d74dc98..3d5f324 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -19,3 +19,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } uuid = { version = "1.29.2", features = ["v4", "v7"] } validator = { version = "0.21.0", features = ["derive"] } + +[dev-dependencies] +http-body-util = "0.1.5" +tower = { version = "0.5.3", features = ["util"] } diff --git a/backend/api.http b/backend/api.http index f77eea9..612e826 100644 --- a/backend/api.http +++ b/backend/api.http @@ -61,3 +61,69 @@ GET {{baseUrl}}/household/invites ### Daveti iptal et (yalnızca hane sahibi) — id'yi yukarıdaki listeden alın DELETE {{baseUrl}}/household/invites/BURAYA-ID + +# ─────────────────────────── Kategoriler ─────────────────────────── +# Gruplar: needs (Zorunlu) · wants (İstek) · culture (Kültürel) · unexpected (Beklenmeyen) +# Silme yok; "is_active": false ile pasife alınır. + +### Kategoriler (aktif olanlar; pasifler için ?include_inactive=true) +GET {{baseUrl}}/categories + +### Kategori ekle (grubun sonuna eklenir) +POST {{baseUrl}}/categories +Content-Type: application/json + +{ + "group": "needs", + "name": "Aidat", + "emoji": "🏢" +} + +### Kategori düzenle / pasife al — "version": listede gördüğünüz sürüm (başkası değiştirdiyse 409) +PATCH {{baseUrl}}/categories/BURAYA-ID +Content-Type: application/json + +{ + "name": "Site Aidatı", + "is_active": true, + "version": 1 +} + +### Grup içi sıralama — gruptaki TÜM kimlikler (pasifler dahil), istenen sırada +PUT {{baseUrl}}/categories/order +Content-Type: application/json + +{ + "group": "culture", + "ids": ["ID-1", "ID-2", "ID-3"] +} + +# ─────────────────────────── Ödeme türleri ─────────────────────────── + +### Ödeme türleri +GET {{baseUrl}}/payment-methods + +### Ödeme türü ekle +POST {{baseUrl}}/payment-methods +Content-Type: application/json + +{ + "name": "Yemek Kartı", + "emoji": "🍽️" +} + +### Ödeme türü düzenle / pasife al +PATCH {{baseUrl}}/payment-methods/BURAYA-ID +Content-Type: application/json + +{ + "is_active": false +} + +### Ödeme türü sıralama — TÜM kimlikler +PUT {{baseUrl}}/payment-methods/order +Content-Type: application/json + +{ + "ids": ["ID-1", "ID-2", "ID-3", "ID-4"] +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 99a1e55..9a0c87b 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -55,7 +55,6 @@ pub async fn prepare(db: &Database) -> mongodb::error::Result<()> { } /// 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"; diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..ee75c05 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,13 @@ +//! Kakeibo backend kütüphanesi. +//! +//! `main.rs` (çalıştırılabilir sunucu) ve `tests/` (entegrasyon testleri) bu modülleri kullanır. + +pub mod auth; +pub mod config; +pub mod db; +pub mod error; +pub mod extract; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; diff --git a/backend/src/main.rs b/backend/src/main.rs index bf471d6..9b3a3e5 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,23 +1,8 @@ -mod auth; -mod config; -mod db; -mod error; -mod extract; -mod models; -mod routes; -mod services; -mod state; - -use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration}; +use std::{net::SocketAddr, process::ExitCode}; +use kakeibo::{config::Config, db, routes, state::AppState}; use tracing_subscriber::EnvFilter; -use crate::{ - auth::{jwt::Jwt, limiter::LoginLimiter}, - config::Config, - state::AppState, -}; - #[tokio::main] async fn main() -> ExitCode { // .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil. @@ -57,13 +42,7 @@ async fn main() -> ExitCode { } let port = config.port; - let state = AppState { - db, - jwt: Arc::new(Jwt::new(&config.jwt_secret)), - // Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi - login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))), - config: Arc::new(config), - }; + let state = AppState::new(db, config); let app = routes::router(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); diff --git a/backend/src/models/common.rs b/backend/src/models/common.rs index d329a49..028b333 100644 --- a/backend/src/models/common.rs +++ b/backend/src/models/common.rs @@ -36,15 +36,6 @@ pub fn scope(household_id: Uuid) -> Document { doc! { "household_id": household_id, "deleted_at": null } } -#[allow(dead_code)] // Faz 1 – Adım 5'te (kategori güncelleme) kullanılacak -/// 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::*; diff --git a/backend/src/models/group.rs b/backend/src/models/group.rs new file mode 100644 index 0000000..0d0f7ee --- /dev/null +++ b/backend/src/models/group.rs @@ -0,0 +1,39 @@ +//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Group { + /// 🏠 Zorunlu + Needs, + /// 🛍️ İstek + Wants, + /// 📚 Kültürel + Culture, + /// ⚡ Beklenmeyen + Unexpected, +} + +impl Group { + pub const ALL: [Group; 4] = [ + Group::Needs, + Group::Wants, + Group::Culture, + Group::Unexpected, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Group::Needs => "needs", + Group::Wants => "wants", + Group::Culture => "culture", + Group::Unexpected => "unexpected", + } + } + + /// Ekranda gösterim sırası + pub fn order(self) -> usize { + Self::ALL.iter().position(|g| *g == self).unwrap_or(0) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index e0ebb51..0673db5 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -2,6 +2,7 @@ //! Şema: `docs/DATA_MODEL.md` pub mod common; +pub mod group; pub mod household; pub mod invite; pub mod user; diff --git a/backend/src/routes/catalog.rs b/backend/src/routes/catalog.rs new file mode 100644 index 0000000..918d547 --- /dev/null +++ b/backend/src/routes/catalog.rs @@ -0,0 +1,230 @@ +//! Kategori ve ödeme türü uç noktaları (hanenin tüm üyeleri kullanabilir): +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/categories?include_inactive=true` | listele | +//! | POST | `/api/categories` | ekle | +//! | PATCH | `/api/categories/{id}` | ad / emoji / aktiflik değiştir | +//! | PUT | `/api/categories/order` | sırala | +//! +//! `/api/payment-methods` aynı yapıdadır (yalnızca `group` alanı yoktur). + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + extract::ValidJson, + models::group::Group, + services::catalog::{self, Catalog, Changes, Item}, + state::AppState, +}; + +// ---------- İstek / yanıt ---------- + +#[derive(Deserialize, Default)] +pub struct ListQuery { + #[serde(default)] + include_inactive: bool, +} + +#[derive(Deserialize, Validate)] +pub struct CreateRequest { + group: Option, + #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] + name: String, + #[validate(length(max = 16, message = "Emoji çok uzun"))] + emoji: Option, +} + +#[derive(Deserialize, Validate)] +pub struct UpdateRequest { + #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] + name: Option, + #[validate(length(max = 16, message = "Emoji çok uzun"))] + emoji: Option, + is_active: Option, + version: Option, +} + +#[derive(Deserialize, Validate)] +pub struct OrderRequest { + group: Option, + #[validate(length(max = 200, message = "Liste çok uzun"))] + ids: Vec, +} + +#[derive(Serialize)] +pub struct ItemDto { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + group: Option, + name: String, + emoji: Option, + sort_order: i32, + is_active: bool, + version: i32, +} + +impl From for ItemDto { + fn from(i: Item) -> Self { + Self { + id: i.id.to_string(), + group: i.group, + name: i.name, + emoji: i.emoji, + sort_order: i.sort_order, + is_active: i.is_active, + version: i.version, + } + } +} + +fn dtos(items: Vec) -> Json> { + Json(items.into_iter().map(ItemDto::from).collect()) +} + +/// URL'deki kimlik bozuksa "bulunamadı" (başka hanenin kaydıyla aynı yanıt). +fn parse_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +// ---------- Ortak işleyiciler ---------- + +async fn list( + c: Catalog, + state: AppState, + auth: AuthUser, + q: ListQuery, +) -> AppResult>> { + let items = catalog::list(&state.db, c, auth.household_id, q.include_inactive).await?; + Ok(dtos(items)) +} + +async fn create( + c: Catalog, + state: AppState, + auth: AuthUser, + req: CreateRequest, +) -> AppResult<(StatusCode, Json)> { + let item = catalog::create( + &state.db, + c, + auth.household_id, + req.group, + req.name, + req.emoji, + ) + .await?; + Ok((StatusCode::CREATED, Json(item.into()))) +} + +async fn update( + c: Catalog, + state: AppState, + auth: AuthUser, + id: String, + req: UpdateRequest, +) -> AppResult> { + let changes = Changes { + name: req.name, + emoji: req.emoji, + is_active: req.is_active, + version: req.version, + }; + let item = catalog::update(&state.db, c, auth.household_id, parse_id(&id)?, changes).await?; + Ok(Json(item.into())) +} + +async fn reorder( + c: Catalog, + state: AppState, + auth: AuthUser, + req: OrderRequest, +) -> AppResult>> { + let ids = req + .ids + .iter() + .map(Uuid::parse_str) + .collect::, _>>() + .map_err(|_| AppError::BadRequest("Sıralama listesinde geçersiz kimlik var".into()))?; + let items = catalog::reorder(&state.db, c, auth.household_id, req.group, ids).await?; + Ok(dtos(items)) +} + +// ---------- Kategoriler ---------- + +pub async fn list_categories( + State(s): State, + auth: AuthUser, + Query(q): Query, +) -> AppResult>> { + list(Catalog::Categories, s, auth, q).await +} + +pub async fn create_category( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + create(Catalog::Categories, s, auth, req).await +} + +pub async fn update_category( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + update(Catalog::Categories, s, auth, id, req).await +} + +pub async fn reorder_categories( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult>> { + reorder(Catalog::Categories, s, auth, req).await +} + +// ---------- Ödeme türleri ---------- + +pub async fn list_payment_methods( + State(s): State, + auth: AuthUser, + Query(q): Query, +) -> AppResult>> { + list(Catalog::PaymentMethods, s, auth, q).await +} + +pub async fn create_payment_method( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + create(Catalog::PaymentMethods, s, auth, req).await +} + +pub async fn update_payment_method( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + update(Catalog::PaymentMethods, s, auth, id, req).await +} + +pub async fn reorder_payment_methods( + State(s): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult>> { + reorder(Catalog::PaymentMethods, s, auth, req).await +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 1a4d8ff..bc85d49 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,16 +1,18 @@ //! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında. mod auth; +mod catalog; mod health; mod household; use axum::{ Router, - routing::{delete, get, post}, + routing::{delete, get, patch, post, put}, }; use crate::state::AppState; +/// Tüm API route'ları. Testler de bu fonksiyonu kullanır. pub fn router(state: AppState) -> Router { let api = Router::new() .route("/health", get(health::health)) @@ -23,7 +25,25 @@ pub fn router(state: AppState) -> Router { "/household/invites", get(household::list_invites).post(household::create_invite), ) - .route("/household/invites/{id}", delete(household::revoke_invite)); + .route("/household/invites/{id}", delete(household::revoke_invite)) + .route( + "/categories", + get(catalog::list_categories).post(catalog::create_category), + ) + .route("/categories/order", put(catalog::reorder_categories)) + .route("/categories/{id}", patch(catalog::update_category)) + .route( + "/payment-methods", + get(catalog::list_payment_methods).post(catalog::create_payment_method), + ) + .route( + "/payment-methods/order", + put(catalog::reorder_payment_methods), + ) + .route( + "/payment-methods/{id}", + patch(catalog::update_payment_method), + ); Router::new().nest("/api", api).with_state(state) } diff --git a/backend/src/services/catalog.rs b/backend/src/services/catalog.rs new file mode 100644 index 0000000..3884f99 --- /dev/null +++ b/backend/src/services/catalog.rs @@ -0,0 +1,318 @@ +//! Kategoriler ve ödeme türleri için ortak iş mantığı (DATA_MODEL.md §3.5, §3.6). +//! +//! İkisi de aynı şekilde çalışır: listele, ekle, düzenle, sırala, pasife al. +//! Tek fark: kategoriler bir ana gruba (`group`) bağlıdır, ödeme türleri değil. +//! **Silme yoktur** — geçmiş harcama kayıtları bozulmasın diye yalnızca pasife alınır. + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, + options::ReturnDocument, +}; +use serde::Deserialize; + +use crate::{ + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + group::Group, + }, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Catalog { + Categories, + PaymentMethods, +} + +impl Catalog { + fn collection(self) -> &'static str { + match self { + Catalog::Categories => crate::db::col::CATEGORIES, + Catalog::PaymentMethods => crate::db::col::PAYMENT_METHODS, + } + } + + fn has_group(self) -> bool { + self == Catalog::Categories + } + + fn noun(self) -> &'static str { + match self { + Catalog::Categories => "kategori", + Catalog::PaymentMethods => "ödeme türü", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Item { + #[serde(rename = "_id")] + pub id: Uuid, + #[serde(default)] + pub group: Option, + pub name: String, + #[serde(default)] + pub emoji: Option, + pub sort_order: i32, + pub is_active: bool, + pub version: i32, +} + +#[derive(Debug, Default)] +pub struct Changes { + pub name: Option, + /// `Some("")` → emoji kaldırılır + pub emoji: Option, + pub is_active: Option, + /// İstemcinin gördüğü sürüm; farklıysa başkası değiştirmiş demektir → 409 + pub version: Option, +} + +fn coll(db: &Database, c: Catalog) -> mongodb::Collection { + db.collection(c.collection()) +} + +/// Hanenin kayıtları. Kategoriler grup sırasına, sonra `sort_order`'a göre sıralanır. +pub async fn list( + db: &Database, + c: Catalog, + household_id: Uuid, + include_inactive: bool, +) -> AppResult> { + let mut filter = scope(household_id); + if !include_inactive { + filter.insert("is_active", true); + } + let mut items = collect(coll(db, c).find(filter).await?).await?; + items.sort_by_key(|i| (i.group.map(Group::order), i.sort_order)); + Ok(items) +} + +pub async fn create( + db: &Database, + c: Catalog, + household_id: Uuid, + group: Option, + name: String, + emoji: Option, +) -> AppResult { + let group = check_group(c, group)?; + let name = clean_name(&name)?; + ensure_unique_name(db, c, household_id, group, &name, None).await?; + + // Grubun (veya listenin) en sonuna ekle + let mut last_filter = scope(household_id); + if let Some(g) = group { + last_filter.insert("group", g.as_str()); + } + let last = coll(db, c) + .find_one(last_filter) + .sort(doc! { "sort_order": -1 }) + .await?; + let sort_order = last.map_or(10, |i| i.sort_order + 10); + + let mut d = new_meta(household_id); + let id = d.get("_id").cloned().unwrap_or(Bson::Null); + if let Some(g) = group { + d.insert("group", g.as_str()); + } + d.insert("name", name); + d.insert("emoji", clean_emoji(emoji)); + d.insert("sort_order", sort_order); + d.insert("is_active", true); + db.collection::(c.collection()) + .insert_one(d) + .await?; + + coll(db, c) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("eklenen kayıt okunamadı".into())) +} + +pub async fn update( + db: &Database, + c: Catalog, + household_id: Uuid, + id: Uuid, + changes: Changes, +) -> AppResult { + let mut filter = scope(household_id); + filter.insert("_id", id); + let current = coll(db, c) + .find_one(filter.clone()) + .await? + .ok_or(AppError::NotFound)?; + + if changes.version.is_some_and(|v| v != current.version) { + return Err(stale()); + } + + let mut set = doc! { "updated_at": now() }; + if let Some(name) = changes.name { + let name = clean_name(&name)?; + if name != current.name { + ensure_unique_name(db, c, household_id, current.group, &name, Some(id)).await?; + } + set.insert("name", name); + } + if let Some(emoji) = changes.emoji { + set.insert("emoji", clean_emoji(Some(emoji))); + } + if let Some(active) = changes.is_active { + set.insert("is_active", active); + } + + // Sürüm filtresi: okuma ile yazma arasında başkası değiştirdiyse güncelleme olmaz + filter.insert("version", current.version); + coll(db, c) + .find_one_and_update(filter, doc! { "$set": set, "$inc": { "version": 1 } }) + .return_document(ReturnDocument::After) + .await? + .ok_or_else(stale) +} + +/// Sıralamayı verilen kimlik listesine göre yeniden yazar. +/// Liste, (kategorilerde ilgili gruptaki) **tüm** kayıtları içermelidir — pasifler dahil. +pub async fn reorder( + db: &Database, + c: Catalog, + household_id: Uuid, + group: Option, + ids: Vec, +) -> AppResult> { + let group = check_group(c, group)?; + let mut filter = scope(household_id); + if let Some(g) = group { + filter.insert("group", g.as_str()); + } + let existing = collect(coll(db, c).find(filter).await?).await?; + + let mut want: Vec = ids.clone(); + let mut have: Vec = existing.iter().map(|i| i.id).collect(); + want.sort_by_key(|u| u.bytes()); + have.sort_by_key(|u| u.bytes()); + if want != have { + return Err(AppError::BadRequest( + "Sıralama listesi eksik veya hatalı, sayfayı yenileyip tekrar deneyin".into(), + )); + } + + let mut session = db.client().start_session().await?; + session.start_transaction().await?; + let ts = now(); + for (i, id) in ids.iter().enumerate() { + let r = db + .collection::(c.collection()) + .update_one( + doc! { "_id": id, "household_id": household_id }, + doc! { + "$set": { "sort_order": (i as i32 + 1) * 10, "updated_at": ts }, + "$inc": { "version": 1 }, + }, + ) + .session(&mut session) + .await; + if let Err(e) = r { + let _ = session.abort_transaction().await; + return Err(e.into()); + } + } + session.commit_transaction().await?; + + let all = list(db, c, household_id, true).await?; + Ok(all.into_iter().filter(|i| i.group == group).collect()) +} + +// ---------- yardımcılar ---------- + +fn check_group(c: Catalog, group: Option) -> AppResult> { + match (c.has_group(), group) { + (true, None) => Err(AppError::BadRequest("Ana grup seçilmeli".into())), + (true, g) => Ok(g), + (false, _) => Ok(None), // ödeme türlerinde grup yok sayılır + } +} + +fn clean_name(name: &str) -> AppResult { + let name = name.trim(); + match name.chars().count() { + 0 => Err(AppError::BadRequest("Ad boş olamaz".into())), + 1..=40 => Ok(name.to_string()), + _ => Err(AppError::BadRequest( + "Ad en fazla 40 karakter olabilir".into(), + )), + } +} + +fn clean_emoji(emoji: Option) -> Bson { + match emoji.map(|e| e.trim().to_string()) { + Some(e) if !e.is_empty() => Bson::String(e), + _ => Bson::Null, + } +} + +/// Aynı grupta (pasifler dahil) aynı adda kayıt olmasın. Büyük/küçük harf duyarsız. +async fn ensure_unique_name( + db: &Database, + c: Catalog, + household_id: Uuid, + group: Option, + name: &str, + exclude: Option, +) -> AppResult<()> { + let mut filter = scope(household_id); + if let Some(g) = group { + filter.insert("group", g.as_str()); + } + let wanted = name.to_lowercase(); + let items = collect(coll(db, c).find(filter).await?).await?; + let clash = items + .iter() + .any(|i| Some(i.id) != exclude && i.name.to_lowercase() == wanted); + if clash { + return Err(AppError::Conflict(format!( + "Bu adla bir {} zaten var (pasif olabilir)", + c.noun() + ))); + } + Ok(()) +} + +fn stale() -> AppError { + AppError::Conflict("Bu kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into()) +} + +async fn collect(mut cursor: mongodb::Cursor) -> AppResult> { + let mut v = Vec::new(); + while cursor.advance().await? { + v.push(cursor.deserialize_current()?); + } + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_rules() { + assert_eq!(clean_name(" Market ").unwrap(), "Market"); + assert!(clean_name(" ").is_err()); + assert!( + clean_name(&"ş".repeat(40)).is_ok(), + "Türkçe karakterler tek sayılmalı" + ); + assert!(clean_name(&"a".repeat(41)).is_err()); + } + + #[test] + fn group_required_only_for_categories() { + assert!(check_group(Catalog::Categories, None).is_err()); + assert_eq!( + check_group(Catalog::PaymentMethods, Some(Group::Needs)).unwrap(), + None + ); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 2808845..9ec4137 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,5 +1,6 @@ //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. pub mod account; +pub mod catalog; pub mod invite; pub mod seed; diff --git a/backend/src/state.rs b/backend/src/state.rs index a87cd6e..da1a4e0 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -1,6 +1,6 @@ //! Tüm route'ların paylaştığı uygulama durumu. -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use mongodb::Database; @@ -17,3 +17,15 @@ pub struct AppState { pub jwt: Arc, pub login_limiter: Arc, } + +impl AppState { + pub fn new(db: Database, config: Config) -> Self { + Self { + db, + jwt: Arc::new(Jwt::new(&config.jwt_secret)), + // Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi + login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))), + config: Arc::new(config), + } + } +} diff --git a/backend/tests/catalog.rs b/backend/tests/catalog.rs new file mode 100644 index 0000000..401cb80 --- /dev/null +++ b/backend/tests/catalog.rs @@ -0,0 +1,216 @@ +//! Kategori ve ödeme türü davranış testleri. + +mod common; + +use axum::http::StatusCode; +use common::TestApp; +use serde_json::{Value, json}; + +fn names(list: &Value) -> Vec<&str> { + list.as_array() + .unwrap() + .iter() + .map(|i| i["name"].as_str().unwrap()) + .collect() +} + +#[tokio::test] +async fn new_household_gets_default_categories_and_payment_methods() { + let app = TestApp::new().await; + let s = app.register("Yeni", None).await; + + let (_, cats) = app.get("/categories", &s).await; + assert_eq!(cats.as_array().unwrap().len(), 13); + // Grup sırası: needs → wants → culture → unexpected + let groups: Vec<&str> = cats + .as_array() + .unwrap() + .iter() + .map(|c| c["group"].as_str().unwrap()) + .collect(); + assert_eq!(groups.first(), Some(&"needs")); + assert_eq!(groups.last(), Some(&"unexpected")); + + let (_, pms) = app.get("/payment-methods", &s).await; + assert_eq!( + names(&pms), + vec!["Nakit", "Kredi Kartı", "Banka Kartı", "Havale/EFT"] + ); + assert!( + pms[0].get("group").is_none(), + "ödeme türünde group alanı olmamalı" + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn create_validates_and_appends_to_end_of_group() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + + // Grup zorunlu + let (st, _) = app + .post("/categories", &s, json!({ "name": "Grupsuz" })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Boş ad + let (st, _) = app + .post( + "/categories", + &s, + json!({ "group": "needs", "name": " " }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Geçersiz grup + let (st, _) = app + .post("/categories", &s, json!({ "group": "luks", "name": "X" })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Aynı ad (büyük/küçük harf duyarsız) → 409 + let (st, body) = app + .post( + "/categories", + &s, + json!({ "group": "needs", "name": "MARKET" }), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT, "{body}"); + // Aynı ad başka grupta serbest + let (st, _) = app + .post( + "/categories", + &s, + json!({ "group": "wants", "name": "Market" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + + // Yeni kayıt grubun sonuna eklenir + let (st, cat) = app + .post( + "/categories", + &s, + json!({ "group": "needs", "name": " Aidat ", "emoji": "🏢" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + assert_eq!(cat["name"], "Aidat"); + let (_, cats) = app.get("/categories", &s).await; + let needs: Vec<&str> = cats + .as_array() + .unwrap() + .iter() + .filter(|c| c["group"] == "needs") + .map(|c| c["name"].as_str().unwrap()) + .collect(); + assert_eq!(needs.last(), Some(&"Aidat")); + + app.cleanup().await; +} + +#[tokio::test] +async fn update_deactivate_and_version_conflict() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let (_, pm) = app + .post( + "/payment-methods", + &s, + json!({ "name": "Yemek Kartı", "emoji": "🍽️" }), + ) + .await; + let path = format!("/payment-methods/{}", pm["id"].as_str().unwrap()); + assert_eq!(pm["version"], 1); + + // Ad + emoji kaldırma, doğru sürümle + let (st, upd) = app + .patch( + &path, + &s, + json!({ "name": "Multinet", "emoji": "", "version": 1 }), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(upd["name"], "Multinet"); + assert_eq!(upd["emoji"], Value::Null); + assert_eq!(upd["version"], 2); + + // Eski sürümle güncelleme → 409 (başkası değiştirmiş) + let (st, _) = app + .patch(&path, &s, json!({ "name": "Sodexo", "version": 1 })) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + // Pasife al: varsayılan listede görünmez, include_inactive ile görünür + let (st, _) = app.patch(&path, &s, json!({ "is_active": false })).await; + assert_eq!(st, StatusCode::OK); + let (_, active) = app.get("/payment-methods", &s).await; + assert!(!names(&active).contains(&"Multinet")); + let (_, all) = app.get("/payment-methods?include_inactive=true", &s).await; + assert!(names(&all).contains(&"Multinet")); + + // Pasif kayıtla aynı adda yeni kayıt açılamaz + let (st, _) = app + .post("/payment-methods", &s, json!({ "name": "multinet" })) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + // Olmayan / bozuk kimlik → 404 + let (st, _) = app + .patch( + "/payment-methods/01a1cc59-0000-7000-8000-000000000000", + &s, + json!({ "name": "X" }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app + .patch("/payment-methods/bozuk", &s, json!({ "name": "X" })) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + + app.cleanup().await; +} + +#[tokio::test] +async fn reorder_requires_complete_list() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + + let (_, cats) = app.get("/categories", &s).await; + let mut culture: Vec = cats + .as_array() + .unwrap() + .iter() + .filter(|c| c["group"] == "culture") + .map(|c| c["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(culture.len(), 3); + + // Eksik liste → 400 + let (st, _) = app + .put( + "/categories/order", + &s, + json!({ "group": "culture", "ids": &culture[..2] }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + // Ters çevir → yeni sıra + culture.reverse(); + let (st, ordered) = app + .put( + "/categories/order", + &s, + json!({ "group": "culture", "ids": culture }), + ) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(names(&ordered), vec!["Sinema/Konser", "Eğitim", "Kitap"]); + assert_eq!(ordered[0]["sort_order"], 10); + + app.cleanup().await; +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..4923c84 --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1,149 @@ +//! Entegrasyon testleri için yardımcılar. +//! +//! Her test kendi geçici veritabanını (`kakeibo_test_`) kullanır ve sonunda siler. +//! Gereken: yerel MongoDB çalışıyor olmalı (`npm run db:up`), `.env` içinde `MONGODB_URI`. + +#![allow(dead_code)] // her test dosyası tüm yardımcıları kullanmaz + +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use kakeibo::{config::Config, db, routes, state::AppState}; +use mongodb::Database; +use serde_json::{Value, json}; +use tower::ServiceExt; + +pub struct TestApp { + router: Router, + pub db: Database, +} + +/// Oturum açmış bir test kullanıcısı +#[derive(Clone)] +pub struct Session { + pub cookie: String, + pub household_id: String, +} + +impl TestApp { + pub async fn new() -> Self { + dotenvy::dotenv().ok(); + let uri = std::env::var("MONGODB_URI") + .expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)"); + + let base = db::connect(&uri) + .await + .expect("MongoDB'ye bağlanılamadı — `npm run db:up` çalıştırın"); + let name = format!("kakeibo_test_{}", uuid::Uuid::new_v4().simple()); + let db = base.client().database(&name); + db::prepare(&db).await.expect("test DB hazırlanamadı"); + + let config = Config { + mongodb_uri: uri, + jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(), + app_url: "http://localhost".into(), + port: 0, + }; + let router = routes::router(AppState::new(db.clone(), config)); + Self { router, db } + } + + /// Test sonunda geçici veritabanını siler. + pub async fn cleanup(self) { + self.db.drop().await.ok(); + } + + /// İstek gönderir; (durum kodu, JSON gövde, set-cookie) döner. + pub async fn send( + &self, + method: Method, + path: &str, + cookie: Option<&str>, + body: Option, + ) -> (StatusCode, Value, Option) { + let mut req = Request::builder().method(method).uri(format!("/api{path}")); + if let Some(c) = cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .unwrap(); + + let res = self.router.clone().oneshot(req).await.unwrap(); + let status = res.status(); + let set_cookie = res + .headers() + .get(header::SET_COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(String::from); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, json, set_cookie) + } + + pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) { + let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await; + (st, v) + } + + pub async fn post(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) { + let (st, v, _) = self + .send(Method::POST, path, Some(&s.cookie), Some(body)) + .await; + (st, v) + } + + pub async fn patch(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) { + let (st, v, _) = self + .send(Method::PATCH, path, Some(&s.cookie), Some(body)) + .await; + (st, v) + } + + pub async fn put(&self, path: &str, s: &Session, body: Value) -> (StatusCode, Value) { + let (st, v, _) = self + .send(Method::PUT, path, Some(&s.cookie), Some(body)) + .await; + (st, v) + } + + pub async fn delete(&self, path: &str, s: &Session) -> (StatusCode, Value) { + let (st, v, _) = self.send(Method::DELETE, path, Some(&s.cookie), None).await; + (st, v) + } + + /// Yeni kullanıcı kaydeder (davet koduyla veya yeni hane olarak) ve oturumunu döner. + pub async fn register(&self, name: &str, invite_code: Option<&str>) -> Session { + let email = format!( + "{}-{}@test.com", + name.to_lowercase(), + uuid::Uuid::new_v4().simple() + ); + let (status, body, cookie) = self + .send( + Method::POST, + "/auth/register", + None, + Some(json!({ + "name": name, + "email": email, + "password": "test-sifre-123", + "invite_code": invite_code, + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "kayıt başarısız: {body}"); + Session { + cookie: cookie.expect("kayıtta cookie dönmedi"), + household_id: body["household"]["id"].as_str().unwrap().to_string(), + } + } +} diff --git a/backend/tests/isolation.rs b/backend/tests/isolation.rs new file mode 100644 index 0000000..ede8f0c --- /dev/null +++ b/backend/tests/isolation.rs @@ -0,0 +1,168 @@ +//! Hane izolasyonu testleri: bir hane, başka hanenin verisini göremez ve değiştiremez. +//! (KAKEIBO_PROMPT.md §11 — "başka hanenin verisine erişim imkânsız olmalı") + +mod common; + +use axum::http::{Method, StatusCode}; +use common::TestApp; +use serde_json::{Value, json}; + +fn ids(list: &Value) -> Vec { + list.as_array() + .unwrap() + .iter() + .map(|i| i["id"].as_str().unwrap().to_string()) + .collect() +} + +#[tokio::test] +async fn households_cannot_see_or_touch_each_other() { + let app = TestApp::new().await; + let a = app.register("Ayse", None).await; + let b = app.register("Burak", None).await; + assert_ne!(a.household_id, b.household_id); + + // A'ya özel kategori ve ödeme türü + let (st, cat) = app + .post( + "/categories", + &a, + json!({ "group": "wants", "name": "A'nın gizli kategorisi" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + let cat_id = cat["id"].as_str().unwrap(); + let (_, pm) = app + .post("/payment-methods", &a, json!({ "name": "A'nın kartı" })) + .await; + let pm_id = pm["id"].as_str().unwrap(); + let (_, invite) = app.post("/household/invites", &a, json!({})).await; + let invite_id = invite["id"].as_str().unwrap(); + + // --- Okuma: B, A'nın kayıtlarını görmez --- + let (_, b_cats) = app.get("/categories?include_inactive=true", &b).await; + assert!(!ids(&b_cats).contains(&cat_id.to_string())); + let (_, b_pms) = app.get("/payment-methods?include_inactive=true", &b).await; + assert!(!ids(&b_pms).contains(&pm_id.to_string())); + let (_, b_hh) = app.get("/household", &b).await; + assert_eq!(b_hh["id"], b.household_id.as_str()); + assert_eq!(b_hh["members"].as_array().unwrap().len(), 1); + let (_, b_invites) = app.get("/household/invites", &b).await; + assert_eq!(b_invites, json!([])); + + // --- Yazma: B, A'nın kayıtlarını değiştiremez (404: varlığı bile sızmaz) --- + let (st, _) = app + .patch( + &format!("/categories/{cat_id}"), + &b, + json!({ "name": "ele geçirildi" }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app + .patch( + &format!("/payment-methods/{pm_id}"), + &b, + json!({ "is_active": false }), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _) = app + .delete(&format!("/household/invites/{invite_id}"), &b) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + + // B, kendi sıralama listesine A'nın kaydını sokamaz + let mut b_wants: Vec = b_cats + .as_array() + .unwrap() + .iter() + .filter(|c| c["group"] == "wants") + .map(|c| c["id"].as_str().unwrap().to_string()) + .collect(); + b_wants.push(cat_id.to_string()); + let (st, _) = app + .put( + "/categories/order", + &b, + json!({ "group": "wants", "ids": b_wants }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + // --- A'nın verisi hiç değişmedi --- + let (_, a_cats) = app.get("/categories", &a).await; + let mine = a_cats + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == cat_id) + .unwrap(); + assert_eq!(mine["name"], "A'nın gizli kategorisi"); + assert_eq!(mine["version"], 1); + let (_, a_invites) = app.get("/household/invites", &a).await; + assert_eq!(ids(&a_invites), vec![invite_id.to_string()]); + + app.cleanup().await; +} + +#[tokio::test] +async fn member_joins_via_invite_and_shares_household_data() { + let app = TestApp::new().await; + let owner = app.register("Sahip", None).await; + let (_, inv) = app.post("/household/invites", &owner, json!({})).await; + let member = app + .register("Uye", Some(inv["code"].as_str().unwrap())) + .await; + + assert_eq!(member.household_id, owner.household_id); + + // Üye, sahibin eklediği kategoriyi görür ve düzenleyebilir (ortak defter) + let (_, cat) = app + .post( + "/categories", + &owner, + json!({ "group": "needs", "name": "Ortak" }), + ) + .await; + let (st, _) = app + .patch( + &format!("/categories/{}", cat["id"].as_str().unwrap()), + &member, + json!({ "emoji": "🤝" }), + ) + .await; + assert_eq!(st, StatusCode::OK); + + // Ama davet oluşturamaz + let (st, _) = app.post("/household/invites", &member, json!({})).await; + assert_eq!(st, StatusCode::FORBIDDEN); + + app.cleanup().await; +} + +#[tokio::test] +async fn protected_endpoints_require_session() { + let app = TestApp::new().await; + let endpoints = [ + (Method::GET, "/auth/me"), + (Method::GET, "/household"), + (Method::GET, "/household/invites"), + (Method::POST, "/household/invites"), + (Method::GET, "/categories"), + (Method::POST, "/categories"), + (Method::PUT, "/categories/order"), + (Method::GET, "/payment-methods"), + (Method::POST, "/payment-methods"), + ]; + for (method, path) in endpoints { + // Oturumsuz ve sahte cookie ile + for cookie in [None, Some("kakeibo_session=sahte.token.degeri")] { + let (st, _, _) = app + .send(method.clone(), path, cookie, Some(json!({}))) + .await; + assert_eq!(st, StatusCode::UNAUTHORIZED, "{method} {path} korumasız!"); + } + } + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index dff54d9..4ee856f 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 1 – Adım 5:** Kategori (`/api/categories`) ve ödeme türü (`/api/payment-methods`) uç noktaları: listele, ekle, düzenle, sırala, pasife al (silme yok). Otomatik hane izolasyonu entegrasyon testleri (gerçek DB, ayrı test veritabanı). `api.http` tamamlanır → Faz 1 kapanır. +**Faz 2 – Başlangıç:** Faz 2 planını sun (Tailwind + shadcn/ui kurulumu, tasarım token'ları, tema, logo/ikonlar, layout + navigasyon, giriş/kayıt ekranları, `lib/format.ts`, `lib/api.ts`), onay al. ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -18,6 +18,7 @@ - Hane izolasyonu kuralı: hane kimliği **her zaman** `AuthUser.household_id`'den alınır, asla URL/gövdeden. Başka hanenin kaydı → 404 (403 değil; varlığı sızdırılmaz). - `AuthUser` her istekte DB'den kullanıcıyı doğrular (silinen kullanıcı → 401, rol DB'den). Owner-only işlemler: `auth.require_owner()?`. - Davet: 8 karakter (31 harflik okunaklı alfabe), 7 gün, tek kullanım; `find_one_and_update` ile atomik claim, kayıt transaction'ı içinde. +- Kategoriler/ödeme türleri: tüm üyeler yönetir; silme yok (pasife alma); grup sonradan değişmez; ad grup içinde benzersiz (büyük/küçük harf duyarsız, pasifler dahil); PATCH'te `version` → iyimser kilit (409); sıralama tam liste ister. - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7. ## ❓ Açık Konular @@ -41,3 +42,4 @@ - **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. - **Adım 3:** Kimlik doğrulama: `auth/` (password argon2id + spawn_blocking, jwt, limiter, session cookie + `AuthUser` extractor), `extract.rs` (`ValidJson` = JSON + validator, Türkçe 400), `services/account.rs` (kayıt tek transaction: hane + owner + 13 kategori + 4 ödeme türü), `services/seed.rs`, `routes/auth.rs` (register/login/logout/me). Yeni crate'ler: argon2, jsonwebtoken, axum-extra(cookie), validator, time. 11 birim testi; 13 senaryo curl ile, rollback DB'de doğrulandı. - **Adım 4:** `AuthUser` DB doğrulaması + `require_owner`, `services/invite.rs` (create/list_active/revoke/claim), kayıt davet koduyla `member` olarak katılma (`services/account.rs` yeniden düzenlendi), `routes/household.rs` (GET /household, invites GET/POST/DELETE). 13 birim testi; 15 senaryo e2e (izolasyon 404, member 403, kod tekrar kullanımı, rollback) geçti. +- **Adım 5:** `models/group.rs`, `services/catalog.rs` (kategori + ödeme türü ortak: list/create/update/reorder), `routes/catalog.rs`. Backend lib+bin'e ayrıldı (`AppState::new`). Entegrasyon testleri: `tests/isolation.rs` (3), `tests/catalog.rs` (4) — toplam 22 test. İzolasyon testi, kasıtlı açık eklenerek doğrulandı (yakaladı). `db:reset` artık test DB'lerini de siler. Dev-deps: tower, http-body-util. **Faz 1 tamamlandı.** diff --git a/docs/PHASES.md b/docs/PHASES.md index f4ee9cb..1f05384 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -5,8 +5,8 @@ | Faz | Kapsam | Çıktı | Durum | |---|---|---|---| | **Faz 0** | Ortam kurulumu, VS Code yapılandırması, monorepo iskeleti, hafıza dosyaları, yerel MongoDB, veri modeli onayı | `npm run dev` ile boş uygulama tarayıcıda açılıyor | ✅ | -| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | 🔄 | -| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | ⏳ | +| **Faz 1** | Backend temeli: config, DB bağlantısı, kimlik doğrulama, hane, kategoriler, ödeme türleri, health check | API `.http` dosyasıyla test edilebiliyor | ✅ | +| **Faz 2** | Frontend temeli: tasarım sistemi, tema, logo/ikonlar, layout, navigasyon, giriş/kayıt | Giriş yapılabilen, temalı iskelet uygulama | 🔄 | | **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ⏳ | | **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ⏳ | | **Faz 5** | Günlük defter | Harcama girişi | ⏳ | @@ -29,4 +29,4 @@ - ✅ 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` +- ✅ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http` diff --git a/package.json b/package.json index e81286b..1829d98 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "db:up": "docker compose -f docker-compose.dev.yml up -d --wait", "db:down": "docker compose -f docker-compose.dev.yml stop", "db:seed": "echo 'Seed betiği Faz 1-4 arasında eklenecek.'", - "db:reset": "docker exec kakeibo-mongo mongosh --quiet kakeibo_dev --eval 'db.dropDatabase()' && echo 'kakeibo_dev veritabanı sıfırlandı.'", + "db:reset": "docker exec kakeibo-mongo mongosh --quiet --eval \"db.getSiblingDB('kakeibo_dev').dropDatabase(); db.adminCommand({listDatabases:1}).databases.filter(d => d.name.startsWith('kakeibo_test_')).forEach(d => db.getSiblingDB(d.name).dropDatabase()); print('kakeibo_dev ve test veritabanları sıfırlandı.')\"", "test": "cd backend && cargo test", "lint": "cd backend && cargo clippy && cd .. && npm --prefix frontend run lint" },