Files
Wabenhain/src/lib/apicore-client.ts
T
Nick SzaunigandClaude Opus 5.5 866d3726d1
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m46s
fix(apicore-sync): explain that HTTP 403 can mean an IP allowlist block
ApiCore API keys can now be limited to source addresses; a 403 then
usually means this server's address is not on the key's allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:42:23 +02:00

229 lines
8.9 KiB
TypeScript

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<typeof harvestSchema>;
/**
* 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<typeof productSchema>;
/**
* 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 "ApiCore verweigert den Zugriff (HTTP 403): Entweder ist dieser Server nicht in den erlaubten IP-Adressen des Schlüssels eingetragen, oder dem Schlüssel fehlt die Berechtigung „Shop-Daten lesen“. In ApiCore unter API-Schlüssel → „Adressen ändern“ prüfen.";
}
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<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: 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,
})),
};
}