Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m46s
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]>
229 lines
8.9 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|