fix(harvests): allow several harvests of the same type per year
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.
- Drop the (year, type) unique index. The slug is now the only unique,
public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
"apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
(decomposed umlauts) and writes out umlauts. The migration repairs
manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
writers cannot produce duplicates; known slugs are skipped up front to
keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f1c0da4673
commit
641afa1ae4
15 files changed
+407
-83
No files matched your search
@@ -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-<ApiCore-ID>" 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<boolean> {
|
||||
return entries > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ernten aus der Zeit, als der Slug "apicore-<ID>" der Sync-Schluessel war,
|
||||
* bekommen eine lesbare Adresse. Gefahrlos auch fuer veroeffentlichte
|
||||
* Ernten: oeffentlich verlinkt war bis dahin /ernten/<jahr>/<sorte>, nie der
|
||||
* Slug. Gibt zurueck, ob umbenannt wurde.
|
||||
*/
|
||||
async function replaceLegacySlug(harvest: { id: string; slug: string; type: string; harvestDate: Date }): Promise<boolean> {
|
||||
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<HarvestSyncCount
|
||||
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||
|
||||
for (const source of sources) {
|
||||
const slug = slugForApiCoreSource(source.id);
|
||||
const date = new Date(source.date);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
counts.failed++;
|
||||
@@ -142,17 +160,19 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
|
||||
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||
const existing = await prisma.harvest.findUnique({ where: { apicoreHarvestId: source.id } });
|
||||
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||
if (existing && shopOwned) {
|
||||
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||
const facts = harvestFactChanges(existing, date);
|
||||
if (!existing.isPublished) counts.protectedDrafts++;
|
||||
const renamed = await replaceLegacySlug(existing);
|
||||
if (Object.keys(facts).length === 0) {
|
||||
counts.skipped++;
|
||||
if (renamed) counts.updated++;
|
||||
else counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
await prisma.harvest.update({ where: { slug }, data: facts });
|
||||
await prisma.harvest.update({ where: { id: existing.id }, data: facts });
|
||||
counts.updated++;
|
||||
continue;
|
||||
}
|
||||
@@ -166,17 +186,23 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await prisma.harvest.update({ where: { slug }, data });
|
||||
await prisma.harvest.update({ where: { id: existing.id }, data });
|
||||
await replaceLegacySlug({ ...existing, ...data });
|
||||
counts.updated++;
|
||||
} else {
|
||||
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
||||
// Der Slug ist die oeffentliche Adresse und wird aus Sorte und Datum
|
||||
// gebildet; mehrere Ernten derselben Sorte im selben Jahr bekommen
|
||||
// ueber das Erntedatum je eine eigene (lib/harvest-slug.ts).
|
||||
await writeWithFreeSlug(harvestSlugCandidates(type, date), (slug) =>
|
||||
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++;
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import { getPublishedHarvestSlugByYearType } from "@/lib/data";
|
||||
|
||||
/**
|
||||
* Alte Ernten-Adresse /ernten/<jahr>/<sorte> (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}`);
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
const { year, type } = await props.params;
|
||||
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
||||
export async function generateMetadata(props: PageProps<"/ernten/[slug]">): Promise<Metadata> {
|
||||
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/<jahr>/<sorte> — 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 (
|
||||
@@ -50,7 +50,7 @@ export default async function ErntenPage() {
|
||||
{byYear[year].map((harvest, i) => (
|
||||
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||
<Link
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
href={`/ernten/${harvest.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<span className="relative block overflow-hidden rounded-lg transition-[transform,box-shadow] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]">
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
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,
|
||||
|
||||
@@ -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> = {}): 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-<ID>'", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-16
@@ -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-<ApiCore-ID>" zugleich der
|
||||
* Idempotenz-Schluessel des Syncs. Seit der Slug die oeffentliche Adresse ist
|
||||
* (/ernten/<slug>), 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-4
@@ -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/<jahr>/<sorte>, 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<string | null> {
|
||||
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<
|
||||
|
||||
@@ -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<string[]> {
|
||||
const rows = await prisma.harvest.findMany({
|
||||
where: { slug: { in: candidates } },
|
||||
select: { slug: true },
|
||||
});
|
||||
return rows.map((row) => row.slug);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { slugify } from "./slugify";
|
||||
|
||||
/**
|
||||
* Oeffentliche Adresse einer Ernte (/ernten/<slug>).
|
||||
*
|
||||
* 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<T>(
|
||||
candidates: string[],
|
||||
write: (slug: string) => Promise<T>,
|
||||
findTaken: (candidates: string[]) => Promise<string[]> = async () => []
|
||||
): Promise<T> {
|
||||
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)}").`);
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
Reference in new issue
Block a user