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:
1 parent
d38f4303ff
commit
5642f0c4af
8 files changed
+224
-65
No files matched your search
+120
-12
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user