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:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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
|
||||
* 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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user