From a55356d41716e1487aa992ef669e3f36b61c5aa3 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 6 Jun 2026 00:41:50 +0200 Subject: [PATCH] feat(plan): validate dynamic category keys, resolve audit label from DB - Replace hardcoded planCategoryEnum z.enum with z.string().trim().min(1) - Add assertActiveCategory() helper that rejects inactive/unknown keys - Add resolveCategoryLabel() helper that resolves the Czech label from plan_categories DB table, falling back to built-in map - Change PlanAuditDescriptionInput.category -> categoryLabel (pre-resolved string) - Update buildPlanAuditDescription to use opts.categoryLabel directly - Update all 6 buildPlanAuditDescription call sites in plan.service.ts to await resolveCategoryLabel(row.category) - Add category validation in createEntry, updateEntry, createOverride, updateOverride - Update planAuditDescription tests to pass categoryLabel instead of category Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/planAuditDescription.test.ts | 12 ++--- src/schemas/plan.schema.ts | 12 ++--- src/services/plan.service.ts | 57 +++++++++++++++++++--- src/utils/planAuditDescription.ts | 10 ++-- 4 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/__tests__/planAuditDescription.test.ts b/src/__tests__/planAuditDescription.test.ts index 11ae902..b887134 100644 --- a/src/__tests__/planAuditDescription.test.ts +++ b/src/__tests__/planAuditDescription.test.ts @@ -24,7 +24,7 @@ describe("buildPlanAuditDescription", () => { it("describes a multi-day entry with project", () => { const desc = buildPlanAuditDescription({ userName: "Jan Novák", - category: "work", + categoryLabel: "Práce", projectName: "Rekonstrukce haly", dateFrom: "2026-06-08", dateTo: "2026-06-12", @@ -37,7 +37,7 @@ describe("buildPlanAuditDescription", () => { it("collapses a single-day range to one date", () => { const desc = buildPlanAuditDescription({ userName: "Jan Novák", - category: "work", + categoryLabel: "Práce", projectName: "Rekonstrukce haly", dateFrom: "2026-06-08", dateTo: "2026-06-08", @@ -48,7 +48,7 @@ describe("buildPlanAuditDescription", () => { it("omits the project segment when there is no project", () => { const desc = buildPlanAuditDescription({ userName: "Petr Svoboda", - category: "leave", + categoryLabel: "Dovolená", projectName: null, dateFrom: "2026-06-10", dateTo: "2026-06-10", @@ -59,7 +59,7 @@ describe("buildPlanAuditDescription", () => { it("appends the emergency-edit note when force is set", () => { const desc = buildPlanAuditDescription({ userName: "Jan Novák", - category: "work", + categoryLabel: "Práce", projectName: null, dateFrom: "2026-01-02", dateTo: "2026-01-02", @@ -71,7 +71,7 @@ describe("buildPlanAuditDescription", () => { it("appends a custom suffix (e.g. replaced override)", () => { const desc = buildPlanAuditDescription({ userName: "Jan Novák", - category: "sick", + categoryLabel: "Nemoc", projectName: null, dateFrom: "2026-06-10", dateTo: "2026-06-10", @@ -85,7 +85,7 @@ describe("buildPlanAuditDescription", () => { it("treats a whitespace-only project name as no project", () => { const desc = buildPlanAuditDescription({ userName: "Jan Novák", - category: "work", + categoryLabel: "Práce", projectName: " ", dateFrom: "2026-06-08", dateTo: "2026-06-08", diff --git a/src/schemas/plan.schema.ts b/src/schemas/plan.schema.ts index 9a1efc4..54bbfbe 100644 --- a/src/schemas/plan.schema.ts +++ b/src/schemas/plan.schema.ts @@ -9,15 +9,9 @@ const intFromForm = z .transform((v) => Number(v)) .refine((n) => Number.isInteger(n) && n > 0, "Neplatné ID"); -const planCategoryEnum = z.enum([ - "work", - "preparation", - "travel", - "leave", - "sick", - "training", - "other", -]); +// Category is now a dynamic key (validated against plan_categories in the +// service). Keep it a non-empty string here. +const planCategoryEnum = z.string().trim().min(1, "Kategorie je povinná"); export const CreatePlanEntrySchema = z .object({ diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index b276fbc..5f4af09 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -1,5 +1,8 @@ import prisma from "../config/database"; -import { buildPlanAuditDescription } from "../utils/planAuditDescription"; +import { + buildPlanAuditDescription, + planCategoryLabel as fallbackCategoryLabel, +} from "../utils/planAuditDescription"; /** * Resolve display names for an audit description: the employee's full name @@ -35,6 +38,28 @@ async function resolvePlanLabels( return { userName, projectName }; } +/** Resolve a category key to its Czech label from the DB, falling back to + * the built-in map then the raw key. */ +async function resolveCategoryLabel(key: string): Promise { + const cat = await prisma.plan_categories.findUnique({ + where: { key }, + select: { label: true }, + }); + return cat?.label ?? fallbackCategoryLabel(key); +} + +/** Returns an error result if the category key is not an active category. */ +async function assertActiveCategory( + key: string, +): Promise<{ error: string; status: number } | null> { + const cat = await prisma.plan_categories.findFirst({ + where: { key, is_active: true }, + select: { id: true }, + }); + if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 }; + return null; +} + /** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this * file reads date-only columns (UTC slice), so it is timezone-stable. */ function isoDay(d: Date): string { @@ -414,6 +439,9 @@ export async function createEntry( const lockTo = assertNotPastDate(input.date_to, force); if (lockTo) return lockTo; + const catErr = await assertActiveCategory(input.category); + if (catErr) return catErr; + const created = await prisma.work_plan_entries.create({ data: { user_id: input.user_id, @@ -432,7 +460,7 @@ export async function createEntry( ); const description = buildPlanAuditDescription({ userName, - category: created.category, + categoryLabel: await resolveCategoryLabel(created.category), projectName, dateFrom: input.date_from, dateTo: input.date_to, @@ -474,6 +502,11 @@ export async function updateEntry( return { error: "Datum do musí být stejné nebo po datumu od", status: 400 }; } + if (input.category) { + const catErr = await assertActiveCategory(input.category); + if (catErr) return catErr; + } + const updated = await prisma.work_plan_entries.update({ where: { id }, data: { @@ -491,7 +524,7 @@ export async function updateEntry( ); const description = buildPlanAuditDescription({ userName, - category: updated.category, + categoryLabel: await resolveCategoryLabel(updated.category), projectName, dateFrom: isoDay(updated.date_from), dateTo: isoDay(updated.date_to), @@ -526,7 +559,7 @@ export async function deleteEntry( ); const description = buildPlanAuditDescription({ userName, - category: existing.category, + categoryLabel: await resolveCategoryLabel(existing.category), projectName, dateFrom: isoDay(existing.date_from), dateTo: isoDay(existing.date_to), @@ -563,6 +596,9 @@ export async function createOverride( const lock = assertNotPastDate(input.shift_date, force); if (lock) return lock; + const catErr = await assertActiveCategory(input.category); + if (catErr) return catErr; + // Application-level enforcement of "at most one active override per // (user_id, shift_date)". The previous DB unique constraint was dropped // (see migration 20260605122718_drop_wpo_unique_constraint) because @@ -613,7 +649,7 @@ export async function createOverride( ); const description = buildPlanAuditDescription({ userName, - category: created.category, + categoryLabel: await resolveCategoryLabel(created.category), projectName, dateFrom: input.shift_date, dateTo: input.shift_date, @@ -625,7 +661,7 @@ export async function createOverride( const r = await resolvePlanLabels(replaced.user_id, replaced.project_id); replacedDescription = buildPlanAuditDescription({ userName: r.userName, - category: replaced.category, + categoryLabel: await resolveCategoryLabel(replaced.category), projectName: r.projectName, dateFrom: isoDay(replaced.shift_date), dateTo: isoDay(replaced.shift_date), @@ -659,6 +695,11 @@ export async function updateOverride( const lock = assertNotPastDate(dateStr, force); if (lock) return lock; + if (input.category) { + const catErr = await assertActiveCategory(input.category); + if (catErr) return catErr; + } + const updated = await prisma.work_plan_overrides.update({ where: { id }, data: { @@ -674,7 +715,7 @@ export async function updateOverride( ); const description = buildPlanAuditDescription({ userName, - category: updated.category, + categoryLabel: await resolveCategoryLabel(updated.category), projectName, dateFrom: isoDay(updated.shift_date), dateTo: isoDay(updated.shift_date), @@ -711,7 +752,7 @@ export async function deleteOverride( ); const description = buildPlanAuditDescription({ userName, - category: existing.category, + categoryLabel: await resolveCategoryLabel(existing.category), projectName, dateFrom: isoDay(existing.shift_date), dateTo: isoDay(existing.shift_date), diff --git a/src/utils/planAuditDescription.ts b/src/utils/planAuditDescription.ts index f202806..e1a5c0d 100644 --- a/src/utils/planAuditDescription.ts +++ b/src/utils/planAuditDescription.ts @@ -47,8 +47,8 @@ export function formatPlanDate(iso: string): string { export interface PlanAuditDescriptionInput { /** Employee the plan row belongs to (display name). */ userName: string; - /** `plan_category` enum value. */ - category: string; + /** Already-resolved Czech category label (resolved from plan_categories). */ + categoryLabel: string; /** Resolved project name, or null when the row has no project. */ projectName?: string | null; /** Range start (YYYY-MM-DD). For overrides, pass the shift date here. */ @@ -74,11 +74,7 @@ export function buildPlanAuditDescription( ? formatPlanDate(opts.dateFrom) : `${formatPlanDate(opts.dateFrom)} – ${formatPlanDate(opts.dateTo)}`; - const parts: string[] = [ - opts.userName, - dateLabel, - planCategoryLabel(opts.category), - ]; + const parts: string[] = [opts.userName, dateLabel, opts.categoryLabel]; if (opts.projectName && opts.projectName.trim()) { parts.push(opts.projectName.trim());