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

@@ -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),