feat(plan): add plan category service with tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
src/__tests__/planCategory.test.ts
Normal file
86
src/__tests__/planCategory.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
121
src/services/planCategory.service.ts
Normal file
121
src/services/planCategory.service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import prisma from "../config/database";
|
||||
|
||||
export type CatResult<T> = { 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<boolean> {
|
||||
const cat = await prisma.plan_categories.findFirst({
|
||||
where: { key, is_active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
return cat !== null;
|
||||
}
|
||||
|
||||
async function uniqueKey(base: string): Promise<string> {
|
||||
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<CatResult<{ ok: true }>> {
|
||||
const res = await updatePlanCategory(id, { is_active: false });
|
||||
if ("error" in res) return res;
|
||||
return { data: { ok: true } };
|
||||
}
|
||||
Reference in New Issue
Block a user