diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index ff3c744..e9450e6 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { notifyStockAvailable } from "@/lib/stock-notifications"; -import { adminProductNumbersSchema } from "@/lib/validation"; +import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation"; import { isValidUploadPath } from "@/lib/uploads"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { succeed } from "@/lib/flash"; @@ -29,7 +29,15 @@ async function assertPricedIfActive(redirectPath: string, isActive: boolean, pri export async function createProductAction(formData: FormData): Promise { const admin = await requireAdmin(); - const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-"); + const parsedIdentity = adminProductIdentitySchema.safeParse({ + name: str(formData, "name"), + slug: str(formData, "slug"), + category: str(formData, "category") || "HONIG", + }); + if (!parsedIdentity.success) { + return fail("/admin/produkte/neu", parsedIdentity.error.issues[0]?.message ?? "Ungültige Eingabe."); + } + const { slug, category } = parsedIdentity.data; const parsedNumbers = adminProductNumbersSchema.safeParse({ intensityMild: str(formData, "intensityMild"), @@ -49,7 +57,6 @@ export async function createProductAction(formData: FormData): Promise { const isActive = formData.get("isActive") === "on"; await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents); - const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS"; // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind // davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien // ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen. diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index ca455ce..5ee87c4 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema } from "./validation"; +import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema, adminProductIdentitySchema } from "./validation"; describe("registerSchema", () => { test("accepts a valid registration", () => { @@ -182,3 +182,27 @@ describe("discountCodeSchema", () => { if (result.success) expect(result.data.code).toBe("SOMMER15"); }); }); + +describe("adminProductIdentitySchema", () => { + test("bildet den Slug aus dem Namen, wenn keiner angegeben ist", () => { + const result = adminProductIdentitySchema.safeParse({ name: "Wald Honig 2026", slug: "", category: "HONIG" }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.slug).toBe("wald-honig-2026"); + }); + + test("bereinigt einen eigenen Slug statt Schraegstriche oder Sonderzeichen durchzulassen", () => { + const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "../Admin/Honig", category: "KERZEN" }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.slug).toBe("admin-b-honig-b"); + }); + + test("lehnt eine unbekannte Kategorie ab", () => { + const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "", category: "GIFT" }); + expect(result.success).toBe(false); + }); + + test("lehnt einen Namen ohne verwertbare Zeichen fuer den Slug ab", () => { + const result = adminProductIdentitySchema.safeParse({ name: "???", slug: "", category: "HONIG" }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 0ce52fb..ea8d388 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -129,6 +129,39 @@ export const adminProductNumbersSchema = z.object({ }); export type AdminProductNumbersInput = z.infer; +const PRODUCT_SLUG_MAX_LENGTH = 80; + +/** Slug als oeffentliche Adresse (/honig/): nur a-z, 0-9 und Bindestrich. */ +function toProductSlug(value: string): string { + return value + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, PRODUCT_SLUG_MAX_LENGTH) + .replace(/-+$/, ""); +} + +// Kategorie und Slug beim Anlegen eines Produkts. Frueher kam die Kategorie +// nur per Typ-Cast aus dem Formular (ein manipulierter Wert endete als +// unbehandelte Prisma-Ausnahme), und ein eigener Slug wurde ungeprueft zur +// oeffentlichen Adresse — samt Schraegstrichen und Sonderzeichen. +export const adminProductIdentitySchema = z + .object({ + name: z.string().trim().min(1, "Bitte einen Namen angeben."), + slug: z.string().trim().default(""), + category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }), + }) + .transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) })) + .refine((input) => input.slug.length > 0, { + message: "Aus Name bzw. Slug laesst sich keine Adresse bilden. Bitte einen Slug mit Buchstaben oder Ziffern eintragen.", + path: ["slug"], + }); +export type AdminProductIdentityInput = z.infer; + export const shippingTierSchema = z .object({ minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),