refactor(apicore-sync): API key and shop feed only, no password login

The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 06:05:11 +02:00
1 parent 55be1fe881
commit 3bc0693e4d
10 files changed
+248 -548

No files matched your search

+4 -4
View File
@@ -5,13 +5,13 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
openApiCoreSession,
getApiCoreConnection,
fetchApiCoreShopData,
isSellableStatus,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreShopData,
type ApiCoreHarvestWithApiaryName,
type ApiCoreHarvest,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client";
@@ -128,7 +128,7 @@ function describeError(error: unknown): string {
// Ernten
// ---------------------------------------------------------------------------
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
for (const source of sources) {
@@ -480,7 +480,7 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
let source: ApiCoreShopData;
try {
source = await fetchApiCoreShopData(await openApiCoreSession());
source = await fetchApiCoreShopData(getApiCoreConnection());
} catch (error: unknown) {
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
}
+112 -137
View File
@@ -1,14 +1,14 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, test, expect, vi, afterEach } from "vitest";
import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
getApiCoreConnection,
fetchApiCoreShopData,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreSession,
type ApiCoreConnection,
} from "./apicore-client";
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
const API_KEY = `ack_abcd1234_${"A".repeat(43)}`;
const connection: ApiCoreConnection = { baseUrl: "http://apicore.test/api/v1", apiKey: API_KEY };
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -17,16 +17,92 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
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");
const feed = {
contractVersion: 1,
generatedAt: "2026-09-27T10:00:00.000Z",
harvests: [
{
id: "h1",
apiaryId: "a1",
apiaryName: "Streuobstwiese",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Sommertracht",
honeySuperCount: null,
grossWeight: null,
netWeight: 41,
// Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen.
futureField: "wird ignoriert",
},
],
products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }],
bottlings: [
{
id: "b1",
lotNumber: "WH-2026-0001",
date: "2026-08-01T00:00:00.000Z",
bestBeforeDate: "2028-08-01T00:00:00.000Z",
jarSize: 500,
jarCount: 60,
productId: "p1",
harvestIds: ["h1", "h2"],
},
],
};
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("getApiCoreConnection", () => {
test("liefert Adresse und API-Schluessel", () => {
// Arrange
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1/");
vi.stubEnv("APICORE_API_KEY", API_KEY);
// Act & Assert
expect(getApiCoreConnection()).toEqual(connection);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
test("verlangt einen API-Schluessel — Benutzer und Passwort reichen nicht mehr", () => {
// Arrange: nur die alte Konfiguration.
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_API_KEY", "");
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
// Act & Assert
expect(() => getApiCoreConnection()).toThrow(ApiCoreConfigError);
});
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", () => {
// Arrange
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
// Act & Assert
expect(() => getApiCoreConnection()).toThrow(/Format eines ApiCore-API-Schlüssels/);
});
});
describe("fetchApiCoreShopData", () => {
test("holt alles in einem Abruf, mit dem Schluessel als Bearer-Token und Zeitlimit", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(connection);
// Assert
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://apicore.test/api/v1/shop-sync");
expect(init.headers).toEqual({ Authorization: `Bearer ${API_KEY}` });
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese" });
expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
expect(data.products[0].id).toBe("p1");
});
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
@@ -37,150 +113,49 @@ describe("apicore-client", () => {
);
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
});
test("nutzt einen API-Schluessel direkt als Bearer-Token, ohne Passwort-Login", async () => {
test("benennt einen widerrufenen oder abgelaufenen Schluessel", async () => {
// Arrange
const key = `ack_abcd1234_${"A".repeat(43)}`;
vi.stubEnv("APICORE_API_KEY", key);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// Act
const opened = await openApiCoreSession();
// Assert
expect(opened).toEqual({ baseUrl: "http://apicore.test/api/v1", token: key });
expect(fetchMock).not.toHaveBeenCalled();
});
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", async () => {
// Arrange
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Unauthorized" }, 401)));
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/Format eines ApiCore-API-Schlüssels/);
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/API-Schlüssel abgelehnt/);
});
test("setzt an jeder Anfrage ein Zeitlimit", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
test("hat keinen Rueckfall auf andere Endpunkte, wenn die Schnittstelle fehlt", async () => {
// Arrange: aelteres ApiCore ohne /shop-sync.
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, 404));
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);
}
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
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.
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
// Act & Assert
await expect(fetchApiCoreHarvests(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
await expect(fetchApiCoreShopData(connection)).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 }])
)
);
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
// Act & Assert
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Schnittstellenversion 2/);
});
describe("fetchApiCoreShopData", () => {
const feed = {
contractVersion: 1,
generatedAt: "2026-09-27T10:00:00.000Z",
harvests: [
{
id: "h1",
apiaryId: "a1",
apiaryName: "Streuobstwiese",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Sommertracht",
honeySuperCount: null,
grossWeight: null,
netWeight: 41,
// Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen.
futureField: "wird ignoriert",
},
],
products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }],
bottlings: [
{
id: "b1",
lotNumber: "WH-2026-0001",
date: "2026-08-01T00:00:00.000Z",
bestBeforeDate: "2028-08-01T00:00:00.000Z",
jarSize: 500,
jarCount: 60,
productId: "p1",
harvestIds: ["h1", "h2"],
},
],
};
test("verwirft eine Antwort mit unerwarteter Struktur als Ganzes", async () => {
// Arrange: Abfuellung ohne Pflichtfeld lotNumber.
const broken = { ...feed, bottlings: [{ ...feed.bottlings[0], lotNumber: undefined }] };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(broken)));
test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
// Assert
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync");
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null });
expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
expect(data.products[0].id).toBe("p1");
});
test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => {
// Arrange
const fetchMock = vi.fn(async (url: string) =>
url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([])
);
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
// Assert
const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, ""));
expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"]));
expect(data).toEqual({ harvests: [], products: [], bottlings: [] });
});
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/);
});
test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403)));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/unerwartete Antwortstruktur/);
});
});
+107 -328
View File
@@ -1,49 +1,28 @@
import { z } from "zod";
/**
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
* die die Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
* HTTP-Client fuer ApiCore (separates Projekt, NestJS-Backend mit eigener
* Datenbank). Wabenhain liest dort ausschliesslich die dafuer abgesicherte
* Shop-Schnittstelle GET /shop-sync: eine Feld-Allowlist aus Ernte-Fakten,
* Artikelstamm und Abfuellungen, ohne interne Notizen.
*
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
* mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs:
* "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData,
* ganz unten). Nur aeltere ApiCore-Staende ohne diese Schnittstelle brauchen
* noch "harvests:read", "products:read", "extractions:read", "bottling:read"
* und optional "apiaries:read".
* Zugang NUR per API-Schluessel (in ApiCore unter Verwaltung →
* Automatisierung → API-Schluessel angelegt, Berechtigung "shop-sync:read").
* Der Schluessel wird direkt als Bearer-Token gesendet: kein Benutzerkonto,
* kein Passwort, kein Login, in ApiCore jederzeit widerrufbar. Einen
* Rueckfall auf Benutzer/Passwort oder allgemeine Lese-Endpunkte gibt es
* bewusst nicht mehr.
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
* "api" laut ApiCore-Backend/src/app.config.ts)
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
* APICORE_API_URL z.B. "https://apicore.example.org/api/v1"
* APICORE_API_KEY der Schluessel, Format ack_<8>_<43>
*
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
* Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError —
* der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an.
*
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
* den eigenen Service-Zugang auszusperren.
*
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
* dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch"
* (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen
* Felder passen fast 1:1: lotNumber/code, date/fillDate,
* bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
* Fuer Wabenhains "Charge" (model Batch) ist ApiCores Abfuellung
* (BottlingBatch) das Gegenstueck, nicht die Rohhonig-Charge: lotNumber/code,
* date/fillDate, bestBeforeDate, jarCount/glassCount.
*/
export class ApiCoreConfigError extends Error {}
@@ -51,11 +30,13 @@ 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.
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt.
*/
const REQUEST_TIMEOUT_MS = 20_000;
// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel.
const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
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.
@@ -66,193 +47,64 @@ function describeFetchFailure(error: unknown): string {
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() }),
]);
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
export interface ApiCoreSession {
/** Adresse und Schluessel fuer ApiCore. */
export interface ApiCoreConnection {
readonly baseUrl: string;
readonly token: string;
readonly apiKey: string;
}
type ApiCoreCredentials =
| { baseUrl: string; apiKey: string }
| { baseUrl: string; email: string; password: string };
// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel.
const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
/**
* Bevorzugt einen ApiCore-API-Schluessel (APICORE_API_KEY): kein Passwort,
* keine MFA-Frage, nur die Berechtigung "shop-sync:read", in ApiCore
* jederzeit widerrufbar. E-Mail/Passwort eines Service-Kontos bleibt als
* Rueckfall fuer aeltere ApiCore-Staende ohne API-Schluessel.
*/
function getConfig(): ApiCoreCredentials {
export function getApiCoreConnection(): ApiCoreConnection {
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
const apiKey = process.env.APICORE_API_KEY?.trim();
const email = process.env.APICORE_SYNC_EMAIL;
const password = process.env.APICORE_SYNC_PASSWORD;
if (baseUrl && apiKey) {
if (!API_KEY_PATTERN.test(apiKey)) {
throw new ApiCoreConfigError(
"APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen."
);
}
return { baseUrl, apiKey };
}
if (baseUrl && email && password) {
return { baseUrl, email, password };
}
throw new ApiCoreConfigError(
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
);
}
export async function openApiCoreSession(): Promise<ApiCoreSession> {
const config = getConfig();
// Ein API-Schluessel wird direkt als Bearer-Token gesendet — kein Login.
if ("apiKey" in config) {
return { baseUrl: config.baseUrl, token: config.apiKey };
}
const { baseUrl, email, password } = config;
let res: Response;
try {
res = await fetch(`${baseUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
}
const parsed = loginResponseSchema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur.");
}
if ("mfaRequired" in parsed.data) {
if (!baseUrl || !apiKey) {
throw new ApiCoreConfigError(
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
);
}
return { baseUrl, token: parsed.data.accessToken };
if (!API_KEY_PATTERN.test(apiKey)) {
throw new ApiCoreConfigError(
"APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen."
);
}
return { baseUrl, apiKey };
}
// ---------------------------------------------------------------------------
// Datenvertrag der Shop-Schnittstelle
// ---------------------------------------------------------------------------
/**
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
* Versionswechsel dazu und werden hier ignoriert.
*/
async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise<Response> {
try {
return await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
}
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
return parseResponse(await fetchFromApiCore(session, path), schema, label);
}
async function parseResponse<T>(res: Response, schema: z.ZodType<T>, label: string): Promise<T> {
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.`
: `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).`
);
}
const parsed = schema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`);
}
return parsed.data;
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
const apiarySchema = z.object({ id: z.string(), name: z.string() });
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
const harvestSchema = z.object({
id: z.string(),
apiaryId: z.string(),
apiaryName: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
honeyType: z.string().nullable(),
honeySuperCount: z.number().nullable(),
grossWeight: z.number().nullable(),
netWeight: z.number().nullable(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
}
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
try {
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
return new Map(apiaries.map((a) => [a.id, a.name]));
} catch {
return new Map();
}
}
export async function fetchApiCoreHarvests(
session: ApiCoreSession
): Promise<ApiCoreHarvestWithApiaryName[]> {
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
const apiaryNames = await fetchApiaryNames(session);
return harvests.map((harvest) => ({
...harvest,
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
* Abweichungen vom dortigen Prisma-Modell:
* ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell:
*
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
* - `status` wird als freier String gelesen, nicht als Enum: kaeme in ApiCore
* ein weiterer Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort
* verwerfen, statt nur den einen unbekannten Artikel zu ignorieren. Siehe
* isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore keine Einheit.
* Angenommen wird GRAMM, weil ApiCore dieselbe Groesse an der Abfuellung
* (jarSize) mit Werten wie 500 fuehrt und in der eigenen Oberflaeche als
* "g" ausgibt. Das uebernommene Gewicht ist am importierten Entwurf im Admin
* sichtbar und aenderbar, bevor das Produkt verkauft werden kann.
*/
const productSchema = z.object({
id: z.string(),
@@ -268,165 +120,92 @@ const productSchema = z.object({
export type ApiCoreProduct = z.infer<typeof productSchema>;
/**
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
* ungeprueft als Shop-Entwurf auftaucht.
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere wird bewusst als "nicht
* verkaufsfaehig" behandelt: ein unbekannter Status darf nicht dazu fuehren,
* dass ein Artikel ungeprueft als Shop-Entwurf auftaucht.
*/
export function isSellableStatus(status: string): boolean {
return status === "ACTIVE";
}
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
return getJson(session, "/products", z.array(productSchema), "Produkte");
}
// ---------------------------------------------------------------------------
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
// ---------------------------------------------------------------------------
const extractionSchema = z.object({
id: z.string(),
// ApiCores GET /extractions liefert je Schleuderung die verknuepften
// Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die
// uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht.
harvests: z.array(z.object({ harvestId: z.string() })),
});
const bottlingBatchSchema = z.object({
const bottlingSchema = z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
jarSize: z.number().nullable(),
jarCount: z.number(),
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
// verwerfen.
jarSize: z.number().nullable().optional(),
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
productId: z.string().nullable().optional(),
honeyBatch: z.object({
id: z.string(),
extractionId: z.string(),
}),
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
});
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
/**
* Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus
* der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren
* Einzel-Endpunkten stammt.
*/
/** Eine Abfuellung, wie der Sync sie braucht. */
export interface ApiCoreBottlingBatchWithHarvestId {
id: string;
lotNumber: string;
date: string;
bestBeforeDate: string;
jarCount: number;
jarSize?: number | null;
productId?: string | null;
jarSize: number | null;
productId: string | null;
/**
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
* Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest
* ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein
* einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste
* verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit
* auf die tatsaechlich passende Ernte korrigieren.
* ApiCore-ID der ersten (aeltesten) Ernte hinter der Abfuellung, oder null.
* Eine Schleuderung kann mehrere Ernten buendeln, Wabenhains Batch.harvestId
* ist dagegen ein einzelner Fremdschluessel — der Admin kann die Zuordnung
* in Wabenhain jederzeit korrigieren.
*/
harvestSourceId: string | null;
}
export async function fetchApiCoreBottlingBatches(
session: ApiCoreSession
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const [extractions, batches] = await Promise.all([
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
]);
const firstHarvestByExtraction = new Map<string, string | null>(
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
);
return batches.map((batch) => ({
...batch,
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Shop-Schnittstelle (ApiCore GET /shop-sync)
// ---------------------------------------------------------------------------
/**
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
* Versionswechsel dazu und werden hier ignoriert.
*/
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
const shopFeedSchema = z.object({
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
harvests: z.array(
z.object({
id: z.string(),
apiaryId: z.string(),
apiaryName: z.string(),
date: z.string(),
honeyType: z.string().nullable(),
honeySuperCount: z.number().nullable(),
grossWeight: z.number().nullable(),
netWeight: z.number().nullable(),
})
),
harvests: z.array(harvestSchema),
products: z.array(productSchema),
bottlings: z.array(
z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
jarSize: z.number().nullable(),
jarCount: z.number(),
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
})
),
bottlings: z.array(bottlingSchema),
});
export interface ApiCoreShopData {
harvests: ApiCoreHarvestWithApiaryName[];
harvests: ApiCoreHarvest[];
products: ApiCoreProduct[];
bottlings: ApiCoreBottlingBatchWithHarvestId[];
}
function httpErrorMessage(status: number): string {
if (status === 401) {
return "ApiCore hat den API-Schlüssel abgelehnt (HTTP 401) — widerrufen, abgelaufen oder falsch kopiert. Bitte in ApiCore einen neuen Schlüssel anlegen.";
}
if (status === 403) {
return "Dem API-Schlüssel fehlt die Berechtigung „Shop-Daten lesen“ (HTTP 403).";
}
if (status === 404) {
return "ApiCore bietet die Shop-Schnittstelle nicht an (HTTP 404) — bitte ApiCore aktualisieren oder APICORE_API_URL prüfen.";
}
return `ApiCore: Shop-Daten konnten nicht geladen werden (HTTP ${status}).`;
}
/**
* Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores
* eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine
* Notizen) und braucht nur die Berechtigung "shop-sync:read".
*
* Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand),
* faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen
* beide Systeme unabhaengig voneinander aktualisiert werden.
* Alles, was der Sync braucht, in EINEM konsistenten Abruf. Jeder Fehler
* (nicht erreichbar, abgelehnt, unbekannte Version, unerwartete Struktur)
* bricht ab — ein fehlgeschlagener Abruf wird nie als leere Datenquelle
* behandelt.
*/
export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<ApiCoreShopData> {
const res = await fetchFromApiCore(session, "/shop-sync");
if (res.status === 404) {
const [harvests, products, bottlings] = await Promise.all([
fetchApiCoreHarvests(session),
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
return { harvests, products, bottlings };
export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promise<ApiCoreShopData> {
let res: Response;
try {
res = await fetch(`${connection.baseUrl}/shop-sync`, {
headers: { Authorization: `Bearer ${connection.apiKey}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(httpErrorMessage(res.status));
}
const body = await parseResponse(res, z.unknown(), "Shop-Daten");
const body: unknown = await res.json();
const version = z.object({ contractVersion: z.number() }).safeParse(body);
if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) {
throw new ApiCoreRequestError(
@@ -435,11 +214,11 @@ export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<Api
}
const parsed = shopFeedSchema.safeParse(body);
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Shop-Daten.");
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Shop-Daten.");
}
return {
harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
harvests: parsed.data.harvests,
products: parsed.data.products,
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
...bottling,
+2 -16
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
@@ -26,9 +26,7 @@ function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
};
}
function makeHarvest(
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
): ApiCoreHarvestWithApiaryName {
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
@@ -37,7 +35,6 @@ function makeHarvest(
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
apiaryName: "Streuobstwiese",
...overrides,
};
@@ -79,17 +76,6 @@ describe("buildHarvestDescription", () => {
);
});
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
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
/**
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
@@ -31,7 +31,7 @@ export function slugForApiCoreSource(sourceId: string): string {
* 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 {
export function buildHarvestDescription(source: ApiCoreHarvest): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
+8 -17
View File
@@ -20,8 +20,7 @@ const FULL = {
DHL_GKP_PASSWORD: "w",
DHL_ACCOUNT_NUMBER: "12345678901234",
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
APICORE_API_KEY: "ack_secret",
};
describe("readIntegrationStatus", () => {
@@ -47,23 +46,15 @@ describe("readIntegrationStatus", () => {
});
it("never returns a configured value", () => {
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|ack_secret/);
});
it("accepts an ApiCore API key instead of the legacy email/password", () => {
const { APICORE_SYNC_EMAIL, APICORE_SYNC_PASSWORD, ...withoutPassword } = FULL;
void APICORE_SYNC_EMAIL;
void APICORE_SYNC_PASSWORD;
const apicore = readIntegrationStatus({ ...withoutPassword, APICORE_API_KEY: "ack_x" }).find(
(s) => s.key === "apicore"
);
expect(apicore).toMatchObject({ configured: true, missing: [] });
});
it("asks for the API key when neither key nor legacy login is set", () => {
const apicore = readIntegrationStatus({ APICORE_API_URL: "https://api.example.org/api" }).find(
(s) => s.key === "apicore"
);
it("needs the ApiCore API key - there is no password login any more", () => {
const apicore = readIntegrationStatus({
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
}).find((s) => s.key === "apicore");
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
});
+1 -11
View File
@@ -28,7 +28,6 @@ const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
// Zugang per APICORE_API_KEY; E-Mail/Passwort zaehlt als Rueckfall, siehe apicoreMissing().
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
];
@@ -46,18 +45,9 @@ function stripeWarning(env: Env): IntegrationWarning | undefined {
return undefined;
}
/** Ein API-Schluessel ODER (Rueckfall) E-Mail + Passwort des Service-Kontos. */
function apicoreMissing(env: Env): string[] {
const missing = env.APICORE_API_URL?.trim() ? [] : ["APICORE_API_URL"];
const hasKey = Boolean(env.APICORE_API_KEY?.trim());
const hasPassword = Boolean(env.APICORE_SYNC_EMAIL?.trim() && env.APICORE_SYNC_PASSWORD?.trim());
return hasKey || hasPassword ? missing : [...missing, "APICORE_API_KEY"];
}
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
return INTEGRATIONS.map(({ key, required, variables }) => {
const missing =
key === "apicore" ? apicoreMissing(env) : variables.filter((name) => !env[name]?.trim());
const missing = variables.filter((name) => !env[name]?.trim());
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
return warning ? { ...status, warning } : status;