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 "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt * (admin/actions/apicore-sync.ts). * * 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 den Berechtigungen "harvests:read" (und optional * "apiaries:read", fuer Standortnamen) empfohlen statt eines echten * Admin-Zugangs. * * 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 fetchApiCoreHarvests()/ * fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der * Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die * Aktion stillschweigend nichts tun zu lassen. * * 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. * Das erfordert zusaetzlich die Berechtigungen "extractions:read" und * "bottling:read" fuer das Sync-Service-Konto. */ 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() }), ]); 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; } 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 }; } async function login(baseUrl: string, email: string, password: string): Promise { 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 parsed.data.accessToken; } /** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */ async function fetchApiaryNames(baseUrl: string, token: string): Promise> { try { const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return new Map(); const parsed = z.array(apiarySchema).safeParse(await res.json()); if (!parsed.success) return new Map(); return new Map(parsed.data.map((a) => [a.id, a.name])); } catch { return new Map(); } } export async function fetchApiCoreHarvests(): Promise { const { baseUrl, email, password } = getConfig(); const token = await login(baseUrl, email, password); let res: Response; try { res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } }); } 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: Ernten konnten nicht geladen werden (HTTP ${res.status}).`); } const parsed = z.array(harvestSchema).safeParse(await res.json()); if (!parsed.success) { throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten."); } const apiaryNames = await fetchApiaryNames(baseUrl, token); return parsed.data.map((harvest) => ({ ...harvest, apiaryName: apiaryNames.get(harvest.apiaryId) ?? null, })); } // --------------------------------------------------------------------------- // 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(), 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(): Promise { const { baseUrl, email, password } = getConfig(); const token = await login(baseUrl, email, password); const authHeader = { Authorization: `Bearer ${token}` }; let extractionsRes: Response; let batchesRes: Response; try { [extractionsRes, batchesRes] = await Promise.all([ fetch(`${baseUrl}/extractions`, { headers: authHeader }), fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }), ]); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`); } if (!extractionsRes.ok) { throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`); } if (!batchesRes.ok) { throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`); } const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json()); if (!parsedExtractions.success) { throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen."); } const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json()); if (!parsedBatches.success) { throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen."); } const firstHarvestByExtraction = new Map( parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null]) ); return parsedBatches.data.map((batch) => ({ ...batch, harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null, })); }