From 906f05f62f04729d818386aeeb9fac935463a559 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 00:39:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(aray=C3=BCz):=20API=20istemcisi,=20TanStac?= =?UTF-8?q?k=20Query,=20ts-rs=20tip=20=C3=BCretimi=20ve=20oturum=20durumu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types) - Hata ve health yanıtları tipli yapılara çevrildi - lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı - lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır) - lib/auth.ts: useMe, useLogin, useRegister, useLogout - Vitest: 7 API istemcisi testi (toplam 29) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 + backend/.cargo/config.toml | 3 + backend/Cargo.lock | 32 ++++++++ backend/Cargo.toml | 1 + backend/src/error.rs | 31 ++++++-- backend/src/models/group.rs | 4 +- backend/src/models/user.rs | 4 +- backend/src/routes/auth.rs | 16 ++-- backend/src/routes/catalog.rs | 14 +++- backend/src/routes/health.rs | 27 ++++++- backend/src/routes/household.rs | 10 ++- docs/MEMORY.md | 5 +- docs/PHASES.md | 4 +- frontend/package-lock.json | 33 ++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 90 ++++++++++++++++++---- frontend/src/lib/api.test.ts | 65 ++++++++++++++++ frontend/src/lib/api.ts | 62 +++++++++++++++ frontend/src/lib/auth.ts | 60 +++++++++++++++ frontend/src/lib/groups.ts | 4 +- frontend/src/lib/query.ts | 42 ++++++++++ frontend/src/main.tsx | 10 ++- frontend/src/types/.gitkeep | 0 frontend/src/types/ApiErrorBody.ts | 7 ++ frontend/src/types/ApiErrorDetail.ts | 11 +++ frontend/src/types/CatalogCreateRequest.ts | 4 + frontend/src/types/CatalogItem.ts | 4 + frontend/src/types/CatalogOrderRequest.ts | 4 + frontend/src/types/CatalogUpdateRequest.ts | 3 + frontend/src/types/Group.ts | 3 + frontend/src/types/HealthResponse.ts | 11 +++ frontend/src/types/Household.ts | 4 + frontend/src/types/HouseholdSummary.ts | 3 + frontend/src/types/Invite.ts | 3 + frontend/src/types/LoginRequest.ts | 3 + frontend/src/types/MeResponse.ts | 5 ++ frontend/src/types/Member.ts | 4 + frontend/src/types/RegisterRequest.ts | 7 ++ frontend/src/types/Role.ts | 3 + frontend/src/types/User.ts | 4 + package.json | 3 +- 41 files changed, 558 insertions(+), 49 deletions(-) create mode 100644 backend/.cargo/config.toml create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/query.ts delete mode 100644 frontend/src/types/.gitkeep create mode 100644 frontend/src/types/ApiErrorBody.ts create mode 100644 frontend/src/types/ApiErrorDetail.ts create mode 100644 frontend/src/types/CatalogCreateRequest.ts create mode 100644 frontend/src/types/CatalogItem.ts create mode 100644 frontend/src/types/CatalogOrderRequest.ts create mode 100644 frontend/src/types/CatalogUpdateRequest.ts create mode 100644 frontend/src/types/Group.ts create mode 100644 frontend/src/types/HealthResponse.ts create mode 100644 frontend/src/types/Household.ts create mode 100644 frontend/src/types/HouseholdSummary.ts create mode 100644 frontend/src/types/Invite.ts create mode 100644 frontend/src/types/LoginRequest.ts create mode 100644 frontend/src/types/MeResponse.ts create mode 100644 frontend/src/types/Member.ts create mode 100644 frontend/src/types/RegisterRequest.ts create mode 100644 frontend/src/types/Role.ts create mode 100644 frontend/src/types/User.ts diff --git a/CLAUDE.md b/CLAUDE.md index bf5f3f6..8008696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,12 +57,15 @@ npm run db:seed npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler npm test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest) npm run lint # clippy + oxlint +npm run types # Rust DTO → frontend/src/types/*.ts (ts-rs; cargo test de üretir) npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick) ``` - `npm run dev` öncesinde DB otomatik başlar (`predev`). - Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - Frontend lint: **oxlint** (Vite şablonu varsayılanı; ESLint yerine). `components/ui/**` shadcn dosyaları. - Frontend: Tailwind v4 (`src/index.css` token'ları), shadcn/ui (style `radix-nova`, `cn` paketi), `@/` → `src/`. Renkler: `bg-needs`/`text-needs-fg`/`bg-needs-soft` (wants, culture, unexpected aynı), `text-income`/`text-expense`/`text-warning`. Tutarlarda `money` sınıfı. Butonlar ≥44 px (shadcn boyutları büyütüldü). +- API: `lib/api.ts` (`api.get/post/...`, hata → `ApiError` Türkçe `message`), `lib/query.ts` (QueryClient + `queryKeys`), `lib/auth.ts` (`useMe` → undefined/null/MeResponse, `useLogin`/`useRegister`/`useLogout`). +- Tipler: `frontend/src/types/` **elle düzenlenmez**; backend'de DTO'ya `#[derive(TS)] #[ts(export)]` ekle → `npm run types`. İstek yapılarında `optional_fields`. - Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`. - API örnekleri: `backend/api.http` - Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_` DB, `tests/common` yardımcıları). diff --git a/backend/.cargo/config.toml b/backend/.cargo/config.toml new file mode 100644 index 0000000..a1656b2 --- /dev/null +++ b/backend/.cargo/config.toml @@ -0,0 +1,3 @@ +# ts-rs: Rust DTO'larından üretilen TypeScript tipleri buraya yazılır (npm run types) +[env] +TS_RS_EXPORT_DIR = { value = "../frontend/src/types", relative = true } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 40de7b8..29484da 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1298,6 +1298,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "ts-rs", "uuid", "validator", ] @@ -2429,6 +2430,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.21" @@ -2656,6 +2666,28 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "typed-builder" version = "0.22.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3d5f324..d80992e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,6 +17,7 @@ time = "0.3.55" tokio = { version = "1.54.1", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +ts-rs = "12.0.1" uuid = { version = "1.29.2", features = ["v4", "v7"] } validator = { version = "0.21.0", features = ["derive"] } diff --git a/backend/src/error.rs b/backend/src/error.rs index 35428b7..698dd17 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -6,7 +6,24 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde_json::json; +use serde::Serialize; +use ts_rs::TS; + +/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }` +#[derive(Serialize, TS)] +#[ts(export)] +pub struct ApiErrorBody { + pub error: ApiErrorDetail, +} + +#[derive(Serialize, TS)] +#[ts(export)] +pub struct ApiErrorDetail { + /// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ... + pub code: String, + /// Kullanıcıya gösterilecek Türkçe mesaj + pub message: String, +} #[derive(Debug, thiserror::Error)] pub enum AppError { @@ -59,11 +76,13 @@ impl IntoResponse for AppError { self.to_string() }; - ( - status, - Json(json!({ "error": { "code": code, "message": message } })), - ) - .into_response() + let body = ApiErrorBody { + error: ApiErrorDetail { + code: code.into(), + message, + }, + }; + (status, Json(body)).into_response() } } diff --git a/backend/src/models/group.rs b/backend/src/models/group.rs index 0d0f7ee..30180a8 100644 --- a/backend/src/models/group.rs +++ b/backend/src/models/group.rs @@ -1,9 +1,11 @@ //! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda. use serde::{Deserialize, Serialize}; +use ts_rs::TS; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] +#[ts(export)] pub enum Group { /// 🏠 Zorunlu Needs, diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 5d75263..b70b414 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -2,9 +2,11 @@ use mongodb::bson::Uuid; use serde::{Deserialize, Serialize}; +use ts_rs::TS; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] +#[ts(export)] pub enum Role { Owner, Member, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index ca7d169..24345d1 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -5,6 +5,7 @@ use axum::{Json, extract::State, http::StatusCode}; use axum_extra::extract::cookie::CookieJar; use mongodb::bson::{Uuid, doc}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use validator::Validate; use crate::{ @@ -26,7 +27,8 @@ use crate::{ // ---------- İstek / yanıt yapıları ---------- -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, TS)] +#[ts(export, optional_fields)] pub struct RegisterRequest { #[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))] name: String, @@ -41,7 +43,8 @@ pub struct RegisterRequest { invite_code: Option, } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, TS)] +#[ts(export)] pub struct LoginRequest { #[validate(length(min = 1, message = "E-posta gerekli"))] email: String, @@ -51,13 +54,15 @@ pub struct LoginRequest { remember_me: bool, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export)] pub struct MeResponse { user: UserDto, household: HouseholdDto, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "User")] struct UserDto { id: String, name: String, @@ -65,7 +70,8 @@ struct UserDto { role: Role, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "HouseholdSummary")] struct HouseholdDto { id: String, name: String, diff --git a/backend/src/routes/catalog.rs b/backend/src/routes/catalog.rs index 918d547..9073cdd 100644 --- a/backend/src/routes/catalog.rs +++ b/backend/src/routes/catalog.rs @@ -16,6 +16,7 @@ use axum::{ }; use mongodb::bson::Uuid; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use validator::Validate; use crate::{ @@ -35,7 +36,8 @@ pub struct ListQuery { include_inactive: bool, } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "CatalogCreateRequest", optional_fields)] pub struct CreateRequest { group: Option, #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] @@ -44,7 +46,8 @@ pub struct CreateRequest { emoji: Option, } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "CatalogUpdateRequest", optional_fields)] pub struct UpdateRequest { #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] name: Option, @@ -54,17 +57,20 @@ pub struct UpdateRequest { version: Option, } -#[derive(Deserialize, Validate)] +#[derive(Deserialize, Validate, TS)] +#[ts(export, rename = "CatalogOrderRequest", optional_fields)] pub struct OrderRequest { group: Option, #[validate(length(max = 200, message = "Liste çok uzun"))] ids: Vec, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "CatalogItem")] pub struct ItemDto { id: String, #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] group: Option, name: String, emoji: Option, diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs index 917b855..7f6cc40 100644 --- a/backend/src/routes/health.rs +++ b/backend/src/routes/health.rs @@ -1,23 +1,42 @@ use axum::{Json, extract::State, http::StatusCode}; -use serde_json::{Value, json}; +use serde::Serialize; +use ts_rs::TS; use crate::{db, state::AppState}; +#[derive(Serialize, TS)] +#[ts(export)] +pub struct HealthResponse { + /// `ok` veya `degraded` + status: &'static str, + /// `ok` veya `error` + db: &'static str, + version: &'static str, +} + /// Sağlık kontrolü (Dokploy health check bunu kullanır). /// DB erişilemezse 503 döner. -pub async fn health(State(state): State) -> (StatusCode, Json) { +pub async fn health(State(state): State) -> (StatusCode, Json) { let version = env!("CARGO_PKG_VERSION"); match db::ping(&state.db).await { Ok(()) => ( StatusCode::OK, - Json(json!({ "status": "ok", "db": "ok", "version": version })), + Json(HealthResponse { + status: "ok", + db: "ok", + version, + }), ), Err(e) => { tracing::warn!(error = %e, "health: veritabanına erişilemiyor"); ( StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "status": "degraded", "db": "error", "version": version })), + Json(HealthResponse { + status: "degraded", + db: "error", + version, + }), ) } } diff --git a/backend/src/routes/household.rs b/backend/src/routes/household.rs index 55d6cd1..8faeba1 100644 --- a/backend/src/routes/household.rs +++ b/backend/src/routes/household.rs @@ -11,6 +11,7 @@ use axum::{ }; use mongodb::bson::{DateTime, Uuid, doc}; use serde::{Deserialize, Serialize}; +use ts_rs::TS; use crate::{ auth::AuthUser, @@ -21,14 +22,16 @@ use crate::{ state::AppState, }; -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "Household")] pub struct HouseholdResponse { id: String, name: String, members: Vec, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "Member")] struct MemberDto { id: String, name: String, @@ -36,7 +39,8 @@ struct MemberDto { role: Role, } -#[derive(Serialize)] +#[derive(Serialize, TS)] +#[ts(export, rename = "Invite")] pub struct InviteDto { id: String, code: String, diff --git a/docs/MEMORY.md b/docs/MEMORY.md index e171334..e30f16a 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 2 – Adım 3:** `lib/api.ts` (fetch sarmalayıcı, `{error:{code,message}}` → `ApiError`), TanStack Query kurulumu, ts-rs ile Rust DTO → `frontend/src/types/` tip üretimi, oturum durumu (`useMe` / auth context). +**Faz 2 – Adım 4:** react-router kurulumu, korumalı sayfalar (useMe null → /giris), layout: mobilde alt menü (📊 Panel, ✍️ Defter, 🗓️ Plan, 🐷 Birikim, ☰ Diğer), masaüstünde sol kenar çubuğu, boş sayfa iskeletleri, App.tsx önizlemesi kaldırılır (tasarım önizlemesi isterse /dev sayfası). ## 📌 Alınan Kararlar - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. @@ -23,6 +23,8 @@ - Tema seçimi localStorage (`kakeibo-theme`), yalnızca cihaz kolaylığı; ileride `users.preferences.theme` ile senkron olabilir. - `parseMoney`: virgül varsa ondalık; virgül yoksa son noktadan sonra 1–2 rakam → ondalık ("1.25" = 1,25 ₺; "1.250" = 1250 ₺). - Logo: Taslak A (indigo zemin, spiral krem defter, ¥) seçildi. Kaynak `frontend/brand/logo.svg` + `logo-fullbleed.svg` (maskable/Apple, içerik %82). Uygulama içi `components/logo.tsx` aynı çizim (elle senkron tutulmalı). Manifest Faz 10'da (vite-plugin-pwa). +- ts-rs 12: `backend/.cargo/config.toml` → `TS_RS_EXPORT_DIR=../frontend/src/types`. TS adları: User, HouseholdSummary, Household, Member, Invite, CatalogItem, Catalog{Create,Update,Order}Request, RegisterRequest, LoginRequest, MeResponse, HealthResponse, ApiErrorBody, Group, Role. +- QueryClient: 4xx tekrar denenmez; herhangi bir sorgu 401 alırsa `me` = null. Çıkışta `qc.clear()`. - 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. ## ❓ Açık Konular @@ -51,3 +53,4 @@ ### 2026-11-12 — Faz 2 - **Adım 1:** Tailwind v4 (@tailwindcss/vite), shadcn init (radix, preset nova; `cn` paketi = shadcn'in clsx+tailwind-merge yerine geçeni, incelendi), Inter (@fontsource-variable, çevrimdışı). `index.css`: grup + durum token'ları, odak halkası, `money` utility, reduced-motion. `lib/format.ts` (+22 vitest), `lib/theme.tsx`, `lib/groups.ts`, `components/theme-toggle.tsx`. Butonlar 44/48 px'e büyütüldü. Geçici önizleme ekranı (App.tsx), 375 px headless ekran görüntüsüyle iki temada kontrol edildi. - **Adım 2:** 3 logo taslağı (A indigo defter, B açık defter + para, C hanko) 512/64/32/16 px önizlemeyle sunuldu; kullanıcı A'yı seçti. `scripts/icons.sh` (`npm run icons`): favicon.svg/.ico, icon-192/512, icon-maskable-512, apple-touch-icon (180), brand/icon-1024 (Tauri). Maskable güvenli alan görsel olarak doğrulandı. `index.html`: ikon linkleri, theme-color, description. +- **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti. diff --git a/docs/PHASES.md b/docs/PHASES.md index 91a68ce..b510a9d 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -34,6 +34,6 @@ ## Faz 2 Adımları - ✅ 1. Tailwind v4 + shadcn/ui (radix, nova), tasarım token'ları (4 grup + durum renkleri, AA doğrulandı), Inter, tema (aydınlık/karanlık/sistem), `lib/format.ts` + Vitest - ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları -- 🔄 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu -- ⏳ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü +- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu +- 🔄 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü - ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3edfba..887c09e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@tailwindcss/vite": "^4.3.5", + "@tanstack/react-query": "^5.107.1", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", "lucide-react": "^1.72.0", @@ -3356,6 +3357,38 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.107.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.107.1.tgz", + "integrity": "sha512-PSyAik11oQBEVOiVy5hjZWHmoUqOXqK1WYy4v55mcxozEfKaQgpUMsgnXhWjuESzSht8JfzqMS1m6M/9CpNgvg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.107.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.107.1.tgz", + "integrity": "sha512-udCOOLp3ZAheUrIHLCe+ZZge6nr2ZUrGMSWcCuXsjY1Bf2JeeE09oxst97PILiCY6N8xo/7bIo3Hvz13zh06FA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.107.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 272d581..4a17ff5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@tailwindcss/vite": "^4.3.5", + "@tanstack/react-query": "^5.107.1", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", "lucide-react": "^1.72.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6774d52..e2f0652 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,25 @@ -import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { useState, type FormEvent } from 'react' +import { useQuery } from '@tanstack/react-query' +import { LogIn, LogOut, Plus } from 'lucide-react' import { Logo } from '@/components/logo' import { ThemeToggle } from '@/components/theme-toggle' import { Button } from '@/components/ui/button' import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format' +import { api } from '@/lib/api' +import { useLogin, useLogout, useMe } from '@/lib/auth' import { GROUPS } from '@/lib/groups' - -type Health = { status: string; db: string; version: string } +import { queryKeys } from '@/lib/query' +import type { HealthResponse } from '@/types/HealthResponse' // Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi. // Adım 4'te gerçek layout ve sayfalarla değiştirilecek. export default function App() { - const [health, setHealth] = useState(null) - const [error, setError] = useState(false) + const health = useQuery({ + queryKey: queryKeys.health, + queryFn: () => api.get('/health'), + }) const [today] = useState(() => toIsoDate(new Date())) - useEffect(() => { - fetch('/api/health') - .then((r) => (r.ok ? r.json() : Promise.reject())) - .then(setHealth) - .catch(() => setError(true)) - }, []) - return (
@@ -35,15 +33,17 @@ export default function App() {

- {error && ● Backend'e ulaşılamadı} - {!error && !health && ● Backend kontrol ediliyor…} - {health && ( + {health.isError && ● {health.error.message}} + {health.isPending && ● Backend kontrol ediliyor…} + {health.data && ( - ● Backend çalışıyor (v{health.version}, DB: {health.db}) + ● Backend çalışıyor (v{health.data.version}, DB: {health.data.db}) )}

+ +

Ana gruplar

@@ -102,3 +102,59 @@ export default function App() {
) } + +/** Adım 3 geçici paneli: oturum akışını uçtan uca dener. Adım 5'te gerçek giriş ekranı gelir. */ +function SessionPanel() { + const me = useMe() + const login = useLogin() + const logout = useLogout() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + const submit = (e: FormEvent) => { + e.preventDefault() + login.mutate({ email, password, remember_me: true }) + } + + return ( +
+

Oturum (geçici test paneli)

+ {me.isPending &&

Oturum kontrol ediliyor…

} + {me.data && ( +
+

+ 👋 {me.data.user.name} · {me.data.household.name} ·{' '} + {me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'} +

+ +
+ )} + {me.data === null && ( +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + +
+ )} + {login.isError &&

{login.error.message}

} +
+ ) +} diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..e92dc76 --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api, ApiError } from './api' + +function mockFetch(status: number, body?: unknown) { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return globalThis.fetch as ReturnType +} + +afterEach(() => vi.restoreAllMocks()) + +describe('api', () => { + it('başarılı JSON yanıtı döner, /api önekini ekler', async () => { + const f = mockFetch(200, { status: 'ok' }) + await expect(api.get('/health')).resolves.toEqual({ status: 'ok' }) + expect(f).toHaveBeenCalledWith('/api/health', expect.objectContaining({ method: 'GET' })) + }) + + it('gövdeyi JSON olarak gönderir', async () => { + const f = mockFetch(201, { id: '1' }) + await api.post('/categories', { name: 'Market' }) + const init = f.mock.calls[0][1] as RequestInit + expect(init.body).toBe('{"name":"Market"}') + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) + }) + + it('204 → undefined', async () => { + mockFetch(204) + await expect(api.post('/auth/logout')).resolves.toBeUndefined() + }) + + it('backend hatasını Türkçe mesajıyla ApiError yapar', async () => { + mockFetch(409, { error: { code: 'conflict', message: 'Bu e-posta adresi zaten kayıtlı' } }) + const err = await api.post('/auth/register', {}).catch((e: unknown) => e) + expect(err).toBeInstanceOf(ApiError) + expect(err).toMatchObject({ + status: 409, + code: 'conflict', + message: 'Bu e-posta adresi zaten kayıtlı', + }) + }) + + it('401 → isUnauthorized', async () => { + mockFetch(401, { error: { code: 'unauthorized', message: 'Oturum açmanız gerekiyor' } }) + const err = (await api.get('/auth/me').catch((e: unknown) => e)) as ApiError + expect(err.isUnauthorized).toBe(true) + }) + + it('JSON olmayan hata (ör. proxy 502) → genel Türkçe mesaj', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(new Response('Bad Gateway', { status: 502 })) + await expect(api.get('/health')).rejects.toMatchObject({ + status: 502, + message: 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.', + }) + }) + + it('ağ hatası → code "network"', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) + await expect(api.get('/health')).rejects.toMatchObject({ status: 0, code: 'network' }) + }) +}) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..296f06d --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,62 @@ +/** + * Backend ile konuşan tek yer. Tüm istekler `/api` altına gider + * (geliştirmede Vite proxy, canlıda aynı sunucu → CORS yok). + * + * Oturum, httpOnly cookie ile taşınır: JavaScript token'ı görmez, tarayıcı kendisi gönderir. + */ +import type { ApiErrorBody } from '@/types/ApiErrorBody' + +/** Backend'in döndürdüğü hata, Türkçe mesajıyla. Arayüzde doğrudan `error.message` gösterilir. */ +export class ApiError extends Error { + readonly status: number + /** `unauthorized`, `bad_request`, `conflict`, `not_found`, `network`, ... */ + readonly code: string + + constructor(status: number, code: string, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } + + get isUnauthorized() { + return this.status === 401 + } +} + +const NETWORK_MESSAGE = 'Sunucuya ulaşılamadı. İnternet bağlantınızı kontrol edip tekrar deneyin.' +const UNKNOWN_MESSAGE = 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.' + +type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +async function request(method: Method, path: string, body?: unknown): Promise { + let res: Response + try { + res = await fetch(`/api${path}`, { + method, + credentials: 'same-origin', + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch { + throw new ApiError(0, 'network', NETWORK_MESSAGE) + } + + if (res.status === 204) return undefined as T + + const data: unknown = await res.json().catch(() => null) + + if (!res.ok) { + const detail = (data as Partial | null)?.error + throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE) + } + return data as T +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body ?? {}), + put: (path: string, body: unknown) => request('PUT', path, body), + patch: (path: string, body: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path), +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..72935c1 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,60 @@ +/** + * Oturum durumu ve giriş / kayıt / çıkış işlemleri. + * + * "Kim giriş yapmış?" bilgisinin tek kaynağı `useMe()`: + * - `undefined` → henüz kontrol ediliyor + * - `null` → oturum yok + * - `MeResponse`→ giriş yapılmış + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { LoginRequest } from '@/types/LoginRequest' +import type { MeResponse } from '@/types/MeResponse' +import type { RegisterRequest } from '@/types/RegisterRequest' +import { api, ApiError } from './api' +import { queryKeys } from './query' + +async function fetchMe(): Promise { + try { + return await api.get('/auth/me') + } catch (e) { + // Oturum yoksa bu bir hata değil, bilinen bir durum + if (e instanceof ApiError && e.isUnauthorized) return null + throw e + } +} + +export function useMe() { + return useQuery({ + queryKey: queryKeys.me, + queryFn: fetchMe, + staleTime: 5 * 60_000, + }) +} + +export function useLogin() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: LoginRequest) => api.post('/auth/login', body), + onSuccess: (me) => qc.setQueryData(queryKeys.me, me), + }) +} + +export function useRegister() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: RegisterRequest) => api.post('/auth/register', body), + onSuccess: (me) => qc.setQueryData(queryKeys.me, me), + }) +} + +export function useLogout() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/auth/logout'), + onSettled: () => { + // Başka kullanıcının verisi ekranda/önbellekte kalmasın + qc.clear() + qc.setQueryData(queryKeys.me, null) + }, + }) +} diff --git a/frontend/src/lib/groups.ts b/frontend/src/lib/groups.ts index 1f918cf..7dcc26f 100644 --- a/frontend/src/lib/groups.ts +++ b/frontend/src/lib/groups.ts @@ -3,8 +3,10 @@ * Renk asla tek başına anlam taşımaz: her zaman emoji/ikon + etiketle birlikte kullanılır. */ import { BookOpen, House, ShoppingBag, Zap, type LucideIcon } from 'lucide-react' +import type { Group } from '@/types/Group' -export type GroupCode = 'needs' | 'wants' | 'culture' | 'unexpected' +/** Backend'den üretilen tip (ts-rs): `"needs" | "wants" | "culture" | "unexpected"` */ +export type GroupCode = Group export type GroupMeta = { code: GroupCode diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts new file mode 100644 index 0000000..7c6384b --- /dev/null +++ b/frontend/src/lib/query.ts @@ -0,0 +1,42 @@ +/** + * TanStack Query ayarları: sunucu verisinin önbelleği, yeniden deneme, yenileme. + */ +import { QueryCache, QueryClient } from '@tanstack/react-query' +import { ApiError } from './api' + +/** Önbellek anahtarları tek yerde (yazım hatası olmasın) */ +export const queryKeys = { + me: ['me'] as const, + health: ['health'] as const, + household: ['household'] as const, + categories: (includeInactive = false) => ['categories', { includeInactive }] as const, + paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const, +} + +/** 4xx hataları tekrar denemenin anlamı yok (yanlış istek / yetki); ağ ve 5xx için 2 kez dene. */ +function shouldRetry(failureCount: number, error: unknown) { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false + return failureCount < 2 +} + +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + // Herhangi bir istek 401 alırsa oturum bitmiş demektir → "me" boşaltılır, + // korumalı sayfalar (Adım 4) kullanıcıyı giriş ekranına yönlendirir. + if (error instanceof ApiError && error.isUnauthorized) { + queryClient.setQueryData(queryKeys.me, null) + } + }, + }), + defaultOptions: { + queries: { + retry: shouldRetry, + staleTime: 30_000, // 30 sn içinde aynı veri tekrar istenmez + refetchOnWindowFocus: true, // sekmeye dönünce güncel veri (ör. eşin eklediği harcama) + }, + mutations: { + retry: false, + }, + }, +}) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 566ab0c..02bdea4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.tsx' +import { queryClient } from '@/lib/query' import { ThemeProvider } from '@/lib/theme' createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/types/ApiErrorBody.ts b/frontend/src/types/ApiErrorBody.ts new file mode 100644 index 0000000..38bdd50 --- /dev/null +++ b/frontend/src/types/ApiErrorBody.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. +import type { ApiErrorDetail } from "./ApiErrorDetail"; + +/** + * Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }` + */ +export type ApiErrorBody = { error: ApiErrorDetail, }; diff --git a/frontend/src/types/ApiErrorDetail.ts b/frontend/src/types/ApiErrorDetail.ts new file mode 100644 index 0000000..7808055 --- /dev/null +++ b/frontend/src/types/ApiErrorDetail.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiErrorDetail = { +/** + * Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ... + */ +code: string, +/** + * Kullanıcıya gösterilecek Türkçe mesaj + */ +message: string, }; diff --git a/frontend/src/types/CatalogCreateRequest.ts b/frontend/src/types/CatalogCreateRequest.ts new file mode 100644 index 0000000..042ac78 --- /dev/null +++ b/frontend/src/types/CatalogCreateRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type CatalogCreateRequest = { group?: Group, name: string, emoji?: string, }; diff --git a/frontend/src/types/CatalogItem.ts b/frontend/src/types/CatalogItem.ts new file mode 100644 index 0000000..93e5e06 --- /dev/null +++ b/frontend/src/types/CatalogItem.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type CatalogItem = { id: string, group?: Group, name: string, emoji: string | null, sort_order: number, is_active: boolean, version: number, }; diff --git a/frontend/src/types/CatalogOrderRequest.ts b/frontend/src/types/CatalogOrderRequest.ts new file mode 100644 index 0000000..84d26c9 --- /dev/null +++ b/frontend/src/types/CatalogOrderRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type CatalogOrderRequest = { group?: Group, ids: Array, }; diff --git a/frontend/src/types/CatalogUpdateRequest.ts b/frontend/src/types/CatalogUpdateRequest.ts new file mode 100644 index 0000000..1c64495 --- /dev/null +++ b/frontend/src/types/CatalogUpdateRequest.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 CatalogUpdateRequest = { name?: string, emoji?: string, is_active?: boolean, version?: number, }; diff --git a/frontend/src/types/Group.ts b/frontend/src/types/Group.ts new file mode 100644 index 0000000..57747db --- /dev/null +++ b/frontend/src/types/Group.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 Group = "needs" | "wants" | "culture" | "unexpected"; diff --git a/frontend/src/types/HealthResponse.ts b/frontend/src/types/HealthResponse.ts new file mode 100644 index 0000000..70f3c1b --- /dev/null +++ b/frontend/src/types/HealthResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HealthResponse = { +/** + * `ok` veya `degraded` + */ +status: string, +/** + * `ok` veya `error` + */ +db: string, version: string, }; diff --git a/frontend/src/types/Household.ts b/frontend/src/types/Household.ts new file mode 100644 index 0000000..919216c --- /dev/null +++ b/frontend/src/types/Household.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Member } from "./Member"; + +export type Household = { id: string, name: string, members: Array, }; diff --git a/frontend/src/types/HouseholdSummary.ts b/frontend/src/types/HouseholdSummary.ts new file mode 100644 index 0000000..c573f79 --- /dev/null +++ b/frontend/src/types/HouseholdSummary.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 HouseholdSummary = { id: string, name: string, }; diff --git a/frontend/src/types/Invite.ts b/frontend/src/types/Invite.ts new file mode 100644 index 0000000..dc944c5 --- /dev/null +++ b/frontend/src/types/Invite.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 Invite = { id: string, code: string, expires_at: string, }; diff --git a/frontend/src/types/LoginRequest.ts b/frontend/src/types/LoginRequest.ts new file mode 100644 index 0000000..bdcbd52 --- /dev/null +++ b/frontend/src/types/LoginRequest.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 LoginRequest = { email: string, password: string, remember_me: boolean, }; diff --git a/frontend/src/types/MeResponse.ts b/frontend/src/types/MeResponse.ts new file mode 100644 index 0000000..351b2ae --- /dev/null +++ b/frontend/src/types/MeResponse.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HouseholdSummary } from "./HouseholdSummary"; +import type { User } from "./User"; + +export type MeResponse = { user: User, household: HouseholdSummary, }; diff --git a/frontend/src/types/Member.ts b/frontend/src/types/Member.ts new file mode 100644 index 0000000..f0ea1bd --- /dev/null +++ b/frontend/src/types/Member.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Role } from "./Role"; + +export type Member = { id: string, name: string, email: string, role: Role, }; diff --git a/frontend/src/types/RegisterRequest.ts b/frontend/src/types/RegisterRequest.ts new file mode 100644 index 0000000..22c88fb --- /dev/null +++ b/frontend/src/types/RegisterRequest.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 RegisterRequest = { name: string, email: string, password: string, household_name?: string, +/** + * Verilirse yeni hane kurulmaz, davet eden haneye `member` olarak katılınır. + */ +invite_code?: string, }; diff --git a/frontend/src/types/Role.ts b/frontend/src/types/Role.ts new file mode 100644 index 0000000..4da42d6 --- /dev/null +++ b/frontend/src/types/Role.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 Role = "owner" | "member"; diff --git a/frontend/src/types/User.ts b/frontend/src/types/User.ts new file mode 100644 index 0000000..dd12a04 --- /dev/null +++ b/frontend/src/types/User.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Role } from "./Role"; + +export type User = { id: string, name: string, email: string, role: Role, }; diff --git a/package.json b/package.json index ac5ad44..7d0b973 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "db:reset": "docker exec kakeibo-mongo mongosh --quiet --eval \"db.getSiblingDB('kakeibo_dev').dropDatabase(); db.adminCommand({listDatabases:1}).databases.filter(d => d.name.startsWith('kakeibo_test_')).forEach(d => db.getSiblingDB(d.name).dropDatabase()); print('kakeibo_dev ve test veritabanları sıfırlandı.')\"", "test": "cd backend && cargo test && cd .. && npm --prefix frontend test", "lint": "cd backend && cargo clippy && cd .. && npm --prefix frontend run lint", - "icons": "bash scripts/icons.sh" + "icons": "bash scripts/icons.sh", + "types": "cd backend && cargo test -q export_bindings" }, "devDependencies": { "concurrently": "^10.0.7"