From 4cc4fa5882f864acff46d48571c14a908bb80aea Mon Sep 17 00:00:00 2001 From: suphicakir Date: Wed, 11 Nov 2026 23:43:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(hane):=20davet=20kodu=20ile=20=C3=BCye=20k?= =?UTF-8?q?at=C4=B1l=C4=B1m=C4=B1=20ve=20hane=20izolasyonu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthUser her istekte kullanıcıyı DB'den doğrular (silinen kullanıcı → 401) - require_owner: yalnızca hane sahibine açık işlemler - Davet kodu: oluştur, listele, iptal et (7 gün, tek kullanım) - Davet koduyla kayıt → mevcut haneye member olarak katılma (transaction) - GET /api/household: hane bilgisi ve üyeler - api.http güncellendi Co-Authored-By: Claude Opus 5.5 --- backend/Cargo.toml | 2 +- backend/api.http | 26 +++++ backend/src/auth/session.rs | 49 +++++++-- backend/src/error.rs | 1 - backend/src/models/invite.rs | 13 +++ backend/src/models/mod.rs | 1 + backend/src/routes/auth.rs | 11 +- backend/src/routes/household.rs | 138 +++++++++++++++++++++++++ backend/src/routes/mod.rs | 11 +- backend/src/services/account.rs | 126 ++++++++++++++--------- backend/src/services/invite.rs | 173 ++++++++++++++++++++++++++++++++ backend/src/services/mod.rs | 1 + docs/MEMORY.md | 7 +- docs/PHASES.md | 4 +- 14 files changed, 497 insertions(+), 66 deletions(-) create mode 100644 backend/src/models/invite.rs create mode 100644 backend/src/routes/household.rs create mode 100644 backend/src/services/invite.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5168efc..d74dc98 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,5 +17,5 @@ time = "0.3.55" 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"] } +uuid = { version = "1.29.2", features = ["v4", "v7"] } validator = { version = "0.21.0", features = ["derive"] } diff --git a/backend/api.http b/backend/api.http index aed0ee2..f77eea9 100644 --- a/backend/api.http +++ b/backend/api.http @@ -20,6 +20,18 @@ Content-Type: application/json "household_name": "Çakır Hanesi" } +### Davet koduyla kayıt (mevcut haneye "member" olarak katılır; household_name yok sayılır) +# Kod büyük/küçük harf ve tireden bağımsızdır: "abcd-efgh" = "ABCDEFGH" +POST {{baseUrl}}/auth/register +Content-Type: application/json + +{ + "name": "Ayşe", + "email": "ayse@ornek.com", + "password": "gizli-sifre-123", + "invite_code": "BURAYA-KOD" +} + ### Giriş ("remember_me": true → 30 gün, false → tarayıcı kapanınca biter) POST {{baseUrl}}/auth/login Content-Type: application/json @@ -35,3 +47,17 @@ GET {{baseUrl}}/auth/me ### Çıkış POST {{baseUrl}}/auth/logout + +# ─────────────────────────── Hane ve davetler ─────────────────────────── + +### Hane bilgisi ve üyeler +GET {{baseUrl}}/household + +### Yeni davet kodu (yalnızca hane sahibi; 7 gün geçerli, tek kullanımlık) +POST {{baseUrl}}/household/invites + +### Aktif davetler (yalnızca hane sahibi) +GET {{baseUrl}}/household/invites + +### Daveti iptal et (yalnızca hane sahibi) — id'yi yukarıdaki listeden alın +DELETE {{baseUrl}}/household/invites/BURAYA-ID diff --git a/backend/src/auth/session.rs b/backend/src/auth/session.rs index ab1b1d1..3c3ef43 100644 --- a/backend/src/auth/session.rs +++ b/backend/src/auth/session.rs @@ -1,15 +1,22 @@ //! Oturum cookie'si ve `AuthUser` extractor'ı. //! -//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce -//! cookie'deki JWT'yi doğrular; geçersizse route hiç çalışmaz, 401 döner. +//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce: +//! 1. cookie'deki JWT'nin imzasını ve süresini doğrular, +//! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder +//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur). +//! +//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner. +//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır, +//! asla istekten (URL/gövde) almaz. use std::time::Duration; use axum::{extract::FromRequestParts, http::request::Parts}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use mongodb::bson::Uuid; +use mongodb::bson::{Uuid, doc}; +use serde::Deserialize; -use crate::{error::AppError, models::user::Role, state::AppState}; +use crate::{db::col, error::AppError, models::user::Role, state::AppState}; pub const COOKIE_NAME: &str = "kakeibo_session"; @@ -43,10 +50,25 @@ pub fn removal_cookie() -> Cookie<'static> { pub struct AuthUser { pub user_id: Uuid, pub household_id: Uuid, - #[allow(dead_code)] // Faz 1 – Adım 4'te (davet, yetki kontrolü) kullanılacak + /// Veritabanındaki güncel rol (JWT'deki değil) pub role: Role, } +impl AuthUser { + /// Yalnızca hane sahibinin yapabileceği işlemler için. + pub fn require_owner(&self) -> Result<(), AppError> { + match self.role { + Role::Owner => Ok(()), + Role::Member => Err(AppError::Forbidden), + } + } +} + +#[derive(Deserialize)] +struct RoleOnly { + role: Role, +} + impl FromRequestParts for AuthUser { type Rejection = AppError; @@ -56,10 +78,21 @@ impl FromRequestParts for AuthUser { let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?; let parse = |s: &str| Uuid::parse_str(s).map_err(|_| AppError::Unauthorized); + let user_id = parse(&claims.sub)?; + let household_id = parse(&claims.hid)?; + + let current = state + .db + .collection::(col::USERS) + .find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null }) + .projection(doc! { "role": 1 }) + .await? + .ok_or(AppError::Unauthorized)?; + Ok(Self { - user_id: parse(&claims.sub)?, - household_id: parse(&claims.hid)?, - role: claims.role, + user_id, + household_id, + role: current.role, }) } } diff --git a/backend/src/error.rs b/backend/src/error.rs index ac2cc77..35428b7 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -8,7 +8,6 @@ use axum::{ }; use serde_json::json; -#[allow(dead_code)] // NotFound/Forbidden: Faz 1 – Adım 4-5'te kullanılacak #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("Kayıt bulunamadı")] diff --git a/backend/src/models/invite.rs b/backend/src/models/invite.rs new file mode 100644 index 0000000..f850a54 --- /dev/null +++ b/backend/src/models/invite.rs @@ -0,0 +1,13 @@ +//! `invites` koleksiyonu (DATA_MODEL.md §3.3). + +use mongodb::bson::{DateTime, Uuid}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Invite { + #[serde(rename = "_id")] + pub id: Uuid, + pub household_id: Uuid, + pub code: String, + pub expires_at: DateTime, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 42d8b8f..e0ebb51 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -3,4 +3,5 @@ pub mod common; pub mod household; +pub mod invite; pub mod user; diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 9dff7b1..ca7d169 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -36,6 +36,9 @@ pub struct RegisterRequest { password: String, #[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))] household_name: Option, + /// Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır. + #[validate(length(max = 20, message = "Davet kodu geçersiz"))] + invite_code: Option, } #[derive(Deserialize, Validate)] @@ -86,6 +89,11 @@ pub async fn register( .filter(|h| !h.is_empty()) .unwrap_or_else(|| format!("{name} Hanesi")); + let invite_code = req + .invite_code + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()); + let password_hash = password::hash(req.password).await?; let created = account::register( &state.db, @@ -94,6 +102,7 @@ pub async fn register( email, password_hash, household_name, + invite_code, }, ) .await?; @@ -101,7 +110,7 @@ pub async fn register( let token = state.jwt.issue( created.user_id, created.household_id, - Role::Owner, + created.role, ttl(true), )?; let jar = jar.add(session_cookie(token, true, state.config.is_https())); diff --git a/backend/src/routes/household.rs b/backend/src/routes/household.rs new file mode 100644 index 0000000..55d6cd1 --- /dev/null +++ b/backend/src/routes/household.rs @@ -0,0 +1,138 @@ +//! Hane ve davet uç noktaları: +//! - `GET /api/household` → hane bilgisi + üyeler (tüm üyeler) +//! - `GET /api/household/invites` → aktif davetler (yalnızca owner) +//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner) +//! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner) + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use mongodb::bson::{DateTime, Uuid, doc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::AuthUser, + db::col, + error::{AppError, AppResult}, + models::{common::scope, household::Household, invite::Invite, user::Role}, + services::invite, + state::AppState, +}; + +#[derive(Serialize)] +pub struct HouseholdResponse { + id: String, + name: String, + members: Vec, +} + +#[derive(Serialize)] +struct MemberDto { + id: String, + name: String, + email: String, + role: Role, +} + +#[derive(Serialize)] +pub struct InviteDto { + id: String, + code: String, + expires_at: String, +} + +impl From for InviteDto { + fn from(i: Invite) -> Self { + Self { + id: i.id.to_string(), + code: i.code, + expires_at: iso(i.expires_at), + } + } +} + +/// Üye listesi için okunan alanlar +#[derive(Deserialize)] +struct MemberRow { + #[serde(rename = "_id")] + id: Uuid, + name: String, + email: String, + role: Role, +} + +pub async fn get( + State(state): State, + auth: AuthUser, +) -> AppResult> { + let hid = auth.household_id; + + let mut hh_filter = scope(hid); + hh_filter.insert("_id", hid); + let household = state + .db + .collection::(col::HOUSEHOLDS) + .find_one(hh_filter) + .await? + .ok_or(AppError::NotFound)?; + + let mut cursor = state + .db + .collection::(col::USERS) + .find(scope(hid)) + .projection(doc! { "name": 1, "email": 1, "role": 1 }) + .sort(doc! { "created_at": 1 }) + .await?; + + let mut members = Vec::new(); + while cursor.advance().await? { + let m = cursor.deserialize_current()?; + members.push(MemberDto { + id: m.id.to_string(), + name: m.name, + email: m.email, + role: m.role, + }); + } + + Ok(Json(HouseholdResponse { + id: hid.to_string(), + name: household.name, + members, + })) +} + +pub async fn list_invites( + State(state): State, + auth: AuthUser, +) -> AppResult>> { + auth.require_owner()?; + let list = invite::list_active(&state.db, auth.household_id).await?; + Ok(Json(list.into_iter().map(InviteDto::from).collect())) +} + +pub async fn create_invite( + State(state): State, + auth: AuthUser, +) -> AppResult<(StatusCode, Json)> { + auth.require_owner()?; + let inv = invite::create(&state.db, auth.household_id, auth.user_id).await?; + Ok((StatusCode::CREATED, Json(inv.into()))) +} + +pub async fn revoke_invite( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + auth.require_owner()?; + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + invite::revoke(&state.db, auth.household_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} + +fn iso(dt: DateTime) -> String { + dt.try_to_rfc3339_string().unwrap_or_default() +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index c4e4752..1a4d8ff 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,10 +2,11 @@ mod auth; mod health; +mod household; use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post}, }; use crate::state::AppState; @@ -16,7 +17,13 @@ pub fn router(state: AppState) -> Router { .route("/auth/register", post(auth::register)) .route("/auth/login", post(auth::login)) .route("/auth/logout", post(auth::logout)) - .route("/auth/me", get(auth::me)); + .route("/auth/me", get(auth::me)) + .route("/household", get(household::get)) + .route( + "/household/invites", + get(household::list_invites).post(household::create_invite), + ) + .route("/household/invites/{id}", delete(household::revoke_invite)); Router::new().nest("/api", api).with_state(state) } diff --git a/backend/src/services/account.rs b/backend/src/services/account.rs index 53c24b3..2f11468 100644 --- a/backend/src/services/account.rs +++ b/backend/src/services/account.rs @@ -1,7 +1,11 @@ -//! Hesap işlemleri: kayıt (kullanıcı + hane + başlangıç verileri). +//! Hesap işlemleri: kayıt. +//! +//! İki yol var: +//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri +//! - **Davet koduyla:** kod kullanılır, mevcut haneye `member` olarak katılınır use mongodb::{ - Database, + ClientSession, Database, bson::{Bson, Document, Uuid, doc}, }; @@ -13,7 +17,7 @@ use crate::{ household::default_settings, user::Role, }, - services::seed, + services::{invite, seed}, }; pub struct NewAccount { @@ -21,74 +25,96 @@ pub struct NewAccount { pub email: String, pub password_hash: String, pub household_name: String, + pub invite_code: Option, } pub struct CreatedAccount { pub user_id: Uuid, pub household_id: Uuid, + pub role: Role, } -/// Kullanıcıyı, hanesini, örnek kategori ve ödeme türlerini **tek transaction** içinde oluşturur. -/// Herhangi bir adım başarısız olursa hiçbir kayıt oluşmaz. +/// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa +/// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır. pub async fn register(db: &Database, acc: NewAccount) -> AppResult { - let household_id = new_id(); let user_id = new_id(); - // Hanede household_id = _id (DATA_MODEL §3.2) - let mut household = new_meta(household_id); - household.insert("_id", household_id); - household.insert("name", acc.household_name); - household.insert("owner_id", user_id); - household.insert("settings", default_settings()); + let mut session = db.client().start_session().await?; + session.start_transaction().await?; + + let result = create_in_tx(db, &mut session, user_id, acc).await; + + match result { + Ok(created) => { + session.commit_transaction().await?; + Ok(created) + } + Err(e) => { + let _ = session.abort_transaction().await; + Err(match e { + AppError::Database(ref err) if is_duplicate_key(err) => { + AppError::Conflict("Bu e-posta adresi zaten kayıtlı".into()) + } + other => other, + }) + } + } +} + +async fn create_in_tx( + db: &Database, + session: &mut ClientSession, + user_id: Uuid, + acc: NewAccount, +) -> AppResult { + let docs = |name| db.collection::(name); + + let (household_id, role) = match acc.invite_code.as_deref() { + Some(code) => ( + invite::claim(db, session, code, user_id).await?, + Role::Member, + ), + None => { + let household_id = new_id(); + // Hanede household_id = _id (DATA_MODEL §3.2) + let mut household = new_meta(household_id); + household.insert("_id", household_id); + household.insert("name", acc.household_name); + household.insert("owner_id", user_id); + household.insert("settings", default_settings()); + + docs(col::HOUSEHOLDS) + .insert_one(household) + .session(&mut *session) + .await?; + docs(col::CATEGORIES) + .insert_many(seed::categories(household_id)) + .session(&mut *session) + .await?; + docs(col::PAYMENT_METHODS) + .insert_many(seed::payment_methods(household_id)) + .session(&mut *session) + .await?; + (household_id, Role::Owner) + } + }; let mut user = new_meta(household_id); user.insert("_id", user_id); user.insert("email", acc.email); user.insert("password_hash", acc.password_hash); user.insert("name", acc.name); - user.insert("role", Role::Owner.as_str()); + user.insert("role", role.as_str()); user.insert("preferences", doc! { "theme": "system" }); user.insert("last_login_at", Bson::Null); - - let mut session = db.client().start_session().await?; - session.start_transaction().await?; - - let result: mongodb::error::Result<()> = async { - let docs = |name| db.collection::(name); - docs(col::HOUSEHOLDS) - .insert_one(household) - .session(&mut session) - .await?; - docs(col::USERS) - .insert_one(user) - .session(&mut session) - .await?; - docs(col::CATEGORIES) - .insert_many(seed::categories(household_id)) - .session(&mut session) - .await?; - docs(col::PAYMENT_METHODS) - .insert_many(seed::payment_methods(household_id)) - .session(&mut session) - .await?; - Ok(()) - } - .await; - - match result { - Ok(()) => session.commit_transaction().await?, - Err(e) => { - let _ = session.abort_transaction().await; - return Err(if is_duplicate_key(&e) { - AppError::Conflict("Bu e-posta adresi zaten kayıtlı".into()) - } else { - e.into() - }); - } - } + docs(col::USERS) + .insert_one(user) + .session(&mut *session) + .await?; Ok(CreatedAccount { user_id, household_id, + role, }) } diff --git a/backend/src/services/invite.rs b/backend/src/services/invite.rs new file mode 100644 index 0000000..9682bdf --- /dev/null +++ b/backend/src/services/invite.rs @@ -0,0 +1,173 @@ +//! Hane davet kodları. + +use std::time::Duration; + +use mongodb::{ + ClientSession, Database, + bson::{DateTime, Document, Uuid, doc}, + options::ReturnDocument, +}; + +use crate::{ + db::{col, is_duplicate_key}, + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + invite::Invite, + }, +}; + +/// Davet kodunun geçerlilik süresi +pub const INVITE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// Karıştırılabilecek karakterler yok: 0/O, 1/I/L (31 karakter). +const ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789"; +const CODE_LEN: usize = 8; + +/// 8 karakterlik rastgele kod üretir (kaynak: UUID v4'ün rastgele baytları). +/// 248 = 31 × 8 ve üzeri baytlar atlanır, böylece her karakter eşit olasılıkla seçilir. +fn generate_code() -> String { + let limit = (256 / ALPHABET.len() * ALPHABET.len()) as u8; + let mut code = String::with_capacity(CODE_LEN); + while code.len() < CODE_LEN { + for b in uuid::Uuid::new_v4().into_bytes() { + if b < limit && code.len() < CODE_LEN { + code.push(ALPHABET[b as usize % ALPHABET.len()] as char); + } + } + } + code +} + +/// Kullanıcının yazdığı kodu karşılaştırılabilir hale getirir: boşluk/tire temizlenir, büyük harf. +pub fn normalize_code(code: &str) -> String { + code.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) + .collect() +} + +/// Yeni davet kodu oluşturur (çok düşük ihtimalle kod çakışırsa yeniden dener). +pub async fn create(db: &Database, household_id: Uuid, created_by: Uuid) -> AppResult { + let expires_at = + DateTime::from_millis(now().timestamp_millis() + INVITE_TTL.as_millis() as i64); + + for _ in 0..3 { + let mut d = new_meta(household_id); + let id = d.get("_id").cloned(); + let code = generate_code(); + d.insert("code", &code); + d.insert("created_by", created_by); + d.insert("expires_at", expires_at); + d.insert("used_by", mongodb::bson::Bson::Null); + d.insert("used_at", mongodb::bson::Bson::Null); + + match db.collection::(col::INVITES).insert_one(d).await { + Ok(_) => { + let filter = doc! { "_id": id }; + return db + .collection::(col::INVITES) + .find_one(filter) + .await? + .ok_or_else(|| AppError::Internal("davet kaydı okunamadı".into())); + } + Err(e) if is_duplicate_key(&e) => continue, + Err(e) => return Err(e.into()), + } + } + Err(AppError::Internal( + "benzersiz davet kodu üretilemedi".into(), + )) +} + +/// Hanenin kullanılmamış ve süresi dolmamış davetleri. +pub async fn list_active(db: &Database, household_id: Uuid) -> AppResult> { + let mut filter = scope(household_id); + filter.insert("used_by", mongodb::bson::Bson::Null); + filter.insert("expires_at", doc! { "$gt": now() }); + + let mut cursor = db + .collection::(col::INVITES) + .find(filter) + .sort(doc! { "created_at": -1 }) + .await?; + + let mut list = Vec::new(); + while cursor.advance().await? { + list.push(cursor.deserialize_current()?); + } + Ok(list) +} + +/// Daveti iptal eder (soft delete). Başka hanenin davetine dokunamaz. +pub async fn revoke(db: &Database, household_id: Uuid, invite_id: Uuid) -> AppResult<()> { + let mut filter = scope(household_id); + filter.insert("_id", invite_id); + let ts = now(); + let res = db + .collection::(col::INVITES) + .update_one( + filter, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .await?; + if res.matched_count == 0 { + return Err(AppError::NotFound); + } + Ok(()) +} + +/// Kodu tek hamlede "kullanıldı" olarak işaretler ve hanenin kimliğini döner. +/// Bul + güncelle tek atomik işlem olduğundan aynı kod iki kişi tarafından kullanılamaz. +/// Kayıt transaction'ının parçasıdır: kullanıcı oluşturulamazsa kod yeniden kullanılabilir olur. +pub async fn claim( + db: &Database, + session: &mut ClientSession, + code: &str, + user_id: Uuid, +) -> AppResult { + let ts = now(); + let invite = db + .collection::(col::INVITES) + .find_one_and_update( + doc! { + "code": normalize_code(code), + "used_by": null, + "deleted_at": null, + "expires_at": { "$gt": ts }, + }, + doc! { + "$set": { "used_by": user_id, "used_at": ts, "updated_at": ts }, + "$inc": { "version": 1 }, + }, + ) + .return_document(ReturnDocument::After) + .session(session) + .await? + .ok_or_else(|| AppError::BadRequest("Davet kodu geçersiz veya süresi dolmuş".into()))?; + + Ok(invite.household_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_format() { + for _ in 0..200 { + let c = generate_code(); + assert_eq!(c.len(), CODE_LEN); + assert!( + c.chars() + .all(|ch| "ABCDEFGHJKMNPQRSTUVWXYZ23456789".contains(ch)), + "{c}" + ); + } + } + + #[test] + fn normalize_accepts_user_typing() { + assert_eq!(normalize_code(" abcd-efgh "), "ABCDEFGH"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index d1a05df..2808845 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,4 +1,5 @@ //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. pub mod account; +pub mod invite; pub mod seed; diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 951f25c..dff54d9 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 1 – Adım 4:** Hane izolasyonu (AuthUser'ın tüm hane route'larında zorunlu kullanımı, `scope()`), üye davet kodu: `POST /api/household/invites` (yalnızca owner), `POST /api/auth/register` ile `invite_code` → mevcut haneye `member` olarak katılma; `GET /api/household` üye listesi. +**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. ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -15,9 +15,13 @@ - Auth: JWT (HS256, jsonwebtoken `rust_crypto`) `kakeibo_session` cookie'de; HttpOnly, SameSite=Lax, Secure yalnızca APP_URL https ise. Beni hatırla → 30 gün Max-Age; değilse 12 saat JWT + oturum cookie'si. Kayıttan sonra 30 gün. - Giriş sınırı: e-posta başına 15 dk'da 5 hatalı deneme (bellek içi, `auth/limiter.rs`). Olmayan kullanıcıda da sahte argon2 doğrulaması (zamanlama sızıntısı yok). - Hata kodları: `{error:{code,message}}`; `invalid_credentials`, `unauthorized`, `bad_request`, `conflict`, `too_many_requests`. +- 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. - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7. ## ❓ Açık Konular +- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+). - Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6). - Altın kuru için veri kaynağı seçimi (Faz 8). - Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9). @@ -36,3 +40,4 @@ - **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. - **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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 5d6105b..f4ee9cb 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -28,5 +28,5 @@ - ✅ 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 -- 🔄 4. Hane izolasyonu (middleware), üye davet kodu -- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http` +- ✅ 4. Hane izolasyonu (middleware), üye davet kodu +- 🔄 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`