import { z } from "zod"; /** * 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. * * 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. "https://apicore.example.org/api/v1" * APICORE_API_KEY der Schluessel, Format ack_<8>_<43> * * Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError — * der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an. * * 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 {} 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. */ 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. 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"; } /** Adresse und Schluessel fuer ApiCore. */ export interface ApiCoreConnection { readonly baseUrl: string; readonly apiKey: string; } export function getApiCoreConnection(): ApiCoreConnection { const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, ""); const apiKey = process.env.APICORE_API_KEY?.trim(); if (!baseUrl || !apiKey) { throw new ApiCoreConfigError( "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)." ); } 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 // --------------------------------------------------------------------------- /** * 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 harvestSchema = 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(), }); export type ApiCoreHarvest = z.infer; /** * ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell: * * - `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(), name: z.string(), description: z.string().nullable().optional(), sku: z.string().nullable().optional(), packagingSize: z.number().nullable().optional(), weight: z.number().nullable().optional(), salesUnit: z.string().nullable().optional(), status: z.string(), }); export type ApiCoreProduct = z.infer; /** * 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"; } 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(), // Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein. productId: z.string().nullable(), harvestIds: z.array(z.string()), }); /** 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; /** * 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; } const shopFeedSchema = z.object({ contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT), harvests: z.array(harvestSchema), products: z.array(productSchema), bottlings: z.array(bottlingSchema), }); export interface ApiCoreShopData { 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. Jeder Fehler * (nicht erreichbar, abgelehnt, unbekannte Version, unerwartete Struktur) * bricht ab — ein fehlgeschlagener Abruf wird nie als leere Datenquelle * behandelt. */ export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promise { 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: 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( `ApiCore liefert die Schnittstellenversion ${version.data.contractVersion}, dieser Wabenhain-Stand versteht Version ${SUPPORTED_SHOP_SYNC_CONTRACT}. Bitte Wabenhain aktualisieren — es wurde nichts geändert.` ); } const parsed = shopFeedSchema.safeParse(body); if (!parsed.success) { throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Shop-Daten."); } return { harvests: parsed.data.harvests, products: parsed.data.products, bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({ ...bottling, harvestSourceId: harvestIds[0] ?? null, })), }; }