Files
butcem/src/features/categories/service.ts
T

332 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}