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. * * 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 ausschliesslich Lese-Berechtigungen empfohlen statt eines echten * Admin-Zugangs: "harvests:read", "products:read", "extractions:read", * "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen). * * 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 * * 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. * * 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. */ export class ApiCoreConfigError extends Error {} export class ApiCoreRequestError extends Error {} 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 { readonly baseUrl: string; readonly token: string; } function getConfig(): { baseUrl: string; email: string; password: string } { const baseUrl = process.env.APICORE_API_URL; const email = process.env.APICORE_SYNC_EMAIL; const password = process.env.APICORE_SYNC_PASSWORD; if (!baseUrl || !email || !password) { throw new ApiCoreConfigError( "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)." ); } return { baseUrl: baseUrl.replace(/\/$/, ""), email, password }; } export async function openApiCoreSession(): Promise { const { baseUrl, email, password } = getConfig(); let res: Response; try { res = await fetch(`${baseUrl}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`); } 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) { 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)." ); } return { baseUrl, token: parsed.data.accessToken }; } /** * 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. */ async function getJson( session: ApiCoreSession, path: string, schema: z.ZodType, label: string ): Promise { let res: Response; try { res = await fetch(`${session.baseUrl}${path}`, { headers: { Authorization: `Bearer ${session.token}` }, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`); } 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() }); const harvestSchema = z.object({ id: z.string(), apiaryId: 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(), }); export type ApiCoreHarvest = z.infer; export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest { apiaryName: string | null; } /** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */ async function fetchApiaryNames(session: ApiCoreSession): Promise> { 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 { 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: * * - `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. */ 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 (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. */ export function isSellableStatus(status: string): boolean { return status === "ACTIVE"; } export async function fetchApiCoreProducts(session: ApiCoreSession): Promise { 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({ id: z.string(), lotNumber: z.string(), date: z.string(), bestBeforeDate: z.string(), 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(), }), }); export type ApiCoreBottlingBatch = z.infer; export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch { /** * 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. */ harvestSourceId: string | null; } export async function fetchApiCoreBottlingBatches( session: ApiCoreSession ): Promise { 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( extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null]) ); return batches.map((batch) => ({ ...batch, harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null, })); }