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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user