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]>
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { prisma } from "@/lib/prisma";
|
|
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
|
|
* auf /charge/[code] das Bild fuellt (Slot 1 links, Slot 2 rechts/Standort) —
|
|
* deshalb ein leerer Slot als "" statt eines Eintrags weniger. Ein ungueltiger
|
|
* Pfad wird ebenfalls zu "" und damit zum Platzhalter, statt die
|
|
* nachfolgenden Slots zu verschieben.
|
|
*/
|
|
function imageSlots(formData: FormData): string[] {
|
|
const slots = strArraySlots(formData, "images").map((path) =>
|
|
isValidUploadPath(path) ? path : ""
|
|
);
|
|
while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop();
|
|
return slots;
|
|
}
|
|
|
|
export async function createHarvestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const year = num(formData, "year");
|
|
const type = str(formData, "type");
|
|
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";
|
|
|
|
// 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",
|
|
entityType: "Harvest",
|
|
entityId: harvest.id,
|
|
detail: `${type} ${year}`,
|
|
});
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
return succeed("/admin/ernten", "Ernte angelegt.");
|
|
}
|
|
|
|
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
const isPublished = formData.get("isPublished") === "on";
|
|
|
|
await prisma.harvest.update({
|
|
where: { id },
|
|
data: {
|
|
year: num(formData, "year"),
|
|
type: str(formData, "type"),
|
|
harvestDate: new Date(str(formData, "harvestDate")),
|
|
location: str(formData, "location") || "Altenmünster",
|
|
description: str(formData, "description"),
|
|
images: imageSlots(formData),
|
|
isPublished,
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
return succeed("/admin/ernten", "Ernte gespeichert.");
|
|
}
|
|
|
|
/**
|
|
* Loescht eine Ernte unwiderruflich. harvestId ist auf Batch UND
|
|
* harvestId/batchId auf Product jeweils onDelete: Restrict (siehe
|
|
* prisma/schema.prisma) — die Datenbank wuerde ein direktes delete() also
|
|
* ohnehin ablehnen, sobald noch Chargen oder Produkte auf diese Ernte
|
|
* verweisen. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine
|
|
* konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers.
|
|
*/
|
|
export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
|
|
const harvest = await prisma.harvest.findUnique({
|
|
where: { id },
|
|
include: { _count: { select: { batches: true, products: true } } },
|
|
});
|
|
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
|
|
|
|
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
|
return fail(
|
|
"/admin/ernten",
|
|
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
|
);
|
|
}
|
|
|
|
await prisma.harvest.delete({ where: { id } });
|
|
|
|
await logAdminAction(admin, {
|
|
action: "harvest.delete",
|
|
entityType: "Harvest",
|
|
entityId: id,
|
|
detail: `${harvest.type} ${harvest.year}`,
|
|
});
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
return succeed("/admin/ernten", "Ernte gelöscht.");
|
|
}
|
|
|
|
/**
|
|
* Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von
|
|
* updateHarvestAction — so kann der Status per Knopfdruck direkt aus der
|
|
* Ernten-Liste umgeschaltet werden, ohne durch das komplette Bearbeiten-
|
|
* Formular zu muessen (insbesondere fuer den "veroeffentlichen"-Bestaetigungs-
|
|
* schritt bei aus ApiCore synchronisierten Entwuerfen, siehe apicore-sync.ts).
|
|
*/
|
|
export async function toggleHarvestPublishedAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
const isPublished = formData.get("isPublished") === "on";
|
|
|
|
const harvest = await prisma.harvest.update({
|
|
where: { id },
|
|
data: { isPublished },
|
|
});
|
|
|
|
await logAdminAction(admin, {
|
|
action: isPublished ? "harvest.publish" : "harvest.unpublish",
|
|
entityType: "Harvest",
|
|
entityId: id,
|
|
detail: `${harvest.type} ${harvest.year}`,
|
|
});
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
|
|
}
|
|
|
|
export async function createBatchAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const code = str(formData, "code");
|
|
const batch = await prisma.batch.create({
|
|
data: {
|
|
code,
|
|
harvestId: str(formData, "harvestId"),
|
|
fillDate: new Date(str(formData, "fillDate")),
|
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
|
glassCount: num(formData, "glassCount"),
|
|
remainingGlasses: num(formData, "remainingGlasses"),
|
|
images: imageSlots(formData),
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
|
|
|
revalidatePath("/admin/chargen");
|
|
return succeed("/admin/chargen", "Charge angelegt.");
|
|
}
|
|
|
|
export async function updateBatchAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
|
|
await prisma.batch.update({
|
|
where: { id },
|
|
data: {
|
|
fillDate: new Date(str(formData, "fillDate")),
|
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
|
glassCount: num(formData, "glassCount"),
|
|
remainingGlasses: num(formData, "remainingGlasses"),
|
|
images: imageSlots(formData),
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
|
|
|
revalidatePath("/admin/chargen");
|
|
return succeed("/admin/chargen", "Charge gespeichert.");
|
|
}
|
|
|
|
/**
|
|
* Loescht eine Charge unwiderruflich. productId/batchId auf OrderItem sind
|
|
* onDelete: Restrict (siehe prisma/schema.prisma) — eine Charge, die schon
|
|
* in mindestens einer Bestellposition verwendet wurde, darf aus
|
|
* Rechnungs-/Nachweispflicht NIE geloescht werden (Chargen-Code steht auf
|
|
* dem QR-Code des Glases und in Rechnungen). Batch.products ist ebenfalls
|
|
* Restrict. Die Zaehlung hier VOR dem Loeschversuch liefert dafuer eine
|
|
* konkrete, verstaendliche Fehlermeldung statt eines rohen Datenbankfehlers.
|
|
*/
|
|
export async function deleteBatchAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
|
|
const batch = await prisma.batch.findUnique({
|
|
where: { id },
|
|
include: { _count: { select: { products: true, orderItems: true } } },
|
|
});
|
|
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
|
|
|
|
if (batch._count.orderItems > 0) {
|
|
return fail(
|
|
"/admin/chargen",
|
|
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
|
);
|
|
}
|
|
if (batch._count.products > 0) {
|
|
return fail(
|
|
"/admin/chargen",
|
|
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
|
);
|
|
}
|
|
|
|
await prisma.batch.delete({ where: { id } });
|
|
|
|
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
|
|
|
revalidatePath("/admin/chargen");
|
|
return succeed("/admin/chargen", "Charge gelöscht.");
|
|
}
|