feat(hane): davet kodu ile üye katılımı ve hane izolasyonu

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 23:43:16 +03:00
parent d6136959b1
commit 4cc4fa5882
14 changed files with 497 additions and 66 deletions
+1 -1
View File
@@ -17,5 +17,5 @@ time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] } tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } 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"] } validator = { version = "0.21.0", features = ["derive"] }
+26
View File
@@ -20,6 +20,18 @@ Content-Type: application/json
"household_name": "Çakır Hanesi" "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) ### Giriş ("remember_me": true → 30 gün, false → tarayıcı kapanınca biter)
POST {{baseUrl}}/auth/login POST {{baseUrl}}/auth/login
Content-Type: application/json Content-Type: application/json
@@ -35,3 +47,17 @@ GET {{baseUrl}}/auth/me
### Çıkış ### Çıkış
POST {{baseUrl}}/auth/logout 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
+41 -8
View File
@@ -1,15 +1,22 @@
//! Oturum cookie'si ve `AuthUser` extractor'ı. //! Oturum cookie'si ve `AuthUser` extractor'ı.
//! //!
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce //! 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. //! 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 std::time::Duration;
use axum::{extract::FromRequestParts, http::request::Parts}; use axum::{extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; 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"; pub const COOKIE_NAME: &str = "kakeibo_session";
@@ -43,10 +50,25 @@ pub fn removal_cookie() -> Cookie<'static> {
pub struct AuthUser { pub struct AuthUser {
pub user_id: Uuid, pub user_id: Uuid,
pub household_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, 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<AppState> for AuthUser { impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError; type Rejection = AppError;
@@ -56,10 +78,21 @@ impl FromRequestParts<AppState> for AuthUser {
let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?; let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?;
let parse = |s: &str| Uuid::parse_str(s).map_err(|_| 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::<RoleOnly>(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 { Ok(Self {
user_id: parse(&claims.sub)?, user_id,
household_id: parse(&claims.hid)?, household_id,
role: claims.role, role: current.role,
}) })
} }
} }
-1
View File
@@ -8,7 +8,6 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
#[allow(dead_code)] // NotFound/Forbidden: Faz 1 – Adım 4-5'te kullanılacak
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum AppError { pub enum AppError {
#[error("Kayıt bulunamadı")] #[error("Kayıt bulunamadı")]
+13
View File
@@ -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,
}
+1
View File
@@ -3,4 +3,5 @@
pub mod common; pub mod common;
pub mod household; pub mod household;
pub mod invite;
pub mod user; pub mod user;
+10 -1
View File
@@ -36,6 +36,9 @@ pub struct RegisterRequest {
password: String, password: String,
#[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))] #[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))]
household_name: Option<String>, household_name: Option<String>,
/// 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<String>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
@@ -86,6 +89,11 @@ pub async fn register(
.filter(|h| !h.is_empty()) .filter(|h| !h.is_empty())
.unwrap_or_else(|| format!("{name} Hanesi")); .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 password_hash = password::hash(req.password).await?;
let created = account::register( let created = account::register(
&state.db, &state.db,
@@ -94,6 +102,7 @@ pub async fn register(
email, email,
password_hash, password_hash,
household_name, household_name,
invite_code,
}, },
) )
.await?; .await?;
@@ -101,7 +110,7 @@ pub async fn register(
let token = state.jwt.issue( let token = state.jwt.issue(
created.user_id, created.user_id,
created.household_id, created.household_id,
Role::Owner, created.role,
ttl(true), ttl(true),
)?; )?;
let jar = jar.add(session_cookie(token, true, state.config.is_https())); let jar = jar.add(session_cookie(token, true, state.config.is_https()));
+138
View File
@@ -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<MemberDto>,
}
#[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<Invite> 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<AppState>,
auth: AuthUser,
) -> AppResult<Json<HouseholdResponse>> {
let hid = auth.household_id;
let mut hh_filter = scope(hid);
hh_filter.insert("_id", hid);
let household = state
.db
.collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter)
.await?
.ok_or(AppError::NotFound)?;
let mut cursor = state
.db
.collection::<MemberRow>(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<AppState>,
auth: AuthUser,
) -> AppResult<Json<Vec<InviteDto>>> {
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<AppState>,
auth: AuthUser,
) -> AppResult<(StatusCode, Json<InviteDto>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
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()
}
+9 -2
View File
@@ -2,10 +2,11 @@
mod auth; mod auth;
mod health; mod health;
mod household;
use axum::{ use axum::{
Router, Router,
routing::{get, post}, routing::{delete, get, post},
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -16,7 +17,13 @@ pub fn router(state: AppState) -> Router {
.route("/auth/register", post(auth::register)) .route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login)) .route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout)) .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) Router::new().nest("/api", api).with_state(state)
} }
+76 -50
View File
@@ -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::{ use mongodb::{
Database, ClientSession, Database,
bson::{Bson, Document, Uuid, doc}, bson::{Bson, Document, Uuid, doc},
}; };
@@ -13,7 +17,7 @@ use crate::{
household::default_settings, household::default_settings,
user::Role, user::Role,
}, },
services::seed, services::{invite, seed},
}; };
pub struct NewAccount { pub struct NewAccount {
@@ -21,74 +25,96 @@ pub struct NewAccount {
pub email: String, pub email: String,
pub password_hash: String, pub password_hash: String,
pub household_name: String, pub household_name: String,
pub invite_code: Option<String>,
} }
pub struct CreatedAccount { pub struct CreatedAccount {
pub user_id: Uuid, pub user_id: Uuid,
pub household_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. /// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa
/// Herhangi bir adım başarısız olursa hiçbir kayıt oluşmaz. /// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır.
pub async fn register(db: &Database, acc: NewAccount) -> AppResult<CreatedAccount> { pub async fn register(db: &Database, acc: NewAccount) -> AppResult<CreatedAccount> {
let household_id = new_id();
let user_id = new_id(); let user_id = new_id();
// Hanede household_id = _id (DATA_MODEL §3.2) let mut session = db.client().start_session().await?;
let mut household = new_meta(household_id); session.start_transaction().await?;
household.insert("_id", household_id);
household.insert("name", acc.household_name); let result = create_in_tx(db, &mut session, user_id, acc).await;
household.insert("owner_id", user_id);
household.insert("settings", default_settings()); 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<CreatedAccount> {
let docs = |name| db.collection::<Document>(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); let mut user = new_meta(household_id);
user.insert("_id", user_id); user.insert("_id", user_id);
user.insert("email", acc.email); user.insert("email", acc.email);
user.insert("password_hash", acc.password_hash); user.insert("password_hash", acc.password_hash);
user.insert("name", acc.name); 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("preferences", doc! { "theme": "system" });
user.insert("last_login_at", Bson::Null); user.insert("last_login_at", Bson::Null);
docs(col::USERS)
let mut session = db.client().start_session().await?; .insert_one(user)
session.start_transaction().await?; .session(&mut *session)
.await?;
let result: mongodb::error::Result<()> = async {
let docs = |name| db.collection::<Document>(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()
});
}
}
Ok(CreatedAccount { Ok(CreatedAccount {
user_id, user_id,
household_id, household_id,
role,
}) })
} }
+173
View File
@@ -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<Invite> {
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::<Document>(col::INVITES).insert_one(d).await {
Ok(_) => {
let filter = doc! { "_id": id };
return db
.collection::<Invite>(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<Vec<Invite>> {
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::<Invite>(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::<Document>(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<Uuid> {
let ts = now();
let invite = db
.collection::<Invite>(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");
}
}
+1
View File
@@ -1,4 +1,5 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar. //! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account; pub mod account;
pub mod invite;
pub mod seed; pub mod seed;
+6 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ## 📌 Alınan Kararlar
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. - 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. - 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). - 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`. - 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. - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7.
## ❓ Açık Konular ## ❓ 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). - 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). - Altın kuru için veri kaynağı seçimi (Faz 8).
- Aylık değerlendirme çıktı formatı kullanıcıdan gelecek (Faz 9). - 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 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 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 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.
+2 -2
View File
@@ -28,5 +28,5 @@
- ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu - ✅ 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 - ✅ 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 - ✅ 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 - ✅ 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`