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
+10 -1
View File
@@ -36,6 +36,9 @@ pub struct RegisterRequest {
password: String,
#[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))]
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)]
@@ -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()));
+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 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)
}