Compare commits
8
Commits
45ca0ea745
...
15a457f66e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a457f66e | ||
|
|
55ae867f68 | ||
|
|
e19827d6af | ||
|
|
3107a02588 | ||
|
|
3bc0693e4d | ||
|
|
55be1fe881 | ||
|
|
5642f0c4af | ||
|
|
d38f4303ff |
No files matched your search
+7
-15
@@ -91,23 +91,15 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
SMTP_USER=
|
||||
SMTP_FROM="Wabenhain <[email protected]>"
|
||||
|
||||
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
||||
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
||||
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
||||
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
||||
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
||||
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
||||
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
|
||||
# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
|
||||
# harvests:read Ernten
|
||||
# products:read Produktkatalog
|
||||
# extractions:read Schleuderungen (verbinden Charge und Ernte)
|
||||
# bottling:read Abfuellungen = Chargen
|
||||
# apiaries:read optional, nur fuer die Standortnamen an den Ernten
|
||||
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
|
||||
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
|
||||
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
|
||||
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
|
||||
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
|
||||
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
|
||||
# Details siehe src/lib/apicore-client.ts.
|
||||
APICORE_API_URL=
|
||||
APICORE_SYNC_EMAIL=
|
||||
APICORE_SYNC_PASSWORD=
|
||||
APICORE_API_KEY=
|
||||
|
||||
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||
|
||||
@@ -337,7 +337,7 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C
|
||||
|
||||
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
|
||||
|
||||
**Einrichtung:** `APICORE_API_URL`, `APICORE_SYNC_EMAIL` und `APICORE_SYNC_PASSWORD` in `.env` setzen (siehe `.env.example`). ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — deshalb dort ein **eigenes, MFA-freies Service-Konto** anlegen statt einen echten Admin-Zugang einzutragen. Es braucht ausschließlich Leserechte: `harvests:read`, `products:read`, `extractions:read`, `bottling:read` und optional `apiaries:read` (nur für die Standortnamen an den Ernten). Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
|
||||
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
|
||||
|
||||
## Vollständiger Docker-Betrieb
|
||||
|
||||
|
||||
@@ -102,17 +102,12 @@ stringData:
|
||||
# ist dagegen oeffentlich und steht dort.
|
||||
GOOGLE_PLACES_API_KEY: ""
|
||||
|
||||
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
|
||||
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
|
||||
# Konfigurationsfehler, statt still nichts zu tun.
|
||||
#
|
||||
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login —
|
||||
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen
|
||||
# echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read,
|
||||
# extractions:read, bottling:read und optional apiaries:read. Die Adresse
|
||||
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml.
|
||||
APICORE_SYNC_EMAIL: ""
|
||||
APICORE_SYNC_PASSWORD: ""
|
||||
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
|
||||
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
|
||||
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
|
||||
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
|
||||
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
|
||||
APICORE_API_KEY: ""
|
||||
|
||||
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
||||
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Herkunftsschluessel fuer den ApiCore-Chargen-Sync: ID der Abfuellung
|
||||
-- (BottlingBatch). Bisher wurde eine Charge nur ueber die Losnummer (code)
|
||||
-- wiedererkannt — wird die Losnummer in ApiCore korrigiert, entstand beim
|
||||
-- naechsten Lauf eine zweite Charge. Nullable: manuell angelegte Chargen
|
||||
-- haben keine Herkunft in ApiCore, bestehende Zeilen bleiben gueltig. Schon
|
||||
-- synchronisierte Chargen erhalten die ID beim naechsten Lauf ueber ihre
|
||||
-- Losnummer.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "batches" ADD COLUMN "apicoreBottlingId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "batches_apicoreBottlingId_key" ON "batches"("apicoreBottlingId");
|
||||
@@ -178,6 +178,10 @@ model Batch {
|
||||
id String @id @default(cuid())
|
||||
/// z.B. FH-26-01
|
||||
code String @unique
|
||||
/// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
|
||||
/// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
|
||||
/// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
|
||||
apicoreBottlingId String? @unique
|
||||
harvestId String
|
||||
fillDate DateTime
|
||||
bestBeforeDate DateTime
|
||||
|
||||
@@ -5,15 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import {
|
||||
openApiCoreSession,
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreProducts,
|
||||
fetchApiCoreBottlingBatches,
|
||||
getApiCoreConnection,
|
||||
fetchApiCoreShopData,
|
||||
isSellableStatus,
|
||||
ApiCoreConfigError,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreSession,
|
||||
type ApiCoreHarvestWithApiaryName,
|
||||
type ApiCoreShopData,
|
||||
type ApiCoreHarvest,
|
||||
type ApiCoreProduct,
|
||||
type ApiCoreBottlingBatchWithHarvestId,
|
||||
} from "@/lib/apicore-client";
|
||||
@@ -23,6 +21,8 @@ import {
|
||||
buildProductDescription,
|
||||
deriveJarWeightGrams,
|
||||
productSlugCandidates,
|
||||
harvestFactChanges,
|
||||
batchFactChanges,
|
||||
} from "@/lib/apicore-mapping";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
@@ -36,26 +36,31 @@ import { requireAdmin } from "./_shared";
|
||||
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
||||
* ein Mensch freigibt.
|
||||
*
|
||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
||||
* false) bzw. als solche aktualisiert — aber nur, solange niemand den
|
||||
* Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der
|
||||
* Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins
|
||||
* (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder
|
||||
* zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt.
|
||||
* Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
|
||||
* Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
|
||||
* nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
|
||||
* Anlegen nie mehr angefasst.
|
||||
*
|
||||
* - Ernten: Datum und Jahr kommen immer aus ApiCore, auch bei
|
||||
* veroeffentlichten Ernten. Sorte, Standort und Text sind nur eine
|
||||
* Vorbelegung: ein unveroeffentlichter, noch von niemandem bearbeiteter
|
||||
* Entwurf wird damit aufgefrischt, sonst bleiben sie, wie der Admin sie
|
||||
* gesetzt hat.
|
||||
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
||||
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
||||
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
|
||||
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
|
||||
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
|
||||
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
|
||||
* Lagerbestand.
|
||||
* - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
|
||||
* Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
|
||||
* eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
|
||||
* liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
|
||||
* Wiedererkannt wird eine Charge an der ApiCore-ID der Abfuellung, nicht an
|
||||
* der Losnummer — die kann in ApiCore korrigiert werden.
|
||||
*
|
||||
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
||||
* ApiCore-Losnummer als Batch.code.
|
||||
* "apicore-<ApiCore-ID>" als Harvest.slug, und die ApiCore-ID der Abfuellung
|
||||
* als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
||||
*
|
||||
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
||||
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
||||
@@ -72,10 +77,18 @@ interface SyncCounts {
|
||||
}
|
||||
|
||||
interface HarvestSyncCounts extends SyncCounts {
|
||||
/** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */
|
||||
/**
|
||||
* Entwuerfe, die ein Mensch bearbeitet hat: ihre Shop-Felder blieben
|
||||
* unveraendert (Fakten wie das Datum werden trotzdem nachgezogen).
|
||||
*/
|
||||
protectedDrafts: number;
|
||||
}
|
||||
|
||||
interface BatchSyncCounts extends SyncCounts {
|
||||
/** Chargen, deren Shop-Bestand ueber der (korrigierten) Abfuellmenge liegt. */
|
||||
stockConflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||
@@ -115,7 +128,7 @@ function describeError(error: unknown): string {
|
||||
// Ernten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
|
||||
async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
|
||||
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||
|
||||
for (const source of sources) {
|
||||
@@ -130,12 +143,17 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Ha
|
||||
|
||||
try {
|
||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||
if (existing?.isPublished) {
|
||||
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||
if (existing && shopOwned) {
|
||||
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||
const facts = harvestFactChanges(existing, date);
|
||||
if (!existing.isPublished) counts.protectedDrafts++;
|
||||
if (Object.keys(facts).length === 0) {
|
||||
counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
if (existing && (await wasEditedByAdmin(existing.id))) {
|
||||
counts.protectedDrafts++;
|
||||
await prisma.harvest.update({ where: { slug }, data: facts });
|
||||
counts.updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,10 +275,13 @@ async function syncProducts(
|
||||
continue;
|
||||
}
|
||||
|
||||
const batch = await prisma.batch.findUnique({
|
||||
where: { code: bottling.lotNumber },
|
||||
const found = await findBatchForBottling(bottling.id, bottling.lotNumber);
|
||||
const batch = found
|
||||
? await prisma.batch.findUnique({
|
||||
where: { id: found.id },
|
||||
include: { harvest: { select: { id: true, year: true } } },
|
||||
});
|
||||
})
|
||||
: null;
|
||||
if (!batch) {
|
||||
counts.pending++;
|
||||
continue;
|
||||
@@ -328,8 +349,21 @@ async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
||||
// Chargen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<SyncCounts> {
|
||||
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
/**
|
||||
* Die Wabenhain-Charge zu einer ApiCore-Abfuellung: zuerst ueber die
|
||||
* gespeicherte ApiCore-ID, sonst — fuer Chargen aus der Zeit vor dieser
|
||||
* Spalte — ueber die Losnummer, sofern die Charge noch keiner anderen
|
||||
* Abfuellung gehoert.
|
||||
*/
|
||||
async function findBatchForBottling(bottlingId: string, lotNumber: string) {
|
||||
const byId = await prisma.batch.findUnique({ where: { apicoreBottlingId: bottlingId } });
|
||||
if (byId) return byId;
|
||||
const byCode = await prisma.batch.findUnique({ where: { code: lotNumber } });
|
||||
return byCode && byCode.apicoreBottlingId === null ? byCode : null;
|
||||
}
|
||||
|
||||
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<BatchSyncCounts> {
|
||||
const counts: BatchSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 };
|
||||
|
||||
for (const source of sources) {
|
||||
if (!source.harvestSourceId) {
|
||||
@@ -346,11 +380,32 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
||||
const existing = await findBatchForBottling(source.id, source.lotNumber);
|
||||
if (existing) {
|
||||
const { changes, stockExceedsFilled } = batchFactChanges(existing, {
|
||||
code: source.lotNumber,
|
||||
fillDate,
|
||||
bestBeforeDate,
|
||||
glassCount: Math.max(Math.round(source.jarCount), 0),
|
||||
});
|
||||
const adopt = existing.apicoreBottlingId === null;
|
||||
if (stockExceedsFilled) {
|
||||
counts.stockConflicts++;
|
||||
console.warn(
|
||||
`[apicore-sync] Charge ${source.lotNumber}: Shop-Bestand (${existing.remainingGlasses}) liegt über der Abfüllmenge — bitte prüfen.`
|
||||
);
|
||||
}
|
||||
if (Object.keys(changes).length === 0 && !adopt) {
|
||||
counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
await prisma.batch.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...changes, apicoreBottlingId: source.id },
|
||||
});
|
||||
counts.updated++;
|
||||
continue;
|
||||
}
|
||||
const harvest = await prisma.harvest.findUnique({
|
||||
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
|
||||
});
|
||||
@@ -366,6 +421,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
||||
await prisma.batch.create({
|
||||
data: {
|
||||
code: source.lotNumber,
|
||||
apicoreBottlingId: source.id,
|
||||
harvestId: harvest.id,
|
||||
fillDate,
|
||||
bestBeforeDate,
|
||||
@@ -414,32 +470,23 @@ function returnTargetFrom(formData: FormData): string {
|
||||
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||
*
|
||||
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
||||
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
||||
*
|
||||
* Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
|
||||
* uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
|
||||
* dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
|
||||
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
|
||||
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
|
||||
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
|
||||
*/
|
||||
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const returnTo = returnTargetFrom(formData);
|
||||
|
||||
let session: ApiCoreSession;
|
||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||
let articles: ApiCoreProduct[];
|
||||
let source: ApiCoreShopData;
|
||||
try {
|
||||
session = await openApiCoreSession();
|
||||
[sourceHarvests, articles] = await Promise.all([
|
||||
fetchApiCoreHarvests(session),
|
||||
fetchApiCoreProducts(session),
|
||||
]);
|
||||
source = await fetchApiCoreShopData(getApiCoreConnection());
|
||||
} catch (error: unknown) {
|
||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||
}
|
||||
|
||||
// 1. Ernten
|
||||
const harvests = await syncHarvests(sourceHarvests);
|
||||
const harvests = await syncHarvests(source.harvests);
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest.sync_apicore",
|
||||
entityType: "Harvest",
|
||||
@@ -448,28 +495,18 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
||||
let batchLoadError: string | null = null;
|
||||
try {
|
||||
bottlings = await fetchApiCoreBottlingBatches(session);
|
||||
} catch (error: unknown) {
|
||||
batchLoadError = describeError(error);
|
||||
}
|
||||
|
||||
const batches = batchLoadError
|
||||
? { created: 0, updated: 0, skipped: 0, failed: 0 }
|
||||
: await syncBatches(bottlings);
|
||||
// 2. Chargen
|
||||
const batches = await syncBatches(source.bottlings);
|
||||
await logAdminAction(admin, {
|
||||
action: "batch.sync_apicore",
|
||||
entityType: "Batch",
|
||||
detail: batchLoadError
|
||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
||||
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
|
||||
detail:
|
||||
`${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
||||
const products = await syncProducts(articles, bottlings);
|
||||
const products = await syncProducts(source.products, source.bottlings);
|
||||
await logAdminAction(admin, {
|
||||
action: "product.sync_apicore",
|
||||
entityType: "Product",
|
||||
@@ -485,9 +522,9 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
`${returnTo}?syncDone=1` +
|
||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||
`&hProtected=${harvests.protectedDrafts}` +
|
||||
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||
`&bConflicts=${batches.stockConflicts}` +
|
||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
|
||||
);
|
||||
}
|
||||
@@ -76,10 +76,12 @@ export default async function HarvestDetailPage(
|
||||
<span className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="font-serif text-xl text-heading tabular-nums">{batch.code}</span>
|
||||
<span className="text-ink/65 tabular-nums">
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses)}
|
||||
</span>
|
||||
</span>
|
||||
{/* Fuellstand der Charge — die Zahl rechts bekommt eine Form. */}
|
||||
{/* Fuellstand der Charge. Der Text nennt bewusst nur den Rest,
|
||||
nicht die Gesamtmenge: "noch 12 verfuegbar" wirkt knapper
|
||||
und kaufanregender als "12 von 300". */}
|
||||
<span aria-hidden="true" className="mt-3 block h-1.5 overflow-hidden rounded-full bg-ink/10">
|
||||
<span
|
||||
className="motion-grow block h-full origin-left rounded-full bg-gradient-to-r from-honey-dark to-honey animate-[wh-grow_0.9s_var(--ease-arrive)_both]"
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
if (customer.role === "ADMIN") {
|
||||
return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||
return fail("/konto/einstellungen", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto/einstellungen", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
return fail("/konto", "Passwort ist falsch.");
|
||||
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||
|
||||
@@ -29,6 +29,6 @@ export async function toggleNewsletterAction(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
revalidatePath("/konto/einstellungen");
|
||||
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { toggleNewsletterAction, logoutAllDevicesAction, deleteOwnAccountAction } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Buendelt alles, was das Konto selbst betrifft statt einer Bestellung:
|
||||
* Sicherheit (Zwei-Faktor, Sitzungen) und Datenschutz (Einwilligung in die
|
||||
* Erntepost, Auskunft nach Art. 15, Loeschung nach Art. 17 DSGVO). Die
|
||||
* Aktionen selbst leben unveraendert in ../actions — diese Seite ist nur
|
||||
* ihr gemeinsamer Ort.
|
||||
*/
|
||||
export default async function EinstellungenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const newsletter = await prisma.newsletterSubscriber.findUnique({
|
||||
where: { customerId: customer.id },
|
||||
});
|
||||
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<BackLink href="/konto" label={t.account.settings.backLabel} />
|
||||
<SectionHeading level="h1" title={t.account.settings.title} subtitle={t.account.settings.subtitle} />
|
||||
|
||||
<section aria-labelledby="einstellungen-sicherheit" className="mt-12">
|
||||
<h2 id="einstellungen-sicherheit" className="text-2xl text-heading mb-4">
|
||||
{t.account.settings.securityHeading}
|
||||
</h2>
|
||||
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-lg text-heading">
|
||||
<ShieldIcon />
|
||||
{t.account.settings.twoFactorHeading}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{customer.twoFactorEnabled
|
||||
? t.account.settings.twoFactorActiveDescription
|
||||
: t.account.settings.twoFactorInactiveDescription}
|
||||
</p>
|
||||
</div>
|
||||
<LinkButton
|
||||
href="/konto/zwei-faktor"
|
||||
variant={customer.twoFactorEnabled ? "secondary" : "primary"}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{customer.twoFactorEnabled ? t.account.settings.twoFactorManage : t.account.settings.twoFactorSetup}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.logoutAllDevicesHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.logoutAllDevicesDescription}</p>
|
||||
</div>
|
||||
<form action={logoutAllDevicesAction} className="flex-shrink-0">
|
||||
<Button type="submit" variant="secondary">
|
||||
{t.account.settings.logoutAllDevicesButton}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="einstellungen-datenschutz" className="mt-12">
|
||||
<h2 id="einstellungen-datenschutz" className="text-2xl text-heading mb-4">
|
||||
{t.account.privacy.heading}
|
||||
</h2>
|
||||
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.newsletterHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
</div>
|
||||
<form action={toggleNewsletterAction} className="flex-shrink-0">
|
||||
<Button type="submit" variant="secondary">
|
||||
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
{t.account.privacy.downloadDataButton}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountAdminBlocked}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
confirmMessage={t.account.privacy.deleteAccountConfirm}
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
{t.account.privacy.deleteAccountButton}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { noIndexPrivate } from "@/lib/seo";
|
||||
|
||||
// Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten
|
||||
// (Bestellungen, Adressen, Wunschliste, Zwei-Faktor-Einrichtung) sowie die
|
||||
// (Bestellungen, Adressen, Wunschliste, Einstellungen, Zwei-Faktor-Einrichtung) sowie die
|
||||
// Anmeldung selbst. Nichts davon gehoert in den Suchindex.
|
||||
//
|
||||
// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell
|
||||
|
||||
+7
-80
@@ -1,23 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
|
||||
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon, SettingsIcon } from "@/components/account-ui";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { isEntraConfigured } from "@/lib/entra";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
toggleNewsletterAction,
|
||||
logoutAllDevicesAction,
|
||||
resendVerificationEmailAction,
|
||||
deleteOwnAccountAction,
|
||||
} from "./actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
@@ -111,11 +106,6 @@ export default async function KontoPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const newsletter = await prisma.newsletterSubscriber.findUnique({
|
||||
where: { customerId: customer.id },
|
||||
});
|
||||
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
@@ -155,10 +145,10 @@ export default async function KontoPage() {
|
||||
description={t.account.navWishlist.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/zwei-faktor"
|
||||
icon={<ShieldIcon />}
|
||||
title={t.account.navSecurity.title}
|
||||
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
|
||||
href="/konto/einstellungen"
|
||||
icon={<SettingsIcon />}
|
||||
title={t.account.navSettings.title}
|
||||
description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`}
|
||||
/>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<AccountNavCard
|
||||
@@ -172,74 +162,11 @@ export default async function KontoPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-heading">{t.account.newsletterHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
</div>
|
||||
<form action={toggleNewsletterAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl text-heading">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{t.account.logoutAllDevicesDescription}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutAllDevicesAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action={logoutAction} className="mt-6 mb-10">
|
||||
<form action={logoutAction} className="mt-10">
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-2xl text-heading mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
{t.account.privacy.downloadDataButton}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
confirmMessage={t.account.privacy.deleteAccountConfirm}
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
{t.account.privacy.deleteAccountButton}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function ZweiFaktorPage() {
|
||||
<div className="py-20">
|
||||
<Container className="max-w-lg">
|
||||
<FormPanel>
|
||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||
<BackLink href="/konto/einstellungen" label={t.twoFactorPage.backLabel} />
|
||||
<SectionHeading
|
||||
title={t.twoFactorPage.title}
|
||||
subtitle={t.twoFactorPage.subtitle}
|
||||
|
||||
@@ -265,6 +265,16 @@ export function HeartIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10" />
|
||||
<circle cx="16" cy="7" r="2" />
|
||||
<circle cx="8" cy="17" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
|
||||
@@ -33,13 +33,13 @@ function text(value: string | string[] | undefined): string | undefined {
|
||||
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
||||
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||
const syncError = text(searchParams.syncError);
|
||||
const batchLoadError = text(searchParams.batchLoadError);
|
||||
const done = text(searchParams.syncDone) !== undefined;
|
||||
|
||||
if (!done && syncError === undefined) return null;
|
||||
|
||||
const pending = count(searchParams.pPending);
|
||||
const protectedDrafts = count(searchParams.hProtected);
|
||||
const stockConflicts = count(searchParams.bConflicts);
|
||||
const discontinued = count(searchParams.pDiscontinued);
|
||||
|
||||
return (
|
||||
@@ -59,12 +59,24 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
count(searchParams.hFailed)
|
||||
)}
|
||||
</p>
|
||||
<p>{t.resultBatches(count(searchParams.bNew), count(searchParams.bSkipped), count(searchParams.bFailed))}</p>
|
||||
<p>
|
||||
{t.resultBatches(
|
||||
count(searchParams.bNew),
|
||||
count(searchParams.bUpdated),
|
||||
count(searchParams.bSkipped),
|
||||
count(searchParams.bFailed)
|
||||
)}
|
||||
</p>
|
||||
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||
{stockConflicts > 0 ? (
|
||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
{t.stockConflicts(stockConflicts)}
|
||||
</p>
|
||||
) : null}
|
||||
{protectedDrafts > 0 ? (
|
||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
{t.protectedDrafts(protectedDrafts)}
|
||||
@@ -80,11 +92,6 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
{t.discontinued(discontinued)}
|
||||
</p>
|
||||
) : null}
|
||||
{batchLoadError !== undefined ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.batchLoadError} ({batchLoadError})
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
|
||||
import { onCartLanded } from "@/components/cart-flight";
|
||||
|
||||
// Der Korb im Kopfzeilen-Symbol (20 px in einem 40-px-Knopf, viewBox 24):
|
||||
// seine Unterkante liegt bei y=15, die Mitte bei x=13.5. Dort bildet sich
|
||||
// der Tropfen — umgerechnet in Pixel des Knopfes.
|
||||
const DRIP_LEFT_PX = 10 + (13.5 / 24) * 20;
|
||||
const DRIP_TOP_PX = 10 + (15 / 24) * 20;
|
||||
const DRIP_WIDTH_PX = 6;
|
||||
const DRIP_DELAY_S = 0.3;
|
||||
|
||||
/**
|
||||
* Nachspiel der Landung eines Honigtropfens (components/cart-flight.tsx):
|
||||
* Der Korb leuchtet zweimal warm in Honigfarbe auf, dann sammelt sich an
|
||||
* seiner Unterkante ein kleiner Tropfen, zieht sich lang und perlt nach
|
||||
* unten ab — als waere der Honig durch den Korb gesickert.
|
||||
*
|
||||
* Liegt als Overlay im Warenkorb-Knopf (relative) und faengt keine Klicks.
|
||||
* Das Leuchten ist eine deckungsgleiche, honigfarbene Kopie des Korbs (gleiche
|
||||
* Groesse, gleiche Strichstaerke, keine Fuellung), deren Deckkraft blinkt.
|
||||
* Das Symbol selbst bleibt unveraendert; nur seine Farbe wechselt kurz — im
|
||||
* hellen wie im dunklen Farbschema. Bei reduzierter Bewegung
|
||||
* bleibt nur ein einzelnes, ruhiges Aufleuchten ohne Tropfen.
|
||||
*/
|
||||
export function CartLandingEffect() {
|
||||
const glowControls = useAnimationControls();
|
||||
const dripControls = useAnimationControls();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onCartLanded(() => {
|
||||
if (reduceMotion) {
|
||||
void glowControls.start({
|
||||
opacity: [0, 0.9, 0],
|
||||
transition: { duration: 0.8, ease: "easeInOut" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void glowControls.start({
|
||||
opacity: [0, 1, 0.35, 1, 0],
|
||||
transition: { duration: 0.75, ease: "easeInOut", times: [0, 0.2, 0.45, 0.65, 1] },
|
||||
});
|
||||
void dripControls.start({
|
||||
// An der Kante sammeln, schwer werden und lang ziehen,
|
||||
// abloesen und nach unten perlen, verblassen.
|
||||
opacity: [0, 1, 1, 1, 0],
|
||||
scaleX: [0.3, 1, 0.85, 0.9, 0.7],
|
||||
scaleY: [0.2, 1, 1.35, 1, 0.8],
|
||||
y: [0, 0, 3, 13, 17],
|
||||
transition: {
|
||||
duration: 1.1,
|
||||
delay: DRIP_DELAY_S,
|
||||
ease: "easeIn",
|
||||
times: [0, 0.3, 0.55, 0.85, 1],
|
||||
},
|
||||
});
|
||||
}),
|
||||
[glowControls, dripControls, reduceMotion]
|
||||
);
|
||||
|
||||
return (
|
||||
<span aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={glowControls}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
className="text-honey drop-shadow-[0_0_3px_var(--color-honey)]"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M6 6h15l-1.5 9h-12z" />
|
||||
<path d="M6 6 5 3H2" />
|
||||
<circle cx="9" cy="20" r="1" />
|
||||
<circle cx="18" cy="20" r="1" />
|
||||
</svg>
|
||||
</motion.span>
|
||||
|
||||
{reduceMotion ? null : (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={dripControls}
|
||||
style={{
|
||||
left: DRIP_LEFT_PX - DRIP_WIDTH_PX / 2,
|
||||
top: DRIP_TOP_PX - 1,
|
||||
width: DRIP_WIDTH_PX,
|
||||
transformOrigin: "50% 0%",
|
||||
}}
|
||||
className="absolute"
|
||||
>
|
||||
{/* Gleiche Honigtoene wie der fliegende Tropfen in cart-flight.tsx. */}
|
||||
<svg viewBox="0 0 6 9" width={DRIP_WIDTH_PX} height="9">
|
||||
<defs>
|
||||
<radialGradient id="wh-drip" cx="40%" cy="65%" r="70%">
|
||||
<stop offset="0" stopColor="#e4cfa4" />
|
||||
<stop offset="0.5" stopColor="#b88a44" />
|
||||
<stop offset="1" stopColor="#8a6531" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path d="M3 0C3 2.5 6 4.2 6 6.1A3 3 0 0 1 0 6.1C0 4.2 3 2.5 3 0Z" fill="url(#wh-drip)" />
|
||||
<ellipse cx="2" cy="5.8" rx="0.7" ry="1" fill="#fff" opacity="0.6" />
|
||||
</svg>
|
||||
</motion.span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||
import { CartLandingEffect } from "@/components/cart-landing-effect";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
@@ -285,7 +286,8 @@ function CartIconButton() {
|
||||
const basketControls = useAnimationControls();
|
||||
|
||||
// Landet ein Honigtropfen (components/cart-flight.tsx), faengt der Korb
|
||||
// ihn sichtbar auf: kurzes Einsinken und Nachwippen.
|
||||
// ihn sichtbar auf: kurzes Einsinken und Nachwippen. Leuchten und
|
||||
// abperlender Tropfen kommen aus CartLandingEffect.
|
||||
useEffect(
|
||||
() =>
|
||||
onCartLanded(() => {
|
||||
@@ -319,6 +321,7 @@ function CartIconButton() {
|
||||
<circle cx="9" cy="20" r="1" />
|
||||
<circle cx="18" cy="20" r="1" />
|
||||
</motion.svg>
|
||||
<CartLandingEffect />
|
||||
<AnimatePresence>
|
||||
{count > 0 ? (
|
||||
<motion.span
|
||||
|
||||
@@ -732,7 +732,7 @@ export const de: Dictionary = {
|
||||
twoFactorPage: {
|
||||
title: "Zwei-Faktor-Authentifizierung",
|
||||
subtitle: "Schützt dein Konto zusätzlich zum Passwort mit einem Code aus einer Authenticator-App.",
|
||||
backLabel: "Zurück zur Übersicht",
|
||||
backLabel: "Zurück zu den Einstellungen",
|
||||
activeStatus: (count) => `Zwei-Faktor-Authentifizierung ist aktiv. Noch ${count} Backup-Code${count === 1 ? "" : "s"} verfügbar.`,
|
||||
disableHeading: "Deaktivieren",
|
||||
passwordConfirmLabel: "Passwort zur Bestätigung",
|
||||
@@ -785,7 +785,12 @@ export const de: Dictionary = {
|
||||
backLabel: "Zurück zu allen Ernten",
|
||||
batchesHeading: "Chargen dieser Ernte",
|
||||
productsHeading: "Produkte aus dieser Ernte",
|
||||
glassesAvailable: (remaining, total) => `${remaining} von ${total} Gläsern verfügbar`,
|
||||
glassesAvailable: (remaining) =>
|
||||
remaining <= 0
|
||||
? "Leider schon vergriffen"
|
||||
: remaining === 1
|
||||
? "Es ist nur noch 1 Glas verfügbar"
|
||||
: `Es sind noch ${remaining} Gläser verfügbar`,
|
||||
},
|
||||
chargePage: {
|
||||
verifiedBatch: "Verifizierte Charge",
|
||||
@@ -1156,12 +1161,12 @@ export const de: Dictionary = {
|
||||
adminApiCoreSync: {
|
||||
heading: "ApiCore-Synchronisierung",
|
||||
intro:
|
||||
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene und im Admin bearbeitete Einträge werden nie überschrieben; interne ApiCore-Notizen werden nicht übernommen.",
|
||||
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen immer aus ApiCore; Texte, Preise, Bilder, Sichtbarkeit und Shop-Bestand bleiben so, wie sie hier gepflegt werden. Interne ApiCore-Notizen werden nicht übernommen.",
|
||||
button: "Alles synchronisieren",
|
||||
resultHarvests: (created, updated, skipped, failed) =>
|
||||
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||
resultBatches: (created, skipped, failed) =>
|
||||
`Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||
resultBatches: (created, updated, skipped, failed) =>
|
||||
`Chargen: ${created} neu, ${updated} aktualisiert, ${skipped} unverändert${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||
resultProducts: (created, skipped, failed) =>
|
||||
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||
pending: (count) =>
|
||||
@@ -1169,11 +1174,12 @@ export const de: Dictionary = {
|
||||
discontinued: (count) =>
|
||||
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
||||
protectedDrafts: (count) =>
|
||||
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet und deshalb nicht überschrieben.`,
|
||||
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet: Sorte, Ort und Text bleiben, nur Datum und Jahr kommen aus ApiCore.`,
|
||||
stockConflicts: (count) =>
|
||||
`Bei ${count} Charge${count === 1 ? "" : "n"} liegt der Shop-Bestand über der in ApiCore erfassten Abfüllmenge. Bitte Bestand unter Chargen prüfen – er wurde nicht automatisch geändert.`,
|
||||
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||
neverRun: "Noch kein vollständiger Lauf.",
|
||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
||||
},
|
||||
adminProductsPage: {
|
||||
backToDashboard: "Zurück zum Dashboard",
|
||||
@@ -1630,8 +1636,9 @@ export const de: Dictionary = {
|
||||
navOrders: { title: "Bestellungen", description: "Bestellhistorie, Versandstatus und Rechnungen" },
|
||||
navAddresses: { title: "Adressen", description: "Lieferadressen verwalten" },
|
||||
navWishlist: { title: "Wunschliste", description: "Gemerkte Produkte ansehen" },
|
||||
navSecurity: {
|
||||
title: "Sicherheit",
|
||||
navSettings: {
|
||||
title: "Einstellungen",
|
||||
description: "Sicherheit und Datenschutz",
|
||||
twoFactorLabel: "Zwei-Faktor-Authentifizierung",
|
||||
twoFactorActive: "aktiv",
|
||||
twoFactorInactive: "nicht aktiv",
|
||||
@@ -1645,6 +1652,18 @@ export const de: Dictionary = {
|
||||
logoutAllDevicesHeading: "Alle Geräte abmelden",
|
||||
logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.",
|
||||
logout: "Abmelden",
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
subtitle: "Sicherheit deines Kontos und deine Daten an einem Ort.",
|
||||
backLabel: "Zurück zu meinem Konto",
|
||||
securityHeading: "Sicherheit",
|
||||
twoFactorHeading: "Zwei-Faktor-Authentifizierung",
|
||||
twoFactorActiveDescription: "Aktiv. Bei der Anmeldung fragen wir zusätzlich einen Code aus deiner Authenticator-App ab.",
|
||||
twoFactorInactiveDescription: "Nicht aktiv. Wir empfehlen, dein Konto zusätzlich mit einem Code aus einer Authenticator-App zu schützen.",
|
||||
twoFactorManage: "Verwalten",
|
||||
twoFactorSetup: "Einrichten",
|
||||
logoutAllDevicesButton: "Überall abmelden",
|
||||
},
|
||||
privacy: {
|
||||
heading: "Datenschutz",
|
||||
downloadDataHeading: "Meine Daten herunterladen",
|
||||
|
||||
@@ -708,7 +708,7 @@ export const en: Dictionary = {
|
||||
twoFactorPage: {
|
||||
title: "Two-Factor Authentication",
|
||||
subtitle: "Protects your account beyond just a password, with a code from an authenticator app.",
|
||||
backLabel: "Back to overview",
|
||||
backLabel: "Back to settings",
|
||||
activeStatus: (count) => `Two-factor authentication is active. ${count} backup code${count === 1 ? "" : "s"} remaining.`,
|
||||
disableHeading: "Disable",
|
||||
passwordConfirmLabel: "Password to confirm",
|
||||
@@ -761,7 +761,12 @@ export const en: Dictionary = {
|
||||
backLabel: "Back to all harvests",
|
||||
batchesHeading: "Batches from this harvest",
|
||||
productsHeading: "Products from this harvest",
|
||||
glassesAvailable: (remaining, total) => `${remaining} of ${total} jars available`,
|
||||
glassesAvailable: (remaining) =>
|
||||
remaining <= 0
|
||||
? "Already sold out"
|
||||
: remaining === 1
|
||||
? "Only 1 jar left"
|
||||
: `Only ${remaining} jars left`,
|
||||
},
|
||||
chargePage: {
|
||||
verifiedBatch: "Verified batch",
|
||||
@@ -1131,12 +1136,12 @@ export const en: Dictionary = {
|
||||
adminApiCoreSync: {
|
||||
heading: "ApiCore sync",
|
||||
intro:
|
||||
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released or edited in the admin are never overwritten; internal ApiCore notes are not copied.",
|
||||
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Beekeeping facts (dates, lot number, best-before date, filled quantity) always come from ApiCore; texts, prices, images, visibility and shop stock stay as maintained here. Internal ApiCore notes are not copied.",
|
||||
button: "Sync everything",
|
||||
resultHarvests: (created, updated, skipped, failed) =>
|
||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||
resultBatches: (created, skipped, failed) =>
|
||||
`Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||
resultBatches: (created, updated, skipped, failed) =>
|
||||
`Batches: ${created} created, ${updated} updated, ${skipped} unchanged${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||
resultProducts: (created, skipped, failed) =>
|
||||
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||
pending: (count) =>
|
||||
@@ -1144,11 +1149,12 @@ export const en: Dictionary = {
|
||||
discontinued: (count) =>
|
||||
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
||||
protectedDrafts: (count) =>
|
||||
`${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin and therefore not overwritten.`,
|
||||
`${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin: type, location and text stay, only date and year come from ApiCore.`,
|
||||
stockConflicts: (count) =>
|
||||
`For ${count} batch${count === 1 ? "" : "es"} the shop stock exceeds the quantity filled according to ApiCore. Please check the stock under batches — it was not changed automatically.`,
|
||||
lastRun: (when) => `Last complete run: ${when}`,
|
||||
neverRun: "No complete run yet.",
|
||||
error: "Sync with ApiCore failed.",
|
||||
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
||||
},
|
||||
adminProductsPage: {
|
||||
backToDashboard: "Back to dashboard",
|
||||
@@ -1604,8 +1610,9 @@ export const en: Dictionary = {
|
||||
navOrders: { title: "Orders", description: "Order history, shipping status, and invoices" },
|
||||
navAddresses: { title: "Addresses", description: "Manage delivery addresses" },
|
||||
navWishlist: { title: "Wishlist", description: "View saved products" },
|
||||
navSecurity: {
|
||||
title: "Security",
|
||||
navSettings: {
|
||||
title: "Settings",
|
||||
description: "Security and privacy",
|
||||
twoFactorLabel: "Two-factor authentication",
|
||||
twoFactorActive: "active",
|
||||
twoFactorInactive: "not active",
|
||||
@@ -1619,6 +1626,18 @@ export const en: Dictionary = {
|
||||
logoutAllDevicesHeading: "Log out all devices",
|
||||
logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.",
|
||||
logout: "Log out",
|
||||
settings: {
|
||||
title: "Settings",
|
||||
subtitle: "Your account security and your data in one place.",
|
||||
backLabel: "Back to my account",
|
||||
securityHeading: "Security",
|
||||
twoFactorHeading: "Two-factor authentication",
|
||||
twoFactorActiveDescription: "Active. When you sign in, we also ask for a code from your authenticator app.",
|
||||
twoFactorInactiveDescription: "Not active. We recommend protecting your account with an additional code from an authenticator app.",
|
||||
twoFactorManage: "Manage",
|
||||
twoFactorSetup: "Set up",
|
||||
logoutAllDevicesButton: "Sign out everywhere",
|
||||
},
|
||||
privacy: {
|
||||
heading: "Privacy",
|
||||
downloadDataHeading: "Download my data",
|
||||
|
||||
@@ -358,7 +358,7 @@ export interface Dictionary {
|
||||
backLabel: string;
|
||||
batchesHeading: string;
|
||||
productsHeading: string;
|
||||
glassesAvailable: (remaining: number, total: number) => string;
|
||||
glassesAvailable: (remaining: number) => string;
|
||||
};
|
||||
chargePage: {
|
||||
verifiedBatch: string;
|
||||
@@ -729,15 +729,15 @@ export interface Dictionary {
|
||||
intro: string;
|
||||
button: string;
|
||||
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => string;
|
||||
resultBatches: (created: number, skipped: number, failed: number) => string;
|
||||
resultBatches: (created: number, updated: number, skipped: number, failed: number) => string;
|
||||
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||
pending: (count: number) => string;
|
||||
discontinued: (count: number) => string;
|
||||
protectedDrafts: (count: number) => string;
|
||||
stockConflicts: (count: number) => string;
|
||||
lastRun: (when: string) => string;
|
||||
neverRun: string;
|
||||
error: string;
|
||||
batchLoadError: string;
|
||||
};
|
||||
adminProductsPage: {
|
||||
backToDashboard: string;
|
||||
@@ -1167,7 +1167,13 @@ export interface Dictionary {
|
||||
navOrders: { title: string; description: string };
|
||||
navAddresses: { title: string; description: string };
|
||||
navWishlist: { title: string; description: string };
|
||||
navSecurity: { title: string; twoFactorLabel: string; twoFactorActive: string; twoFactorInactive: string };
|
||||
navSettings: {
|
||||
title: string;
|
||||
description: string;
|
||||
twoFactorLabel: string;
|
||||
twoFactorActive: string;
|
||||
twoFactorInactive: string;
|
||||
};
|
||||
navAdmin: { title: string; description: string };
|
||||
newsletterHeading: string;
|
||||
newsletterSubscribed: string;
|
||||
@@ -1177,6 +1183,18 @@ export interface Dictionary {
|
||||
logoutAllDevicesHeading: string;
|
||||
logoutAllDevicesDescription: string;
|
||||
logout: string;
|
||||
settings: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
backLabel: string;
|
||||
securityHeading: string;
|
||||
twoFactorHeading: string;
|
||||
twoFactorActiveDescription: string;
|
||||
twoFactorInactiveDescription: string;
|
||||
twoFactorManage: string;
|
||||
twoFactorSetup: string;
|
||||
logoutAllDevicesButton: string;
|
||||
};
|
||||
privacy: {
|
||||
heading: string;
|
||||
downloadDataHeading: string;
|
||||
|
||||
+119
-38
@@ -1,13 +1,14 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, test, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
openApiCoreSession,
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreBottlingBatches,
|
||||
getApiCoreConnection,
|
||||
fetchApiCoreShopData,
|
||||
ApiCoreConfigError,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreSession,
|
||||
type ApiCoreConnection,
|
||||
} from "./apicore-client";
|
||||
|
||||
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
|
||||
const API_KEY = `ack_abcd1234_${"A".repeat(43)}`;
|
||||
const connection: ApiCoreConnection = { baseUrl: "http://apicore.test/api/v1", apiKey: API_KEY };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -16,16 +17,92 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
describe("apicore-client", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
|
||||
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
|
||||
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
|
||||
});
|
||||
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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("getApiCoreConnection", () => {
|
||||
test("liefert Adresse und API-Schluessel", () => {
|
||||
// Arrange
|
||||
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1/");
|
||||
vi.stubEnv("APICORE_API_KEY", API_KEY);
|
||||
|
||||
// Act & Assert
|
||||
expect(getApiCoreConnection()).toEqual(connection);
|
||||
});
|
||||
|
||||
test("verlangt einen API-Schluessel — Benutzer und Passwort reichen nicht mehr", () => {
|
||||
// Arrange: nur die alte Konfiguration.
|
||||
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
|
||||
vi.stubEnv("APICORE_API_KEY", "");
|
||||
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
|
||||
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
|
||||
|
||||
// Act & Assert
|
||||
expect(() => getApiCoreConnection()).toThrow(ApiCoreConfigError);
|
||||
});
|
||||
|
||||
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", () => {
|
||||
// Arrange
|
||||
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
|
||||
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
|
||||
|
||||
// Act & Assert
|
||||
expect(() => getApiCoreConnection()).toThrow(/Format eines ApiCore-API-Schlüssels/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchApiCoreShopData", () => {
|
||||
test("holt alles in einem Abruf, mit dem Schluessel als Bearer-Token und Zeitlimit", async () => {
|
||||
// Arrange
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Act
|
||||
const data = await fetchApiCoreShopData(connection);
|
||||
|
||||
// Assert
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("http://apicore.test/api/v1/shop-sync");
|
||||
expect(init.headers).toEqual({ Authorization: `Bearer ${API_KEY}` });
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese" });
|
||||
expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
|
||||
expect(data.products[0].id).toBe("p1");
|
||||
});
|
||||
|
||||
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
|
||||
@@ -36,45 +113,49 @@ describe("apicore-client", () => {
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
|
||||
});
|
||||
|
||||
test("setzt an jeder Anfrage ein Zeitlimit", async () => {
|
||||
test("benennt einen widerrufenen oder abgelaufenen Schluessel", async () => {
|
||||
// Arrange
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Unauthorized" }, 401)));
|
||||
|
||||
// Act & Assert
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/API-Schlüssel abgelehnt/);
|
||||
});
|
||||
|
||||
test("hat keinen Rueckfall auf andere Endpunkte, wenn die Schnittstelle fehlt", async () => {
|
||||
// Arrange: aelteres ApiCore ohne /shop-sync.
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, 404));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Act
|
||||
await fetchApiCoreHarvests(session);
|
||||
|
||||
// Assert
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
expect((call[1] as RequestInit).signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
// Act & Assert
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
|
||||
// Arrange: ApiCore antwortet mit 500 — eine leere Liste daraus zu machen,
|
||||
// hiesse fuer den Sync "es gibt nichts", und darauf darf nichts aufbauen.
|
||||
// Arrange
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
|
||||
|
||||
// Act & Assert
|
||||
await expect(fetchApiCoreHarvests(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
||||
});
|
||||
|
||||
test("verwirft eine Antwort mit unerwarteter Struktur statt sie teilweise zu uebernehmen", async () => {
|
||||
// Arrange: Schleuderungen ok, Chargen ohne Pflichtfeld lotNumber.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) =>
|
||||
url.endsWith("/extractions")
|
||||
? jsonResponse([])
|
||||
: jsonResponse([{ id: "b1", date: "2026-08-01", bestBeforeDate: "2028-08-01", jarCount: 10 }])
|
||||
)
|
||||
);
|
||||
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(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Schnittstellenversion 2/);
|
||||
});
|
||||
|
||||
test("verwirft eine Antwort mit unerwarteter Struktur als Ganzes", async () => {
|
||||
// Arrange: Abfuellung ohne Pflichtfeld lotNumber.
|
||||
const broken = { ...feed, bottlings: [{ ...feed.bottlings[0], lotNumber: undefined }] };
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(broken)));
|
||||
|
||||
// Act & Assert
|
||||
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/unerwartete Antwortstruktur/);
|
||||
});
|
||||
});
|
||||
+140
-226
@@ -1,47 +1,28 @@
|
||||
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.
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* 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. "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
|
||||
* APICORE_API_URL z.B. "https://apicore.example.org/api/v1"
|
||||
* APICORE_API_KEY der Schluessel, Format ack_<8>_<43>
|
||||
*
|
||||
* 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.
|
||||
* Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError —
|
||||
* der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an.
|
||||
*
|
||||
* 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.
|
||||
* 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 {}
|
||||
@@ -49,11 +30,13 @@ 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 — der Knopf
|
||||
* dreht sich, und es gibt weder Ergebnis noch Fehlermeldung.
|
||||
* 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.
|
||||
@@ -64,160 +47,64 @@ function describeFetchFailure(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
}
|
||||
|
||||
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 {
|
||||
/** Adresse und Schluessel fuer ApiCore. */
|
||||
export interface ApiCoreConnection {
|
||||
readonly baseUrl: string;
|
||||
readonly token: string;
|
||||
readonly apiKey: 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) {
|
||||
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, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)."
|
||||
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
|
||||
);
|
||||
}
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
|
||||
}
|
||||
|
||||
export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
||||
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 }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
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) {
|
||||
if (!API_KEY_PATTERN.test(apiKey)) {
|
||||
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)."
|
||||
"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, token: parsed.data.accessToken };
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Datenvertrag der Shop-Schnittstelle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
async function getJson<T>(
|
||||
session: ApiCoreSession,
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = 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)}`);
|
||||
}
|
||||
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() });
|
||||
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().optional(),
|
||||
honeySuperCount: z.number().nullable().optional(),
|
||||
grossWeight: z.number().nullable().optional(),
|
||||
netWeight: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
honeyType: z.string().nullable(),
|
||||
honeySuperCount: z.number().nullable(),
|
||||
grossWeight: z.number().nullable(),
|
||||
netWeight: z.number().nullable(),
|
||||
});
|
||||
|
||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||
|
||||
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
||||
apiaryName: string | null;
|
||||
}
|
||||
|
||||
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
||||
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
|
||||
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<ApiCoreHarvestWithApiaryName[]> {
|
||||
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:
|
||||
* ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen 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.
|
||||
* - `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(),
|
||||
@@ -233,82 +120,109 @@ const productSchema = z.object({
|
||||
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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";
|
||||
}
|
||||
|
||||
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
|
||||
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({
|
||||
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(),
|
||||
// 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(),
|
||||
}),
|
||||
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
|
||||
productId: z.string().nullable(),
|
||||
harvestIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
||||
|
||||
export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch {
|
||||
/** 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 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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
export async function fetchApiCoreBottlingBatches(
|
||||
session: ApiCoreSession
|
||||
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
||||
const [extractions, batches] = await Promise.all([
|
||||
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
|
||||
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
|
||||
]);
|
||||
const shopFeedSchema = z.object({
|
||||
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
|
||||
harvests: z.array(harvestSchema),
|
||||
products: z.array(productSchema),
|
||||
bottlings: z.array(bottlingSchema),
|
||||
});
|
||||
|
||||
const firstHarvestByExtraction = new Map<string, string | null>(
|
||||
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
||||
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 "Dem API-Schlüssel fehlt die Berechtigung „Shop-Daten lesen“ (HTTP 403).";
|
||||
}
|
||||
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 batches.map((batch) => ({
|
||||
...batch,
|
||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
||||
}));
|
||||
return {
|
||||
harvests: parsed.data.harvests,
|
||||
products: parsed.data.products,
|
||||
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
|
||||
...bottling,
|
||||
harvestSourceId: harvestIds[0] ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
|
||||
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
|
||||
import {
|
||||
slugForApiCoreSource,
|
||||
buildHarvestDescription,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
deriveJarWeightGrams,
|
||||
slugifyProductName,
|
||||
productSlugCandidates,
|
||||
harvestFactChanges,
|
||||
batchFactChanges,
|
||||
} from "./apicore-mapping";
|
||||
|
||||
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
||||
@@ -24,9 +26,7 @@ function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarvest(
|
||||
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
|
||||
): ApiCoreHarvestWithApiaryName {
|
||||
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
|
||||
return {
|
||||
id: "cm-harvest-1",
|
||||
apiaryId: "cm-apiary-1",
|
||||
@@ -35,7 +35,6 @@ function makeHarvest(
|
||||
honeySuperCount: null,
|
||||
grossWeight: null,
|
||||
netWeight: null,
|
||||
notes: null,
|
||||
apiaryName: "Streuobstwiese",
|
||||
...overrides,
|
||||
};
|
||||
@@ -77,17 +76,6 @@ describe("buildHarvestDescription", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("uebernimmt interne ApiCore-Notizen nie in den Shoptext", () => {
|
||||
// Arrange: Betriebsnotiz, die nicht oeffentlich werden darf.
|
||||
const source = makeHarvest({ netWeight: 41, notes: "Volk 7 nach Varroa-Behandlung schwach" });
|
||||
|
||||
// Act
|
||||
const description = buildHarvestDescription(source);
|
||||
|
||||
// Assert
|
||||
expect(description).not.toContain("Varroa");
|
||||
expect(description).not.toContain("Volk 7");
|
||||
});
|
||||
|
||||
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
|
||||
// Arrange
|
||||
@@ -272,3 +260,70 @@ describe("isSellableStatus", () => {
|
||||
expect(isSellableStatus("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("harvestFactChanges", () => {
|
||||
test("liefert nichts, wenn Datum und Jahr unveraendert sind", () => {
|
||||
// Arrange
|
||||
const date = new Date("2026-07-14T00:00:00.000Z");
|
||||
|
||||
// Act
|
||||
const changes = harvestFactChanges({ harvestDate: date, year: 2026 }, new Date(date));
|
||||
|
||||
// Assert
|
||||
expect(changes).toEqual({});
|
||||
});
|
||||
|
||||
test("uebernimmt ein in ApiCore korrigiertes Erntedatum samt Jahr", () => {
|
||||
// Arrange
|
||||
const existing = { harvestDate: new Date("2025-12-31T12:00:00.000Z"), year: 2025 };
|
||||
const corrected = new Date("2026-01-02T12:00:00.000Z");
|
||||
|
||||
// Act
|
||||
const changes = harvestFactChanges(existing, corrected);
|
||||
|
||||
// Assert
|
||||
expect(changes).toEqual({ harvestDate: corrected, year: 2026 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("batchFactChanges", () => {
|
||||
const base = {
|
||||
code: "WH-2026-0001",
|
||||
fillDate: new Date("2026-08-01T00:00:00.000Z"),
|
||||
bestBeforeDate: new Date("2028-08-01T00:00:00.000Z"),
|
||||
glassCount: 100,
|
||||
};
|
||||
|
||||
test("aktualisiert nur die ApiCore-eigenen Felder, die sich geaendert haben", () => {
|
||||
// Arrange: MHD in ApiCore korrigiert, Shop hat 40 Glaeser verkauft.
|
||||
const source = { ...base, bestBeforeDate: new Date("2028-07-31T00:00:00.000Z") };
|
||||
|
||||
// Act
|
||||
const result = batchFactChanges({ ...base, remainingGlasses: 60 }, source);
|
||||
|
||||
// Assert
|
||||
expect(result.changes).toEqual({ bestBeforeDate: source.bestBeforeDate });
|
||||
expect(result.changes).not.toHaveProperty("remainingGlasses");
|
||||
expect(result.stockExceedsFilled).toBe(false);
|
||||
});
|
||||
|
||||
test("uebernimmt eine korrigierte Losnummer statt eine zweite Charge anzulegen", () => {
|
||||
// Act
|
||||
const result = batchFactChanges({ ...base, remainingGlasses: 100 }, { ...base, code: "WH-2026-0002" });
|
||||
|
||||
// Assert
|
||||
expect(result.changes).toEqual({ code: "WH-2026-0002" });
|
||||
});
|
||||
|
||||
test("meldet einen Shop-Bestand ueber der korrigierten Abfuellmenge, statt ihn anzugleichen", () => {
|
||||
// Arrange: ApiCore korrigiert 100 auf 80 Glaeser, im Shop stehen noch 90.
|
||||
const source = { ...base, glassCount: 80 };
|
||||
|
||||
// Act
|
||||
const result = batchFactChanges({ ...base, remainingGlasses: 90 }, source);
|
||||
|
||||
// Assert
|
||||
expect(result.changes).toEqual({ glassCount: 80 });
|
||||
expect(result.stockExceedsFilled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
|
||||
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
|
||||
|
||||
/**
|
||||
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
|
||||
@@ -31,7 +31,7 @@ export function slugForApiCoreSource(sourceId: string): string {
|
||||
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
|
||||
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
|
||||
*/
|
||||
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
|
||||
export function buildHarvestDescription(source: ApiCoreHarvest): string {
|
||||
const parts: string[] = [];
|
||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||
@@ -135,3 +135,69 @@ export function productSlugCandidates(name: string, year: number, lotNumber: str
|
||||
const lot = slugifyProductName(lotNumber);
|
||||
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zustaendigkeit pro Feld
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ApiCore ist die Quelle der Wahrheit fuer Imkerei-Fakten, Wabenhain fuer
|
||||
// alles, was den Verkauf betrifft. Die Funktionen unten liefern fuer einen
|
||||
// bereits vorhandenen Datensatz NUR die ApiCore-eigenen Felder, und nur
|
||||
// dann, wenn sie sich tatsaechlich geaendert haben. Alles andere (Texte,
|
||||
// Bilder, Preis, Sichtbarkeit, Shop-Bestand, Zuordnungen) bleibt unberuehrt —
|
||||
// auch dann, wenn ApiCore dort etwas anderes stehen haette.
|
||||
|
||||
/** Ernte: Datum und Jahr sind Fakten aus ApiCore. */
|
||||
export interface HarvestFacts {
|
||||
harvestDate: Date;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function harvestFactChanges(
|
||||
existing: HarvestFacts,
|
||||
sourceDate: Date
|
||||
): Partial<HarvestFacts> {
|
||||
const changes: Partial<HarvestFacts> = {};
|
||||
if (existing.harvestDate.getTime() !== sourceDate.getTime()) changes.harvestDate = sourceDate;
|
||||
const year = sourceDate.getFullYear();
|
||||
if (existing.year !== year) changes.year = year;
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge: Losnummer, Abfuelldatum, Mindesthaltbarkeit und abgefuellte
|
||||
* Glasanzahl sind Fakten aus ApiCore. `remainingGlasses` ist dagegen
|
||||
* Wabenhains Shop-Bestand, veraendert durch echte Verkaeufe, und wird nie
|
||||
* gesetzt.
|
||||
*/
|
||||
export interface BatchFacts {
|
||||
code: string;
|
||||
fillDate: Date;
|
||||
bestBeforeDate: Date;
|
||||
glassCount: number;
|
||||
}
|
||||
|
||||
export interface BatchFactUpdate {
|
||||
changes: Partial<BatchFacts>;
|
||||
/**
|
||||
* Der Shop-Bestand liegt nach der Korrektur ueber der abgefuellten Menge —
|
||||
* das kann nur ein Mensch aufloesen (Zaehlfehler hier oder dort?), also
|
||||
* wird es gemeldet statt still angeglichen.
|
||||
*/
|
||||
stockExceedsFilled: boolean;
|
||||
}
|
||||
|
||||
export function batchFactChanges(
|
||||
existing: BatchFacts & { remainingGlasses: number },
|
||||
source: BatchFacts
|
||||
): BatchFactUpdate {
|
||||
const changes: Partial<BatchFacts> = {};
|
||||
if (existing.code !== source.code) changes.code = source.code;
|
||||
if (existing.fillDate.getTime() !== source.fillDate.getTime()) changes.fillDate = source.fillDate;
|
||||
if (existing.bestBeforeDate.getTime() !== source.bestBeforeDate.getTime()) {
|
||||
changes.bestBeforeDate = source.bestBeforeDate;
|
||||
}
|
||||
if (existing.glassCount !== source.glassCount) changes.glassCount = source.glassCount;
|
||||
const glassCount = changes.glassCount ?? existing.glassCount;
|
||||
return { changes, stockExceedsFilled: existing.remainingGlasses > glassCount };
|
||||
}
|
||||
@@ -20,8 +20,7 @@ const FULL = {
|
||||
DHL_GKP_PASSWORD: "w",
|
||||
DHL_ACCOUNT_NUMBER: "12345678901234",
|
||||
APICORE_API_URL: "https://api.example.org/api",
|
||||
APICORE_SYNC_EMAIL: "[email protected]",
|
||||
APICORE_SYNC_PASSWORD: "pw",
|
||||
APICORE_API_KEY: "ack_secret",
|
||||
};
|
||||
|
||||
describe("readIntegrationStatus", () => {
|
||||
@@ -47,7 +46,16 @@ describe("readIntegrationStatus", () => {
|
||||
});
|
||||
|
||||
it("never returns a configured value", () => {
|
||||
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
|
||||
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|ack_secret/);
|
||||
});
|
||||
|
||||
it("needs the ApiCore API key - there is no password login any more", () => {
|
||||
const apicore = readIntegrationStatus({
|
||||
APICORE_API_URL: "https://api.example.org/api",
|
||||
APICORE_SYNC_EMAIL: "[email protected]",
|
||||
APICORE_SYNC_PASSWORD: "pw",
|
||||
}).find((s) => s.key === "apicore");
|
||||
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
|
||||
});
|
||||
|
||||
it("marks shop-critical integrations as required", () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[
|
||||
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
|
||||
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
|
||||
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
|
||||
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_SYNC_EMAIL", "APICORE_SYNC_PASSWORD"] },
|
||||
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
|
||||
];
|
||||
|
||||
function stripeMode(key: string): "live" | "test" | null {
|
||||
|
||||
Reference in new issue
Block a user