feat(katalog): kategori ve ödeme türü uç noktaları, hane izolasyonu entegrasyon testleri
- /api/categories ve /api/payment-methods: listele, ekle, düzenle, sırala, pasife al - Sürüm kontrolü ile eşzamanlı düzenleme çakışması (409) - Backend lib + bin yapısına ayrıldı (entegrasyon testleri için) - tests/: hane izolasyonu (3) ve katalog (4) entegrasyon testleri - db:reset artık artık test veritabanlarını da siler - api.http tamamlandı; Faz 1 tamamlandı Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
+3
-24
@@ -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));
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Şema: `docs/DATA_MODEL.md`
|
||||
|
||||
pub mod common;
|
||||
pub mod group;
|
||||
pub mod household;
|
||||
pub mod invite;
|
||||
pub mod user;
|
||||
|
||||
@@ -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<Group>,
|
||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||
name: String,
|
||||
#[validate(length(max = 16, message = "Emoji çok uzun"))]
|
||||
emoji: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateRequest {
|
||||
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
|
||||
name: Option<String>,
|
||||
#[validate(length(max = 16, message = "Emoji çok uzun"))]
|
||||
emoji: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct OrderRequest {
|
||||
group: Option<Group>,
|
||||
#[validate(length(max = 200, message = "Liste çok uzun"))]
|
||||
ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ItemDto {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
group: Option<Group>,
|
||||
name: String,
|
||||
emoji: Option<String>,
|
||||
sort_order: i32,
|
||||
is_active: bool,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<Item> 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<Item>) -> Json<Vec<ItemDto>> {
|
||||
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> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
// ---------- Ortak işleyiciler ----------
|
||||
|
||||
async fn list(
|
||||
c: Catalog,
|
||||
state: AppState,
|
||||
auth: AuthUser,
|
||||
q: ListQuery,
|
||||
) -> AppResult<Json<Vec<ItemDto>>> {
|
||||
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<ItemDto>)> {
|
||||
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<Json<ItemDto>> {
|
||||
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<Json<Vec<ItemDto>>> {
|
||||
let ids = req
|
||||
.ids
|
||||
.iter()
|
||||
.map(Uuid::parse_str)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.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<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<ItemDto>>> {
|
||||
list(Catalog::Categories, s, auth, q).await
|
||||
}
|
||||
|
||||
pub async fn create_category(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<ItemDto>)> {
|
||||
create(Catalog::Categories, s, auth, req).await
|
||||
}
|
||||
|
||||
pub async fn update_category(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<ItemDto>> {
|
||||
update(Catalog::Categories, s, auth, id, req).await
|
||||
}
|
||||
|
||||
pub async fn reorder_categories(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<OrderRequest>,
|
||||
) -> AppResult<Json<Vec<ItemDto>>> {
|
||||
reorder(Catalog::Categories, s, auth, req).await
|
||||
}
|
||||
|
||||
// ---------- Ödeme türleri ----------
|
||||
|
||||
pub async fn list_payment_methods(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<ItemDto>>> {
|
||||
list(Catalog::PaymentMethods, s, auth, q).await
|
||||
}
|
||||
|
||||
pub async fn create_payment_method(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<ItemDto>)> {
|
||||
create(Catalog::PaymentMethods, s, auth, req).await
|
||||
}
|
||||
|
||||
pub async fn update_payment_method(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<ItemDto>> {
|
||||
update(Catalog::PaymentMethods, s, auth, id, req).await
|
||||
}
|
||||
|
||||
pub async fn reorder_payment_methods(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<OrderRequest>,
|
||||
) -> AppResult<Json<Vec<ItemDto>>> {
|
||||
reorder(Catalog::PaymentMethods, s, auth, req).await
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Group>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub emoji: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub is_active: bool,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Changes {
|
||||
pub name: Option<String>,
|
||||
/// `Some("")` → emoji kaldırılır
|
||||
pub emoji: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
/// İstemcinin gördüğü sürüm; farklıysa başkası değiştirmiş demektir → 409
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
fn coll(db: &Database, c: Catalog) -> mongodb::Collection<Item> {
|
||||
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<Vec<Item>> {
|
||||
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<Group>,
|
||||
name: String,
|
||||
emoji: Option<String>,
|
||||
) -> AppResult<Item> {
|
||||
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::<Document>(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<Item> {
|
||||
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<Group>,
|
||||
ids: Vec<Uuid>,
|
||||
) -> AppResult<Vec<Item>> {
|
||||
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<Uuid> = ids.clone();
|
||||
let mut have: Vec<Uuid> = 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::<Document>(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<Group>) -> AppResult<Option<Group>> {
|
||||
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<String> {
|
||||
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<String>) -> 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<Group>,
|
||||
name: &str,
|
||||
exclude: Option<Uuid>,
|
||||
) -> 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<Item>) -> AppResult<Vec<Item>> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+13
-1
@@ -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<Jwt>,
|
||||
pub login_limiter: Arc<LoginLimiter>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user