From 0482fd490eeb4d60c616f41867f0fe788858c192 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 27 Sep 2026 05:39:09 +0200 Subject: [PATCH] fix(apicore-sync): keep internal notes out of the shop and protect edited drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Harvest descriptions no longer include ApiCore's internal harvest notes. The description becomes public on "Veröffentlichen", so a draft published without a careful read could have carried operational notes into the shop. - A repeated sync overwrote unpublished harvest drafts, undoing the admin's corrections (season instead of honey type, location, sales text). Drafts with an admin audit entry are now left alone and reported as protected. - Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted the connection but never answered left the admin action hanging. - The sync panel shows the time of the last complete run. - Client tests: timeout, failed fetch is never treated as an empty source, malformed responses are rejected as a whole. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/admin/actions/apicore-sync.ts | 37 +++++++++++-- src/components/apicore-sync-panel.tsx | 28 +++++++++- src/i18n/dictionaries/de.ts | 6 +- src/i18n/dictionaries/en.ts | 6 +- src/i18n/dictionary-type.ts | 3 + src/lib/apicore-client.test.ts | 80 +++++++++++++++++++++++++++ src/lib/apicore-client.ts | 25 +++++++-- src/lib/apicore-mapping.test.ts | 18 +++++- src/lib/apicore-mapping.ts | 8 ++- 9 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 src/lib/apicore-client.test.ts diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index dc378dc..b1968f4 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -37,8 +37,11 @@ import { requireAdmin } from "./_shared"; * ein Mensch freigibt. * * - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished - * false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher - * synchronisierte Ernten bleiben unberuehrt. + * false) bzw. als solche aktualisiert — aber nur, solange niemand den + * Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der + * Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins + * (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder + * zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt. * - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false, * Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein * bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis, @@ -68,6 +71,23 @@ interface SyncCounts { failed: number; } +interface HarvestSyncCounts extends SyncCounts { + /** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */ + protectedDrafts: number; +} + +/** + * Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht, + * zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt + * damit nicht mit. + */ +async function wasEditedByAdmin(harvestId: string): Promise { + const entries = await prisma.auditLog.count({ + where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } }, + }); + return entries > 0; +} + /** * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt, * siehe syncProducts(). @@ -95,8 +115,8 @@ function describeError(error: unknown): string { // Ernten // --------------------------------------------------------------------------- -async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { - const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 }; +async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { + const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 }; for (const source of sources) { const slug = slugForApiCoreSource(source.id); @@ -114,6 +134,10 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { await logAdminAction(admin, { action: "harvest.sync_apicore", entityType: "Harvest", - detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`, + detail: + `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` + + `${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`, }); // 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen. @@ -458,6 +484,7 @@ export async function syncApiCoreAction(formData: FormData): Promise { redirect( `${returnTo}?syncDone=1` + `&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` + + `&hProtected=${harvests.protectedDrafts}` + `&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` + `&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` + `&pPending=${products.pending}&pDiscontinued=${products.discontinued}` + diff --git a/src/components/apicore-sync-panel.tsx b/src/components/apicore-sync-panel.tsx index e0dc923..82b620e 100644 --- a/src/components/apicore-sync-panel.tsx +++ b/src/components/apicore-sync-panel.tsx @@ -1,4 +1,5 @@ import { syncApiCoreAction } from "@/app/admin/actions"; +import { prisma } from "@/lib/prisma"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { Button } from "@/components/ui"; @@ -38,6 +39,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe if (!done && syncError === undefined) return null; const pending = count(searchParams.pPending); + const protectedDrafts = count(searchParams.hProtected); const discontinued = count(searchParams.pDiscontinued); return ( @@ -63,6 +65,11 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe ) : null} {/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung, es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */} + {protectedDrafts > 0 ? ( +

+ {t.protectedDrafts(protectedDrafts)} +

+ ) : null} {pending > 0 ? (

{t.pending(pending)} @@ -88,12 +95,31 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe * daraus keine offene Weiterleitung wird. */ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) { - const t = getDictionary(await getLocale()).adminApiCoreSync; + const locale = await getLocale(); + const t = getDictionary(locale).adminApiCoreSync; + // Jeder vollstaendige Lauf schreibt zuletzt den Produkt-Eintrag — der ist + // damit der Zeitpunkt des letzten Laufs, der bis zum Ende kam. + const lastRun = await prisma.auditLog.findFirst({ + where: { action: "product.sync_apicore" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); return (

{t.heading}

{t.intro}

+

+ {lastRun + ? t.lastRun( + lastRun.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Berlin", + }) + ) + : t.neverRun} +