feat(apicore-sync): read from ApiCore's dedicated shop feed

The sync now fetches everything from ApiCore's GET /shop-sync in one call:
an allowlisted feed (harvest facts, article master data, bottlings) that
never contains internal notes and needs only the single permission
"shop-sync:read" instead of read access to harvests, extractions etc.

- Contract version is checked; an unknown version stops the run with a
  clear message and writes nothing. Extra fields are ignored.
- One consistent snapshot, processed only after it arrived completely: a
  failed fetch writes nothing (no more half state of new harvests and old
  batches); the batch-load-error path is gone.
- Falls back to the previous per-resource endpoints when ApiCore answers
  404, so both packages can be deployed independently.
- .env.example documents the reduced permission.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 20:30:33 +02:00
1 parent d38f4303ff
commit 5642f0c4af
8 files changed
+224 -65

No files matched your search

+83
View File
@@ -3,6 +3,7 @@ import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
fetchApiCoreShopData,
ApiCoreRequestError,
type ApiCoreSession,
} from "./apicore-client";
@@ -77,4 +78,86 @@ describe("apicore-client", () => {
// Act & Assert
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
describe("fetchApiCoreShopData", () => {
const feed = {
contractVersion: 1,
generatedAt: "2026-09-27T10:00:00.000Z",
harvests: [
{
id: "h1",
apiaryId: "a1",
apiaryName: "Streuobstwiese",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Sommertracht",
honeySuperCount: null,
grossWeight: null,
netWeight: 41,
// Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen.
futureField: "wird ignoriert",
},
],
products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }],
bottlings: [
{
id: "b1",
lotNumber: "WH-2026-0001",
date: "2026-08-01T00:00:00.000Z",
bestBeforeDate: "2028-08-01T00:00:00.000Z",
jarSize: 500,
jarCount: 60,
productId: "p1",
harvestIds: ["h1", "h2"],
},
],
};
test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
// Assert
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync");
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null });
expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
expect(data.products[0].id).toBe("p1");
});
test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => {
// Arrange
const fetchMock = vi.fn(async (url: string) =>
url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([])
);
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
// Assert
const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, ""));
expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"]));
expect(data).toEqual({ harvests: [], products: [], bottlings: [] });
});
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/);
});
test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403)));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
});
});
+120 -12
View File
@@ -13,9 +13,11 @@ import { z } from "zod";
* 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).
* mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs:
* "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData,
* ganz unten). Nur aeltere ApiCore-Staende ohne diese Schnittstelle brauchen
* noch "harvests:read", "products:read", "extractions:read", "bottling:read"
* und optional "apiaries:read".
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
@@ -123,21 +125,27 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
*/
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
let res: Response;
async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise<Response> {
try {
res = await fetch(`${session.baseUrl}${path}`, {
return await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
}
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
return parseResponse(await fetchFromApiCore(session, path), schema, label);
}
async function parseResponse<T>(res: Response, schema: z.ZodType<T>, label: string): Promise<T> {
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
@@ -282,7 +290,19 @@ const bottlingBatchSchema = z.object({
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch {
/**
* Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus
* der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren
* Einzel-Endpunkten stammt.
*/
export interface ApiCoreBottlingBatchWithHarvestId {
id: string;
lotNumber: string;
date: string;
bestBeforeDate: string;
jarCount: number;
jarSize?: number | null;
productId?: string | null;
/**
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
@@ -312,3 +332,91 @@ export async function fetchApiCoreBottlingBatches(
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Shop-Schnittstelle (ApiCore GET /shop-sync)
// ---------------------------------------------------------------------------
/**
* 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 shopFeedSchema = z.object({
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
harvests: z.array(
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(),
})
),
products: z.array(productSchema),
bottlings: z.array(
z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
jarSize: z.number().nullable(),
jarCount: z.number(),
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
})
),
});
export interface ApiCoreShopData {
harvests: ApiCoreHarvestWithApiaryName[];
products: ApiCoreProduct[];
bottlings: ApiCoreBottlingBatchWithHarvestId[];
}
/**
* Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores
* eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine
* Notizen) und braucht nur die Berechtigung "shop-sync:read".
*
* Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand),
* faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen
* beide Systeme unabhaengig voneinander aktualisiert werden.
*/
export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<ApiCoreShopData> {
const res = await fetchFromApiCore(session, "/shop-sync");
if (res.status === 404) {
const [harvests, products, bottlings] = await Promise.all([
fetchApiCoreHarvests(session),
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
return { harvests, products, bottlings };
}
const body = await parseResponse(res, z.unknown(), "Shop-Daten");
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 fuer Shop-Daten.");
}
return {
harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
products: parsed.data.products,
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
...bottling,
harvestSourceId: harvestIds[0] ?? null,
})),
};
}