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:
2026-11-12 03:34:35 +03:00
parent b883530f76
commit 7f3fde074c
23 changed files with 1337 additions and 8 deletions
+137
View File
@@ -0,0 +1,137 @@
//! Dönem notları:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/notes?context=planning` | notlar (sayfa filtresi isteğe bağlı) |
//! | POST | `/api/periods/{id}/notes` | not ekle |
//! | PATCH | `/api/notes/{id}` | düzenle |
//! | DELETE | `/api/notes/{id}` | sil |
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::note::{Note, NoteContext},
services::note,
state::AppState,
};
#[derive(Deserialize)]
pub struct ListQuery {
context: Option<NoteContext>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteCreateRequest")]
pub struct CreateRequest {
context: NoteContext,
title: String,
/// Markdown
#[serde(default)]
body_md: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "NoteUpdateRequest", optional_fields)]
pub struct UpdateRequest {
title: Option<String>,
body_md: Option<String>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Note")]
pub struct NoteDto {
id: String,
context: NoteContext,
title: String,
body_md: String,
created_by: String,
/// ISO 8601
updated_at: String,
version: i32,
}
impl From<Note> for NoteDto {
fn from(n: Note) -> Self {
Self {
id: n.id.to_string(),
context: n.context,
title: n.title,
body_md: n.body_md,
created_by: n.created_by.to_string(),
updated_at: n.updated_at.try_to_rfc3339_string().unwrap_or_default(),
version: n.version,
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn list(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Vec<NoteDto>>> {
let notes = note::list(&s.db, auth.household_id, parse_id(&period_id)?, q.context).await?;
Ok(Json(notes.into_iter().map(NoteDto::from).collect()))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
Path(period_id): Path<String>,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<NoteDto>)> {
let n = note::create(
&s.db,
auth.household_id,
auth.user_id,
parse_id(&period_id)?,
req.context,
&req.title,
&req.body_md,
)
.await?;
Ok((StatusCode::CREATED, Json(n.into())))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<NoteDto>> {
let n = note::update(
&s.db,
auth.household_id,
parse_id(&id)?,
req.title.as_deref(),
req.body_md.as_deref(),
req.version,
)
.await?;
Ok(Json(n.into()))
}
pub async fn delete(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<StatusCode> {
note::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(StatusCode::NO_CONTENT)
}