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