fix(apicore-sync): keep internal notes out of the shop and protect edited drafts

- 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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 05:39:09 +02:00
1 parent e0b47faf45
commit 0482fd490e
9 files changed
+194 -17

No files matched your search

+32 -5
View File
@@ -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<boolean> {
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<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
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<Sy
counts.skipped++;
continue;
}
if (existing && (await wasEditedByAdmin(existing.id))) {
counts.protectedDrafts++;
continue;
}
const data = {
year: date.getFullYear(),
@@ -419,7 +443,9 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
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<void> {
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}` +
+27 -1
View File
@@ -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 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.protectedDrafts(protectedDrafts)}
</p>
) : null}
{pending > 0 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{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 (
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
<p className="text-sm text-ink/65 mb-4">
{lastRun
? t.lastRun(
lastRun.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Berlin",
})
)
: t.neverRun}
</p>
<form action={syncApiCoreAction}>
<input type="hidden" name="returnTo" value={returnTo} />
<Button type="submit" className="px-5 py-2.5">
+5 -1
View File
@@ -1156,7 +1156,7 @@ export const de: Dictionary = {
adminApiCoreSync: {
heading: "ApiCore-Synchronisierung",
intro:
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene Einträge werden nie überschrieben.",
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene und im Admin bearbeitete Einträge werden nie überschrieben; interne ApiCore-Notizen werden nicht übernommen.",
button: "Alles synchronisieren",
resultHarvests: (created, updated, skipped, failed) =>
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
@@ -1168,6 +1168,10 @@ export const de: Dictionary = {
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
discontinued: (count) =>
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
protectedDrafts: (count) =>
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet und deshalb nicht überschrieben.`,
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
neverRun: "Noch kein vollständiger Lauf.",
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
},
+5 -1
View File
@@ -1131,7 +1131,7 @@ export const en: Dictionary = {
adminApiCoreSync: {
heading: "ApiCore sync",
intro:
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released are never overwritten.",
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released or edited in the admin are never overwritten; internal ApiCore notes are not copied.",
button: "Sync everything",
resultHarvests: (created, updated, skipped, failed) =>
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
@@ -1143,6 +1143,10 @@ export const en: Dictionary = {
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
discontinued: (count) =>
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
protectedDrafts: (count) =>
`${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin and therefore not overwritten.`,
lastRun: (when) => `Last complete run: ${when}`,
neverRun: "No complete run yet.",
error: "Sync with ApiCore failed.",
batchLoadError: "Batches could not be loaded — without them no products are created either",
},
+3
View File
@@ -733,6 +733,9 @@ export interface Dictionary {
resultProducts: (created: number, skipped: number, failed: number) => string;
pending: (count: number) => string;
discontinued: (count: number) => string;
protectedDrafts: (count: number) => string;
lastRun: (when: string) => string;
neverRun: string;
error: string;
batchLoadError: string;
};
+80
View File
@@ -0,0 +1,80 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
ApiCoreRequestError,
type ApiCoreSession,
} from "./apicore-client";
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("apicore-client", () => {
beforeEach(() => {
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
// Arrange
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError"))
);
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
});
test("setzt an jeder Anfrage ein Zeitlimit", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
// Act
await fetchApiCoreHarvests(session);
// Assert
expect(fetchMock).toHaveBeenCalled();
for (const call of fetchMock.mock.calls) {
expect((call[1] as RequestInit).signal).toBeInstanceOf(AbortSignal);
}
});
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
// Arrange: ApiCore antwortet mit 500 — eine leere Liste daraus zu machen,
// hiesse fuer den Sync "es gibt nichts", und darauf darf nichts aufbauen.
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
// Act & Assert
await expect(fetchApiCoreHarvests(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
test("verwirft eine Antwort mit unerwarteter Struktur statt sie teilweise zu uebernehmen", async () => {
// Arrange: Schleuderungen ok, Chargen ohne Pflichtfeld lotNumber.
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/extractions")
? jsonResponse([])
: jsonResponse([{ id: "b1", date: "2026-08-01", bestBeforeDate: "2028-08-01", jarCount: 10 }])
)
);
// Act & Assert
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
});
+21 -4
View File
@@ -47,6 +47,23 @@ import { z } from "zod";
export class ApiCoreConfigError extends Error {}
export class ApiCoreRequestError extends Error {}
/**
* Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore,
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt — der Knopf
* dreht sich, und es gibt weder Ergebnis noch Fehlermeldung.
*/
const REQUEST_TIMEOUT_MS = 20_000;
function describeFetchFailure(error: unknown): string {
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
// a DOMException, which is not an Error subclass in every runtime.
const name = typeof error === "object" && error !== null && "name" in error ? error.name : undefined;
if (name === "TimeoutError" || name === "AbortError") {
return `keine Antwort innerhalb von ${REQUEST_TIMEOUT_MS / 1000} Sekunden`;
}
return error instanceof Error ? error.message : "Unbekannter Fehler";
}
const loginResponseSchema = z.union([
z.object({ accessToken: z.string().min(1) }),
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
@@ -79,10 +96,10 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
@@ -116,10 +133,10 @@ async function getJson<T>(
try {
res = await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(
+15 -3
View File
@@ -64,19 +64,31 @@ describe("slugForApiCoreSource", () => {
});
describe("buildHarvestDescription", () => {
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
test("fasst die vorhandenen Mengenangaben zusammen", () => {
// Arrange
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41 });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg"
);
});
test("uebernimmt interne ApiCore-Notizen nie in den Shoptext", () => {
// Arrange: Betriebsnotiz, die nicht oeffentlich werden darf.
const source = makeHarvest({ netWeight: 41, notes: "Volk 7 nach Varroa-Behandlung schwach" });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).not.toContain("Varroa");
expect(description).not.toContain("Volk 7");
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
const source = makeHarvest();
+6 -2
View File
@@ -24,14 +24,18 @@ export function slugForApiCoreSource(sourceId: string): string {
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
* vorhandenen Mengenfeldern, die der Admin beim Review verfeinert.
*
* ApiCores `notes` werden bewusst NICHT uebernommen: das sind interne
* Betriebsnotizen des Imkers (Beobachtungen, Probleme, Behandlungshinweise),
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
*/
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}