Files
kakeibo/backend/src/services/note.rs
T
suphicakir 7f3fde074c feat(plan): sabit gelir/gider kalemleri ve dönem notları (backend)
- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:34:35 +03:00

144 lines
4.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dönem notları (KAKEIBO_PROMPT §8.6): başlık + Markdown metin, `planning` ya da `review`.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
note::{Note, NoteContext},
},
services::period,
};
pub const MAX_BODY: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Note> {
db.collection(col::NOTES)
}
/// Dönemin notları (isteğe bağlı sayfa filtresiyle), en yeni değişen başta.
pub async fn list(
db: &Database,
household_id: Uuid,
period_id: Uuid,
context: Option<NoteContext>,
) -> AppResult<Vec<Note>> {
period::get(db, household_id, period_id).await?;
let mut f = scope(household_id);
f.insert("period_id", period_id);
if let Some(c) = context {
f.insert("context", c.as_str());
}
let mut cursor = coll(db).find(f).sort(doc! { "updated_at": -1 }).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn create(
db: &Database,
household_id: Uuid,
user_id: Uuid,
period_id: Uuid,
context: NoteContext,
title: &str,
body_md: &str,
) -> AppResult<Note> {
period::get_open(db, household_id, period_id).await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("period_id", period_id);
d.insert("context", context.as_str());
d.insert("title", clean_title(title)?);
d.insert("body_md", clean_body(body_md)?);
d.insert("created_by", user_id);
db.collection::<Document>(col::NOTES).insert_one(d).await?;
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("eklenen not okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
title: Option<&str>,
body_md: Option<&str>,
version: Option<i32>,
) -> AppResult<Note> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
if version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
if let Some(t) = title {
set.insert("title", clean_title(t)?);
}
if let Some(b) = body_md {
set.insert("body_md", clean_body(b)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
let current = get(db, household_id, id).await?;
period::get_open(db, household_id, current.period_id).await?;
let ts = now();
let mut f = scope(household_id);
f.insert("_id", id);
db.collection::<Document>(col::NOTES)
.update_one(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Note> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
fn clean_title(t: &str) -> AppResult<String> {
let t = t.trim();
match t.chars().count() {
0 => Err(AppError::BadRequest("Not başlığı boş olamaz".into())),
1..=120 => Ok(t.to_string()),
_ => Err(AppError::BadRequest(
"Not başlığı en fazla 120 karakter olabilir".into(),
)),
}
}
fn clean_body(b: &str) -> AppResult<String> {
if b.chars().count() > MAX_BODY {
return Err(AppError::BadRequest(format!(
"Not metni en fazla {MAX_BODY} karakter olabilir"
)));
}
Ok(b.to_string())
}
fn stale() -> AppError {
AppError::Conflict("Not başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}