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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user