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
+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::{
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<String>,
}
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<CreatedAccount> {
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<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);
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::<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()
});
}
}
docs(col::USERS)
.insert_one(user)
.session(&mut *session)
.await?;
Ok(CreatedAccount {
user_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.
pub mod account;
pub mod invite;
pub mod seed;