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

+39 -13
View File
@@ -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++;
+23 -13
View File
@@ -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",