From 7f3fde074ca26f817808d9f08662a7d881e931fd Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 03:34:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(plan):=20sabit=20gelir/gider=20kalemleri?= =?UTF-8?q?=20ve=20d=C3=B6nem=20notlar=C4=B1=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- backend/api.http | 60 ++++ backend/src/models/mod.rs | 2 + backend/src/models/note.rs | 38 ++ backend/src/models/plan_item.rs | 38 ++ backend/src/routes/mod.rs | 13 + backend/src/routes/note.rs | 137 +++++++ backend/src/routes/plan.rs | 159 +++++++++ backend/src/services/mod.rs | 2 + backend/src/services/note.rs | 143 ++++++++ backend/src/services/period.rs | 22 +- backend/src/services/plan.rs | 283 +++++++++++++++ backend/tests/plan_notes.rs | 375 ++++++++++++++++++++ docs/MEMORY.md | 7 +- docs/PHASES.md | 4 +- frontend/src/types/CopyPreviousResult.ts | 7 + frontend/src/types/Note.ts | 8 + frontend/src/types/NoteContext.ts | 6 + frontend/src/types/NoteCreateRequest.ts | 8 + frontend/src/types/NoteUpdateRequest.ts | 3 + frontend/src/types/PlanItem.ts | 8 + frontend/src/types/PlanItemCreateRequest.ts | 12 + frontend/src/types/PlanItemKind.ts | 3 + frontend/src/types/PlanItemUpdateRequest.ts | 7 + 23 files changed, 1337 insertions(+), 8 deletions(-) create mode 100644 backend/src/models/note.rs create mode 100644 backend/src/models/plan_item.rs create mode 100644 backend/src/routes/note.rs create mode 100644 backend/src/routes/plan.rs create mode 100644 backend/src/services/note.rs create mode 100644 backend/src/services/plan.rs create mode 100644 backend/tests/plan_notes.rs create mode 100644 frontend/src/types/CopyPreviousResult.ts create mode 100644 frontend/src/types/Note.ts create mode 100644 frontend/src/types/NoteContext.ts create mode 100644 frontend/src/types/NoteCreateRequest.ts create mode 100644 frontend/src/types/NoteUpdateRequest.ts create mode 100644 frontend/src/types/PlanItem.ts create mode 100644 frontend/src/types/PlanItemCreateRequest.ts create mode 100644 frontend/src/types/PlanItemKind.ts create mode 100644 frontend/src/types/PlanItemUpdateRequest.ts diff --git a/backend/api.http b/backend/api.http index c4d0eb4..8a0f9e2 100644 --- a/backend/api.http +++ b/backend/api.http @@ -170,3 +170,63 @@ Content-Type: application/json "savings_goal": 750000, "version": 1 } + +# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ─────────────────────────── +# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde + +### Dönemin kalemleri (önce gelirler) +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items + +### Kalem ekle +POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items +Content-Type: application/json + +{ + "kind": "expense", + "name": "Kira", + "amount": 1500000, + "date": "2026-12-05" +} + +### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır) +POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous + +### Kalemi düzenle ("date": "" → tarihi kaldır) +PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID +Content-Type: application/json + +{ + "amount": 1600000, + "version": 1 +} + +### Kalemi sil +DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID + +# ─────────────────────────── Notlar ─────────────────────────── +# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown + +### Notlar (?context=planning ile filtre) +GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning + +### Not ekle +POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes +Content-Type: application/json + +{ + "context": "planning", + "title": "Tatil birikimi", + "body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt" +} + +### Notu düzenle +PATCH {{baseUrl}}/notes/BURAYA-NOT-ID +Content-Type: application/json + +{ + "body_md": "Güncel metin", + "version": 1 +} + +### Notu sil +DELETE {{baseUrl}}/notes/BURAYA-NOT-ID diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 35b6412..2598768 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -5,5 +5,7 @@ pub mod common; pub mod group; pub mod household; pub mod invite; +pub mod note; pub mod period; +pub mod plan_item; pub mod user; diff --git a/backend/src/models/note.rs b/backend/src/models/note.rs new file mode 100644 index 0000000..f1ff7a3 --- /dev/null +++ b/backend/src/models/note.rs @@ -0,0 +1,38 @@ +//! `notes` koleksiyonu (DATA_MODEL.md §3.11). Notlar döneme özeldir. + +use mongodb::bson::{DateTime, Uuid}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Notun bağlı olduğu sayfa +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, rename = "NoteContext")] +pub enum NoteContext { + /// Aylık planlama + Planning, + /// Ay sonu değerlendirmesi + Review, +} + +impl NoteContext { + pub fn as_str(self) -> &'static str { + match self { + NoteContext::Planning => "planning", + NoteContext::Review => "review", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Note { + #[serde(rename = "_id")] + pub id: Uuid, + pub period_id: Uuid, + pub context: NoteContext, + pub title: String, + pub body_md: String, + pub created_by: Uuid, + pub updated_at: DateTime, + pub version: i32, +} diff --git a/backend/src/models/plan_item.rs b/backend/src/models/plan_item.rs new file mode 100644 index 0000000..34077d9 --- /dev/null +++ b/backend/src/models/plan_item.rs @@ -0,0 +1,38 @@ +//! `plan_items` koleksiyonu — sabit gelir / gider kalemleri (DATA_MODEL.md §3.7). + +use mongodb::bson::Uuid; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, rename = "PlanItemKind")] +pub enum PlanKind { + Income, + Expense, +} + +impl PlanKind { + pub fn as_str(self) -> &'static str { + match self { + PlanKind::Income => "income", + PlanKind::Expense => "expense", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PlanItem { + #[serde(rename = "_id")] + pub id: Uuid, + pub period_id: Uuid, + pub kind: PlanKind, + pub name: String, + /// Kuruş + pub amount: i64, + /// `"YYYY-MM-DD"` (isteğe bağlı) + #[serde(default)] + pub date: Option, + pub sort_order: i32, + pub version: i32, +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 2b9fb61..4a735d8 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -4,7 +4,9 @@ mod auth; mod catalog; mod health; mod household; +mod note; mod period; +mod plan; mod spa; use axum::{ @@ -63,6 +65,17 @@ pub fn router(state: AppState) -> Router { .route("/periods", get(period::list).post(period::create)) .route("/periods/current", get(period::current)) .route("/periods/{id}", get(period::get).patch(period::update)) + .route( + "/periods/{id}/plan-items", + get(plan::list).post(plan::create), + ) + .route( + "/periods/{id}/plan-items/copy-previous", + post(plan::copy_previous), + ) + .route("/plan-items/{id}", patch(plan::update).delete(plan::delete)) + .route("/periods/{id}/notes", get(note::list).post(note::create)) + .route("/notes/{id}", patch(note::update).delete(note::delete)) // Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin) .fallback(|| async { AppError::NotFound }); diff --git a/backend/src/routes/note.rs b/backend/src/routes/note.rs new file mode 100644 index 0000000..0872d86 --- /dev/null +++ b/backend/src/routes/note.rs @@ -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, +} + +#[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, + body_md: Option, + version: Option, +} + +#[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 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::parse_str(id).map_err(|_| AppError::NotFound) +} + +pub async fn list( + State(s): State, + auth: AuthUser, + Path(period_id): Path, + Query(q): Query, +) -> AppResult>> { + 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, + auth: AuthUser, + Path(period_id): Path, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + 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, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + note::delete(&s.db, auth.household_id, parse_id(&id)?).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/routes/plan.rs b/backend/src/routes/plan.rs new file mode 100644 index 0000000..a9857ea --- /dev/null +++ b/backend/src/routes/plan.rs @@ -0,0 +1,159 @@ +//! Sabit gelir / gider kalemleri: +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods/{id}/plan-items` | dönemin kalemleri | +//! | POST | `/api/periods/{id}/plan-items` | kalem ekle | +//! | POST | `/api/periods/{id}/plan-items/copy-previous` | önceki dönemden kopyala | +//! | PATCH | `/api/plan-items/{id}` | düzenle | +//! | DELETE | `/api/plan-items/{id}` | sil | + +use axum::{ + Json, + extract::{Path, 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::plan_item::{PlanItem, PlanKind}, + services::plan::{self, ItemChanges, NewItem}, + state::AppState, +}; + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "PlanItemCreateRequest", optional_fields)] +pub struct CreateRequest { + kind: PlanKind, + name: String, + /// Kuruş, > 0 + #[ts(type = "number")] + amount: i64, + /// `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı) + date: Option, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "PlanItemUpdateRequest", optional_fields)] +pub struct UpdateRequest { + name: Option, + #[ts(type = "number", optional)] + amount: Option, + /// Boş metin → tarihi kaldır + date: Option, + version: Option, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "PlanItem")] +pub struct PlanItemDto { + id: String, + kind: PlanKind, + name: String, + /// Kuruş + #[ts(type = "number")] + amount: i64, + date: Option, + sort_order: i32, + version: i32, +} + +#[derive(Serialize, TS)] +#[ts(export)] +pub struct CopyPreviousResult { + /// Eklenen kalem sayısı (zaten var olanlar atlanır) + added: usize, +} + +impl From for PlanItemDto { + fn from(i: PlanItem) -> Self { + Self { + id: i.id.to_string(), + kind: i.kind, + name: i.name, + amount: i.amount, + date: i.date, + sort_order: i.sort_order, + version: i.version, + } + } +} + +fn parse_id(id: &str) -> AppResult { + Uuid::parse_str(id).map_err(|_| AppError::NotFound) +} + +pub async fn list( + State(s): State, + auth: AuthUser, + Path(period_id): Path, +) -> AppResult>> { + let items = plan::list(&s.db, auth.household_id, parse_id(&period_id)?).await?; + Ok(Json(items.into_iter().map(PlanItemDto::from).collect())) +} + +pub async fn create( + State(s): State, + auth: AuthUser, + Path(period_id): Path, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, Json)> { + let item = plan::create( + &s.db, + auth.household_id, + parse_id(&period_id)?, + NewItem { + kind: req.kind, + name: req.name, + amount: req.amount, + date: req.date, + }, + ) + .await?; + Ok((StatusCode::CREATED, Json(item.into()))) +} + +pub async fn copy_previous( + State(s): State, + auth: AuthUser, + Path(period_id): Path, +) -> AppResult> { + let added = plan::copy_previous(&s.db, auth.household_id, parse_id(&period_id)?).await?; + Ok(Json(CopyPreviousResult { added })) +} + +pub async fn update( + State(s): State, + auth: AuthUser, + Path(id): Path, + ValidJson(req): ValidJson, +) -> AppResult> { + let item = plan::update( + &s.db, + auth.household_id, + parse_id(&id)?, + ItemChanges { + name: req.name, + amount: req.amount, + date: req.date, + version: req.version, + }, + ) + .await?; + Ok(Json(item.into())) +} + +pub async fn delete( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + plan::delete(&s.db, auth.household_id, parse_id(&id)?).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 58cd1ea..0a972c5 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -4,5 +4,7 @@ pub mod account; pub mod calendar; pub mod catalog; pub mod invite; +pub mod note; pub mod period; +pub mod plan; pub mod seed; diff --git a/backend/src/services/note.rs b/backend/src/services/note.rs new file mode 100644 index 0000000..76e4824 --- /dev/null +++ b/backend/src/services/note.rs @@ -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 { + 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, +) -> AppResult> { + 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 { + 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::(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, +) -> AppResult { + 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::(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 { + 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 { + 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 { + 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()) +} diff --git a/backend/src/services/period.rs b/backend/src/services/period.rs index 2df356c..aaf669f 100644 --- a/backend/src/services/period.rs +++ b/backend/src/services/period.rs @@ -73,6 +73,22 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult AppResult { + let p = get(db, household_id, id).await?; + if p.status == PeriodStatus::Closed { + return Err(closed()); + } + Ok(p) +} + +pub fn closed() -> AppError { + AppError::Conflict( + "Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(), + ) +} + pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult { let (start, end) = validate_range(&p.start_date, &p.end_date)?; let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?; @@ -135,9 +151,7 @@ pub async fn update( ) -> AppResult { let current = get(db, household_id, id).await?; if current.status == PeriodStatus::Closed { - return Err(AppError::Conflict( - "Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(), - )); + return Err(closed()); } if ch.version.is_some_and(|v| v != current.version) { return Err(stale()); @@ -192,7 +206,7 @@ fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> { Ok((start, end)) } -fn validate_amount(v: i64) -> AppResult { +pub fn validate_amount(v: i64) -> AppResult { if !(0..=MAX_AMOUNT).contains(&v) { return Err(AppError::BadRequest("Tutar geçersiz".into())); } diff --git a/backend/src/services/plan.rs b/backend/src/services/plan.rs new file mode 100644 index 0000000..7b3f206 --- /dev/null +++ b/backend/src/services/plan.rs @@ -0,0 +1,283 @@ +//! Sabit gelir / gider kalemleri (KAKEIBO_PROMPT §8.3). +//! +//! - Tutar kuruş, > 0. Tarih isteğe bağlı; verilirse dönem içinde olmalı. +//! - Kapalı dönemde yazma yok. +//! - "Önceki dönemden kopyala": kalemler göreli günüyle taşınır (5 Ara → 5 Oca); +//! aynı tür + ad zaten varsa atlanır (iki kez basınca kopya oluşmaz). + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, + options::ReturnDocument, +}; +use time::Duration; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::{ + common::{new_meta, now, scope}, + period::Period, + plan_item::{PlanItem, PlanKind}, + }, + services::{ + calendar::{format_date, parse_date}, + period, + }, +}; + +pub struct NewItem { + pub kind: PlanKind, + pub name: String, + pub amount: i64, + pub date: Option, +} + +#[derive(Default)] +pub struct ItemChanges { + pub name: Option, + pub amount: Option, + /// `Some("")` → tarihi kaldır + pub date: Option, + pub version: Option, +} + +fn coll(db: &Database) -> mongodb::Collection { + db.collection(col::PLAN_ITEMS) +} + +/// Dönemin kalemleri: önce gelirler, sonra giderler; her biri sıralı. +pub async fn list(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult> { + period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi? + let mut f = scope(household_id); + f.insert("period_id", period_id); + let mut cursor = coll(db) + .find(f) + .sort(doc! { "kind": -1, "sort_order": 1 }) // "income" > "expense" → gelir önce + .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, + period_id: Uuid, + item: NewItem, +) -> AppResult { + let p = period::get_open(db, household_id, period_id).await?; + let name = clean_name(&item.name)?; + let amount = clean_amount(item.amount)?; + let date = clean_date(item.date.as_deref(), &p)?; + let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?; + insert( + db, + household_id, + period_id, + item.kind, + name, + amount, + date, + sort_order, + None, + ) + .await +} + +pub async fn update( + db: &Database, + household_id: Uuid, + id: Uuid, + ch: ItemChanges, +) -> AppResult { + let current = get(db, household_id, id).await?; + let p = period::get_open(db, household_id, current.period_id).await?; + if ch.version.is_some_and(|v| v != current.version) { + return Err(stale()); + } + + let mut set = doc! { "updated_at": now() }; + if let Some(name) = ch.name { + set.insert("name", clean_name(&name)?); + } + if let Some(amount) = ch.amount { + set.insert("amount", clean_amount(amount)?); + } + if let Some(date) = ch.date { + set.insert( + "date", + clean_date(Some(&date), &p)?.map_or(Bson::Null, Bson::String), + ); + } + + 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) +} + +/// Soft delete (geçmiş/eşitleme için kayıt kalır). +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::(col::PLAN_ITEMS) + .update_one( + f, + doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, + ) + .await?; + Ok(()) +} + +/// Önceki dönemin kalemlerini bu döneme kopyalar. Eklenen kalem sayısını döner. +pub async fn copy_previous(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { + let target = period::get_open(db, household_id, period_id).await?; + + // Bir önceki dönem: başlangıcı bu dönemden önce olan en yeni dönem + let mut pf = scope(household_id); + pf.insert("start_date", doc! { "$lt": &target.start_date }); + let source = db + .collection::(col::PERIODS) + .find_one(pf) + .sort(doc! { "start_date": -1 }) + .await? + .ok_or_else(|| AppError::BadRequest("Kopyalanacak önceki dönem yok".into()))?; + + let existing = list(db, household_id, period_id).await?; + let src_items = list(db, household_id, source.id).await?; + + let src_start = parse_date(&source.start_date).ok_or_else(bad_period)?; + let dst_start = parse_date(&target.start_date).ok_or_else(bad_period)?; + let dst_last = parse_date(&target.end_date).ok_or_else(bad_period)? - Duration::days(1); + + let mut added = 0; + for item in src_items { + let duplicate = existing + .iter() + .any(|e| e.kind == item.kind && e.name.to_lowercase() == item.name.to_lowercase()); + if duplicate { + continue; + } + // Göreli gün korunur, dönem dışına taşarsa son güne çekilir + let date = item.date.as_deref().and_then(parse_date).map(|d| { + let shifted = dst_start + (d - src_start); + format_date(shifted.clamp(dst_start, dst_last)) + }); + let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?; + insert( + db, + household_id, + period_id, + item.kind, + item.name, + item.amount, + date, + sort_order, + Some(item.id), + ) + .await?; + added += 1; + } + Ok(added) +} + +// ---------- yardımcılar ---------- + +async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult { + let mut f = scope(household_id); + f.insert("_id", id); + coll(db).find_one(f).await?.ok_or(AppError::NotFound) +} + +#[allow(clippy::too_many_arguments)] +async fn insert( + db: &Database, + household_id: Uuid, + period_id: Uuid, + kind: PlanKind, + name: String, + amount: i64, + date: Option, + sort_order: i32, + copied_from: Option, +) -> AppResult { + 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("kind", kind.as_str()); + d.insert("name", name); + d.insert("amount", amount); + d.insert("date", date.map_or(Bson::Null, Bson::String)); + d.insert("sort_order", sort_order); + d.insert("copied_from_id", copied_from.map_or(Bson::Null, Bson::from)); + db.collection::(col::PLAN_ITEMS) + .insert_one(d) + .await?; + coll(db) + .find_one(doc! { "_id": id }) + .await? + .ok_or_else(|| AppError::Internal("eklenen kalem okunamadı".into())) +} + +async fn next_sort_order( + db: &Database, + household_id: Uuid, + period_id: Uuid, + kind: PlanKind, +) -> AppResult { + let mut f = scope(household_id); + f.insert("period_id", period_id); + f.insert("kind", kind.as_str()); + let last = coll(db).find_one(f).sort(doc! { "sort_order": -1 }).await?; + Ok(last.map_or(10, |i| i.sort_order + 10)) +} + +fn clean_name(name: &str) -> AppResult { + let n = name.trim(); + match n.chars().count() { + 0 => Err(AppError::BadRequest("Kalem adı boş olamaz".into())), + 1..=60 => Ok(n.to_string()), + _ => Err(AppError::BadRequest( + "Kalem adı en fazla 60 karakter olabilir".into(), + )), + } +} + +fn clean_amount(v: i64) -> AppResult { + if v <= 0 { + return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into())); + } + period::validate_amount(v) +} + +/// Boş → `None`; doluysa geçerli ve dönem içinde olmalı. +fn clean_date(date: Option<&str>, p: &Period) -> AppResult> { + match date.map(str::trim) { + None | Some("") => Ok(None), + Some(s) => { + parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?; + if s < p.start_date.as_str() || s >= p.end_date.as_str() { + return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into())); + } + Ok(Some(s.to_string())) + } + } +} + +fn stale() -> AppError { + AppError::Conflict("Kalem başka biri tarafından değiştirildi, sayfayı yenileyin".into()) +} + +fn bad_period() -> AppError { + AppError::Internal("dönem tarihi okunamadı".into()) +} diff --git a/backend/tests/plan_notes.rs b/backend/tests/plan_notes.rs new file mode 100644 index 0000000..a928e97 --- /dev/null +++ b/backend/tests/plan_notes.rs @@ -0,0 +1,375 @@ +//! Plan kalemleri ve notlar. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use mongodb::bson::{Document, doc}; +use serde_json::{Value, json}; + +async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": start, "end_date": end }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + p["id"].as_str().unwrap().to_string() +} + +async fn close_period(app: &TestApp, id: &str) { + let uuid = mongodb::bson::Uuid::parse_str(id).unwrap(); + app.db + .collection::("periods") + .update_one( + doc! { "_id": uuid }, + doc! { "$set": { "status": "closed" } }, + ) + .await + .unwrap(); +} + +fn names(list: &Value) -> Vec { + list.as_array() + .unwrap() + .iter() + .map(|i| { + format!( + "{}:{}", + i["kind"].as_str().unwrap(), + i["name"].as_str().unwrap() + ) + }) + .collect() +} + +#[tokio::test] +async fn plan_items_crud_and_validation() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; + let items = format!("/periods/{pid}/plan-items"); + + // Gider önce eklense de liste gelirle başlar + let (st, rent) = app + .post( + &items, + &s, + json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{rent}"); + app.post( + &items, + &s, + json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }), + ) + .await; + app.post( + &items, + &s, + json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }), + ) + .await; + let (_, list) = app.get(&items, &s).await; + assert_eq!( + names(&list), + vec!["income:Maaş", "expense:Kira", "expense:İnternet"] + ); + assert_eq!(list[0]["date"], Value::Null); + + // Doğrulamalar + for bad in [ + json!({ "kind": "expense", "name": "X", "amount": 0 }), + json!({ "kind": "expense", "name": " ", "amount": 100 }), + json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç) + json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }), + json!({ "kind": "gift", "name": "X", "amount": 100 }), + ] { + let (st, _) = app.post(&items, &s, bad.clone()).await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}"); + } + + // Düzenle: tutar + tarihi kaldır; eski sürüm → 409 + let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap()); + let (st, upd) = app + .patch( + &rent_path, + &s, + json!({ "amount": 1_600_000, "date": "", "version": 1 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{upd}"); + assert_eq!( + (upd["amount"].as_i64(), &upd["date"]), + (Some(1_600_000), &Value::Null) + ); + let (st, _) = app + .patch(&rent_path, &s, json!({ "name": "Y", "version": 1 })) + .await; + assert_eq!(st, StatusCode::CONFLICT); + + // Sil → listeden çıkar + assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT); + let (_, list) = app.get(&items, &s).await; + assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]); + + app.cleanup().await; +} + +#[tokio::test] +async fn copy_previous_shifts_dates_and_skips_duplicates() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await; + let dec_items = format!("/periods/{dec}/plan-items"); + app.post( + &dec_items, + &s, + json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }), + ) + .await; + app.post( + &dec_items, + &s, + json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }), + ) + .await; + app.post( + &dec_items, + &s, + json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }), + ) + .await; + app.post( + &dec_items, + &s, + json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }), + ) + .await; + close_period(&app, &dec).await; + + let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await; + let jan_items = format!("/periods/{jan}/plan-items"); + // Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı + app.post( + &jan_items, + &s, + json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }), + ) + .await; + + let (st, res) = app + .post(&format!("{jan_items}/copy-previous"), &s, json!({})) + .await; + assert_eq!(st, StatusCode::OK, "{res}"); + assert_eq!(res["added"], 3); + + let (_, list) = app.get(&jan_items, &s).await; + let by_name = |n: &str| { + list.as_array() + .unwrap() + .iter() + .find(|i| i["name"] == n) + .unwrap() + .clone() + }; + assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur"); + assert_eq!(by_name("Aidat")["date"], Value::Null); + assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur"); + + assert_eq!(by_name("Fatura")["date"], "2027-01-31"); + + // Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir + close_period(&app, &jan).await; + let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await; + let feb_items = format!("/periods/{feb}/plan-items"); + app.post(&format!("{feb_items}/copy-previous"), &s, json!({})) + .await; + let (_, feb_list) = app.get(&feb_items, &s).await; + let fatura = feb_list + .as_array() + .unwrap() + .iter() + .find(|i| i["name"] == "Fatura") + .unwrap(); + assert_eq!(fatura["date"], "2027-02-28"); + + // İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz) + let (_, res) = app + .post(&format!("{feb_items}/copy-previous"), &s, json!({})) + .await; + assert_eq!(res["added"], 0); + + // İlk dönemde önceki yok → 400 + let (st, _) = app + .post(&format!("{dec_items}/copy-previous"), &s, json!({})) + .await; + assert!( + st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT, + "{st}" + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn closed_period_is_read_only() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; + let (_, item) = app + .post( + &format!("/periods/{pid}/plan-items"), + &s, + json!({ "kind": "income", "name": "Maaş", "amount": 100 }), + ) + .await; + let (_, note) = app + .post( + &format!("/periods/{pid}/notes"), + &s, + json!({ "context": "planning", "title": "Hedef" }), + ) + .await; + close_period(&app, &pid).await; + + let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap()); + let note_path = format!("/notes/{}", note["id"].as_str().unwrap()); + let checks = [ + app.post( + &format!("/periods/{pid}/plan-items"), + &s, + json!({ "kind": "income", "name": "X", "amount": 1 }), + ) + .await + .0, + app.patch(&item_path, &s, json!({ "amount": 5 })).await.0, + app.delete(&item_path, &s).await.0, + app.post( + &format!("/periods/{pid}/notes"), + &s, + json!({ "context": "review", "title": "X" }), + ) + .await + .0, + app.patch(¬e_path, &s, json!({ "title": "Y" })).await.0, + app.delete(¬e_path, &s).await.0, + ]; + assert!( + checks.iter().all(|st| *st == StatusCode::CONFLICT), + "{checks:?}" + ); + + // Okuma serbest + assert_eq!( + app.get(&format!("/periods/{pid}/plan-items"), &s).await.0, + StatusCode::OK + ); + assert_eq!( + app.get(&format!("/periods/{pid}/notes"), &s).await.0, + StatusCode::OK + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn notes_crud_and_context_filter() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; + let notes = format!("/periods/{pid}/notes"); + + let (st, n) = app + .post(¬es, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" })) + .await; + assert_eq!(st, StatusCode::CREATED, "{n}"); + assert_eq!(n["title"], "Tatil birikimi"); + app.post(¬es, &s, json!({ "context": "review", "title": "Ders" })) + .await; + + let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await; + assert_eq!(planning.as_array().unwrap().len(), 1); + let (_, all) = app.get(¬es, &s).await; + assert_eq!(all.as_array().unwrap().len(), 2); + + let (st, _) = app + .post(¬es, &s, json!({ "context": "planning", "title": "" })) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık"); + + let path = format!("/notes/{}", n["id"].as_str().unwrap()); + let (st, upd) = app + .patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 })) + .await; + assert_eq!( + (st, upd["body_md"].as_str()), + (StatusCode::OK, Some("Güncel")) + ); + assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT); + let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await; + assert_eq!(planning, json!([])); + + app.cleanup().await; +} + +#[tokio::test] +async fn plan_items_and_notes_are_isolated() { + let app = TestApp::new().await; + let a = app.register("A", None).await; + let b = app.register("B", None).await; + let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await; + let (_, item) = app + .post( + &format!("/periods/{pid}/plan-items"), + &a, + json!({ "kind": "income", "name": "Maaş", "amount": 100 }), + ) + .await; + let (_, note) = app + .post( + &format!("/periods/{pid}/notes"), + &a, + json!({ "context": "planning", "title": "Gizli" }), + ) + .await; + + let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap()); + let note_path = format!("/notes/{}", note["id"].as_str().unwrap()); + let checks = [ + app.get(&format!("/periods/{pid}/plan-items"), &b).await.0, + app.post( + &format!("/periods/{pid}/plan-items"), + &b, + json!({ "kind": "income", "name": "X", "amount": 1 }), + ) + .await + .0, + app.post( + &format!("/periods/{pid}/plan-items/copy-previous"), + &b, + json!({}), + ) + .await + .0, + app.patch(&item_path, &b, json!({ "amount": 1 })).await.0, + app.delete(&item_path, &b).await.0, + app.get(&format!("/periods/{pid}/notes"), &b).await.0, + app.patch(¬e_path, &b, json!({ "title": "X" })).await.0, + app.delete(¬e_path, &b).await.0, + ]; + assert!( + checks.iter().all(|st| *st == StatusCode::NOT_FOUND), + "{checks:?}" + ); + + // A'nın verisi değişmedi + let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await; + assert_eq!(list[0]["amount"], 100); + assert_eq!(list[0]["version"], 1); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index e312469..276009f 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 4 – Adım 2:** Backend plan kalemleri (`plan_items`: income/expense, ad, tutar kuruş, tarih dönem içinde, sort_order; CRUD + `POST /api/periods/{id}/plan-items/copy-previous`) ve notlar (`notes`: context planning|review, başlık, body_md; CRUD). Kapalı dönemde yazma yok. İzolasyon testleri. +**Faz 4 – Adım 3:** Backend bütçe: `budgets` (dönem × grup aylık tutar; haftalık yalnızca elle değiştirilirse `scope: week`), haftalık otomatik = aylık × hafta günü / dönem günü (kuruş yuvarlama: toplam aylığa eşit kalsın), öneri (`default` yüzdeler; `previous`/`avg3` entries gerektirir → şimdilik boş/varsayılana düşer), özet endpoint'i: gelir − sabit gider − birikim − devreden borç = harcanabilir; dağıtılan > harcanabilir uyarısı. ## 📌 Alınan Kararlar - **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`). @@ -38,10 +38,12 @@ - Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo). - Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir. - ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor). +- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete. +- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü). - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5. ## ❓ Açık Konular -- **Geliştirme makinesinde Node çalışmıyor (2026-11-12):** CachyOS'ta simdjson 5.1.1 (.so.36) geldi, nodejs 26.13.0-2 hâlâ .so.34 istiyor. Çözüm: `sudo pacman -U /var/cache/pacman/pkg/simdjson-2:5.0.5-1-x86_64.pkg.tar.zst`, sonraki güncellemelerde `--ignore simdjson`. Faz 4 Adım 1'in frontend kontrolleri (tsc/vitest) Node düzelince çalıştırılacak. +- Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur. - **Canlı yedekleme yok** (Faz 3 Adım 5 ertelendi) → gerçek veri girişinden önce, en geç Faz 5 sonunda: günlük mongodump, 14 yedek, geri yükleme testi. - **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4. - Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+). @@ -84,3 +86,4 @@ ### 2026-11-12 — Faz 4 - **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test. +- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index a9e366b..21f9d1c 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -47,8 +47,8 @@ ## Faz 4 Adımları - ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler -- ⏳ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar -- ⏳ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar) +- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar +- 🔄 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar) - ⏳ 4. Frontend: ilk dönem sihirbazı - ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı) - ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle) diff --git a/frontend/src/types/CopyPreviousResult.ts b/frontend/src/types/CopyPreviousResult.ts new file mode 100644 index 0000000..cfd419f --- /dev/null +++ b/frontend/src/types/CopyPreviousResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CopyPreviousResult = { +/** + * Eklenen kalem sayısı (zaten var olanlar atlanır) + */ +added: number, }; diff --git a/frontend/src/types/Note.ts b/frontend/src/types/Note.ts new file mode 100644 index 0000000..ca7f100 --- /dev/null +++ b/frontend/src/types/Note.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NoteContext } from "./NoteContext"; + +export type Note = { id: string, context: NoteContext, title: string, body_md: string, created_by: string, +/** + * ISO 8601 + */ +updated_at: string, version: number, }; diff --git a/frontend/src/types/NoteContext.ts b/frontend/src/types/NoteContext.ts new file mode 100644 index 0000000..8326937 --- /dev/null +++ b/frontend/src/types/NoteContext.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Notun bağlı olduğu sayfa + */ +export type NoteContext = "planning" | "review"; diff --git a/frontend/src/types/NoteCreateRequest.ts b/frontend/src/types/NoteCreateRequest.ts new file mode 100644 index 0000000..46b0410 --- /dev/null +++ b/frontend/src/types/NoteCreateRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NoteContext } from "./NoteContext"; + +export type NoteCreateRequest = { context: NoteContext, title: string, +/** + * Markdown + */ +body_md: string, }; diff --git a/frontend/src/types/NoteUpdateRequest.ts b/frontend/src/types/NoteUpdateRequest.ts new file mode 100644 index 0000000..2d4187b --- /dev/null +++ b/frontend/src/types/NoteUpdateRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NoteUpdateRequest = { title?: string, body_md?: string, version?: number, }; diff --git a/frontend/src/types/PlanItem.ts b/frontend/src/types/PlanItem.ts new file mode 100644 index 0000000..f875a15 --- /dev/null +++ b/frontend/src/types/PlanItem.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PlanItemKind } from "./PlanItemKind"; + +export type PlanItem = { id: string, kind: PlanItemKind, name: string, +/** + * Kuruş + */ +amount: number, date: string | null, sort_order: number, version: number, }; diff --git a/frontend/src/types/PlanItemCreateRequest.ts b/frontend/src/types/PlanItemCreateRequest.ts new file mode 100644 index 0000000..760929e --- /dev/null +++ b/frontend/src/types/PlanItemCreateRequest.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PlanItemKind } from "./PlanItemKind"; + +export type PlanItemCreateRequest = { kind: PlanItemKind, name: string, +/** + * Kuruş, > 0 + */ +amount: number, +/** + * `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı) + */ +date?: string, }; diff --git a/frontend/src/types/PlanItemKind.ts b/frontend/src/types/PlanItemKind.ts new file mode 100644 index 0000000..9ad3be6 --- /dev/null +++ b/frontend/src/types/PlanItemKind.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PlanItemKind = "income" | "expense"; diff --git a/frontend/src/types/PlanItemUpdateRequest.ts b/frontend/src/types/PlanItemUpdateRequest.ts new file mode 100644 index 0000000..1530b60 --- /dev/null +++ b/frontend/src/types/PlanItemUpdateRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PlanItemUpdateRequest = { name?: string, amount?: number, +/** + * Boş metin → tarihi kaldır + */ +date?: string, version?: number, };