diff --git a/src/__tests__/planCategory.test.ts b/src/__tests__/planCategory.test.ts new file mode 100644 index 0000000..483e91c --- /dev/null +++ b/src/__tests__/planCategory.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterAll } from "vitest"; +import prisma from "../config/database"; +import { + listPlanCategories, + createPlanCategory, + updatePlanCategory, + deactivatePlanCategory, + isCategoryKeyActive, + slugifyCategory, +} from "../services/planCategory.service"; + +const created: number[] = []; + +afterAll(async () => { + if (created.length) { + await prisma.plan_categories.deleteMany({ where: { id: { in: created } } }); + } + await prisma.$disconnect(); +}); + +describe("slugifyCategory", () => { + it("strips Czech diacritics and non-alphanumerics", () => { + expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz"); + expect(slugifyCategory("Příprava")).toBe("priprava"); + }); +}); + +describe("plan category service", () => { + it("seeds are present and ordered", async () => { + const cats = await listPlanCategories(true); + const keys = cats.map((c) => c.key); + expect(keys).toContain("work"); + expect(keys).toContain("other"); + }); + + it("creates a category with a unique slug and next sort_order", async () => { + const res = await createPlanCategory({ + label: "Test Kategorie ZZ", + color: "#123456", + }); + if ("error" in res) throw new Error(res.error); + created.push(res.data.id); + expect(res.data.key).toBe("test_kategorie_zz"); + expect(res.data.color).toBe("#123456"); + expect(res.data.is_active).toBe(true); + expect(await isCategoryKeyActive("test_kategorie_zz")).toBe(true); + }); + + it("dedupes a colliding slug", async () => { + const res = await createPlanCategory({ + label: "Test Kategorie ZZ", + color: "#222222", + }); + if ("error" in res) throw new Error(res.error); + created.push(res.data.id); + expect(res.data.key).toBe("test_kategorie_zz_2"); + }); + + it("updates label and color (key immutable)", async () => { + const res = await createPlanCategory({ + label: "Test Upd ZZ", + color: "#aaaaaa", + }); + if ("error" in res) throw new Error(res.error); + created.push(res.data.id); + const upd = await updatePlanCategory(res.data.id, { + label: "Test Upd2 ZZ", + color: "#bbbbbb", + }); + if ("error" in upd) throw new Error(upd.error); + expect(upd.data.label).toBe("Test Upd2 ZZ"); + expect(upd.data.color).toBe("#bbbbbb"); + expect(upd.data.key).toBe("test_upd_zz"); + }); + + it("deactivate hides the key from active check", async () => { + const res = await createPlanCategory({ + label: "Test Deact ZZ", + color: "#cccccc", + }); + if ("error" in res) throw new Error(res.error); + created.push(res.data.id); + await deactivatePlanCategory(res.data.id); + expect(await isCategoryKeyActive("test_deact_zz")).toBe(false); + }); +}); diff --git a/src/services/planCategory.service.ts b/src/services/planCategory.service.ts new file mode 100644 index 0000000..193ac7b --- /dev/null +++ b/src/services/planCategory.service.ts @@ -0,0 +1,121 @@ +import prisma from "../config/database"; + +export type CatResult = { data: T } | { error: string; status: number }; + +/** ASCII slug: strip diacritics, lowercase, non-alphanumerics → underscore. */ +export function slugifyCategory(label: string): string { + return label + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +export async function listPlanCategories(includeInactive = true) { + return prisma.plan_categories.findMany({ + where: includeInactive ? {} : { is_active: true }, + orderBy: [{ sort_order: "asc" }, { id: "asc" }], + }); +} + +export async function isCategoryKeyActive(key: string): Promise { + const cat = await prisma.plan_categories.findFirst({ + where: { key, is_active: true }, + select: { id: true }, + }); + return cat !== null; +} + +async function uniqueKey(base: string): Promise { + const root = base || "kategorie"; + let candidate = root; + let n = 2; + while ( + await prisma.plan_categories.findUnique({ + where: { key: candidate }, + select: { id: true }, + }) + ) { + candidate = `${root}_${n++}`; + } + return candidate; +} + +export async function createPlanCategory(input: { + label: string; + color: string; +}): Promise< + CatResult<{ + id: number; + key: string; + label: string; + color: string; + sort_order: number; + is_active: boolean; + }> +> { + const key = await uniqueKey(slugifyCategory(input.label)); + const max = await prisma.plan_categories.aggregate({ + _max: { sort_order: true }, + }); + const sort_order = (max._max.sort_order ?? 0) + 1; + const data = await prisma.plan_categories.create({ + data: { + key, + label: input.label, + color: input.color, + sort_order, + is_active: true, + }, + }); + return { data }; +} + +export async function updatePlanCategory( + id: number, + input: { label?: string; color?: string; is_active?: boolean }, +): Promise< + CatResult<{ + id: number; + key: string; + label: string; + color: string; + sort_order: number; + is_active: boolean; + }> +> { + const existing = await prisma.plan_categories.findUnique({ where: { id } }); + if (!existing) return { error: "Kategorie nenalezena", status: 404 }; + + // Guard: never deactivate the last active category. + if (input.is_active === false && existing.is_active) { + const activeCount = await prisma.plan_categories.count({ + where: { is_active: true }, + }); + if (activeCount <= 1) { + return { + error: "Musí zůstat alespoň jedna aktivní kategorie", + status: 400, + }; + } + } + + const data = await prisma.plan_categories.update({ + where: { id }, + data: { + label: input.label ?? undefined, + color: input.color ?? undefined, + is_active: input.is_active ?? undefined, + }, + }); + return { data }; +} + +export async function deactivatePlanCategory( + id: number, +): Promise> { + const res = await updatePlanCategory(id, { is_active: false }); + if ("error" in res) return res; + return { data: { ok: true } }; +}