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); }