From 380ddaf303683c571a634f11127b42ffaf7d7c08 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:57:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(categories):=20kategori=20a=C4=9Fac=C4=B1,?= =?UTF-8?q?=20varsay=C4=B1lan=20T=C3=BCrk=C3=A7e=20set=20ve=20y=C3=B6netim?= =?UTF-8?q?=20ekran=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/features/categories/actions.ts | 65 ++++ .../components/categories-manager.tsx | 238 +++++++++++++ .../components/category-form-dialog.tsx | 217 ++++++++++++ src/features/categories/defaults.ts | 117 +++++++ src/features/categories/service.int.test.ts | 159 +++++++++ src/features/categories/service.ts | 331 ++++++++++++++++++ src/features/categories/tree.test.ts | 121 +++++++ src/features/categories/tree.ts | 163 +++++++++ src/models/category.ts | 38 ++ src/shared/schemas/category.ts | 20 ++ 10 files changed, 1469 insertions(+) create mode 100644 src/features/categories/actions.ts create mode 100644 src/features/categories/components/categories-manager.tsx create mode 100644 src/features/categories/components/category-form-dialog.tsx create mode 100644 src/features/categories/defaults.ts create mode 100644 src/features/categories/service.int.test.ts create mode 100644 src/features/categories/service.ts create mode 100644 src/features/categories/tree.test.ts create mode 100644 src/features/categories/tree.ts create mode 100644 src/models/category.ts create mode 100644 src/shared/schemas/category.ts diff --git a/src/features/categories/actions.ts b/src/features/categories/actions.ts new file mode 100644 index 0000000..6cc5bfa --- /dev/null +++ b/src/features/categories/actions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { categoryFormSchema } from "@/shared/schemas/category"; +import { createCategory, reorderCategory, setCategoryArchived, updateCategory } from "./service"; + +const PATH = "/kategoriler"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); +const checkFields = tr.adminUsers.errors.checkFields; + +export async function createCategoryAction(input: unknown): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = categoryFormSchema.safeParse(input); + if (!parsed.success) return fail(checkFields); + const result = await createCategory(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function updateCategoryAction( + categoryId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const parsed = categoryFormSchema.safeParse(input); + if (!id.success || !parsed.success) return fail(checkFields); + const result = await updateCategory(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function reorderCategoryAction( + categoryId: unknown, + direction: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const dir = z.enum(["up", "down"]).safeParse(direction); + if (!id.success || !dir.success) return fail(tr.common.genericError); + const result = await reorderCategory(ctx.user.id, ctx.household.id, id.data, dir.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function setCategoryArchivedAction( + categoryId: unknown, + archived: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const a = z.boolean().safeParse(archived); + if (!id.success || !a.success) return fail(tr.common.genericError); + const result = await setCategoryArchived(ctx.user.id, ctx.household.id, id.data, a.data); + if (result.ok) revalidatePath(PATH); + return result; +} diff --git a/src/features/categories/components/categories-manager.tsx b/src/features/categories/components/categories-manager.tsx new file mode 100644 index 0000000..cf40f8b --- /dev/null +++ b/src/features/categories/components/categories-manager.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowDown, + ArrowUp, + MoreHorizontal, + Pencil, + Plus, +} from "lucide-react"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import type { CategoryKind } from "@/shared/schemas/category"; +import { reorderCategoryAction, setCategoryArchivedAction } from "../actions"; +import type { CategoryView } from "../service"; +import { buildTree, MAX_DEPTH, type CategoryNode } from "../tree"; +import { CategoryFormDialog, type CategoryDialogState } from "./category-form-dialog"; + +const t = tr.categories; + +export function CategoriesManager({ + categories, + canEdit, +}: { + categories: CategoryView[]; + canEdit: boolean; +}) { + const [kind, setKind] = useState("expense"); + const [showArchived, setShowArchived] = useState(false); + const [dialog, setDialog] = useState(null); + const [archiving, setArchiving] = useState(null); + const [pending, startTransition] = useTransition(); + + const tree = useMemo( + () => buildTree(categories.filter((c) => c.kind === kind && (showArchived || !c.archived))), + [categories, kind, showArchived], + ); + const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length; + + function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) { + startTransition(async () => { + const result = await promise; + if (result.ok) { + if (success) toast.success(success); + } else toast.error(result.error ?? tr.common.genericError); + }); + } + + function Row({ + node, + index, + siblings, + }: { + node: CategoryNode; + index: number; + siblings: number; + }) { + return ( + <> +
  • + + + {node.name} + + {node.archived ? {t.archived} : null} + {canEdit ? ( + + + + + + {!node.archived ? ( + <> + {node.depth < MAX_DEPTH - 1 ? ( + setDialog({ mode: "create", kind, parentId: node.id })} + > + + {t.addChild} + + ) : null} + setDialog({ mode: "edit", category: node })}> + + {t.edit} + + run(reorderCategoryAction(node.id, "up"))} + > + + {t.moveUp} + + run(reorderCategoryAction(node.id, "down"))} + > + + {t.moveDown} + + + setArchiving(node)}> + + {t.archive} + + + ) : ( + + run(setCategoryArchivedAction(node.id, false), t.unarchivedToast(node.name)) + } + > + + {t.unarchive} + + )} + + + ) : null} +
  • + {node.children.map((child, i) => ( + + ))} + + ); + } + + return ( +
    +
    + setKind(v as CategoryKind)}> + + {t.expense} + {t.income} + + + {archivedCount ? ( +
    + setShowArchived(v === true)} + /> + +
    + ) : null} + {canEdit ? ( + + ) : null} +
    + {!canEdit ?

    {t.readOnly}

    : null} + + {tree.length ? ( +
      + {tree.map((node, i) => ( + + ))} +
    + ) : ( +

    + {t.empty} +

    + )} + + !o && setDialog(null)} + /> + + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.name) : null} + + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/categories/components/category-form-dialog.tsx b/src/features/categories/components/category-form-dialog.tsx new file mode 100644 index 0000000..5e5ba35 --- /dev/null +++ b/src/features/categories/components/category-form-dialog.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { toast } from "sonner"; +import { IconPicker, SwatchPicker } from "@/components/appearance/pickers"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { + categoryFormSchema, + type CategoryForm, + type CategoryFormInput, + type CategoryKind, +} from "@/shared/schemas/category"; +import { createCategoryAction, updateCategoryAction } from "../actions"; +import type { CategoryView } from "../service"; +import { buildTree, descendantIds, MAX_DEPTH, toOptions } from "../tree"; + +const t = tr.categories; +const ROOT = "__root__"; + +export type CategoryDialogState = + | { mode: "create"; kind: CategoryKind; parentId: string | null } + | { mode: "edit"; category: CategoryView } + | null; + +export function CategoryFormDialog({ + state, + categories, + onOpenChange, +}: { + state: CategoryDialogState; + categories: CategoryView[]; + onOpenChange: (open: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(categoryFormSchema), + }); + const kind = state?.mode === "edit" ? state.category.kind : (state?.kind ?? "expense"); + + useEffect(() => { + if (!state) return; + if (state.mode === "edit") { + const c = state.category; + form.reset({ + kind: c.kind, + name: c.name, + parentId: c.parentId ?? "", + icon: c.icon, + color: c.color, + }); + } else { + const parent = categories.find((c) => c.id === state.parentId); + form.reset({ + kind: state.kind, + name: "", + parentId: state.parentId ?? "", + icon: parent?.icon ?? "tag", + color: parent?.color ?? "blue", + }); + } + }, [state, categories, form]); + + /** Üst kategori seçenekleri: aynı tür, aktif; düzenlemede kendisi ve alt kategorileri hariç. */ + const parentOptions = useMemo(() => { + const sameKind = categories.filter((c) => c.kind === kind); + const excluded = new Set( + state?.mode === "edit" + ? [state.category.id, ...descendantIds(sameKind, state.category.id)] + : [], + ); + return toOptions(buildTree(sameKind)).filter( + (o) => !excluded.has(o.id) && o.depth < MAX_DEPTH - 1, + ); + }, [categories, kind, state]); + + /** Ham form değerleri gönderilir; sunucu aynı şemayla yeniden ayrıştırır (tek doğruluk kaynağı). */ + async function onSubmit() { + const values = form.getValues(); + const result = + state?.mode === "edit" + ? await updateCategoryAction(state.category.id, values) + : await createCategoryAction(values); + if (result.ok) { + toast.success(state?.mode === "edit" ? t.updated(values.name) : t.created(values.name)); + onOpenChange(false); + return; + } + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (message) form.setError(name as keyof CategoryFormInput, { message }); + } + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + const color = useWatch({ control: form.control, name: "color" }) ?? "blue"; + + return ( + + + + + } + > +
    + + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + { + const current = parentOptions.find((o) => o.id === field.value); + return ( + + {t.parent} + + {fieldState.invalid ? : null} + + ); + }} + /> + ( + + {t.color} + + + )} + /> + ( + + {t.icon} + + {t.icon} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
    +
    + ); +} diff --git a/src/features/categories/defaults.ts b/src/features/categories/defaults.ts new file mode 100644 index 0000000..9a95e08 --- /dev/null +++ b/src/features/categories/defaults.ts @@ -0,0 +1,117 @@ +import type { IconKey, Swatch } from "@/shared/appearance"; +import type { CategoryKind } from "@/shared/schemas/category"; + +/** + * Hane oluşturulunca yüklenen varsayılan Türkçe kategori seti (Bölüm 6.2). + * `key` sabittir: set tekrar yüklendiğinde kopya oluşmaz, kullanıcı adı değiştirse bile tanınır. + */ +export type DefaultCategory = { + key: string; + name: string; + icon: IconKey; + color: Swatch; + children?: Omit[]; +}; + +export const DEFAULT_CATEGORIES: Record = { + expense: [ + { + key: "market", + name: "Market", + icon: "shopping-cart", + color: "green", + children: [ + { key: "market.gida", name: "Gıda", icon: "shopping-basket", color: "green" }, + { key: "market.temizlik", name: "Temizlik", icon: "spray-can", color: "teal" }, + { key: "market.kisisel-bakim", name: "Kişisel bakım", icon: "sparkles", color: "pink" }, + ], + }, + { + key: "faturalar", + name: "Faturalar", + icon: "receipt", + color: "amber", + children: [ + { key: "faturalar.elektrik", name: "Elektrik", icon: "zap", color: "amber" }, + { key: "faturalar.su", name: "Su", icon: "droplet", color: "sky" }, + { key: "faturalar.dogalgaz", name: "Doğalgaz", icon: "flame", color: "orange" }, + { key: "faturalar.internet", name: "İnternet", icon: "wifi", color: "indigo" }, + { key: "faturalar.telefon", name: "Telefon", icon: "smartphone", color: "purple" }, + ], + }, + { key: "kira", name: "Kira", icon: "house", color: "blue" }, + { key: "aidat", name: "Aidat", icon: "building", color: "slate" }, + { + key: "ulasim", + name: "Ulaşım", + icon: "car", + color: "sky", + children: [ + { key: "ulasim.yakit", name: "Yakıt", icon: "fuel", color: "orange" }, + { key: "ulasim.toplu-tasima", name: "Toplu taşıma", icon: "bus", color: "sky" }, + { key: "ulasim.arac-bakim", name: "Araç bakım ve sigorta", icon: "wrench", color: "slate" }, + ], + }, + { + key: "saglik", + name: "Sağlık", + icon: "heart-pulse", + color: "red", + children: [ + { key: "saglik.ilac", name: "İlaç", icon: "pill", color: "red" }, + { key: "saglik.muayene", name: "Muayene ve tahlil", icon: "heart-pulse", color: "rose" }, + ], + }, + { key: "egitim", name: "Eğitim", icon: "graduation-cap", color: "indigo" }, + { key: "giyim", name: "Giyim", icon: "shirt", color: "purple" }, + { + key: "eglence", + name: "Eğlence", + icon: "film", + color: "pink", + children: [ + { + key: "eglence.disarida-yemek", + name: "Dışarıda yemek", + icon: "utensils", + color: "orange", + }, + { key: "eglence.kafe", name: "Kafe", icon: "coffee", color: "amber" }, + { key: "eglence.tatil", name: "Tatil", icon: "plane", color: "sky" }, + ], + }, + { key: "abonelikler", name: "Abonelikler", icon: "repeat", color: "purple" }, + { key: "ev", name: "Ev ve mobilya", icon: "sofa", color: "teal" }, + { key: "cocuk", name: "Çocuk", icon: "baby", color: "rose" }, + { key: "evcil-hayvan", name: "Evcil hayvan", icon: "paw-print", color: "lime" }, + { key: "hediye", name: "Hediye ve bağış", icon: "gift", color: "pink" }, + { + key: "banka", + name: "Banka ve kart giderleri", + icon: "percent", + color: "slate", + children: [ + { key: "banka.faiz", name: "Faiz", icon: "percent", color: "red" }, + { key: "banka.vergi", name: "KKDF / BSMV", icon: "receipt", color: "slate" }, + { + key: "banka.ucret", + name: "Kart ve hesap ücretleri", + icon: "credit-card", + color: "slate", + }, + ], + }, + { key: "vergi", name: "Vergi ve harçlar", icon: "landmark", color: "slate" }, + { key: "diger-gider", name: "Diğer", icon: "circle-ellipsis", color: "slate" }, + ], + income: [ + { key: "maas", name: "Maaş", icon: "briefcase", color: "green" }, + { key: "ek-gelir", name: "Ek gelir", icon: "coins", color: "teal" }, + { key: "yemek-karti", name: "Yemek kartı yüklemesi", icon: "utensils", color: "lime" }, + { key: "kira-geliri", name: "Kira geliri", icon: "house", color: "blue" }, + { key: "getiri", name: "Faiz ve getiri", icon: "trending-up", color: "indigo" }, + { key: "iade", name: "İade", icon: "undo", color: "sky" }, + { key: "hediye-gelir", name: "Hediye", icon: "gift", color: "pink" }, + { key: "diger-gelir", name: "Diğer", icon: "circle-ellipsis", color: "slate" }, + ], +}; diff --git a/src/features/categories/service.int.test.ts b/src/features/categories/service.int.test.ts new file mode 100644 index 0000000..16b73bd --- /dev/null +++ b/src/features/categories/service.int.test.ts @@ -0,0 +1,159 @@ +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CategoryModel } from "@/models/category"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + createCategory, + installDefaultCategories, + listCategories, + setCategoryArchived, + updateCategory, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); +}); +afterAll(teardownDb); + +const byKey = (key: string, householdId = cakir) => + CategoryModel.findOne({ householdId, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); +const form = (over: Partial[2]> = {}) => ({ + kind: "expense" as const, + name: "Test", + parentId: null, + icon: "tag" as const, + color: "blue" as const, + ...over, +}); + +describe("varsayılan kategoriler", () => { + it("tekrar yüklemede kopya oluşmaz", async () => { + const before = await CategoryModel.countDocuments({ householdId: cakir }); + const added = await installDefaultCategories( + new Types.ObjectId(cakir), + new Types.ObjectId(ids.user("admin")), + ); + expect(added).toBe(0); + expect(await CategoryModel.countDocuments({ householdId: cakir })).toBe(before); + }); + + it("alt kategoriler doğru ancestors zinciriyle yüklenir", async () => { + const elektrik = await CategoryModel.findOne({ + householdId: cakir, + defaultKey: "faturalar.elektrik", + }).lean(); + const faturalar = await byKey("faturalar"); + expect(elektrik?.ancestors.map(String)).toEqual([faturalar]); + }); +}); + +describe("izolasyon ve roller", () => { + it("başka hanenin kategorilerini okuyamaz ve değiştiremez", async () => { + expect(await listCategories(ids.user("fatma"), cakir)).toBeNull(); + const kira = await byKey("kira"); + // fatma kendi hanesinin kimliğiyle, Çakır'ın kategorisini hedefler → bulunamaz. + const r = await updateCategory( + ids.user("fatma"), + annemler, + kira, + form({ name: "Ele geçirildi" }), + ); + expect(r.ok).toBe(false); + expect((await CategoryModel.findById(kira).lean())?.name).toBe("Kira"); + const r2 = await setCategoryArchived(ids.user("fatma"), annemler, kira, true); + expect(r2.ok).toBe(false); + }); + + it("izleyici ekleyemez, üye ekler", async () => { + expect((await createCategory(ids.user("zeynep"), cakir, form({ name: "Harçlık" }))).ok).toBe( + false, + ); + expect((await createCategory(ids.user("emre"), cakir, form({ name: "Harçlık" }))).ok).toBe( + true, + ); + }); + + it("başka hanenin kategorisini üst kategori yapamaz", async () => { + const annemlerMarket = await byKey("market", annemler); + const r = await createCategory( + ids.user("ayse"), + cakir, + form({ name: "Sızma", parentId: annemlerMarket }), + ); + expect(r.ok).toBe(false); + }); +}); + +describe("ağaç kuralları", () => { + it("aynı düzeyde aynı ad (Türkçe normalize) reddedilir", async () => { + const r = await createCategory(ids.user("ayse"), cakir, form({ name: " KİRA " })); + expect(r.ok).toBe(false); + // farklı düzeyde aynı ad serbest + const market = await byKey("market"); + expect( + (await createCategory(ids.user("ayse"), cakir, form({ name: "Kira", parentId: market }))).ok, + ).toBe(true); + }); + + it("taşıma alt ağacın ancestors alanlarını günceller", async () => { + const ulasim = await byKey("ulasim"); + const yakit = await byKey("ulasim.yakit"); + const created = await createCategory( + ids.user("ayse"), + cakir, + form({ name: "LPG", parentId: yakit }), + ); + const lpg = created.ok ? created.data.id : ""; + const ev = await byKey("ev"); + // Yakıt'ı (alt kategorisi LPG ile) "Ev ve mobilya" altına taşı. + const r = await updateCategory( + ids.user("ayse"), + cakir, + yakit, + form({ name: "Yakıt", parentId: ev, icon: "fuel" }), + ); + expect(r.ok).toBe(true); + expect((await CategoryModel.findById(yakit).lean())?.ancestors.map(String)).toEqual([ev]); + expect((await CategoryModel.findById(lpg).lean())?.ancestors.map(String)).toEqual([ev, yakit]); + expect((await CategoryModel.findById(yakit).lean())?.parentId?.toString()).not.toBe(ulasim); + }); + + it("döngü ve tür uyuşmazlığı reddedilir", async () => { + const market = await byKey("market"); + const gida = await byKey("market.gida"); + expect( + ( + await updateCategory( + ids.user("ayse"), + cakir, + market, + form({ name: "Market", parentId: gida }), + ) + ).ok, + ).toBe(false); + const maas = await byKey("maas"); + expect( + (await updateCategory(ids.user("ayse"), cakir, gida, form({ name: "Gıda", parentId: maas }))) + .ok, + ).toBe(false); + }); + + it("arşivleme alt ağaçla birlikte yapılır; üstü arşivliyken alt kategori geri alınamaz", async () => { + const saglik = await byKey("saglik"); + const ilac = await byKey("saglik.ilac"); + expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, true)).ok).toBe(true); + const active = (await listCategories(ids.user("emre"), cakir))!.map((c) => c.id); + expect(active).not.toContain(saglik); + expect(active).not.toContain(ilac); + expect((await setCategoryArchived(ids.user("emre"), cakir, ilac, false)).ok).toBe(false); + expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, false)).ok).toBe(true); + }); +}); diff --git a/src/features/categories/service.ts b/src/features/categories/service.ts new file mode 100644 index 0000000..4af3981 --- /dev/null +++ b/src/features/categories/service.ts @@ -0,0 +1,331 @@ +import "server-only"; +import { Types, type ClientSession } from "mongoose"; +import { resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { CategoryModel } from "@/models/category"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import type { CategoryForm, CategoryKind } from "@/shared/schemas/category"; +import { foldTr } from "@/shared/search"; +import { DEFAULT_CATEGORIES } from "./defaults"; +import { + checkCreate, + checkMove, + descendantIds, + type FlatCategory, + type MoveViolation, +} from "./tree"; + +/** + * Kategori servisi. Okuma: viewer; yazma: member. Hane kapsamı her fonksiyonda doğrulanır. + * Silme yoktur: arşivleme (alt ağaçla birlikte). İşlemler arşivli kategoriyi göstermeye devam eder. + */ + +const t = tr.categories; + +export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean }; + +type Lean = { + _id: Types.ObjectId; + kind: CategoryKind; + name: string; + parentId: Types.ObjectId | null; + order: number; + archivedAt: Date | null; + icon: string; + color: string; + defaultKey: string | null; + ancestors: Types.ObjectId[]; +}; + +const toView = (c: Lean): CategoryView => ({ + id: String(c._id), + kind: c.kind, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: Boolean(c.archivedAt), + icon: c.icon as IconKey, + color: c.color as Swatch, + isDefault: Boolean(c.defaultKey), +}); + +const violationMessage = (v: MoveViolation) => t.violations[v]; + +export async function listCategories( + userId: string, + householdId: string, + opts: { kind?: CategoryKind; includeArchived?: boolean } = {}, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const filter: Record = { householdId: m.householdId }; + if (opts.kind) filter.kind = opts.kind; + if (!opts.includeArchived) filter.archivedAt = null; + const rows = await CategoryModel.find(filter).lean(); + return rows.map(toView); +} + +async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) { + return CategoryModel.find({ householdId, kind }) + .session(session ?? null) + .lean(); +} + +function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exceptId?: string) { + const key = foldTr(name); + return all.some( + (c) => + !c.archivedAt && + String(c._id) !== exceptId && + (c.parentId ? String(c.parentId) : null) === parentId && + foldTr(c.name) === key, + ); +} + +/** + * Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı + * transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner. + */ +export async function installDefaultCategories( + householdId: Types.ObjectId, + actor: Types.ObjectId, + session?: ClientSession, +): Promise { + await connectMongoose(); + let inserted = 0; + const upsert = async ( + kind: CategoryKind, + def: { key: string; name: string; icon: string; color: string }, + order: number, + parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null, + ) => { + const res = await CategoryModel.findOneAndUpdate( + { householdId, defaultKey: def.key }, + { + $setOnInsert: { + householdId, + kind, + name: def.name, + nameKey: foldTr(def.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + icon: def.icon, + color: def.color, + order, + defaultKey: def.key, + createdBy: actor, + updatedBy: actor, + }, + }, + { upsert: true, returnDocument: "after", includeResultMetadata: true, session }, + ); + if (!res.lastErrorObject?.updatedExisting) inserted++; + return res.value!; + }; + + for (const kind of ["expense", "income"] as const) { + for (const [i, def] of DEFAULT_CATEGORIES[kind].entries()) { + const parent = await upsert(kind, def, i, null); + for (const [j, child] of (def.children ?? []).entries()) { + await upsert(kind, child, j, { _id: parent._id, ancestors: parent.ancestors }); + } + } + } + return inserted; +} + +export async function createCategory( + actorId: string, + householdId: string, + input: CategoryForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const all = await loadKind(m.householdId, input.kind); + const violation = checkCreate(all.map(toView), input.kind, input.parentId); + if (violation) + return fail(violationMessage(violation), { parentId: violationMessage(violation) }); + if (hasSiblingNamed(all, input.parentId, input.name)) + return fail(t.duplicate, { name: t.duplicate }); + + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId) : null; + const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId); + const actor = new Types.ObjectId(actorId); + const doc = await CategoryModel.create({ + householdId: m.householdId, + kind: input.kind, + name: input.name, + nameKey: foldTr(input.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + icon: input.icon, + color: input.color, + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +async function loadOne(householdId: Types.ObjectId, categoryId: string) { + const id = toObjectId(categoryId); + if (!id) return null; + // householdId filtresi: başka hanenin kategorisi asla bulunmaz. + return CategoryModel.findOne({ _id: id, householdId }).lean(); +} + +/** Ad, ikon, renk ve (değiştiyse) üst kategori. Taşıma alt ağaçla birlikte transaction'da. */ +export async function updateCategory( + actorId: string, + householdId: string, + categoryId: string, + input: CategoryForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current || current.kind !== input.kind) return fail(tr.common.notFound); + const all = await loadKind(m.householdId, current.kind); + const currentParent = current.parentId ? String(current.parentId) : null; + const moving = currentParent !== input.parentId; + + if (moving) { + const violation = checkMove(all.map(toView), categoryId, input.parentId); + if (violation) + return fail(violationMessage(violation), { parentId: violationMessage(violation) }); + } + if (hasSiblingNamed(all, input.parentId, input.name, categoryId)) + return fail(t.duplicate, { name: t.duplicate }); + + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + await mongoose.connection.transaction(async (session) => { + const set: Record = { + name: input.name, + nameKey: foldTr(input.name), + icon: input.icon, + color: input.color, + updatedBy: actor, + }; + if (moving) { + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null; + const newAncestors = parent ? [...parent.ancestors, parent._id] : []; + const siblings = all.filter( + (c) => (c.parentId ? String(c.parentId) : null) === input.parentId, + ); + Object.assign(set, { + parentId: parent?._id ?? null, + ancestors: newAncestors, + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + }); + // Alt ağaç: yeni üst zincir + (taşınan düğüm) + düğümün altındaki eski yol. + for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) { + const idx = d.ancestors.findIndex((a) => a.equals(current._id)); + await CategoryModel.updateOne( + { _id: d._id, householdId: m.householdId }, + { $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } }, + { session }, + ); + } + await writeAudit( + { + action: "category.move", + actorId: actor, + householdId: m.householdId, + targetType: "category", + targetId: categoryId, + diff: { parentId: { from: currentParent, to: input.parentId } }, + }, + session, + ); + } + await CategoryModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: set }, + { session }, + ); + }); + return ok(undefined); +} + +/** Kardeşler arasında bir yukarı/aşağı taşır. */ +export async function reorderCategory( + actorId: string, + householdId: string, + categoryId: string, + direction: "up" | "down", +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current) return fail(tr.common.notFound); + const siblings = (await loadKind(m.householdId, current.kind)) + .filter((c) => !c.archivedAt && String(c.parentId ?? "") === String(current.parentId ?? "")) + .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr")); + const i = siblings.findIndex((c) => c._id.equals(current._id)); + const j = direction === "up" ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= siblings.length) return ok(undefined); + [siblings[i], siblings[j]] = [siblings[j]!, siblings[i]!]; + await CategoryModel.bulkWrite( + siblings.map((c, order) => ({ + updateOne: { + filter: { _id: c._id, householdId: m.householdId }, + update: { $set: { order } }, + }, + })), + ); + return ok(undefined); +} + +/** Arşivler (alt kategorilerle birlikte) veya arşivden çıkarır (üstü aktif olmalı). */ +export async function setCategoryArchived( + actorId: string, + householdId: string, + categoryId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current) return fail(tr.common.notFound); + const all = await loadKind(m.householdId, current.kind); + + if (!archived && current.parentId) { + const parent = all.find((c) => c._id.equals(current.parentId!)); + if (parent?.archivedAt) return fail(t.violations.parentArchived); + } + if ( + !archived && + hasSiblingNamed( + all, + current.parentId ? String(current.parentId) : null, + current.name, + categoryId, + ) + ) { + return fail(t.duplicate); + } + + const ids = [ + current._id, + ...descendantIds(all.map(toView), categoryId).map((id) => new Types.ObjectId(id)), + ]; + const actor = new Types.ObjectId(actorId); + await CategoryModel.updateMany( + { _id: { $in: ids }, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "category.archive" : "category.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "category", + targetId: categoryId, + diff: { name: current.name, affected: ids.length }, + }); + return ok(undefined); +} diff --git a/src/features/categories/tree.test.ts b/src/features/categories/tree.test.ts new file mode 100644 index 0000000..e532e9a --- /dev/null +++ b/src/features/categories/tree.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + ancestorIds, + buildTree, + checkCreate, + checkMove, + descendantIds, + rollupTotals, + toOptions, + type FlatCategory, +} from "./tree"; + +const c = ( + id: string, + name: string, + parentId: string | null, + order = 0, + kind: "expense" | "income" = "expense", +): FlatCategory => ({ + id, + kind, + name, + parentId, + order, + archived: false, +}); + +const items: FlatCategory[] = [ + c("fat", "Faturalar", null, 1), + c("elk", "Elektrik", "fat", 0), + c("su", "Su", "fat", 1), + c("mar", "Market", null, 0), + c("gida", "Gıda", "mar", 0), + c("meyve", "Meyve", "gida", 0), + c("maas", "Maaş", null, 0, "income"), +]; + +describe("buildTree / toOptions", () => { + it("sıra ve derinlikle ağaç kurar, yol etiketleri üretir", () => { + const tree = buildTree(items.filter((i) => i.kind === "expense")); + expect(tree.map((n) => n.name)).toEqual(["Market", "Faturalar"]); + expect(toOptions(tree).map((o) => [o.path, o.depth])).toEqual([ + ["Market", 0], + ["Market › Gıda", 1], + ["Market › Gıda › Meyve", 2], + ["Faturalar", 0], + ["Faturalar › Elektrik", 1], + ["Faturalar › Su", 1], + ]); + }); + + it("arşivlenenleri varsayılan olarak seçeneklere koymaz", () => { + const tree = buildTree([c("a", "A", null), { ...c("b", "B", "a"), archived: true }]); + expect(toOptions(tree).map((o) => o.id)).toEqual(["a"]); + expect(toOptions(tree, { includeArchived: true }).map((o) => o.id)).toEqual(["a", "b"]); + }); + + it("üstü olmayan düğümü köke alır, döngüde takılmaz", () => { + const loop = [c("x", "X", "y"), c("y", "Y", "x"), c("z", "Z", "yok")]; + expect(() => buildTree(loop)).not.toThrow(); + expect(buildTree(loop).map((n) => n.id)).toContain("z"); + }); +}); + +describe("ancestorIds / descendantIds", () => { + it("üst ve alt kategorileri bulur", () => { + expect(ancestorIds(items, "meyve")).toEqual(["mar", "gida"]); + expect(descendantIds(items, "mar").sort()).toEqual(["gida", "meyve"]); + expect(descendantIds(items, "elk")).toEqual([]); + }); +}); + +describe("checkMove", () => { + it("kendine, alt kategorisine ve farklı türe taşımayı reddeder", () => { + expect(checkMove(items, "mar", "mar")).toBe("self"); + expect(checkMove(items, "mar", "meyve")).toBe("descendant"); + expect(checkMove(items, "elk", "maas")).toBe("kind"); + expect(checkMove(items, "elk", "yok")).toBe("parentNotFound"); + }); + + it("geçerli taşımaya ve köke taşımaya izin verir", () => { + expect(checkMove(items, "su", "mar")).toBeNull(); + expect(checkMove(items, "gida", null)).toBeNull(); + }); + + it("derinlik sınırını uygular (en fazla 5 seviye: derinlik 0–4)", () => { + const deep = [ + c("1", "1", null), + c("2", "2", "1"), + c("3", "3", "2"), + c("4", "4", "3"), + c("5", "5", "4"), + c("x", "X", null), + c("y", "Y", "x"), + ]; + expect(checkCreate(deep, "expense", "4")).toBeNull(); // yeni düğüm derinlik 4 + expect(checkCreate(deep, "expense", "5")).toBe("depth"); // derinlik 5 + expect(checkMove(deep, "y", "4")).toBeNull(); + expect(checkMove(deep, "y", "5")).toBe("depth"); + expect(checkMove(deep, "x", "4")).toBe("depth"); // x derinlik 4, alt kategorisi y derinlik 5 + expect(checkCreate(deep, "income", "3")).toBe("kind"); + }); +}); + +describe("rollupTotals", () => { + it("alt kategori tutarlarını üst kategorilere toplar", () => { + const totals = rollupTotals( + items, + new Map([ + ["meyve", 300], + ["gida", 500], + ["elk", 1000], + ["su", 250], + ]), + ); + expect(totals.get("mar")).toBe(800); + expect(totals.get("gida")).toBe(800); + expect(totals.get("fat")).toBe(1250); + expect(totals.get("maas") ?? 0).toBe(0); + }); +}); diff --git a/src/features/categories/tree.ts b/src/features/categories/tree.ts new file mode 100644 index 0000000..91ecad6 --- /dev/null +++ b/src/features/categories/tree.ts @@ -0,0 +1,163 @@ +import type { CategoryKind } from "@/shared/schemas/category"; +import { compareTr } from "@/shared/search"; + +/** + * Kategori ağacı saf fonksiyonları (framework'ten bağımsız, birim testli). + * Derinlik pratikte 3 seviye; güvenlik için en fazla MAX_DEPTH seviye. + */ +export const MAX_DEPTH = 5; + +export type FlatCategory = { + id: string; + kind: CategoryKind; + name: string; + parentId: string | null; + order: number; + archived: boolean; +}; + +export type CategoryNode = T & { + depth: number; + children: CategoryNode[]; +}; + +const bySortOrder = (a: FlatCategory, b: FlatCategory) => + a.order - b.order || compareTr(a.name, b.name); + +/** Düz listeden ağaç kurar. Üstü bulunamayan (veya döngülü) düğümler köke alınır. */ +export function buildTree(items: readonly T[]): CategoryNode[] { + const nodes = new Map( + items.map((c) => [c.id, { ...c, depth: 0, children: [] as CategoryNode[] }]), + ); + const roots: CategoryNode[] = []; + for (const node of nodes.values()) { + const parent = node.parentId ? nodes.get(node.parentId) : undefined; + if (parent && parent !== node) parent.children.push(node); + else roots.push(node); + } + const visit = (list: CategoryNode[], depth: number, seen: Set) => { + list.sort(bySortOrder); + for (const n of list) { + if (seen.has(n.id)) continue; + seen.add(n.id); + n.depth = depth; + visit(n.children, depth + 1, seen); + } + }; + visit(roots, 0, new Set()); + return roots; +} + +export type CategoryOption = { + id: string; + label: string; + path: string; + depth: number; + kind: CategoryKind; +}; + +/** Seçim listesi (işlem formları): derinlik sırasıyla, "Faturalar › Elektrik" yoluyla. */ +export function toOptions( + tree: CategoryNode[], + { includeArchived = false } = {}, +): CategoryOption[] { + const out: CategoryOption[] = []; + const walk = (list: CategoryNode[], prefix: string[]) => { + for (const n of list) { + if (n.archived && !includeArchived) continue; + const path = [...prefix, n.name]; + out.push({ id: n.id, label: n.name, path: path.join(" › "), depth: n.depth, kind: n.kind }); + walk(n.children, path); + } + }; + walk(tree, []); + return out; +} + +/** `id`'nin tüm alt kategorileri (kendisi hariç). */ +export function descendantIds(items: readonly FlatCategory[], id: string): string[] { + const children = new Map(); + for (const c of items) + if (c.parentId) children.set(c.parentId, [...(children.get(c.parentId) ?? []), c.id]); + const out: string[] = []; + const stack = [...(children.get(id) ?? [])]; + while (stack.length) { + const next = stack.pop()!; + if (out.includes(next)) continue; + out.push(next); + stack.push(...(children.get(next) ?? [])); + } + return out; +} + +/** Kökten `id`'ye kadar üst kategori kimlikleri (id hariç). */ +export function ancestorIds(items: readonly FlatCategory[], id: string): string[] { + const byId = new Map(items.map((c) => [c.id, c])); + const out: string[] = []; + let cur = byId.get(id)?.parentId ?? null; + while (cur && !out.includes(cur)) { + out.unshift(cur); + cur = byId.get(cur)?.parentId ?? null; + } + return out; +} + +/** Alt ağacın yüksekliği (yaprak = 0). */ +function subtreeHeight(items: readonly FlatCategory[], id: string): number { + const kids = items.filter((c) => c.parentId === id); + return kids.length ? 1 + Math.max(...kids.map((k) => subtreeHeight(items, k.id))) : 0; +} + +export type MoveViolation = + "self" | "descendant" | "kind" | "depth" | "parentNotFound" | "parentArchived"; + +/** `id` kategorisi `newParentId` altına (null = kök) taşınabilir mi? */ +export function checkMove( + items: readonly FlatCategory[], + id: string, + newParentId: string | null, +): MoveViolation | null { + if (newParentId === null) return null; + if (newParentId === id) return "self"; + const parent = items.find((c) => c.id === newParentId); + const self = items.find((c) => c.id === id); + if (!parent || !self) return "parentNotFound"; + if (parent.kind !== self.kind) return "kind"; + if (parent.archived) return "parentArchived"; + if (descendantIds(items, id).includes(newParentId)) return "descendant"; + const newDepth = ancestorIds(items, newParentId).length + 1; + if (newDepth + subtreeHeight(items, id) >= MAX_DEPTH) return "depth"; + return null; +} + +/** Yeni kategori `parentId` altına eklenebilir mi? */ +export function checkCreate( + items: readonly FlatCategory[], + kind: CategoryKind, + parentId: string | null, +): MoveViolation | null { + if (!parentId) return null; + const parent = items.find((c) => c.id === parentId); + if (!parent) return "parentNotFound"; + if (parent.kind !== kind) return "kind"; + if (parent.archived) return "parentArchived"; + if (ancestorIds(items, parentId).length + 1 >= MAX_DEPTH) return "depth"; + return null; +} + +/** + * Kategori bazlı tutarları üst kategorilere toplar (raporlar ve bütçe için): + * her kategori kendi tutarı + tüm alt kategorilerinin tutarı. + */ +export function rollupTotals( + items: readonly FlatCategory[], + totals: ReadonlyMap, +): Map { + const out = new Map(); + for (const c of items) { + const own = totals.get(c.id) ?? 0; + for (const target of [c.id, ...ancestorIds(items, c.id)]) + out.set(target, (out.get(target) ?? 0) + own); + } + return out; +} diff --git a/src/models/category.ts b/src/models/category.ts new file mode 100644 index 0000000..e36a91d --- /dev/null +++ b/src/models/category.ts @@ -0,0 +1,38 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ICON_KEYS, SWATCHES } from "@/shared/appearance"; +import { CATEGORY_KINDS } from "@/shared/schemas/category"; + +/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */ +const categorySchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + kind: { type: String, enum: CATEGORY_KINDS, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + /** Kardeşler arasında ad tekrarını önlemek için normalize ad (foldTr). */ + nameKey: { type: String, required: true }, + parentId: { type: Schema.Types.ObjectId, default: null }, + /** Kökten üst kategoriye kadar kimlikler (alt ağaç sorguları ve toplama için). */ + ancestors: { type: [Schema.Types.ObjectId], default: [] }, + icon: { type: String, enum: ICON_KEYS, default: "tag" }, + color: { type: String, enum: SWATCHES, default: "slate" }, + order: { type: Number, default: 0 }, + archivedAt: { type: Date, default: null }, + /** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */ + defaultKey: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "categories" }, +); + +categorySchema.index({ householdId: 1, kind: 1, parentId: 1, order: 1 }); +categorySchema.index({ householdId: 1, ancestors: 1 }); +categorySchema.index( + { householdId: 1, defaultKey: 1 }, + { unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } }, +); + +export type Category = InferSchemaType; + +export const CategoryModel: Model = + (models.Category as Model | undefined) ?? model("Category", categorySchema); diff --git a/src/shared/schemas/category.ts b/src/shared/schemas/category.ts new file mode 100644 index 0000000..b300718 --- /dev/null +++ b/src/shared/schemas/category.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { ICON_KEYS, SWATCHES } from "../appearance"; + +export const CATEGORY_KINDS = ["expense", "income"] as const; +export type CategoryKind = (typeof CATEGORY_KINDS)[number]; + +export const categoryFormSchema = z.object({ + kind: z.enum(CATEGORY_KINDS), + name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax), + parentId: z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((s) => (s === "" ? null : s)), + icon: z.enum(ICON_KEYS), + color: z.enum(SWATCHES), +}); + +export type CategoryFormInput = z.input; +export type CategoryForm = z.output;