diff --git a/prisma/migrations/20261002020000_harvest_multiple_per_year/migration.sql b/prisma/migrations/20261002020000_harvest_multiple_per_year/migration.sql new file mode 100644 index 0000000..8a78d9d --- /dev/null +++ b/prisma/migrations/20261002020000_harvest_multiple_per_year/migration.sql @@ -0,0 +1,56 @@ +-- Mehrere Ernten derselben Sorte im selben Jahr zulassen. +-- +-- Bisher erzwang "harvests_year_type_key" genau eine Ernte je (Jahr, Sorte). +-- Eine Imkerei schleudert dieselbe Sorte aber oft mehrmals im Jahr; der +-- ApiCore-Sync scheiterte daran mit P2002. Eindeutig ist ab jetzt nur noch +-- der Slug (oeffentliche Adresse /ernten/). +-- +-- Der Sync erkannte importierte Ernten bisher am Slug "apicore-". Diese +-- ID wandert in eine eigene Spalte, damit der Slug lesbar werden darf. +-- Keine Zeile wird geloescht; die Aenderung ist rein additiv bis auf den +-- entfernten Unique-Index. + +-- AlterTable +ALTER TABLE "harvests" ADD COLUMN "apicoreHarvestId" TEXT; + +-- Backfill: bisherige Sync-Ernten behalten ihre ApiCore-Zuordnung. +UPDATE "harvests" +SET "apicoreHarvestId" = substring("slug" from 9) +WHERE "slug" LIKE 'apicore-%'; + +-- Von Hand angelegte Ernten bekamen ihren Slug bisher ohne Umlaut-Behandlung +-- ("Frühtracht" 2026 -> "fr-htracht-2026"). Bis jetzt war das unsichtbar; +-- ab jetzt ist der Slug die oeffentliche Adresse. Genau diese Form wird auf +-- die Schreibweise von lib/slugify.ts gebracht ("fruehtracht-2026") — nur, +-- wenn sie sich unterscheidet und die neue Adresse noch frei ist. Sonst +-- bleibt der alte Slug: er funktioniert, ist nur weniger schoen. +WITH renamed AS ( + SELECT + "id", + trim(both '-' from regexp_replace( + replace(replace(replace(replace(lower("type"), 'ä', 'ae'), 'ö', 'oe'), 'ü', 'ue'), 'ß', 'ss'), + '[^a-z0-9]+', '-', 'g' + )) || '-' || "year" AS "newSlug" + FROM "harvests" + WHERE "apicoreHarvestId" IS NULL + AND "slug" = regexp_replace(lower("type"), '[^a-z0-9]+', '-', 'g') || '-' || "year" +) +UPDATE "harvests" h +SET "slug" = r."newSlug" +FROM renamed r +WHERE h."id" = r."id" + AND r."newSlug" <> h."slug" + AND r."newSlug" <> ('-' || h."year") + AND NOT EXISTS (SELECT 1 FROM "harvests" o WHERE o."slug" = r."newSlug") + -- z.B. "Frühtracht" und "Fruehtracht" im selben Jahr: beide ergaeben + -- denselben Slug, der Unique-Index liesse die Migration scheitern. + AND (SELECT count(*) FROM renamed r2 WHERE r2."newSlug" = r."newSlug") = 1; + +-- CreateIndex +CREATE UNIQUE INDEX "harvests_apicoreHarvestId_key" ON "harvests"("apicoreHarvestId"); + +-- DropIndex +DROP INDEX "harvests_year_type_key"; + +-- CreateIndex +CREATE INDEX "harvests_year_type_idx" ON "harvests"("year", "type"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fb5b79..9587df5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,9 +148,14 @@ model Address { model Harvest { id String @id @default(cuid()) year Int - /// z.B. "Fruehtracht", "Sommertracht" + /// z.B. "Fruehtracht", "Sommertracht". Bewusst NICHT eindeutig je Jahr: + /// dieselbe Sorte wird oft mehrmals im Jahr geerntet. type String + /// Oeffentliche Adresse /ernten/ — siehe lib/harvest-slug.ts. slug String @unique + /// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs + /// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten. + apicoreHarvestId String? @unique harvestDate DateTime location String @default("Altenmuenster") description String @@ -170,7 +175,8 @@ model Harvest { batches Batch[] products Product[] - @@unique([year, type]) + /// Nur noch fuer die Weiterleitung alter Adressen /ernten//. + @@index([year, type]) @@map("harvests") } diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index e5807f0..1980e42 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -15,8 +15,10 @@ import { type ApiCoreProduct, type ApiCoreBottlingBatchWithHarvestId, } from "@/lib/apicore-client"; +import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug"; +import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db"; import { - slugForApiCoreSource, + hasLegacyApiCoreSlug, buildHarvestDescription, buildProductDescription, deriveJarWeightGrams, @@ -59,8 +61,10 @@ import { requireAdmin } from "./_shared"; * der Losnummer — die kann in ApiCore korrigiert werden. * * Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz: - * "apicore-" als Harvest.slug, und die ApiCore-ID der Abfuellung - * als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId. + * die ApiCore-ID der Ernte als Harvest.apicoreHarvestId, und die ApiCore-ID + * der Abfuellung als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId. + * Mehrere Ernten derselben Sorte im selben Jahr sind ausdruecklich erlaubt; + * sie unterscheiden sich im Slug am Erntedatum (lib/harvest-slug.ts). * * Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und * ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE @@ -101,6 +105,21 @@ async function wasEditedByAdmin(harvestId: string): Promise { return entries > 0; } +/** + * Ernten aus der Zeit, als der Slug "apicore-" der Sync-Schluessel war, + * bekommen eine lesbare Adresse. Gefahrlos auch fuer veroeffentlichte + * Ernten: oeffentlich verlinkt war bis dahin /ernten//, nie der + * Slug. Gibt zurueck, ob umbenannt wurde. + */ +async function replaceLegacySlug(harvest: { id: string; slug: string; type: string; harvestDate: Date }): Promise { + if (!hasLegacyApiCoreSlug(harvest.slug)) return false; + await writeWithFreeSlug(harvestSlugCandidates(harvest.type, harvest.harvestDate), (slug) => + prisma.harvest.update({ where: { id: harvest.id }, data: { slug } }), + findTakenHarvestSlugs + ); + return true; +} + /** * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt, * siehe syncProducts(). @@ -132,7 +151,6 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise + prisma.harvest.create({ data: { ...data, slug, apicoreHarvestId: source.id, isPublished: false } }), + findTakenHarvestSlugs + ); counts.created++; } } catch (error: unknown) { counts.failed++; - // z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten - // im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer - // den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision. + // Bewusst nicht fatal fuer den gesamten Sync-Lauf: die uebrigen Ernten + // werden weiter verarbeitet, der Fehler steht im Server-Log. console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`); } } @@ -407,7 +433,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis continue; } const harvest = await prisma.harvest.findUnique({ - where: { slug: slugForApiCoreSource(source.harvestSourceId) }, + where: { apicoreHarvestId: source.harvestSourceId }, }); if (!harvest) { counts.failed++; diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts index f4919b1..bc090b4 100644 --- a/src/app/admin/actions/harvests-batches.ts +++ b/src/app/admin/actions/harvests-batches.ts @@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log"; import { isValidUploadPath } from "@/lib/uploads"; import { requireAdmin, fail, str, num, strArraySlots } from "./_shared"; import { succeed } from "@/lib/flash"; +import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug"; +import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db"; /** * Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel @@ -26,25 +28,33 @@ export async function createHarvestAction(formData: FormData): Promise { const admin = await requireAdmin(); const year = num(formData, "year"); const type = str(formData, "type"); - const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`; + const harvestDate = new Date(str(formData, "harvestDate")); + if (!type || !Number.isInteger(year) || year < 1 || Number.isNaN(harvestDate.getTime())) { + return fail("/admin/ernten", "Bitte Sorte, Jahr und ein gültiges Erntedatum angeben."); + } // Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz // (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer // manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor // Einfuehrung von isPublished). const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on"; - const harvest = await prisma.harvest.create({ - data: { - year, - type, - slug, - harvestDate: new Date(str(formData, "harvestDate")), - location: str(formData, "location") || "Altenmünster", - description: str(formData, "description"), - images: imageSlots(formData), - isPublished, - }, - }); + // Dieselbe Sorte darf mehrmals im Jahr geerntet werden; die zweite bekommt + // eine Adresse mit Erntedatum statt eines Fehlers (lib/harvest-slug.ts). + const harvest = await writeWithFreeSlug(harvestSlugCandidates(type, harvestDate), (slug) => + prisma.harvest.create({ + data: { + year, + type, + slug, + harvestDate, + location: str(formData, "location") || "Altenmünster", + description: str(formData, "description"), + images: imageSlots(formData), + isPublished, + }, + }), + findTakenHarvestSlugs + ); await logAdminAction(admin, { action: "harvest.create", diff --git a/src/app/ernten/[slug]/[type]/page.tsx b/src/app/ernten/[slug]/[type]/page.tsx new file mode 100644 index 0000000..8a777ff --- /dev/null +++ b/src/app/ernten/[slug]/[type]/page.tsx @@ -0,0 +1,36 @@ +import { notFound, permanentRedirect } from "next/navigation"; +import { getPublishedHarvestSlugByYearType } from "@/lib/data"; + +/** + * Alte Ernten-Adresse /ernten// (z.B. /ernten/2026/Fr%C3%BChtracht). + * + * Sie stand in Sitemap, Links und Suchmaschinen, bevor Ernten eigene Slugs + * bekamen. 308 statt 404, damit diese Links weiter funktionieren und + * Suchmaschinen die neue Adresse uebernehmen. + * + * Das erste Segment heisst [slug], weil Next.js auf derselben Ebene keine + * zwei unterschiedlich benannten dynamischen Segmente erlaubt — hier steht + * darin das Jahr. + */ +export const dynamic = "force-dynamic"; + +const YEAR_PATTERN = /^\d{4}$/; + +function decodeSegment(segment: string): string | null { + try { + // Next.js liefert das Segment hier noch kodiert ("Fr%C3%BChtracht"). + return decodeURIComponent(segment).normalize("NFC"); + } catch { + return null; + } +} + +export default async function LegacyHarvestRedirect(props: PageProps<"/ernten/[slug]/[type]">) { + const { slug: year, type } = await props.params; + const decodedType = decodeSegment(type); + if (!YEAR_PATTERN.test(year) || !decodedType) notFound(); + + const target = await getPublishedHarvestSlugByYearType(Number(year), decodedType); + if (!target) notFound(); + permanentRedirect(`/ernten/${target}`); +} diff --git a/src/app/ernten/[year]/[type]/page.tsx b/src/app/ernten/[slug]/page.tsx similarity index 84% rename from src/app/ernten/[year]/[type]/page.tsx rename to src/app/ernten/[slug]/page.tsx index 0086b1c..cdf0425 100644 --- a/src/app/ernten/[year]/[type]/page.tsx +++ b/src/app/ernten/[slug]/page.tsx @@ -6,7 +6,7 @@ import { ContentImage } from "@/components/content-image"; import { ScrollReveal } from "@/components/scroll-reveal"; import { BackLink } from "@/components/back-link"; import { ProductTeaserLink } from "@/components/product-card"; -import { getHarvestByYearType } from "@/lib/data"; +import { getHarvestBySlug } from "@/lib/data"; import { formatDate } from "@/lib/format"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -15,29 +15,29 @@ import { getDictionary } from "@/i18n/get-dictionary"; // entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell. export const dynamic = "force-dynamic"; -export async function generateMetadata( - props: PageProps<"/ernten/[year]/[type]"> -): Promise { - const { year, type } = await props.params; - const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type)); +export async function generateMetadata(props: PageProps<"/ernten/[slug]">): Promise { + const { slug } = await props.params; + const harvest = await getHarvestBySlug(slug); if (!harvest) return {}; return { title: `${harvest.type} ${harvest.year}`, description: harvest.description, - alternates: { canonical: `/ernten/${year}/${type}` }, + alternates: { canonical: `/ernten/${harvest.slug}` }, }; } -export default async function HarvestDetailPage( - props: PageProps<"/ernten/[year]/[type]"> -) { +/** + * Eine Ernte je Slug. Frueher /ernten// — das reichte nicht, + * sobald dieselbe Sorte mehrmals im Jahr geerntet wird. Alte Adressen leitet + * ./[type]/page.tsx hierher weiter. + */ +export default async function HarvestDetailPage(props: PageProps<"/ernten/[slug]">) { const locale = await getLocale(); const t = getDictionary(locale); - const { year, type } = await props.params; - // Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht" - // statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf - // automatisches Decoding zu verlassen. - const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type)); + const { slug } = await props.params; + // Slugs bestehen nur aus a-z, 0-9 und "-" (lib/slugify.ts) — ein Decoding + // wie bei der frueheren Sorten-Adresse ist nicht noetig. + const harvest = await getHarvestBySlug(slug); if (!harvest) notFound(); return ( diff --git a/src/app/ernten/page.tsx b/src/app/ernten/page.tsx index 5e5ab54..2053002 100644 --- a/src/app/ernten/page.tsx +++ b/src/app/ernten/page.tsx @@ -50,7 +50,7 @@ export default async function ErntenPage() { {byYear[year].map((harvest, i) => ( diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index a300625..e1516a9 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -45,7 +45,7 @@ export default async function sitemap(): Promise { priority: 0.8, })), ...harvests.map((h) => ({ - url: `${base}/ernten/${h.year}/${encodeURIComponent(h.type)}`, + url: `${base}/ernten/${h.slug}`, lastModified: h.updatedAt, changeFrequency: "monthly" as const, priority: 0.5, diff --git a/src/lib/apicore-mapping.test.ts b/src/lib/apicore-mapping.test.ts index 9142d1b..262ff55 100644 --- a/src/lib/apicore-mapping.test.ts +++ b/src/lib/apicore-mapping.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest"; import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client"; import { - slugForApiCoreSource, + hasLegacyApiCoreSlug, buildHarvestDescription, buildProductDescription, deriveWeightGrams, @@ -40,25 +40,13 @@ function makeHarvest(overrides: Partial = {}): ApiCoreHarvest { }; } -describe("slugForApiCoreSource", () => { - test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => { - // Arrange - const sourceId = "cm-product-1"; - - // Act - const slug = slugForApiCoreSource(sourceId); - - // Assert - expect(slug).toBe("apicore-cm-product-1"); +describe("hasLegacyApiCoreSlug", () => { + test("erkennt das alte Slug-Format 'apicore-'", () => { + expect(hasLegacyApiCoreSlug("apicore-cmtx1ri7x001j012bvcqh9tpt")).toBe(true); }); - test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => { - // Arrange: derselbe Datensatz, in ApiCore umbenannt. - const before = makeProduct({ name: "Sommertracht 500 g" }); - const after = makeProduct({ name: "Sommertracht cremig, 500 g" }); - - // Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen. - expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id)); + test("laesst lesbare Adressen in Ruhe", () => { + expect(hasLegacyApiCoreSlug("bluetenhonig-2026")).toBe(false); }); }); diff --git a/src/lib/apicore-mapping.ts b/src/lib/apicore-mapping.ts index 046b55a..ecf6eb6 100644 --- a/src/lib/apicore-mapping.ts +++ b/src/lib/apicore-mapping.ts @@ -1,4 +1,5 @@ import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client"; +import { slugify } from "./slugify"; /** * Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von @@ -10,15 +11,15 @@ import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client"; */ /** - * Stabiler, eindeutiger Slug aus der ApiCore-ID — zugleich der - * Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob - * ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf - * Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den - * manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen - * neuen Slug und wuerde ein zweites Mal importiert. + * Frueher war der Harvest.slug "apicore-" zugleich der + * Idempotenz-Schluessel des Syncs. Seit der Slug die oeffentliche Adresse ist + * (/ernten/), traegt Harvest.apicoreHarvestId diese Aufgabe; Ernten mit + * dem alten Slug-Format bekommen beim naechsten Sync eine lesbare Adresse. */ -export function slugForApiCoreSource(sourceId: string): string { - return `apicore-${sourceId}`; +export const LEGACY_APICORE_SLUG_PREFIX = "apicore-"; + +export function hasLegacyApiCoreSlug(slug: string): boolean { + return slug.startsWith(LEGACY_APICORE_SLUG_PREFIX); } /** @@ -104,14 +105,7 @@ export function deriveJarWeightGrams( /** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */ export function slugifyProductName(name: string): string { - return name - .toLowerCase() - .replace(/ä/g, "ae") - .replace(/ö/g, "oe") - .replace(/ü/g, "ue") - .replace(/ß/g, "ss") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); + return slugify(name); } /** diff --git a/src/lib/data.ts b/src/lib/data.ts index 593e8d9..8fea141 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -132,9 +132,8 @@ export async function getHarvests(): Promise<(HarvestWithImages & { batches: Bat * ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte) * Ernten ueber ihre Shop-URL nicht erratbar sind. */ -export async function getHarvestByYearType( - year: number, - type: string +export async function getHarvestBySlug( + slug: string ): Promise< | (HarvestWithImages & { batches: Batch[]; @@ -143,13 +142,28 @@ export async function getHarvestByYearType( | null > { const harvest = await prisma.harvest.findUnique({ - where: { year_type: { year, type } }, + where: { slug }, include: { batches: true, products: true }, }); if (!harvest || !harvest.isPublished) return null; return { ...withImages(harvest), products: harvest.products.map(withImages) }; } +/** + * Fuer die Weiterleitung alter Adressen /ernten//, die vor den + * Slug-Adressen verlinkt und indexiert wurden. Damals gab es je Jahr und + * Sorte hoechstens eine Ernte; gibt es inzwischen mehrere, fuehrt die alte + * Adresse zur fruehesten — der, die sie damals bezeichnete. + */ +export async function getPublishedHarvestSlugByYearType(year: number, type: string): Promise { + const harvest = await prisma.harvest.findFirst({ + where: { year, type, isPublished: true }, + orderBy: { harvestDate: "asc" }, + select: { slug: true }, + }); + return harvest?.slug ?? null; +} + export async function getBatchByCode( code: string ): Promise< diff --git a/src/lib/harvest-slug-db.ts b/src/lib/harvest-slug-db.ts new file mode 100644 index 0000000..e4efc55 --- /dev/null +++ b/src/lib/harvest-slug-db.ts @@ -0,0 +1,14 @@ +import { prisma } from "@/lib/prisma"; + +/** + * Welche der vorgeschlagenen Ernten-Slugs schon vergeben sind — fuer + * writeWithFreeSlug (lib/harvest-slug.ts). Getrennt von dort, damit jene + * Datei ohne Datenbank testbar bleibt. + */ +export async function findTakenHarvestSlugs(candidates: string[]): Promise { + const rows = await prisma.harvest.findMany({ + where: { slug: { in: candidates } }, + select: { slug: true }, + }); + return rows.map((row) => row.slug); +} diff --git a/src/lib/harvest-slug.test.ts b/src/lib/harvest-slug.test.ts new file mode 100644 index 0000000..7f0d94b --- /dev/null +++ b/src/lib/harvest-slug.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { harvestSlugCandidates, writeWithFreeSlug, isUniqueConstraintError, MAX_SLUG_SUFFIX } from "./harvest-slug"; +import { slugify } from "./slugify"; + +const JULY_14 = new Date("2026-07-14T00:00:00.000Z"); + +describe("slugify", () => { + it("schreibt Umlaute aus", () => { + expect(slugify("Blütenhonig")).toBe("bluetenhonig"); + expect(slugify("Süßer Honig")).toBe("suesser-honig"); + expect(slugify("FRÜHTRACHT")).toBe("fruehtracht"); + }); + + it("behandelt zerlegte Umlaute (u + Trema) wie fertige", () => { + const decomposed = "Blütenhonig"; + expect(decomposed).not.toBe("Blütenhonig"); + expect(slugify(decomposed)).toBe("bluetenhonig"); + }); + + it("entfernt sonstige Akzente und Sonderzeichen", () => { + expect(slugify("Crème brûlée & Akazie (fein)")).toBe("creme-brulee-akazie-fein"); + }); +}); + +describe("harvestSlugCandidates", () => { + it("bevorzugt Sorte und Jahr, dann das Erntedatum", () => { + const [first, second, third] = harvestSlugCandidates("Blütenhonig", JULY_14); + expect(first).toBe("bluetenhonig-2026"); + expect(second).toBe("bluetenhonig-2026-07-14"); + expect(third).toBe("bluetenhonig-2026-07-14-2"); + }); + + it("unterscheidet zwei Ernten derselben Sorte im selben Jahr", () => { + const june = harvestSlugCandidates("Blütenhonig", new Date("2026-06-02T00:00:00.000Z")); + expect(june[1]).not.toBe(harvestSlugCandidates("Blütenhonig", JULY_14)[1]); + }); + + it("faellt ohne verwertbare Sorte auf 'ernte' zurueck", () => { + expect(harvestSlugCandidates("???", JULY_14)[0]).toBe("ernte-2026"); + }); + + it("liefert eine begrenzte Zahl Vorschlaege", () => { + expect(harvestSlugCandidates("Waldhonig", JULY_14)).toHaveLength(MAX_SLUG_SUFFIX + 1); + }); +}); + +describe("writeWithFreeSlug", () => { + const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" }); + + it("nimmt den ersten Vorschlag, wenn er frei ist", async () => { + const write = vi.fn(async (slug: string) => slug); + await expect(writeWithFreeSlug(["a", "b"], write)).resolves.toBe("a"); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("weicht bei belegtem Slug auf den naechsten aus", async () => { + const write = vi.fn(async (slug: string) => { + if (slug === "a") throw conflict; + return slug; + }); + await expect(writeWithFreeSlug(["a", "b"], write)).resolves.toBe("b"); + }); + + it("wirft andere Fehler sofort weiter", async () => { + const write = vi.fn(async () => { + throw new Error("Verbindung verloren"); + }); + await expect(writeWithFreeSlug(["a", "b"], write)).rejects.toThrow("Verbindung verloren"); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("ueberspringt vorab bekannte Belegungen ohne Schreibversuch", async () => { + const write = vi.fn(async (slug: string) => slug); + await expect(writeWithFreeSlug(["a", "b"], write, async () => ["a"])).resolves.toBe("b"); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("meldet, wenn alle Vorschlaege belegt sind", async () => { + const write = vi.fn(async () => { + throw conflict; + }); + await expect(writeWithFreeSlug(["a", "b"], write)).rejects.toThrow(/belegt/); + }); +}); + +describe("isUniqueConstraintError", () => { + it("erkennt nur P2002", () => { + expect(isUniqueConstraintError({ code: "P2002" })).toBe(true); + expect(isUniqueConstraintError({ code: "P2025" })).toBe(false); + expect(isUniqueConstraintError(null)).toBe(false); + }); +}); diff --git a/src/lib/harvest-slug.ts b/src/lib/harvest-slug.ts new file mode 100644 index 0000000..5e78445 --- /dev/null +++ b/src/lib/harvest-slug.ts @@ -0,0 +1,65 @@ +import { slugify } from "./slugify"; + +/** + * Oeffentliche Adresse einer Ernte (/ernten/). + * + * Eine Imkerei erntet dieselbe Sorte mehrmals im Jahr (z.B. zwei + * Schleuderungen Bluetenhonig im Juni und Juli). Sorte und Jahr allein + * unterscheiden Ernten deshalb nicht — eindeutig ist nur der Slug. + * + * Vorschlaege in absteigender Lesbarkeit: + * 1. "bluetenhonig-2026" — die erste Ernte einer Sorte im Jahr + * 2. "bluetenhonig-2026-07-14" — jede weitere, am Erntedatum erkennbar + * 3. "bluetenhonig-2026-07-14-2", "-3", … — gleicher Tag (selten, aber moeglich) + * + * Das Datum wird in UTC gelesen: ApiCore und das Admin-Formular liefern + * reine Kalendertage als Mitternacht UTC. + */ +export const MAX_SLUG_SUFFIX = 20; + +export function harvestSlugCandidates(type: string, harvestDate: Date): string[] { + const base = slugify(type) || "ernte"; + const year = harvestDate.getUTCFullYear(); + const day = harvestDate.toISOString().slice(0, 10); + const withDay = `${base}-${day}`; + const numbered = Array.from({ length: MAX_SLUG_SUFFIX - 1 }, (_, i) => `${withDay}-${i + 2}`); + return [`${base}-${year}`, withDay, ...numbered]; +} + +/** Prisma-Fehler "Unique constraint failed" — ohne Bindung an EINEN der beiden generierten Clients. */ +export function isUniqueConstraintError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "P2002"; +} + +/** + * Fuehrt `write` mit dem ersten noch freien Slug aus. + * + * Bewusst KEIN "erst pruefen, dann schreiben": zwischen beiden Schritten + * koennte ein zweiter Admin-Tab oder ein paralleler Sync denselben Slug + * belegen. Stattdessen entscheidet der Unique-Index der Datenbank — schlaegt + * das Schreiben daran fehl, wird der naechste Vorschlag versucht. + * + * Voraussetzung: der einzige Unique-Index, den `write` verletzen kann, ist + * der auf `slug`. Andere Fehler werden unveraendert weitergeworfen. + * + * `findTaken` filtert die schon bekannten Belegungen vorab heraus. Das ist + * nur Komfort: Prisma protokolliert jeden abgefangenen Konflikt trotzdem als + * "prisma:error", und ein Log voller scheinbarer Fehler fuehrt in die Irre. + * Die Sicherheit kommt weiterhin allein vom Unique-Index. + */ +export async function writeWithFreeSlug( + candidates: string[], + write: (slug: string) => Promise, + findTaken: (candidates: string[]) => Promise = async () => [] +): Promise { + const taken = new Set(await findTaken(candidates)); + const free = candidates.filter((slug) => !taken.has(slug)); + for (const slug of free) { + try { + return await write(slug); + } catch (error: unknown) { + if (!isUniqueConstraintError(error)) throw error; + } + } + throw new Error(`Alle ${candidates.length} Adressvorschläge sind belegt (zuletzt "${candidates.at(-1)}").`); +} diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts new file mode 100644 index 0000000..ffe2a1b --- /dev/null +++ b/src/lib/slugify.ts @@ -0,0 +1,23 @@ +/** + * Lesbarer URL-Teil aus beliebigem Text: "Blütenhonig, cremig" wird zu + * "bluetenhonig-cremig". + * + * Zuerst NFC: ein "ü" kann als ein Zeichen (U+00FC) oder als "u" plus + * Trema (U+0308) ankommen — je nachdem, ob der Text aus einem Formular, aus + * ApiCore oder aus einer kopierten macOS-Datei stammt. Ohne Normalisierung + * wuerde die zweite Form zu "u-" statt zu "ue". Deutsche Umlaute werden + * ausgeschrieben, uebrige Akzente (é, à, …) entfernt. + */ +export function slugify(text: string): string { + return text + .normalize("NFC") + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +}