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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 00:41:50 +02:00
parent c983747418
commit a55356d417
4 changed files with 61 additions and 30 deletions

View File

@@ -24,7 +24,7 @@ describe("buildPlanAuditDescription", () => {
it("describes a multi-day entry with project", () => { it("describes a multi-day entry with project", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Jan Novák", userName: "Jan Novák",
category: "work", categoryLabel: "Práce",
projectName: "Rekonstrukce haly", projectName: "Rekonstrukce haly",
dateFrom: "2026-06-08", dateFrom: "2026-06-08",
dateTo: "2026-06-12", dateTo: "2026-06-12",
@@ -37,7 +37,7 @@ describe("buildPlanAuditDescription", () => {
it("collapses a single-day range to one date", () => { it("collapses a single-day range to one date", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Jan Novák", userName: "Jan Novák",
category: "work", categoryLabel: "Práce",
projectName: "Rekonstrukce haly", projectName: "Rekonstrukce haly",
dateFrom: "2026-06-08", dateFrom: "2026-06-08",
dateTo: "2026-06-08", dateTo: "2026-06-08",
@@ -48,7 +48,7 @@ describe("buildPlanAuditDescription", () => {
it("omits the project segment when there is no project", () => { it("omits the project segment when there is no project", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Petr Svoboda", userName: "Petr Svoboda",
category: "leave", categoryLabel: "Dovolená",
projectName: null, projectName: null,
dateFrom: "2026-06-10", dateFrom: "2026-06-10",
dateTo: "2026-06-10", dateTo: "2026-06-10",
@@ -59,7 +59,7 @@ describe("buildPlanAuditDescription", () => {
it("appends the emergency-edit note when force is set", () => { it("appends the emergency-edit note when force is set", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Jan Novák", userName: "Jan Novák",
category: "work", categoryLabel: "Práce",
projectName: null, projectName: null,
dateFrom: "2026-01-02", dateFrom: "2026-01-02",
dateTo: "2026-01-02", dateTo: "2026-01-02",
@@ -71,7 +71,7 @@ describe("buildPlanAuditDescription", () => {
it("appends a custom suffix (e.g. replaced override)", () => { it("appends a custom suffix (e.g. replaced override)", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Jan Novák", userName: "Jan Novák",
category: "sick", categoryLabel: "Nemoc",
projectName: null, projectName: null,
dateFrom: "2026-06-10", dateFrom: "2026-06-10",
dateTo: "2026-06-10", dateTo: "2026-06-10",
@@ -85,7 +85,7 @@ describe("buildPlanAuditDescription", () => {
it("treats a whitespace-only project name as no project", () => { it("treats a whitespace-only project name as no project", () => {
const desc = buildPlanAuditDescription({ const desc = buildPlanAuditDescription({
userName: "Jan Novák", userName: "Jan Novák",
category: "work", categoryLabel: "Práce",
projectName: " ", projectName: " ",
dateFrom: "2026-06-08", dateFrom: "2026-06-08",
dateTo: "2026-06-08", dateTo: "2026-06-08",

View File

@@ -9,15 +9,9 @@ const intFromForm = z
.transform((v) => Number(v)) .transform((v) => Number(v))
.refine((n) => Number.isInteger(n) && n > 0, "Neplatné ID"); .refine((n) => Number.isInteger(n) && n > 0, "Neplatné ID");
const planCategoryEnum = z.enum([ // Category is now a dynamic key (validated against plan_categories in the
"work", // service). Keep it a non-empty string here.
"preparation", const planCategoryEnum = z.string().trim().min(1, "Kategorie je povinná");
"travel",
"leave",
"sick",
"training",
"other",
]);
export const CreatePlanEntrySchema = z export const CreatePlanEntrySchema = z
.object({ .object({

View File

@@ -1,5 +1,8 @@
import prisma from "../config/database"; 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 * Resolve display names for an audit description: the employee's full name
@@ -35,6 +38,28 @@ async function resolvePlanLabels(
return { userName, projectName }; 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<string> {
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 /** 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. */ * file reads date-only columns (UTC slice), so it is timezone-stable. */
function isoDay(d: Date): string { function isoDay(d: Date): string {
@@ -414,6 +439,9 @@ export async function createEntry(
const lockTo = assertNotPastDate(input.date_to, force); const lockTo = assertNotPastDate(input.date_to, force);
if (lockTo) return lockTo; if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const created = await prisma.work_plan_entries.create({ const created = await prisma.work_plan_entries.create({
data: { data: {
user_id: input.user_id, user_id: input.user_id,
@@ -432,7 +460,7 @@ export async function createEntry(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: created.category, categoryLabel: await resolveCategoryLabel(created.category),
projectName, projectName,
dateFrom: input.date_from, dateFrom: input.date_from,
dateTo: input.date_to, 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 }; 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({ const updated = await prisma.work_plan_entries.update({
where: { id }, where: { id },
data: { data: {
@@ -491,7 +524,7 @@ export async function updateEntry(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: updated.category, categoryLabel: await resolveCategoryLabel(updated.category),
projectName, projectName,
dateFrom: isoDay(updated.date_from), dateFrom: isoDay(updated.date_from),
dateTo: isoDay(updated.date_to), dateTo: isoDay(updated.date_to),
@@ -526,7 +559,7 @@ export async function deleteEntry(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: existing.category, categoryLabel: await resolveCategoryLabel(existing.category),
projectName, projectName,
dateFrom: isoDay(existing.date_from), dateFrom: isoDay(existing.date_from),
dateTo: isoDay(existing.date_to), dateTo: isoDay(existing.date_to),
@@ -563,6 +596,9 @@ export async function createOverride(
const lock = assertNotPastDate(input.shift_date, force); const lock = assertNotPastDate(input.shift_date, force);
if (lock) return lock; if (lock) return lock;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
// Application-level enforcement of "at most one active override per // Application-level enforcement of "at most one active override per
// (user_id, shift_date)". The previous DB unique constraint was dropped // (user_id, shift_date)". The previous DB unique constraint was dropped
// (see migration 20260605122718_drop_wpo_unique_constraint) because // (see migration 20260605122718_drop_wpo_unique_constraint) because
@@ -613,7 +649,7 @@ export async function createOverride(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: created.category, categoryLabel: await resolveCategoryLabel(created.category),
projectName, projectName,
dateFrom: input.shift_date, dateFrom: input.shift_date,
dateTo: 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); const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
replacedDescription = buildPlanAuditDescription({ replacedDescription = buildPlanAuditDescription({
userName: r.userName, userName: r.userName,
category: replaced.category, categoryLabel: await resolveCategoryLabel(replaced.category),
projectName: r.projectName, projectName: r.projectName,
dateFrom: isoDay(replaced.shift_date), dateFrom: isoDay(replaced.shift_date),
dateTo: isoDay(replaced.shift_date), dateTo: isoDay(replaced.shift_date),
@@ -659,6 +695,11 @@ export async function updateOverride(
const lock = assertNotPastDate(dateStr, force); const lock = assertNotPastDate(dateStr, force);
if (lock) return lock; 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({ const updated = await prisma.work_plan_overrides.update({
where: { id }, where: { id },
data: { data: {
@@ -674,7 +715,7 @@ export async function updateOverride(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: updated.category, categoryLabel: await resolveCategoryLabel(updated.category),
projectName, projectName,
dateFrom: isoDay(updated.shift_date), dateFrom: isoDay(updated.shift_date),
dateTo: isoDay(updated.shift_date), dateTo: isoDay(updated.shift_date),
@@ -711,7 +752,7 @@ export async function deleteOverride(
); );
const description = buildPlanAuditDescription({ const description = buildPlanAuditDescription({
userName, userName,
category: existing.category, categoryLabel: await resolveCategoryLabel(existing.category),
projectName, projectName,
dateFrom: isoDay(existing.shift_date), dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date), dateTo: isoDay(existing.shift_date),

View File

@@ -47,8 +47,8 @@ export function formatPlanDate(iso: string): string {
export interface PlanAuditDescriptionInput { export interface PlanAuditDescriptionInput {
/** Employee the plan row belongs to (display name). */ /** Employee the plan row belongs to (display name). */
userName: string; userName: string;
/** `plan_category` enum value. */ /** Already-resolved Czech category label (resolved from plan_categories). */
category: string; categoryLabel: string;
/** Resolved project name, or null when the row has no project. */ /** Resolved project name, or null when the row has no project. */
projectName?: string | null; projectName?: string | null;
/** Range start (YYYY-MM-DD). For overrides, pass the shift date here. */ /** 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.dateFrom)} ${formatPlanDate(opts.dateTo)}`; : `${formatPlanDate(opts.dateFrom)} ${formatPlanDate(opts.dateTo)}`;
const parts: string[] = [ const parts: string[] = [opts.userName, dateLabel, opts.categoryLabel];
opts.userName,
dateLabel,
planCategoryLabel(opts.category),
];
if (opts.projectName && opts.projectName.trim()) { if (opts.projectName && opts.projectName.trim()) {
parts.push(opts.projectName.trim()); parts.push(opts.projectName.trim());