feat(categories): kategori ağacı, varsayılan Türkçe set ve yönetim ekranı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent eaf80c6bb4
commit 380ddaf303
10 changed files with 1469 additions and 0 deletions
+331
View File
@@ -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<CategoryView[] | null> {
const m = await resolveMembership(userId, householdId, "viewer");
if (!m) return null;
const filter: Record<string, unknown> = { householdId: m.householdId };
if (opts.kind) filter.kind = opts.kind;
if (!opts.includeArchived) filter.archivedAt = null;
const rows = await CategoryModel.find(filter).lean<Lean[]>();
return rows.map(toView);
}
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
return CategoryModel.find({ householdId, kind })
.session(session ?? null)
.lean<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<number> {
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<ActionResult<{ id: string }>> {
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<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<ActionResult> {
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<string, unknown> = {
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<ActionResult> {
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<ActionResult> {
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);
}