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>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
//! 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())
|
||||
}
|
||||
Reference in New Issue
Block a user