fix(harvests): allow several harvests of the same type per year
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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 04:25:02 +02:00
1 parent f1c0da4673
commit 641afa1ae4
15 files changed
+407 -83

No files matched your search

+6 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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<
+14
View File
@@ -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);
}
+92
View File
@@ -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);
});
});
+65
View File
@@ -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)}").`);
}
+23
View File
@@ -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, "");
}