From 4dbd2eb4414bfc352e90f0c1f37a654b41a8bab1 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 01:35:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(shared):=20zod=20=C5=9Femalar=C4=B1,=20T?= =?UTF-8?q?=C3=BCrk=C3=A7e=20arama=20ve=20TL/tarih=20bi=C3=A7imleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/i18n/tr.ts | 198 +++++++++++++++++++++++++++++++- src/lib/action-result.ts | 17 +++ src/shared/.gitkeep | 0 src/shared/format.test.ts | 29 +++++ src/shared/format.ts | 42 +++++++ src/shared/schemas/auth.ts | 10 ++ src/shared/schemas/common.ts | 36 ++++++ src/shared/schemas/user.test.ts | 73 ++++++++++++ src/shared/schemas/user.ts | 39 +++++++ src/shared/search.test.ts | 38 ++++++ src/shared/search.ts | 25 ++++ 11 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/lib/action-result.ts delete mode 100644 src/shared/.gitkeep create mode 100644 src/shared/format.test.ts create mode 100644 src/shared/format.ts create mode 100644 src/shared/schemas/auth.ts create mode 100644 src/shared/schemas/common.ts create mode 100644 src/shared/schemas/user.test.ts create mode 100644 src/shared/schemas/user.ts create mode 100644 src/shared/search.test.ts create mode 100644 src/shared/search.ts diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 777ae1d..9b583a2 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1,15 +1,207 @@ /** - * Arayüz metinleri (tr-TR). Tüm kullanıcıya görünen metinler burada toplanır. + * Arayüz metinleri (tr-TR). Kullanıcıya görünen tüm metinler burada toplanır. * Şimdilik tek dil; ileride çok dil gerekirse bu nesnenin şekli sözleşme olur. */ export const tr = { app: { name: "BÜTÇEM", tagline: "Aile bütçesi, birikim, ev stoğu ve akıllı alışveriş", - setupInProgress: "Kurulum sürüyor", - setupDescription: "Altyapı hazır. Giriş ekranı ve arayüz bir sonraki fazda eklenecek.", version: "Sürüm", }, + common: { + save: "Kaydet", + cancel: "Vazgeç", + close: "Kapat", + delete: "Sil", + edit: "Düzenle", + create: "Oluştur", + search: "Ara", + actions: "İşlemler", + loading: "Yükleniyor…", + comingSoon: "Yakında", + required: "Bu alan zorunlu", + yes: "Evet", + no: "Hayır", + previous: "Önceki", + next: "Sonraki", + noResults: "Sonuç bulunamadı.", + page: (current: number, total: number) => `Sayfa ${current} / ${total}`, + rowsPerPage: "Sayfa başına", + totalRows: (n: number) => `Toplam ${n} kayıt`, + showPassword: "Şifreyi göster", + hidePassword: "Şifreyi gizle", + genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + }, + theme: { + label: "Tema", + light: "Açık", + dark: "Koyu", + system: "Sistem", + }, + nav: { + main: "Menü", + dashboard: "Ana sayfa", + transactions: "İşlemler", + accounts: "Hesaplar", + budget: "Bütçe", + planned: "Planlı işlemler", + contacts: "Cari hesaplar", + savings: "Birikim", + stock: "Ev stoğu", + shopping: "Alışveriş", + reports: "Raporlar", + settings: "Ayarlar", + more: "Daha fazla", + admin: "Yönetim", + adminUsers: "Kullanıcılar", + adminHouseholds: "Haneler", + toggleSidebar: "Menüyü aç/kapat", + }, + household: { + label: "Aktif hane", + select: "Hane seçin", + roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" }, + }, + userMenu: { + open: "Kullanıcı menüsü", + profile: "Profil", + logout: "Çıkış yap", + loggedOut: "Çıkış yapıldı.", + }, + roles: { admin: "Yönetici", user: "Kullanıcı" }, + quickAdd: { + button: "Hızlı ekle", + title: "Hızlı ekle", + description: "Ne eklemek istiyorsunuz?", + expense: "Gider", + income: "Gelir", + transfer: "Transfer", + expenseHint: "Harcama, fatura, alışveriş", + incomeHint: "Maaş, ek gelir, iade", + transferHint: "Hesaplar ve cariler arası", + notYet: "İşlem formu Faz 6'da eklenecek.", + }, + auth: { + loginTitle: "Giriş yap", + loginDescription: "Hesabınıza kullanıcı adı veya e-posta ile giriş yapın.", + identifier: "Kullanıcı adı veya e-posta", + identifierPlaceholder: "ornek veya ornek@eposta.com", + password: "Şifre", + submit: "Giriş yap", + submitting: "Giriş yapılıyor…", + invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.", + noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.", + identifierRequired: "Kullanıcı adı veya e-posta girin", + passwordRequired: "Şifre girin", + }, + dashboard: { + title: "Ana sayfa", + welcome: (name: string) => `Hoş geldin, ${name}`, + welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.", + cards: { + monthIncome: "Bu ay gelir", + monthExpense: "Bu ay gider", + monthRemaining: "Kalan", + totalBalance: "Toplam hesap bakiyesi", + cardDebt: "Kart borçları", + netWorth: "Net varlık", + }, + noData: "Henüz veri yok", + }, + adminUsers: { + title: "Kullanıcılar", + description: "Kullanıcı oluşturma, rol, şifre ve erişim yönetimi.", + newUser: "Yeni kullanıcı", + searchPlaceholder: "Ad, kullanıcı adı veya e-posta ara…", + columns: { + name: "Ad soyad", + username: "Kullanıcı adı", + email: "E-posta", + role: "Rol", + status: "Durum", + createdAt: "Oluşturulma", + }, + status: { active: "Aktif", banned: "Askıda" }, + actions: { + edit: "Düzenle", + changeRole: "Rolü değiştir", + makeAdmin: "Yönetici yap", + makeUser: "Kullanıcı yap", + resetPassword: "Şifre sıfırla", + ban: "Askıya al", + unban: "Askıyı kaldır", + delete: "Sil", + }, + form: { + createTitle: "Yeni kullanıcı", + createDescription: "Kullanıcı kendi hesabını açamaz; hesapları buradan oluşturun.", + editTitle: "Kullanıcıyı düzenle", + editDescription: "Ad, kullanıcı adı, e-posta ve rol bilgilerini güncelleyin.", + name: "Ad soyad", + username: "Kullanıcı adı", + usernameHint: "Küçük harf, rakam, nokta, alt çizgi; 3–30 karakter.", + email: "E-posta", + emailHint: "İsteğe bağlı. Boş bırakılırsa giriş kullanıcı adıyla yapılır.", + role: "Sistem rolü", + password: "Geçici şifre", + passwordHint: "En az 8 karakter. Kullanıcıya güvenli bir kanaldan iletin.", + }, + password: { + title: "Şifre sıfırla", + description: (name: string) => `${name} için yeni bir şifre belirleyin.`, + newPassword: "Yeni şifre", + confirm: "Şifreyi belirle", + }, + ban: { + title: "Kullanıcıyı askıya al", + description: (name: string) => `${name} giriş yapamayacak ve açık oturumları kapatılacak.`, + reason: "Neden (isteğe bağlı)", + confirm: "Askıya al", + }, + remove: { + title: "Kullanıcı silinsin mi?", + description: (name: string) => `${name} kalıcı olarak silinecek. Bu işlem geri alınamaz.`, + confirm: "Kalıcı olarak sil", + }, + toast: { + created: (name: string) => `${name} oluşturuldu.`, + updated: (name: string) => `${name} güncellendi.`, + roleChanged: (name: string) => `${name} için rol değiştirildi.`, + passwordSet: (name: string) => `${name} için şifre güncellendi.`, + banned: (name: string) => `${name} askıya alındı.`, + unbanned: (name: string) => `${name} için askı kaldırıldı.`, + removed: (name: string) => `${name} silindi.`, + }, + guards: { + self: "Kendi hesabınız için bu işlem yapılamaz.", + lastAdmin: "Sistemdeki son yönetici silinemez veya rolü düşürülemez.", + }, + errors: { + usernameTaken: "Bu kullanıcı adı kullanılıyor.", + emailTaken: "Bu e-posta adresi kullanılıyor.", + checkFields: "Lütfen işaretli alanları düzeltin.", + }, + you: "Siz", + noEmail: "—", + }, + validation: { + nameMin: "Ad en az 2 karakter olmalı", + nameMax: "Ad en fazla 80 karakter olabilir", + usernameFormat: "Yalnızca küçük harf, rakam, nokta ve alt çizgi; 3–30 karakter", + email: "Geçerli bir e-posta adresi girin", + passwordMin: "Şifre en az 8 karakter olmalı", + passwordMax: "Şifre en fazla 128 karakter olabilir", + banReasonMax: "Neden en fazla 200 karakter olabilir", + }, + errors: { + notFoundTitle: "Sayfa bulunamadı", + notFoundDescription: "Aradığınız sayfa taşınmış veya hiç var olmamış olabilir.", + backHome: "Ana sayfaya dön", + errorTitle: "Bir şeyler ters gitti", + errorDescription: "Sayfa yüklenirken bir hata oluştu. Tekrar deneyebilirsiniz.", + retry: "Tekrar dene", + reference: "Hata kodu", + }, } as const; export type Messages = typeof tr; diff --git a/src/lib/action-result.ts b/src/lib/action-result.ts new file mode 100644 index 0000000..8dffab9 --- /dev/null +++ b/src/lib/action-result.ts @@ -0,0 +1,17 @@ +/** + * Server Action ve servis dönüş tipi (Faz 3+). Hata durumunda kullanıcıya gösterilecek + * genel mesaj ve isteğe bağlı alan bazlı hatalar döner; iç detay asla sızdırılmaz. + */ +export type ActionResult = + | { ok: true; data: T } + | { ok: false; error: string; fieldErrors?: Partial> }; + +export const ok = (data: T): ActionResult => ({ ok: true, data }); +export const fail = ( + error: string, + fieldErrors?: Partial>, +): ActionResult => ({ + ok: false, + error, + fieldErrors, +}); diff --git a/src/shared/.gitkeep b/src/shared/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/format.test.ts b/src/shared/format.test.ts new file mode 100644 index 0000000..79ee47d --- /dev/null +++ b/src/shared/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { formatDate, formatDateTime, formatMoney } from "./format"; + +// Intl çıktısındaki boşluklar (ör. U+00A0) ortama göre değişebilir; karşılaştırmada normalleştir. +const norm = (s: string) => s.replace(/\s/g, " "); + +describe("formatMoney", () => { + it("kuruşu TL olarak Türkçe biçimde gösterir", () => { + expect(norm(formatMoney(125050))).toBe("₺1.250,50"); + expect(norm(formatMoney(0))).toBe("₺0,00"); + expect(norm(formatMoney(-9999))).toBe("-₺99,99"); + }); + + it("tamsayı olmayan tutarı reddeder", () => { + expect(() => formatMoney(12.5)).toThrow(TypeError); + }); +}); + +describe("formatDate", () => { + it("İstanbul saatine göre gün sınırını uygular", () => { + // 31.12.2026 21:30 UTC = 01.01.2027 00:30 İstanbul (UTC+3) + expect(formatDate("2026-12-31T21:30:00Z")).toBe("01.01.2027"); + expect(formatDate("2026-12-31T20:59:00Z")).toBe("31.12.2026"); + }); + + it("tarih-saat biçimi", () => { + expect(norm(formatDateTime("2026-11-05T07:05:00Z"))).toBe("05.11.2026 10:05"); + }); +}); diff --git a/src/shared/format.ts b/src/shared/format.ts new file mode 100644 index 0000000..10e9014 --- /dev/null +++ b/src/shared/format.ts @@ -0,0 +1,42 @@ +/** + * Görüntüleme biçimleri (tr-TR, Europe/Istanbul). Sunucu ve istemci aynı sonucu üretir + * çünkü yerel ayar ve saat dilimi her zaman açıkça verilir (hydration uyumsuzluğu olmaz). + */ +export const LOCALE = "tr-TR"; +export const TIME_ZONE = "Europe/Istanbul"; + +const moneyFmt = new Intl.NumberFormat(LOCALE, { style: "currency", currency: "TRY" }); + +/** Kuruş cinsinden tamsayı tutarı TL olarak biçimlendirir: 125050 → "₺1.250,50". */ +export function formatMoney(amountMinor: number): string { + if (!Number.isSafeInteger(amountMinor)) { + throw new TypeError(`amountMinor tamsayı olmalı: ${amountMinor}`); + } + return moneyFmt.format(amountMinor / 100); +} + +const dateFmt = new Intl.DateTimeFormat(LOCALE, { + timeZone: TIME_ZONE, + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +const dateTimeFmt = new Intl.DateTimeFormat(LOCALE, { + timeZone: TIME_ZONE, + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy" biçiminde gösterir. */ +export function formatDate(date: Date | string): string { + return dateFmt.format(new Date(date)); +} + +/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy HH:mm" biçiminde gösterir. */ +export function formatDateTime(date: Date | string): string { + return dateTimeFmt.format(new Date(date)); +} diff --git a/src/shared/schemas/auth.ts b/src/shared/schemas/auth.ts new file mode 100644 index 0000000..d84f753 --- /dev/null +++ b/src/shared/schemas/auth.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; + +/** Giriş formu: kullanıcı adı veya e-posta + şifre. Biçim kontrolü sunucuda yapılır. */ +export const loginSchema = z.object({ + identifier: z.string().trim().min(1, tr.auth.identifierRequired), + password: z.string().min(1, tr.auth.passwordRequired), +}); + +export type LoginInput = z.infer; diff --git a/src/shared/schemas/common.ts b/src/shared/schemas/common.ts new file mode 100644 index 0000000..6b90e49 --- /dev/null +++ b/src/shared/schemas/common.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; + +const v = tr.validation; + +/** Sistem rolleri (Better Auth admin plugin). */ +export const SYSTEM_ROLES = ["admin", "user"] as const; +export const systemRoleSchema = z.enum(SYSTEM_ROLES); +export type SystemRole = z.infer; + +export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/; + +export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax); + +/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */ +export const usernameSchema = z + .string() + .trim() + .transform((s) => s.toLocaleLowerCase("tr-TR")) + .pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat)); + +export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email)); + +/** Boş bırakılabilen e-posta: "" → undefined. */ +export const optionalEmailSchema = z + .string() + .trim() + .transform((s) => (s === "" ? undefined : s.toLowerCase())) + .pipe(z.email(v.email).optional()); + +export const passwordSchema = z.string().min(8, v.passwordMin).max(128, v.passwordMax); + +/** Girilen değer e-posta mı (giriş sırasında e-posta/kullanıcı adı yöntemini seçmek için)? */ +export function looksLikeEmail(value: string): boolean { + return value.includes("@"); +} diff --git a/src/shared/schemas/user.test.ts b/src/shared/schemas/user.test.ts new file mode 100644 index 0000000..a1d2630 --- /dev/null +++ b/src/shared/schemas/user.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { loginSchema } from "./auth"; +import { looksLikeEmail, usernameSchema } from "./common"; +import { banUserSchema, userCreateSchema, userUpdateSchema } from "./user"; + +const base = { + name: "Ayşe Yılmaz", + username: "ayse", + email: "", + role: "user", + password: "gizli-1234", +}; + +describe("usernameSchema", () => { + it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => { + expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k"); + expect(usernameSchema.parse("Ali_2")).toBe("ali_2"); + }); + + it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => { + for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) { + expect(usernameSchema.safeParse(bad).success, bad).toBe(false); + } + }); +}); + +describe("userCreateSchema", () => { + it("boş e-postayı undefined yapar", () => { + const out = userCreateSchema.parse(base); + expect(out.email).toBeUndefined(); + expect(out.name).toBe("Ayşe Yılmaz"); + }); + + it("e-postayı küçük harfe çevirir ve doğrular", () => { + expect(userCreateSchema.parse({ ...base, email: " Ayse@Ornek.COM " }).email).toBe( + "ayse@ornek.com", + ); + expect(userCreateSchema.safeParse({ ...base, email: "gecersiz" }).success).toBe(false); + }); + + it("kısa şifreyi ve geçersiz rolü reddeder", () => { + expect(userCreateSchema.safeParse({ ...base, password: "1234567" }).success).toBe(false); + expect(userCreateSchema.safeParse({ ...base, role: "owner" }).success).toBe(false); + }); + + it("güncelleme şemasında şifre yoktur", () => { + const { password: _password, ...rest } = base; + expect(userUpdateSchema.parse(rest)).not.toHaveProperty("password"); + }); +}); + +describe("banUserSchema", () => { + it("boş nedeni undefined yapar, uzun nedeni reddeder", () => { + expect(banUserSchema.parse({ reason: " " }).reason).toBeUndefined(); + expect(banUserSchema.safeParse({ reason: "x".repeat(201) }).success).toBe(false); + }); +}); + +describe("loginSchema", () => { + it("boş alanları Türkçe mesajla reddeder", () => { + const r = loginSchema.safeParse({ identifier: " ", password: "" }); + expect(r.success).toBe(false); + expect(r.error?.issues.map((i) => i.message)).toEqual([ + "Kullanıcı adı veya e-posta girin", + "Şifre girin", + ]); + }); + + it("e-posta / kullanıcı adı ayrımı", () => { + expect(looksLikeEmail("ayse@ornek.com")).toBe(true); + expect(looksLikeEmail("ayse")).toBe(false); + }); +}); diff --git a/src/shared/schemas/user.ts b/src/shared/schemas/user.ts new file mode 100644 index 0000000..2c9c4ef --- /dev/null +++ b/src/shared/schemas/user.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { + nameSchema, + optionalEmailSchema, + passwordSchema, + systemRoleSchema, + usernameSchema, +} from "./common"; + +/** Admin: yeni kullanıcı. E-posta isteğe bağlı (çocuklar vb. için kullanıcı adıyla giriş). */ +export const userCreateSchema = z.object({ + name: nameSchema, + username: usernameSchema, + email: optionalEmailSchema, + role: systemRoleSchema, + password: passwordSchema, +}); + +/** Admin: kullanıcı bilgisi güncelleme (şifre ayrı akışta). */ +export const userUpdateSchema = userCreateSchema.omit({ password: true }); + +export const setPasswordSchema = z.object({ password: passwordSchema }); + +export const banUserSchema = z.object({ + reason: z + .string() + .trim() + .max(200, tr.validation.banReasonMax) + .transform((s) => (s === "" ? undefined : s)) + .optional(), +}); + +export type UserCreateInput = z.input; +export type UserCreate = z.output; +export type UserUpdateInput = z.input; +export type UserUpdate = z.output; +export type SetPasswordInput = z.input; +export type BanUserInput = z.input; diff --git a/src/shared/search.test.ts b/src/shared/search.test.ts new file mode 100644 index 0000000..305e3ca --- /dev/null +++ b/src/shared/search.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { compareTr, foldTr, includesTr } from "./search"; + +describe("foldTr", () => { + it("Türkçe harfleri ve büyük/küçük farkını normalize eder", () => { + expect(foldTr("İrem")).toBe("irem"); + expect(foldTr("IŞIK")).toBe("isik"); + expect(foldTr("Ayşe Çağrı Öztürk Ünal")).toBe("ayse cagri ozturk unal"); + }); +}); + +describe("includesTr", () => { + it("aksansız ve büyük harfli aramayla eşleşir", () => { + expect(includesTr("İrem Güneş", "irem")).toBe(true); + expect(includesTr("İrem Güneş", "GUNES")).toBe(true); + expect(includesTr("Ayşe", "ayse")).toBe(true); + expect(includesTr("Ayşe", "mehmet")).toBe(false); + }); + + it("boş arama her şeyle eşleşir, boş değer hiçbir aramayla eşleşmez", () => { + expect(includesTr("x", " ")).toBe(true); + expect(includesTr(undefined, "a")).toBe(false); + }); +}); + +describe("compareTr", () => { + it("Türk alfabesine göre sıralar", () => { + expect(["Zeynep", "Çiğdem", "Can", "Şule", "Selin", "İrem", "Ilgın"].sort(compareTr)).toEqual([ + "Can", + "Çiğdem", + "Ilgın", + "İrem", + "Selin", + "Şule", + "Zeynep", + ]); + }); +}); diff --git a/src/shared/search.ts b/src/shared/search.ts new file mode 100644 index 0000000..9b75410 --- /dev/null +++ b/src/shared/search.ts @@ -0,0 +1,25 @@ +/** + * Türkçe arama normalizasyonu: büyük/küçük harf ve aksan farkını yok sayar. + * "İrem" ↔ "irem", "IŞIK" ↔ "isik", "Ayşe" ↔ "ayse", "Çağrı" ↔ "cagri". + */ +export function foldTr(value: string): string { + return value + .toLocaleLowerCase("tr-TR") + .replace(/ı/g, "i") + .normalize("NFD") + .replace(/\p{M}/gu, "") + .trim(); +} + +/** `haystack` içinde `needle` (normalize edilmiş) geçiyor mu? */ +export function includesTr(haystack: string | undefined | null, needle: string): boolean { + if (!needle.trim()) return true; + return haystack ? foldTr(haystack).includes(foldTr(needle)) : false; +} + +const collator = new Intl.Collator("tr-TR", { sensitivity: "base" }); + +/** Türkçe alfabetik sıralama karşılaştırıcısı (Ç, Ğ, İ, Ö, Ş, Ü doğru yerde). */ +export function compareTr(a: string, b: string): number { + return collator.compare(a, b); +}