feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 00:39:56 +03:00
parent f9be08114d
commit 906f05f62f
41 changed files with 558 additions and 49 deletions
+3
View File
@@ -57,12 +57,15 @@ npm run db:seed
npm run db:reset # kakeibo_dev + artık kakeibo_test_* veritabanlarını siler 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 test # backend (cargo test, MongoDB açık olmalı) + frontend (vitest)
npm run lint # clippy + oxlint 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 icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonları (rsvg-convert + magick)
``` ```
- `npm run dev` öncesinde DB otomatik başlar (`predev`). - `npm run dev` öncesinde DB otomatik başlar (`predev`).
- Yerel MongoDB host portu **27020** (27017 sistem mongod, 27018/27019 diğer projeler). - 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 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ü). - 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`. - 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` - API örnekleri: `backend/api.http`
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları). - Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
+3
View File
@@ -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 }
+32
View File
@@ -1298,6 +1298,7 @@ dependencies = [
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ts-rs",
"uuid", "uuid",
"validator", "validator",
] ]
@@ -2429,6 +2430,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.21" version = "2.0.21"
@@ -2656,6 +2666,28 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "typed-builder" name = "typed-builder"
version = "0.22.0" version = "0.22.0"
+1
View File
@@ -17,6 +17,7 @@ time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] } tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
ts-rs = "12.0.1"
uuid = { version = "1.29.2", features = ["v4", "v7"] } uuid = { version = "1.29.2", features = ["v4", "v7"] }
validator = { version = "0.21.0", features = ["derive"] } validator = { version = "0.21.0", features = ["derive"] }
+25 -6
View File
@@ -6,7 +6,24 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, 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)] #[derive(Debug, thiserror::Error)]
pub enum AppError { pub enum AppError {
@@ -59,11 +76,13 @@ impl IntoResponse for AppError {
self.to_string() self.to_string()
}; };
( let body = ApiErrorBody {
status, error: ApiErrorDetail {
Json(json!({ "error": { "code": code, "message": message } })), code: code.into(),
) message,
.into_response() },
};
(status, Json(body)).into_response()
} }
} }
+3 -1
View File
@@ -1,9 +1,11 @@
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda. //! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum Group { pub enum Group {
/// 🏠 Zorunlu /// 🏠 Zorunlu
Needs, Needs,
+3 -1
View File
@@ -2,9 +2,11 @@
use mongodb::bson::Uuid; use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum Role { pub enum Role {
Owner, Owner,
Member, Member,
+11 -5
View File
@@ -5,6 +5,7 @@ use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use mongodb::bson::{Uuid, doc}; use mongodb::bson::{Uuid, doc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate; use validator::Validate;
use crate::{ use crate::{
@@ -26,7 +27,8 @@ use crate::{
// ---------- İstek / yanıt yapıları ---------- // ---------- İstek / yanıt yapıları ----------
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate, TS)]
#[ts(export, optional_fields)]
pub struct RegisterRequest { pub struct RegisterRequest {
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))] #[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
name: String, name: String,
@@ -41,7 +43,8 @@ pub struct RegisterRequest {
invite_code: Option<String>, invite_code: Option<String>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct LoginRequest { pub struct LoginRequest {
#[validate(length(min = 1, message = "E-posta gerekli"))] #[validate(length(min = 1, message = "E-posta gerekli"))]
email: String, email: String,
@@ -51,13 +54,15 @@ pub struct LoginRequest {
remember_me: bool, remember_me: bool,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export)]
pub struct MeResponse { pub struct MeResponse {
user: UserDto, user: UserDto,
household: HouseholdDto, household: HouseholdDto,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "User")]
struct UserDto { struct UserDto {
id: String, id: String,
name: String, name: String,
@@ -65,7 +70,8 @@ struct UserDto {
role: Role, role: Role,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "HouseholdSummary")]
struct HouseholdDto { struct HouseholdDto {
id: String, id: String,
name: String, name: String,
+10 -4
View File
@@ -16,6 +16,7 @@ use axum::{
}; };
use mongodb::bson::Uuid; use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate; use validator::Validate;
use crate::{ use crate::{
@@ -35,7 +36,8 @@ pub struct ListQuery {
include_inactive: bool, include_inactive: bool,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogCreateRequest", optional_fields)]
pub struct CreateRequest { pub struct CreateRequest {
group: Option<Group>, group: Option<Group>,
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
@@ -44,7 +46,8 @@ pub struct CreateRequest {
emoji: Option<String>, emoji: Option<String>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogUpdateRequest", optional_fields)]
pub struct UpdateRequest { pub struct UpdateRequest {
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))] #[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: Option<String>, name: Option<String>,
@@ -54,17 +57,20 @@ pub struct UpdateRequest {
version: Option<i32>, version: Option<i32>,
} }
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogOrderRequest", optional_fields)]
pub struct OrderRequest { pub struct OrderRequest {
group: Option<Group>, group: Option<Group>,
#[validate(length(max = 200, message = "Liste çok uzun"))] #[validate(length(max = 200, message = "Liste çok uzun"))]
ids: Vec<String>, ids: Vec<String>,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "CatalogItem")]
pub struct ItemDto { pub struct ItemDto {
id: String, id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
group: Option<Group>, group: Option<Group>,
name: String, name: String,
emoji: Option<String>, emoji: Option<String>,
+23 -4
View File
@@ -1,23 +1,42 @@
use axum::{Json, extract::State, http::StatusCode}; use axum::{Json, extract::State, http::StatusCode};
use serde_json::{Value, json}; use serde::Serialize;
use ts_rs::TS;
use crate::{db, state::AppState}; 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). /// Sağlık kontrolü (Dokploy health check bunu kullanır).
/// DB erişilemezse 503 döner. /// DB erişilemezse 503 döner.
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<Value>) { pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
match db::ping(&state.db).await { match db::ping(&state.db).await {
Ok(()) => ( Ok(()) => (
StatusCode::OK, StatusCode::OK,
Json(json!({ "status": "ok", "db": "ok", "version": version })), Json(HealthResponse {
status: "ok",
db: "ok",
version,
}),
), ),
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "health: veritabanına erişilemiyor"); tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
( (
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "degraded", "db": "error", "version": version })), Json(HealthResponse {
status: "degraded",
db: "error",
version,
}),
) )
} }
} }
+7 -3
View File
@@ -11,6 +11,7 @@ use axum::{
}; };
use mongodb::bson::{DateTime, Uuid, doc}; use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{ use crate::{
auth::AuthUser, auth::AuthUser,
@@ -21,14 +22,16 @@ use crate::{
state::AppState, state::AppState,
}; };
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "Household")]
pub struct HouseholdResponse { pub struct HouseholdResponse {
id: String, id: String,
name: String, name: String,
members: Vec<MemberDto>, members: Vec<MemberDto>,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "Member")]
struct MemberDto { struct MemberDto {
id: String, id: String,
name: String, name: String,
@@ -36,7 +39,8 @@ struct MemberDto {
role: Role, role: Role,
} }
#[derive(Serialize)] #[derive(Serialize, TS)]
#[ts(export, rename = "Invite")]
pub struct InviteDto { pub struct InviteDto {
id: String, id: String,
code: String, code: String,
+4 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ## 📌 Alınan Kararlar
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. - 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. - 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 ₺). - `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). - 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. - 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 ## ❓ Açık Konular
@@ -51,3 +53,4 @@
### 2026-11-12 — Faz 2 ### 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 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 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.
+2 -2
View File
@@ -34,6 +34,6 @@
## Faz 2 Adımları ## 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 - ✅ 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ı - ✅ 2. Logo ve uygulama ikonu (SVG), favicon, PWA/Apple ikonları
- 🔄 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu - ✅ 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ü - 🔄 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 - ⏳ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
+33
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@tailwindcss/vite": "^4.3.5", "@tailwindcss/vite": "^4.3.5",
"@tanstack/react-query": "^5.107.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"lucide-react": "^1.72.0", "lucide-react": "^1.72.0",
@@ -3356,6 +3357,38 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "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": { "node_modules/@ts-morph/common": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@tailwindcss/vite": "^4.3.5", "@tailwindcss/vite": "^4.3.5",
"@tanstack/react-query": "^5.107.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.2", "cn": "^0.5.2",
"lucide-react": "^1.72.0", "lucide-react": "^1.72.0",
+73 -17
View File
@@ -1,27 +1,25 @@
import { useEffect, useState } from 'react' import { useState, type FormEvent } from 'react'
import { Plus } from 'lucide-react' import { useQuery } from '@tanstack/react-query'
import { LogIn, LogOut, Plus } from 'lucide-react'
import { Logo } from '@/components/logo' import { Logo } from '@/components/logo'
import { ThemeToggle } from '@/components/theme-toggle' import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatDate, formatMoney, formatQuantity, toIsoDate } from '@/lib/format' 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' import { GROUPS } from '@/lib/groups'
import { queryKeys } from '@/lib/query'
type Health = { status: string; db: string; version: string } import type { HealthResponse } from '@/types/HealthResponse'
// Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi. // Faz 2 – Adım 1 geçici ekranı: tasarım sistemi önizlemesi.
// Adım 4'te gerçek layout ve sayfalarla değiştirilecek. // Adım 4'te gerçek layout ve sayfalarla değiştirilecek.
export default function App() { export default function App() {
const [health, setHealth] = useState<Health | null>(null) const health = useQuery({
const [error, setError] = useState(false) queryKey: queryKeys.health,
queryFn: () => api.get<HealthResponse>('/health'),
})
const [today] = useState(() => toIsoDate(new Date())) const [today] = useState(() => toIsoDate(new Date()))
useEffect(() => {
fetch('/api/health')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then(setHealth)
.catch(() => setError(true))
}, [])
return ( return (
<main className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8"> <main className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8">
<header className="flex flex-wrap items-center justify-between gap-4"> <header className="flex flex-wrap items-center justify-between gap-4">
@@ -35,15 +33,17 @@ export default function App() {
</header> </header>
<p className="text-sm"> <p className="text-sm">
{error && <span className="text-expense">● Backend'e ulaşılamadı</span>} {health.isError && <span className="text-expense">● {health.error.message}</span>}
{!error && !health && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>} {health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
{health && ( {health.data && (
<span className="text-income"> <span className="text-income">
● Backend çalışıyor (v{health.version}, DB: {health.db}) ● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
</span> </span>
)} )}
</p> </p>
<SessionPanel />
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-xl font-semibold">Ana gruplar</h2> <h2 className="text-xl font-semibold">Ana gruplar</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -102,3 +102,59 @@ export default function App() {
</main> </main>
) )
} }
/** 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 (
<section className="space-y-3 rounded-xl border bg-card p-4">
<h2 className="text-xl font-semibold">Oturum (geçici test paneli)</h2>
{me.isPending && <p className="text-muted-foreground">Oturum kontrol ediliyor…</p>}
{me.data && (
<div className="flex flex-wrap items-center justify-between gap-3">
<p>
👋 <strong>{me.data.user.name}</strong> · {me.data.household.name} ·{' '}
<span className="text-muted-foreground">{me.data.user.role === 'owner' ? 'Hane sahibi' : 'Üye'}</span>
</p>
<Button variant="outline" onClick={() => logout.mutate()} disabled={logout.isPending}>
<LogOut /> Çıkış
</Button>
</div>
)}
{me.data === null && (
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
<input
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
type="email"
placeholder="E-posta"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
className="h-11 rounded-md border bg-background px-3 sm:flex-1"
type="password"
placeholder="Şifre"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="submit" disabled={login.isPending}>
<LogIn /> Giriş
</Button>
</form>
)}
{login.isError && <p className="text-sm text-expense">{login.error.message}</p>}
</section>
)
}
+65
View File
@@ -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<typeof vi.fn>
}
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' })
})
})
+62
View File
@@ -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<T>(method: Method, path: string, body?: unknown): Promise<T> {
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<ApiErrorBody> | null)?.error
throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE)
}
return data as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T = void>(path: string) => request<T>('DELETE', path),
}
+60
View File
@@ -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<MeResponse | null> {
try {
return await api.get<MeResponse>('/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<MeResponse>('/auth/login', body),
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
})
}
export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post<void>('/auth/logout'),
onSettled: () => {
// Başka kullanıcının verisi ekranda/önbellekte kalmasın
qc.clear()
qc.setQueryData(queryKeys.me, null)
},
})
}
+3 -1
View File
@@ -3,8 +3,10 @@
* Renk asla tek başına anlam taşımaz: her zaman emoji/ikon + etiketle birlikte kullanılır. * 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 { 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 = { export type GroupMeta = {
code: GroupCode code: GroupCode
+42
View File
@@ -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,
},
},
})
+7 -3
View File
@@ -1,13 +1,17 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { queryClient } from '@/lib/query'
import { ThemeProvider } from '@/lib/theme' import { ThemeProvider } from '@/lib/theme'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <QueryClientProvider client={queryClient}>
<App /> <ThemeProvider>
</ThemeProvider> <App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
View File
+7
View File
@@ -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, };
+11
View File
@@ -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, };
@@ -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, };
+4
View File
@@ -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, };
@@ -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<string>, };
@@ -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, };
+3
View File
@@ -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";
+11
View File
@@ -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, };
+4
View File
@@ -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<Member>, };
+3
View File
@@ -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, };
+3
View File
@@ -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, };
+3
View File
@@ -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, };
+5
View File
@@ -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, };
+4
View File
@@ -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, };
+7
View File
@@ -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, };
+3
View File
@@ -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";
+4
View File
@@ -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, };
+2 -1
View File
@@ -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ı.')\"", "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", "test": "cd backend && cargo test && cd .. && npm --prefix frontend test",
"lint": "cd backend && cargo clippy && cd .. && npm --prefix frontend run lint", "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": { "devDependencies": {
"concurrently": "^10.0.7" "concurrently": "^10.0.7"