380ddaf303
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
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);
|
||
}
|