Compare commits

..
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 641afa1ae4 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]>
2026-10-02 04:25:02 +02:00
Nick SzaunigandClaude Opus 5.5 f1c0da4673 feat(admin): archive tabs for business inquiries and reviews
Inquiries: completed and rejected inquiries move to an "Archiv" tab;
resetting the status brings them back. Reviews: new "Ablehnen" action
(Review.rejectedAt) instead of only deleting; the archive shows decided
reviews in two columns, accepted and rejected, and either decision can
be reversed. Pending counters in the sidebar and dashboard exclude
rejected reviews, and the product page tells the author their review was
not published instead of "still being checked".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:13:07 +02:00
30 changed files with 815 additions and 145 deletions

No files matched your search

@@ -0,0 +1,5 @@
-- Abgelehnte Bewertungen werden nicht mehr geloescht, sondern markiert und
-- im Archiv unter "Abgelehnt" gefuehrt. null = nicht abgelehnt.
-- AlterTable
ALTER TABLE "reviews" ADD COLUMN "rejectedAt" TIMESTAMP(3);
@@ -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/<slug>).
--
-- Der Sync erkannte importierte Ernten bisher am Slug "apicore-<ID>". 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");
+12 -2
View File
@@ -148,9 +148,14 @@ model Address {
model Harvest { model Harvest {
id String @id @default(cuid()) id String @id @default(cuid())
year Int 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 type String
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
slug String @unique 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 harvestDate DateTime
location String @default("Altenmuenster") location String @default("Altenmuenster")
description String description String
@@ -170,7 +175,8 @@ model Harvest {
batches Batch[] batches Batch[]
products Product[] products Product[]
@@unique([year, type]) /// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
@@index([year, type])
@@map("harvests") @@map("harvests")
} }
@@ -326,6 +332,10 @@ model Review {
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt — /// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung. /// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
isApproved Boolean @default(false) isApproved Boolean @default(false)
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
rejectedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+39 -13
View File
@@ -15,8 +15,10 @@ import {
type ApiCoreProduct, type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId, type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client"; } from "@/lib/apicore-client";
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
import { import {
slugForApiCoreSource, hasLegacyApiCoreSlug,
buildHarvestDescription, buildHarvestDescription,
buildProductDescription, buildProductDescription,
deriveJarWeightGrams, deriveJarWeightGrams,
@@ -59,8 +61,10 @@ import { requireAdmin } from "./_shared";
* der Losnummer — die kann in ApiCore korrigiert werden. * der Losnummer — die kann in ApiCore korrigiert werden.
* *
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz: * Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
* "apicore-<ApiCore-ID>" als Harvest.slug, und die ApiCore-ID der Abfuellung * die ApiCore-ID der Ernte als Harvest.apicoreHarvestId, und die ApiCore-ID
* als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId. * 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 * Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE * ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
@@ -101,6 +105,21 @@ async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
return entries > 0; 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, * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
* siehe syncProducts(). * 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 }; const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
for (const source of sources) { for (const source of sources) {
const slug = slugForApiCoreSource(source.id);
const date = new Date(source.date); const date = new Date(source.date);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
counts.failed++; counts.failed++;
@@ -142,17 +160,19 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`; const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
try { 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))); const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
if (existing && shopOwned) { if (existing && shopOwned) {
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop. // Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
const facts = harvestFactChanges(existing, date); const facts = harvestFactChanges(existing, date);
if (!existing.isPublished) counts.protectedDrafts++; if (!existing.isPublished) counts.protectedDrafts++;
const renamed = await replaceLegacySlug(existing);
if (Object.keys(facts).length === 0) { if (Object.keys(facts).length === 0) {
counts.skipped++; if (renamed) counts.updated++;
else counts.skipped++;
continue; continue;
} }
await prisma.harvest.update({ where: { slug }, data: facts }); await prisma.harvest.update({ where: { id: existing.id }, data: facts });
counts.updated++; counts.updated++;
continue; continue;
} }
@@ -166,17 +186,23 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
}; };
if (existing) { if (existing) {
await prisma.harvest.update({ where: { slug }, data }); await prisma.harvest.update({ where: { id: existing.id }, data });
await replaceLegacySlug({ ...existing, ...data });
counts.updated++; counts.updated++;
} else { } 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++; counts.created++;
} }
} catch (error: unknown) { } catch (error: unknown) {
counts.failed++; counts.failed++;
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten // Bewusst nicht fatal fuer den gesamten Sync-Lauf: die uebrigen Ernten
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer // werden weiter verarbeitet, der Fehler steht im Server-Log.
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`); console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
} }
} }
@@ -407,7 +433,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
continue; continue;
} }
const harvest = await prisma.harvest.findUnique({ const harvest = await prisma.harvest.findUnique({
where: { slug: slugForApiCoreSource(source.harvestSourceId) }, where: { apicoreHarvestId: source.harvestSourceId },
}); });
if (!harvest) { if (!harvest) {
counts.failed++; counts.failed++;
+14 -4
View File
@@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared"; import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
import { succeed } from "@/lib/flash"; 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 * 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 admin = await requireAdmin();
const year = num(formData, "year"); const year = num(formData, "year");
const type = str(formData, "type"); 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 // Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer // (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor // manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
// Einfuehrung von isPublished). // Einfuehrung von isPublished).
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on"; const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
const harvest = await prisma.harvest.create({ // 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: { data: {
year, year,
type, type,
slug, slug,
harvestDate: new Date(str(formData, "harvestDate")), harvestDate,
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
images: imageSlots(formData), images: imageSlots(formData),
isPublished, isPublished,
}, },
}); }),
findTakenHarvestSlugs
);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "harvest.create", action: "harvest.create",
+3 -2
View File
@@ -5,16 +5,17 @@ import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str, fail } from "./_shared"; import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash"; import { setFlash } from "@/lib/flash";
import { isInquiryStatus } from "@/lib/inquiry-status";
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> { export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
const status = str(formData, "status"); const status = str(formData, "status");
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status."); if (!isInquiryStatus(status)) return fail("/admin/anfragen", "Ungültiger Status.");
await prisma.businessInquiry.update({ await prisma.businessInquiry.update({
where: { id }, where: { id },
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" }, data: { status },
}); });
await logAdminAction(admin, { await logAdminAction(admin, {
+28 -1
View File
@@ -12,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
const review = await prisma.review.update({ const review = await prisma.review.update({
where: { id }, where: { id },
data: { isApproved: true }, data: { isApproved: true, rejectedAt: null },
select: { product: { select: { slug: true, name: true } } }, select: { product: { select: { slug: true, name: true } } },
}); });
@@ -28,6 +28,33 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar."); await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
} }
/**
* Lehnt eine Bewertung ab, statt sie zu loeschen: sie verschwindet aus dem
* Shop (falls sie dort war) und steht im Archiv unter "Abgelehnt", damit die
* Entscheidung nachvollziehbar bleibt und sich rueckgaengig machen laesst.
*/
export async function rejectReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const review = await prisma.review.update({
where: { id },
data: { isApproved: false, rejectedAt: new Date() },
select: { product: { select: { slug: true, name: true } } },
});
await logAdminAction(admin, {
action: "review.reject",
entityType: "Review",
entityId: id,
detail: review.product.name,
});
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung abgelehnt — sie steht jetzt im Archiv.");
}
export async function deleteReviewAction(formData: FormData): Promise<void> { export async function deleteReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
+41 -8
View File
@@ -9,6 +9,9 @@ import {
AdminSearchForm, AdminSearchForm,
type AdminBadgeTone, type AdminBadgeTone,
} from "@/components/admin-list"; } from "@/components/admin-list";
import { AdminArchiveTabs } from "@/components/admin-archive";
import { isArchiveView } from "@/lib/archive";
import { inquiryViewWhere } from "@/lib/inquiry-status";
import { updateBusinessInquiryStatusAction } from "../actions"; import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -28,11 +31,11 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted, ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
ABGELEHNT: t.adminInquiriesPage.statusRejected, ABGELEHNT: t.adminInquiriesPage.statusRejected,
}; };
const { q } = await props.searchParams; const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const inquiries = await prisma.businessInquiry.findMany({ const filterWhere = query
where: query
? { ? {
OR: [ OR: [
{ companyName: insensitiveContains(query) }, { companyName: insensitiveContains(query) },
@@ -40,23 +43,45 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
{ email: insensitiveContains(query) }, { email: insensitiveContains(query) },
], ],
} }
: undefined, : {};
orderBy: { createdAt: "desc" },
const [inquiries, activeCount, archivedCount] = await Promise.all([
prisma.businessInquiry.findMany({
where: { ...filterWhere, ...inquiryViewWhere(isArchive) },
// Aktiv: zuletzt eingegangen oben. Archiv: zuletzt erledigt oben.
orderBy: isArchive ? { updatedAt: "desc" } : { createdAt: "desc" },
take: 100, take: 100,
}); }),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(false) } }),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(true) } }),
]);
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
<AdminPageHeader title={t.adminInquiriesPage.title} /> <AdminPageHeader title={t.adminInquiriesPage.title} />
<AdminArchiveTabs
basePath="/admin/anfragen"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{ q: query }}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleInquiries}</p> : null}
<AdminSearchForm <AdminSearchForm
query={query} query={query}
label={t.adminInquiriesPage.searchLabel} label={t.adminInquiriesPage.searchLabel}
placeholder={t.adminInquiriesPage.searchPlaceholder} placeholder={t.adminInquiriesPage.searchPlaceholder}
submitLabel={t.adminInquiriesPage.filterButton} submitLabel={t.adminInquiriesPage.filterButton}
resetHref="/admin/anfragen" resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
resetLabel={t.adminInquiriesPage.resetFilter} resetLabel={t.adminInquiriesPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/> />
<div className="space-y-4"> <div className="space-y-4">
{inquiries.map((inquiry) => ( {inquiries.map((inquiry) => (
@@ -119,7 +144,15 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
{inquiries.length === 0 ? ( {inquiries.length === 0 ? (
<AdminEmptyState <AdminEmptyState
bordered bordered
message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet} message={
query
? t.adminInquiriesPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: archivedCount > 0
? t.adminInquiriesPage.noOpenInquiries
: t.adminInquiriesPage.noInquiriesYet
}
/> />
) : null} ) : null}
</div> </div>
+178 -57
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { Prisma } from "@/generated/prisma/client";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
@@ -12,58 +13,109 @@ import {
AdminRowActions, AdminRowActions,
AdminSearchForm, AdminSearchForm,
} from "@/components/admin-list"; } from "@/components/admin-list";
import { AdminArchiveTabs } from "@/components/admin-archive";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { approveReviewAction, deleteReviewAction } from "../actions"; import { isArchiveView } from "@/lib/archive";
import { approvedReviewWhere, pendingReviewWhere, rejectedReviewWhere } from "@/lib/review-status";
import { approveReviewAction, deleteReviewAction, rejectReviewAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) { const MAX_ROWS = 100;
const t = getDictionary(await getLocale()); const BASE_PATH = "/admin/bewertungen";
const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const reviews = await prisma.review.findMany({ const REVIEW_INCLUDE = {
where: query product: { select: { name: true, slug: true } },
? { customer: { select: { firstName: true, lastName: true } },
} satisfies Prisma.ReviewInclude;
type AdminReview = Prisma.ReviewGetPayload<{ include: typeof REVIEW_INCLUDE }>;
type ReviewTexts = Dictionary["adminReviewsPage"];
function searchWhere(query: string): Prisma.ReviewWhereInput {
if (!query) return {};
return {
OR: [ OR: [
{ product: { name: insensitiveContains(query) } }, { product: { name: insensitiveContains(query) } },
{ customer: { firstName: insensitiveContains(query) } }, { customer: { firstName: insensitiveContains(query) } },
{ customer: { lastName: insensitiveContains(query) } }, { customer: { lastName: insensitiveContains(query) } },
], ],
};
} }
: undefined,
include: { export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
product: { select: { name: true, slug: true } }, const t = getDictionary(await getLocale());
customer: { select: { firstName: true, lastName: true } }, const s = t.adminReviewsPage;
}, const { q, ansicht } = await props.searchParams;
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }], const query = typeof q === "string" ? q.trim() : "";
take: 100, const isArchive = isArchiveView(ansicht);
}); const filter = searchWhere(query);
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
]);
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} /> <BackLink href="/admin" label={s.backToDashboard} />
<AdminPageHeader title={t.adminReviewsPage.title} /> <AdminPageHeader title={s.title} />
<AdminArchiveTabs
basePath={BASE_PATH}
isArchive={isArchive}
counts={{ active: pendingCount, archived: decidedCount }}
keepParams={{ q: query }}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReviews}</p> : null}
<AdminSearchForm <AdminSearchForm
query={query} query={query}
label={t.adminReviewsPage.searchLabel} label={s.searchLabel}
placeholder={t.adminReviewsPage.searchPlaceholder} placeholder={s.searchPlaceholder}
submitLabel={t.adminReviewsPage.filterButton} submitLabel={s.filterButton}
resetHref="/admin/bewertungen" resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
resetLabel={t.adminReviewsPage.resetFilter} resetLabel={s.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/> />
{isArchive ? (
<div className="grid gap-6 lg:grid-cols-2">
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
</div>
) : (
<PendingTable reviews={pending} query={query} s={s} />
)}
</div>
);
}
function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) {
return (
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
<tr> <tr>
<th className="p-4">{t.adminReviewsPage.colProduct}</th> <th className="p-4">{s.colProduct}</th>
<th className="p-4">{t.adminReviewsPage.colCustomer}</th> <th className="p-4">{s.colCustomer}</th>
<th className="p-4">{t.adminReviewsPage.colRating}</th> <th className="p-4">{s.colRating}</th>
<th className="p-4">{t.adminReviewsPage.colComment}</th> <th className="p-4">{s.colComment}</th>
<th className="p-4">{t.adminReviewsPage.colDate}</th> <th className="p-4">{s.colDate}</th>
<th className="p-4">{t.adminReviewsPage.colStatus}</th> <th className="p-4">{s.colStatus}</th>
<th className="p-4" /> <th className="p-4" />
</tr> </tr>
</thead> </thead>
@@ -71,55 +123,124 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
{reviews.map((review) => ( {reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4"> <td className="p-4">
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading"> <ProductLink review={review} />
{review.product.name}
</Link>
</td> </td>
<td className="p-4"> <td className="p-4">
{review.customer.firstName} {review.customer.lastName} {review.customer.firstName} {review.customer.lastName}
</td> </td>
<td className="p-4 whitespace-nowrap"> <td className="p-4 whitespace-nowrap">
<StarRating value={review.rating} /> <Stars rating={review.rating} />
<span className="sr-only">{review.rating} / 5</span>
</td> </td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td> <td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td> <td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4"> <td className="p-4">
{review.isApproved ? (
<AdminBadge tone="positive">{t.adminReviewsPage.statusPublished}</AdminBadge>
) : (
<AdminBadge tone="pending" pulse> <AdminBadge tone="pending" pulse>
{t.adminReviewsPage.statusPending} {s.statusPending}
</AdminBadge> </AdminBadge>
)}
</td> </td>
<td className="p-4"> <td className="p-4">
<AdminRowActions> <ReviewActions review={review} decision="pending" s={s} />
{!review.isApproved ? (
<form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
</form>
) : null}
<ConfirmForm
action={deleteReviewAction}
confirmMessage={t.adminReviewsPage.deleteConfirm}
>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
</ConfirmForm>
</AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{reviews.length === 0 ? ( {reviews.length === 0 ? (
<AdminEmptyState <AdminEmptyState message={query ? s.noResultsFiltered : s.noPendingReviews} />
message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
/>
) : null} ) : null}
</div> </div>
);
}
function DecidedColumn({
title,
reviews,
decision,
s,
}: {
title: string;
reviews: AdminReview[];
decision: "approved" | "rejected";
s: ReviewTexts;
}) {
return (
<section aria-label={title} className="bg-background border border-ink/10">
<h2 className="flex items-center justify-between gap-3 border-b border-ink/10 px-5 py-3 text-heading">
{title}
<AdminBadge tone={decision === "approved" ? "positive" : "neutral"}>{reviews.length}</AdminBadge>
</h2>
{reviews.length === 0 ? (
<p className="px-5 py-6 text-sm text-ink/60">{s.emptyColumn}</p>
) : (
<ul className="divide-y divide-ink/10">
{reviews.map((review) => (
<li key={review.id} className="space-y-2 px-5 py-4 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<ProductLink review={review} />
<Stars rating={review.rating} />
</div> </div>
<p className="text-ink/70">
{review.customer.firstName} {review.customer.lastName} ·{" "}
<span className="tabular-nums">{formatDate(review.createdAt)}</span>
</p>
{review.comment ? <p className="text-ink/80">{review.comment}</p> : null}
{decision === "rejected" && review.rejectedAt ? (
<p className="text-xs text-ink/55">{s.rejectedOn(formatDate(review.rejectedAt))}</p>
) : null}
<ReviewActions review={review} decision={decision} s={s} />
</li>
))}
</ul>
)}
</section>
);
}
function ProductLink({ review }: { review: AdminReview }) {
return (
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
{review.product.name}
</Link>
);
}
function Stars({ rating }: { rating: number }) {
return (
<span className="whitespace-nowrap">
<StarRating value={rating} />
<span className="sr-only">{rating} / 5</span>
</span>
);
}
/** Freischalten und Ablehnen jeweils nur dort, wo es den Stand aendert; Loeschen immer. */
function ReviewActions({
review,
decision,
s,
}: {
review: AdminReview;
decision: "pending" | "approved" | "rejected";
s: ReviewTexts;
}) {
return (
<AdminRowActions>
{decision !== "approved" ? (
<form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{s.approve}</AdminActionButton>
</form>
) : null}
{decision !== "rejected" ? (
<ConfirmForm action={rejectReviewAction} confirmMessage={s.rejectConfirm}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton>{s.reject}</AdminActionButton>
</ConfirmForm>
) : null}
<ConfirmForm action={deleteReviewAction} confirmMessage={s.deleteConfirm}>
<input type="hidden" name="id" value={review.id} />
<AdminActionButton tone="danger">{s.delete}</AdminActionButton>
</ConfirmForm>
</AdminRowActions>
); );
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice"; import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { logoutAction } from "@/app/konto/actions"; import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell"; import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -58,7 +59,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
}), }),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }), prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
prisma.review.count({ where: { isApproved: false } }), prisma.review.count({ where: pendingReviewWhere() }),
prisma.businessInquiry.count({ where: { status: "NEU" } }), prisma.businessInquiry.count({ where: { status: "NEU" } }),
]); ]);
+2 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { COUNTED_SALE_WHERE } from "@/lib/sales"; import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -264,7 +265,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}), }),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: { isApproved: false } }), prisma.review.count({ where: pendingReviewWhere() }),
prisma.businessInquiry.count({ where: { status: "NEU" } }), prisma.businessInquiry.count({ where: { status: "NEU" } }),
]); ]);
const toDo = [ const toDo = [
+1
View File
@@ -76,6 +76,7 @@ export async function GET() {
comment: r.comment, comment: r.comment,
createdAt: r.createdAt, createdAt: r.createdAt,
isApproved: r.isApproved, isApproved: r.isApproved,
rejectedAt: r.rejectedAt,
})), })),
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })), wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
newsletter: newsletter newsletter: newsletter
+36
View File
@@ -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 { ScrollReveal } from "@/components/scroll-reveal";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ProductTeaserLink } from "@/components/product-card"; import { ProductTeaserLink } from "@/components/product-card";
import { getHarvestByYearType } from "@/lib/data"; import { getHarvestBySlug } from "@/lib/data";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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. // entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata( export async function generateMetadata(props: PageProps<"/ernten/[slug]">): Promise<Metadata> {
props: PageProps<"/ernten/[year]/[type]"> const { slug } = await props.params;
): Promise<Metadata> { const harvest = await getHarvestBySlug(slug);
const { year, type } = await props.params;
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
if (!harvest) return {}; if (!harvest) return {};
return { return {
title: `${harvest.type} ${harvest.year}`, title: `${harvest.type} ${harvest.year}`,
description: harvest.description, 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 locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const { year, type } = await props.params; const { slug } = await props.params;
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht" // Slugs bestehen nur aus a-z, 0-9 und "-" (lib/slugify.ts) — ein Decoding
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf // wie bei der frueheren Sorten-Adresse ist nicht noetig.
// automatisches Decoding zu verlassen. const harvest = await getHarvestBySlug(slug);
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
if (!harvest) notFound(); if (!harvest) notFound();
return ( return (
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function ErntenPage() {
{byYear[year].map((harvest, i) => ( {byYear[year].map((harvest, i) => (
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}> <ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
<Link <Link
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`} href={`/ernten/${harvest.slug}`}
className="group block" 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)]"> <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)]">
+5 -1
View File
@@ -482,7 +482,11 @@ export default async function ProductDetailPage(
</p> </p>
) : ownReview ? ( ) : ownReview ? (
<p className="text-sm text-ink/70"> <p className="text-sm text-ink/70">
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed} {ownReview.isApproved
? t.productPage.alreadyReviewed
: ownReview.rejectedAt
? t.productPage.alreadyReviewedRejected
: t.productPage.alreadyReviewedPending}
</p> </p>
) : !canReview ? ( ) : !canReview ? (
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p> <p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
+1 -1
View File
@@ -45,7 +45,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.8, priority: 0.8,
})), })),
...harvests.map((h) => ({ ...harvests.map((h) => ({
url: `${base}/ernten/${h.year}/${encodeURIComponent(h.type)}`, url: `${base}/ernten/${h.slug}`,
lastModified: h.updatedAt, lastModified: h.updatedAt,
changeFrequency: "monthly" as const, changeFrequency: "monthly" as const,
priority: 0.5, priority: 0.5,
+14
View File
@@ -835,6 +835,14 @@ export const de: Dictionary = {
deleteConfirm: "Diese Bewertung wirklich löschen?", deleteConfirm: "Diese Bewertung wirklich löschen?",
delete: "Löschen", delete: "Löschen",
noReviewsYet: "Noch keine Bewertungen vorhanden.", noReviewsYet: "Noch keine Bewertungen vorhanden.",
statusRejected: "Abgelehnt",
reject: "Ablehnen",
rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.",
noPendingReviews: "Keine offenen Bewertungen — alles entschieden.",
columnApproved: "Akzeptiert",
columnRejected: "Abgelehnt",
rejectedOn: (date) => `Abgelehnt am ${date}`,
emptyColumn: "Keine Einträge.",
}, },
adminAuditLogPage: { adminAuditLogPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
@@ -1102,6 +1110,10 @@ export const de: Dictionary = {
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.", "Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
autoRuleProducts: autoRuleProducts:
"Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.", "Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.",
autoRuleInquiries:
"Abgeschlossene und abgelehnte Anfragen landen sofort hier. Wird der Status zurückgesetzt, kehrt die Anfrage in die aktive Liste zurück.",
autoRuleReviews:
"Freigeschaltete und abgelehnte Bewertungen landen sofort hier. Die Entscheidung lässt sich jederzeit umkehren.",
emptyArchive: "Das Archiv ist leer.", emptyArchive: "Das Archiv ist leer.",
}, },
errorPage: { errorPage: {
@@ -1341,6 +1353,7 @@ export const de: Dictionary = {
unitsSuffix: (quantity) => `${quantity} Stück`, unitsSuffix: (quantity) => `${quantity} Stück`,
deliveryDateLabel: "Liefertermin", deliveryDateLabel: "Liefertermin",
noInquiriesYet: "Noch keine Firmenanfragen eingegangen.", noInquiriesYet: "Noch keine Firmenanfragen eingegangen.",
noOpenInquiries: "Keine offenen Firmenanfragen.",
}, },
adminOrderDetailPage: { adminOrderDetailPage: {
postNumberLabel: "DHL-Postnummer", postNumberLabel: "DHL-Postnummer",
@@ -1876,6 +1889,7 @@ export const de: Dictionary = {
loginToReview: "um dieses Produkt zu bewerten.", loginToReview: "um dieses Produkt zu bewerten.",
alreadyReviewed: "Du hast dieses Produkt bereits bewertet.", alreadyReviewed: "Du hast dieses Produkt bereits bewertet.",
alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.", alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.",
alreadyReviewedRejected: "Du hast dieses Produkt bereits bewertet. Deine Bewertung wurde nicht veröffentlicht.",
onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.", onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.",
yourRating: "Deine Bewertung", yourRating: "Deine Bewertung",
commentPlaceholder: "Was hat dir gefallen? (optional)", commentPlaceholder: "Was hat dir gefallen? (optional)",
+14
View File
@@ -811,6 +811,14 @@ export const en: Dictionary = {
deleteConfirm: "Really delete this review?", deleteConfirm: "Really delete this review?",
delete: "Delete", delete: "Delete",
noReviewsYet: "No reviews yet.", noReviewsYet: "No reviews yet.",
statusRejected: "Rejected",
reject: "Reject",
rejectConfirm: "Reject this review? It won't be shown in the shop and moves to the archive.",
noPendingReviews: "No open reviews — everything has been decided.",
columnApproved: "Accepted",
columnRejected: "Rejected",
rejectedOn: (date) => `Rejected on ${date}`,
emptyColumn: "No entries.",
}, },
adminAuditLogPage: { adminAuditLogPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
@@ -1077,6 +1085,10 @@ export const en: Dictionary = {
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.", autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
autoRuleProducts: autoRuleProducts:
"Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.", "Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.",
autoRuleInquiries:
"Completed and rejected inquiries move here right away. Resetting the status brings an inquiry back to the active list.",
autoRuleReviews:
"Approved and rejected reviews move here right away. The decision can be reversed at any time.",
emptyArchive: "The archive is empty.", emptyArchive: "The archive is empty.",
}, },
errorPage: { errorPage: {
@@ -1315,6 +1327,7 @@ export const en: Dictionary = {
unitsSuffix: (quantity) => `${quantity} units`, unitsSuffix: (quantity) => `${quantity} units`,
deliveryDateLabel: "Delivery date", deliveryDateLabel: "Delivery date",
noInquiriesYet: "No business inquiries yet.", noInquiriesYet: "No business inquiries yet.",
noOpenInquiries: "No open business inquiries.",
}, },
adminOrderDetailPage: { adminOrderDetailPage: {
postNumberLabel: "DHL Post Number", postNumberLabel: "DHL Post Number",
@@ -1850,6 +1863,7 @@ export const en: Dictionary = {
loginToReview: "to review this product.", loginToReview: "to review this product.",
alreadyReviewed: "You've already reviewed this product.", alreadyReviewed: "You've already reviewed this product.",
alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.", alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.",
alreadyReviewedRejected: "You've already reviewed this product. Your review was not published.",
onlyBuyersCanReview: "Only customers who purchased this product can review it.", onlyBuyersCanReview: "Only customers who purchased this product can review it.",
yourRating: "Your rating", yourRating: "Your rating",
commentPlaceholder: "What did you like? (optional)", commentPlaceholder: "What did you like? (optional)",
+12
View File
@@ -403,6 +403,14 @@ export interface Dictionary {
deleteConfirm: string; deleteConfirm: string;
delete: string; delete: string;
noReviewsYet: string; noReviewsYet: string;
statusRejected: string;
reject: string;
rejectConfirm: string;
noPendingReviews: string;
columnApproved: string;
columnRejected: string;
rejectedOn: (date: string) => string;
emptyColumn: string;
}; };
adminAuditLogPage: { adminAuditLogPage: {
backToDashboard: string; backToDashboard: string;
@@ -664,6 +672,8 @@ export interface Dictionary {
autoRuleOrders: string; autoRuleOrders: string;
autoRuleReturns: string; autoRuleReturns: string;
autoRuleProducts: string; autoRuleProducts: string;
autoRuleInquiries: string;
autoRuleReviews: string;
emptyArchive: string; emptyArchive: string;
}; };
errorPage: { errorPage: {
@@ -895,6 +905,7 @@ export interface Dictionary {
unitsSuffix: (quantity: number) => string; unitsSuffix: (quantity: number) => string;
deliveryDateLabel: string; deliveryDateLabel: string;
noInquiriesYet: string; noInquiriesYet: string;
noOpenInquiries: string;
}; };
adminOrderDetailPage: { adminOrderDetailPage: {
postNumberLabel: string; postNumberLabel: string;
@@ -1382,6 +1393,7 @@ export interface Dictionary {
loginToReview: string; loginToReview: string;
alreadyReviewed: string; alreadyReviewed: string;
alreadyReviewedPending: string; alreadyReviewedPending: string;
alreadyReviewedRejected: string;
onlyBuyersCanReview: string; onlyBuyersCanReview: string;
yourRating: string; yourRating: string;
commentPlaceholder: string; commentPlaceholder: string;
+6 -18
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client"; import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
import { import {
slugForApiCoreSource, hasLegacyApiCoreSlug,
buildHarvestDescription, buildHarvestDescription,
buildProductDescription, buildProductDescription,
deriveWeightGrams, deriveWeightGrams,
@@ -40,25 +40,13 @@ function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
}; };
} }
describe("slugForApiCoreSource", () => { describe("hasLegacyApiCoreSlug", () => {
test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => { test("erkennt das alte Slug-Format 'apicore-<ID>'", () => {
// Arrange expect(hasLegacyApiCoreSlug("apicore-cmtx1ri7x001j012bvcqh9tpt")).toBe(true);
const sourceId = "cm-product-1";
// Act
const slug = slugForApiCoreSource(sourceId);
// Assert
expect(slug).toBe("apicore-cm-product-1");
}); });
test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => { test("laesst lesbare Adressen in Ruhe", () => {
// Arrange: derselbe Datensatz, in ApiCore umbenannt. expect(hasLegacyApiCoreSlug("bluetenhonig-2026")).toBe(false);
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));
}); });
}); });
+10 -16
View File
@@ -1,4 +1,5 @@
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client"; import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
import { slugify } from "./slugify";
/** /**
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von * 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 * Frueher war der Harvest.slug "apicore-<ApiCore-ID>" zugleich der
* Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob * Idempotenz-Schluessel des Syncs. Seit der Slug die oeffentliche Adresse ist
* ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf * (/ernten/<slug>), traegt Harvest.apicoreHarvestId diese Aufgabe; Ernten mit
* Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den * dem alten Slug-Format bekommen beim naechsten Sync eine lesbare Adresse.
* manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen
* neuen Slug und wuerde ein zweites Mal importiert.
*/ */
export function slugForApiCoreSource(sourceId: string): string { export const LEGACY_APICORE_SLUG_PREFIX = "apicore-";
return `apicore-${sourceId}`;
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". */ /** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
export function slugifyProductName(name: string): string { export function slugifyProductName(name: string): string {
return name return slugify(name);
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} }
/** /**
+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) * ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
* Ernten ueber ihre Shop-URL nicht erratbar sind. * Ernten ueber ihre Shop-URL nicht erratbar sind.
*/ */
export async function getHarvestByYearType( export async function getHarvestBySlug(
year: number, slug: string
type: string
): Promise< ): Promise<
| (HarvestWithImages & { | (HarvestWithImages & {
batches: Batch[]; batches: Batch[];
@@ -143,13 +142,28 @@ export async function getHarvestByYearType(
| null | null
> { > {
const harvest = await prisma.harvest.findUnique({ const harvest = await prisma.harvest.findUnique({
where: { year_type: { year, type } }, where: { slug },
include: { batches: true, products: true }, include: { batches: true, products: true },
}); });
if (!harvest || !harvest.isPublished) return null; if (!harvest || !harvest.isPublished) return null;
return { ...withImages(harvest), products: harvest.products.map(withImages) }; 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( export async function getBatchByCode(
code: string code: string
): Promise< ): 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)}").`);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Aktiv/Archiv fuer Firmenanfragen.
*
* Abgeschlossene und abgelehnte Anfragen wandern ins Archiv, sobald der
* Status gesetzt ist. Wird der Status wieder auf "Neu" oder "In Bearbeitung"
* gestellt, steht die Anfrage automatisch wieder in der aktiven Liste — es
* gibt keine eigene Archiv-Markierung, die man vergessen koennte.
*/
import type { Prisma } from "@/generated/prisma/client";
export const INQUIRY_STATUSES = ["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"] as const;
export type InquiryStatusValue = (typeof INQUIRY_STATUSES)[number];
export const INQUIRY_DONE_STATUSES = ["ABGESCHLOSSEN", "ABGELEHNT"] as const satisfies readonly InquiryStatusValue[];
export function isInquiryStatus(value: string): value is InquiryStatusValue {
return (INQUIRY_STATUSES as readonly string[]).includes(value);
}
export function inquiryViewWhere(isArchive: boolean) {
return {
status: isArchive ? { in: [...INQUIRY_DONE_STATUSES] } : { notIn: [...INQUIRY_DONE_STATUSES] },
} satisfies Prisma.BusinessInquiryWhereInput;
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import {
reviewDecision,
pendingReviewWhere,
approvedReviewWhere,
rejectedReviewWhere,
} from "./review-status";
import { inquiryViewWhere, isInquiryStatus } from "./inquiry-status";
describe("reviewDecision", () => {
it("ist offen, solange weder freigeschaltet noch abgelehnt", () => {
expect(reviewDecision({ isApproved: false, rejectedAt: null })).toBe("pending");
});
it("ist abgelehnt, wenn rejectedAt gesetzt ist", () => {
expect(reviewDecision({ isApproved: false, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("rejected");
});
it("Freischaltung schlaegt eine fruehere Ablehnung", () => {
expect(reviewDecision({ isApproved: true, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("approved");
});
});
describe("Bewertungs-Filter", () => {
it("offene Bewertungen: weder freigeschaltet noch abgelehnt", () => {
expect(pendingReviewWhere()).toEqual({ isApproved: false, rejectedAt: null });
});
it("akzeptiert und abgelehnt ueberschneiden sich nicht", () => {
expect(approvedReviewWhere()).toEqual({ isApproved: true });
expect(rejectedReviewWhere()).toEqual({ isApproved: false, rejectedAt: { not: null } });
});
});
describe("inquiryViewWhere", () => {
it("aktive Liste: alles ausser abgeschlossen und abgelehnt", () => {
expect(inquiryViewWhere(false)).toEqual({ status: { notIn: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
});
it("Archiv: abgeschlossen und abgelehnt", () => {
expect(inquiryViewWhere(true)).toEqual({ status: { in: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
});
});
describe("isInquiryStatus", () => {
it("akzeptiert nur die vier bekannten Status", () => {
expect(isInquiryStatus("NEU")).toBe(true);
expect(isInquiryStatus("ABGELEHNT")).toBe(true);
expect(isInquiryStatus("GELOESCHT")).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Bearbeitungsstand einer Bewertung im Admin.
*
* Eine Bewertung ist offen, bis der Admin sie freischaltet (isApproved) oder
* ablehnt (rejectedAt). Offene stehen in der aktiven Liste, entschiedene im
* Archiv — dort getrennt nach "Akzeptiert" und "Abgelehnt". Der Shop zeigt
* weiterhin ausschliesslich isApproved-Bewertungen (lib/reviews.ts).
*
* Anders als Bestellungen und Retouren (lib/archive.ts) gibt es hier keine
* Monatsfrist: eine entschiedene Bewertung braucht keine weitere Arbeit, sie
* gehoert sofort ins Archiv.
*/
import type { Prisma } from "@/generated/prisma/client";
export type ReviewDecision = "pending" | "approved" | "rejected";
export function reviewDecision(review: { isApproved: boolean; rejectedAt: Date | null }): ReviewDecision {
if (review.isApproved) return "approved";
if (review.rejectedAt) return "rejected";
return "pending";
}
export function pendingReviewWhere() {
return { isApproved: false, rejectedAt: null } satisfies Prisma.ReviewWhereInput;
}
export function approvedReviewWhere() {
return { isApproved: true } satisfies Prisma.ReviewWhereInput;
}
export function rejectedReviewWhere() {
return { isApproved: false, rejectedAt: { not: null } } satisfies Prisma.ReviewWhereInput;
}
+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, "");
}