Compare commits
8
Commits
| 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_USER=
|
||||||
SMTP_FROM="Wabenhain <[email protected]>"
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
|
|
||||||
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
|
||||||
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
|
||||||
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
|
||||||
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
|
||||||
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
|
||||||
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
|
||||||
# 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
|
|
||||||
# Details siehe src/lib/apicore-client.ts.
|
# Details siehe src/lib/apicore-client.ts.
|
||||||
APICORE_API_URL=
|
APICORE_API_URL=
|
||||||
APICORE_SYNC_EMAIL=
|
APICORE_API_KEY=
|
||||||
APICORE_SYNC_PASSWORD=
|
|
||||||
|
|
||||||
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
# 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`.
|
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
|
## Vollständiger Docker-Betrieb
|
||||||
|
|
||||||
|
|||||||
@@ -102,17 +102,12 @@ stringData:
|
|||||||
# ist dagegen oeffentlich und steht dort.
|
# ist dagegen oeffentlich und steht dort.
|
||||||
GOOGLE_PLACES_API_KEY: ""
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
|
||||||
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
|
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
|
||||||
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
|
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
|
||||||
# Konfigurationsfehler, statt still nichts zu tun.
|
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
|
||||||
#
|
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
|
||||||
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login —
|
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
|
||||||
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen
|
APICORE_API_KEY: ""
|
||||||
# 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: ""
|
|
||||||
|
|
||||||
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
||||||
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
# .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())
|
id String @id @default(cuid())
|
||||||
/// z.B. FH-26-01
|
/// z.B. FH-26-01
|
||||||
code String @unique
|
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
|
harvestId String
|
||||||
fillDate DateTime
|
fillDate DateTime
|
||||||
bestBeforeDate DateTime
|
bestBeforeDate DateTime
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
getApiCoreConnection,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreShopData,
|
||||||
fetchApiCoreProducts,
|
|
||||||
fetchApiCoreBottlingBatches,
|
|
||||||
isSellableStatus,
|
isSellableStatus,
|
||||||
ApiCoreConfigError,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreShopData,
|
||||||
type ApiCoreHarvestWithApiaryName,
|
type ApiCoreHarvest,
|
||||||
type ApiCoreProduct,
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
} from "@/lib/apicore-client";
|
} from "@/lib/apicore-client";
|
||||||
@@ -23,6 +21,8 @@ import {
|
|||||||
buildProductDescription,
|
buildProductDescription,
|
||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
productSlugCandidates,
|
productSlugCandidates,
|
||||||
|
harvestFactChanges,
|
||||||
|
batchFactChanges,
|
||||||
} from "@/lib/apicore-mapping";
|
} from "@/lib/apicore-mapping";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
@@ -36,26 +36,31 @@ import { requireAdmin } from "./_shared";
|
|||||||
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
||||||
* ein Mensch freigibt.
|
* ein Mensch freigibt.
|
||||||
*
|
*
|
||||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
* Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
|
||||||
* false) bzw. als solche aktualisiert — aber nur, solange niemand den
|
* Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
|
||||||
* Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der
|
* nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
|
||||||
* Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins
|
* Anlegen nie mehr angefasst.
|
||||||
* (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder
|
*
|
||||||
* zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt.
|
* - 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,
|
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||||
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
||||||
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
||||||
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
|
* - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
|
||||||
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
|
* Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
|
||||||
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
|
* eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
|
||||||
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
|
* liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
|
||||||
* Lagerbestand.
|
* 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:
|
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||||
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
* "apicore-<ApiCore-ID>" als Harvest.slug, und die ApiCore-ID der Abfuellung
|
||||||
* ApiCore-Losnummer als Batch.code.
|
* als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
||||||
*
|
*
|
||||||
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
||||||
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
||||||
@@ -72,10 +77,18 @@ interface SyncCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HarvestSyncCounts extends 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;
|
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,
|
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||||
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||||
@@ -115,7 +128,7 @@ function describeError(error: unknown): string {
|
|||||||
// Ernten
|
// 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 };
|
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
@@ -130,12 +143,17 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Ha
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||||
if (existing?.isPublished) {
|
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||||
counts.skipped++;
|
if (existing && shopOwned) {
|
||||||
continue;
|
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||||
}
|
const facts = harvestFactChanges(existing, date);
|
||||||
if (existing && (await wasEditedByAdmin(existing.id))) {
|
if (!existing.isPublished) counts.protectedDrafts++;
|
||||||
counts.protectedDrafts++;
|
if (Object.keys(facts).length === 0) {
|
||||||
|
counts.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.harvest.update({ where: { slug }, data: facts });
|
||||||
|
counts.updated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,10 +275,13 @@ async function syncProducts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = await prisma.batch.findUnique({
|
const found = await findBatchForBottling(bottling.id, bottling.lotNumber);
|
||||||
where: { code: bottling.lotNumber },
|
const batch = found
|
||||||
include: { harvest: { select: { id: true, year: true } } },
|
? await prisma.batch.findUnique({
|
||||||
});
|
where: { id: found.id },
|
||||||
|
include: { harvest: { select: { id: true, year: true } } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (!batch) {
|
if (!batch) {
|
||||||
counts.pending++;
|
counts.pending++;
|
||||||
continue;
|
continue;
|
||||||
@@ -328,8 +349,21 @@ async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
|||||||
// Chargen
|
// 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) {
|
for (const source of sources) {
|
||||||
if (!source.harvestSourceId) {
|
if (!source.harvestSourceId) {
|
||||||
@@ -346,9 +380,30 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
const existing = await findBatchForBottling(source.id, source.lotNumber);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
counts.skipped++;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
@@ -366,6 +421,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
await prisma.batch.create({
|
await prisma.batch.create({
|
||||||
data: {
|
data: {
|
||||||
code: source.lotNumber,
|
code: source.lotNumber,
|
||||||
|
apicoreBottlingId: source.id,
|
||||||
harvestId: harvest.id,
|
harvestId: harvest.id,
|
||||||
fillDate,
|
fillDate,
|
||||||
bestBeforeDate,
|
bestBeforeDate,
|
||||||
@@ -414,32 +470,23 @@ function returnTargetFrom(formData: FormData): string {
|
|||||||
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||||
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||||
*
|
*
|
||||||
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
|
||||||
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
|
||||||
*
|
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const returnTo = returnTargetFrom(formData);
|
const returnTo = returnTargetFrom(formData);
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
let source: ApiCoreShopData;
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
|
||||||
let articles: ApiCoreProduct[];
|
|
||||||
try {
|
try {
|
||||||
session = await openApiCoreSession();
|
source = await fetchApiCoreShopData(getApiCoreConnection());
|
||||||
[sourceHarvests, articles] = await Promise.all([
|
|
||||||
fetchApiCoreHarvests(session),
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Ernten
|
// 1. Ernten
|
||||||
const harvests = await syncHarvests(sourceHarvests);
|
const harvests = await syncHarvests(source.harvests);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
entityType: "Harvest",
|
entityType: "Harvest",
|
||||||
@@ -448,28 +495,18 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
// 2. Chargen
|
||||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
const batches = await syncBatches(source.bottlings);
|
||||||
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);
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batchLoadError
|
detail:
|
||||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
`${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||||
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
|
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
// 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, {
|
await logAdminAction(admin, {
|
||||||
action: "product.sync_apicore",
|
action: "product.sync_apicore",
|
||||||
entityType: "Product",
|
entityType: "Product",
|
||||||
@@ -485,9 +522,9 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`${returnTo}?syncDone=1` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
`&hProtected=${harvests.protectedDrafts}` +
|
`&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}` +
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
|
||||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,10 +76,12 @@ export default async function HarvestDetailPage(
|
|||||||
<span className="flex items-center justify-between gap-4 text-sm">
|
<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="font-serif text-xl text-heading tabular-nums">{batch.code}</span>
|
||||||
<span className="text-ink/65 tabular-nums">
|
<span className="text-ink/65 tabular-nums">
|
||||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses)}
|
||||||
</span>
|
</span>
|
||||||
</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 aria-hidden="true" className="mt-3 block h-1.5 overflow-hidden rounded-full bg-ink/10">
|
||||||
<span
|
<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]"
|
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) return fail("/konto", "Bitte melde dich an.");
|
||||||
|
|
||||||
if (customer.role === "ADMIN") {
|
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();
|
const ip = await getClientIp();
|
||||||
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
|||||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
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 password = String(formData.get("password") ?? "");
|
||||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||||
if (!passwordValid) {
|
if (!passwordValid) {
|
||||||
return fail("/konto", "Passwort ist falsch.");
|
return fail("/konto/einstellungen", "Passwort ist falsch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
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.");
|
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";
|
import { noIndexPrivate } from "@/lib/seo";
|
||||||
|
|
||||||
// Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten
|
// 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.
|
// Anmeldung selbst. Nichts davon gehoert in den Suchindex.
|
||||||
//
|
//
|
||||||
// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell
|
// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell
|
||||||
|
|||||||
+7
-80
@@ -1,23 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui";
|
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon, SettingsIcon } from "@/components/account-ui";
|
||||||
import { TurnstileField } from "@/components/turnstile-widget";
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { PasswordField } from "@/components/password-field";
|
import { PasswordField } from "@/components/password-field";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { isEntraConfigured } from "@/lib/entra";
|
import { isEntraConfigured } from "@/lib/entra";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import {
|
import {
|
||||||
registerAction,
|
registerAction,
|
||||||
loginAction,
|
loginAction,
|
||||||
logoutAction,
|
logoutAction,
|
||||||
toggleNewsletterAction,
|
|
||||||
logoutAllDevicesAction,
|
|
||||||
resendVerificationEmailAction,
|
resendVerificationEmailAction,
|
||||||
deleteOwnAccountAction,
|
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Mein Konto" };
|
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 (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-2xl">
|
<Container className="max-w-2xl">
|
||||||
@@ -155,10 +145,10 @@ export default async function KontoPage() {
|
|||||||
description={t.account.navWishlist.description}
|
description={t.account.navWishlist.description}
|
||||||
/>
|
/>
|
||||||
<AccountNavCard
|
<AccountNavCard
|
||||||
href="/konto/zwei-faktor"
|
href="/konto/einstellungen"
|
||||||
icon={<ShieldIcon />}
|
icon={<SettingsIcon />}
|
||||||
title={t.account.navSecurity.title}
|
title={t.account.navSettings.title}
|
||||||
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
|
description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`}
|
||||||
/>
|
/>
|
||||||
{customer.role === "ADMIN" ? (
|
{customer.role === "ADMIN" ? (
|
||||||
<AccountNavCard
|
<AccountNavCard
|
||||||
@@ -172,74 +162,11 @@ export default async function KontoPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
<form action={logoutAction} className="mt-10">
|
||||||
<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">
|
|
||||||
<Button type="submit" variant="ghost">
|
<Button type="submit" variant="ghost">
|
||||||
{t.account.logout}
|
{t.account.logout}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function ZweiFaktorPage() {
|
|||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-lg">
|
<Container className="max-w-lg">
|
||||||
<FormPanel>
|
<FormPanel>
|
||||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
<BackLink href="/konto/einstellungen" label={t.twoFactorPage.backLabel} />
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t.twoFactorPage.title}
|
title={t.twoFactorPage.title}
|
||||||
subtitle={t.twoFactorPage.subtitle}
|
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() {
|
export function ShieldIcon() {
|
||||||
return (
|
return (
|
||||||
<svg {...ICON_PROPS}>
|
<svg {...ICON_PROPS}>
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ function text(value: string | string[] | undefined): string | undefined {
|
|||||||
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
||||||
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
const syncError = text(searchParams.syncError);
|
const syncError = text(searchParams.syncError);
|
||||||
const batchLoadError = text(searchParams.batchLoadError);
|
|
||||||
const done = text(searchParams.syncDone) !== undefined;
|
const done = text(searchParams.syncDone) !== undefined;
|
||||||
|
|
||||||
if (!done && syncError === undefined) return null;
|
if (!done && syncError === undefined) return null;
|
||||||
|
|
||||||
const pending = count(searchParams.pPending);
|
const pending = count(searchParams.pPending);
|
||||||
const protectedDrafts = count(searchParams.hProtected);
|
const protectedDrafts = count(searchParams.hProtected);
|
||||||
|
const stockConflicts = count(searchParams.bConflicts);
|
||||||
const discontinued = count(searchParams.pDiscontinued);
|
const discontinued = count(searchParams.pDiscontinued);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,12 +59,24 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
count(searchParams.hFailed)
|
count(searchParams.hFailed)
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||||
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
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 ? (
|
{protectedDrafts > 0 ? (
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
{t.protectedDrafts(protectedDrafts)}
|
{t.protectedDrafts(protectedDrafts)}
|
||||||
@@ -80,11 +92,6 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
{t.discontinued(discontinued)}
|
{t.discontinued(discontinued)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||||
|
import { CartLandingEffect } from "@/components/cart-landing-effect";
|
||||||
import { HexMark } from "@/components/honeycomb";
|
import { HexMark } from "@/components/honeycomb";
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
@@ -285,7 +286,8 @@ function CartIconButton() {
|
|||||||
const basketControls = useAnimationControls();
|
const basketControls = useAnimationControls();
|
||||||
|
|
||||||
// Landet ein Honigtropfen (components/cart-flight.tsx), faengt der Korb
|
// 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(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onCartLanded(() => {
|
onCartLanded(() => {
|
||||||
@@ -319,6 +321,7 @@ function CartIconButton() {
|
|||||||
<circle cx="9" cy="20" r="1" />
|
<circle cx="9" cy="20" r="1" />
|
||||||
<circle cx="18" cy="20" r="1" />
|
<circle cx="18" cy="20" r="1" />
|
||||||
</motion.svg>
|
</motion.svg>
|
||||||
|
<CartLandingEffect />
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{count > 0 ? (
|
{count > 0 ? (
|
||||||
<motion.span
|
<motion.span
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ export const de: Dictionary = {
|
|||||||
twoFactorPage: {
|
twoFactorPage: {
|
||||||
title: "Zwei-Faktor-Authentifizierung",
|
title: "Zwei-Faktor-Authentifizierung",
|
||||||
subtitle: "Schützt dein Konto zusätzlich zum Passwort mit einem Code aus einer Authenticator-App.",
|
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.`,
|
activeStatus: (count) => `Zwei-Faktor-Authentifizierung ist aktiv. Noch ${count} Backup-Code${count === 1 ? "" : "s"} verfügbar.`,
|
||||||
disableHeading: "Deaktivieren",
|
disableHeading: "Deaktivieren",
|
||||||
passwordConfirmLabel: "Passwort zur Bestätigung",
|
passwordConfirmLabel: "Passwort zur Bestätigung",
|
||||||
@@ -785,7 +785,12 @@ export const de: Dictionary = {
|
|||||||
backLabel: "Zurück zu allen Ernten",
|
backLabel: "Zurück zu allen Ernten",
|
||||||
batchesHeading: "Chargen dieser Ernte",
|
batchesHeading: "Chargen dieser Ernte",
|
||||||
productsHeading: "Produkte aus 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: {
|
chargePage: {
|
||||||
verifiedBatch: "Verifizierte Charge",
|
verifiedBatch: "Verifizierte Charge",
|
||||||
@@ -1156,12 +1161,12 @@ export const de: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore-Synchronisierung",
|
heading: "ApiCore-Synchronisierung",
|
||||||
intro:
|
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",
|
button: "Alles synchronisieren",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||||
resultBatches: (created, skipped, failed) =>
|
resultBatches: (created, updated, skipped, failed) =>
|
||||||
`Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
`Chargen: ${created} neu, ${updated} aktualisiert, ${skipped} unverändert${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
resultProducts: (created, skipped, failed) =>
|
resultProducts: (created, skipped, failed) =>
|
||||||
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
pending: (count) =>
|
pending: (count) =>
|
||||||
@@ -1169,11 +1174,12 @@ export const de: Dictionary = {
|
|||||||
discontinued: (count) =>
|
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.`,
|
`${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) =>
|
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}`,
|
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||||
neverRun: "Noch kein vollständiger Lauf.",
|
neverRun: "Noch kein vollständiger Lauf.",
|
||||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
|
||||||
},
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
@@ -1630,8 +1636,9 @@ export const de: Dictionary = {
|
|||||||
navOrders: { title: "Bestellungen", description: "Bestellhistorie, Versandstatus und Rechnungen" },
|
navOrders: { title: "Bestellungen", description: "Bestellhistorie, Versandstatus und Rechnungen" },
|
||||||
navAddresses: { title: "Adressen", description: "Lieferadressen verwalten" },
|
navAddresses: { title: "Adressen", description: "Lieferadressen verwalten" },
|
||||||
navWishlist: { title: "Wunschliste", description: "Gemerkte Produkte ansehen" },
|
navWishlist: { title: "Wunschliste", description: "Gemerkte Produkte ansehen" },
|
||||||
navSecurity: {
|
navSettings: {
|
||||||
title: "Sicherheit",
|
title: "Einstellungen",
|
||||||
|
description: "Sicherheit und Datenschutz",
|
||||||
twoFactorLabel: "Zwei-Faktor-Authentifizierung",
|
twoFactorLabel: "Zwei-Faktor-Authentifizierung",
|
||||||
twoFactorActive: "aktiv",
|
twoFactorActive: "aktiv",
|
||||||
twoFactorInactive: "nicht aktiv",
|
twoFactorInactive: "nicht aktiv",
|
||||||
@@ -1645,6 +1652,18 @@ export const de: Dictionary = {
|
|||||||
logoutAllDevicesHeading: "Alle Geräte abmelden",
|
logoutAllDevicesHeading: "Alle Geräte abmelden",
|
||||||
logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.",
|
logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.",
|
||||||
logout: "Abmelden",
|
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: {
|
privacy: {
|
||||||
heading: "Datenschutz",
|
heading: "Datenschutz",
|
||||||
downloadDataHeading: "Meine Daten herunterladen",
|
downloadDataHeading: "Meine Daten herunterladen",
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ export const en: Dictionary = {
|
|||||||
twoFactorPage: {
|
twoFactorPage: {
|
||||||
title: "Two-Factor Authentication",
|
title: "Two-Factor Authentication",
|
||||||
subtitle: "Protects your account beyond just a password, with a code from an authenticator app.",
|
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.`,
|
activeStatus: (count) => `Two-factor authentication is active. ${count} backup code${count === 1 ? "" : "s"} remaining.`,
|
||||||
disableHeading: "Disable",
|
disableHeading: "Disable",
|
||||||
passwordConfirmLabel: "Password to confirm",
|
passwordConfirmLabel: "Password to confirm",
|
||||||
@@ -761,7 +761,12 @@ export const en: Dictionary = {
|
|||||||
backLabel: "Back to all harvests",
|
backLabel: "Back to all harvests",
|
||||||
batchesHeading: "Batches from this harvest",
|
batchesHeading: "Batches from this harvest",
|
||||||
productsHeading: "Products 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: {
|
chargePage: {
|
||||||
verifiedBatch: "Verified batch",
|
verifiedBatch: "Verified batch",
|
||||||
@@ -1131,12 +1136,12 @@ export const en: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore sync",
|
heading: "ApiCore sync",
|
||||||
intro:
|
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",
|
button: "Sync everything",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
resultBatches: (created, skipped, failed) =>
|
resultBatches: (created, updated, skipped, failed) =>
|
||||||
`Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
`Batches: ${created} created, ${updated} updated, ${skipped} unchanged${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
resultProducts: (created, skipped, failed) =>
|
resultProducts: (created, skipped, failed) =>
|
||||||
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
pending: (count) =>
|
pending: (count) =>
|
||||||
@@ -1144,11 +1149,12 @@ export const en: Dictionary = {
|
|||||||
discontinued: (count) =>
|
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.`,
|
`${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) =>
|
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}`,
|
lastRun: (when) => `Last complete run: ${when}`,
|
||||||
neverRun: "No complete run yet.",
|
neverRun: "No complete run yet.",
|
||||||
error: "Sync with ApiCore failed.",
|
error: "Sync with ApiCore failed.",
|
||||||
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
|
||||||
},
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
@@ -1604,8 +1610,9 @@ export const en: Dictionary = {
|
|||||||
navOrders: { title: "Orders", description: "Order history, shipping status, and invoices" },
|
navOrders: { title: "Orders", description: "Order history, shipping status, and invoices" },
|
||||||
navAddresses: { title: "Addresses", description: "Manage delivery addresses" },
|
navAddresses: { title: "Addresses", description: "Manage delivery addresses" },
|
||||||
navWishlist: { title: "Wishlist", description: "View saved products" },
|
navWishlist: { title: "Wishlist", description: "View saved products" },
|
||||||
navSecurity: {
|
navSettings: {
|
||||||
title: "Security",
|
title: "Settings",
|
||||||
|
description: "Security and privacy",
|
||||||
twoFactorLabel: "Two-factor authentication",
|
twoFactorLabel: "Two-factor authentication",
|
||||||
twoFactorActive: "active",
|
twoFactorActive: "active",
|
||||||
twoFactorInactive: "not active",
|
twoFactorInactive: "not active",
|
||||||
@@ -1619,6 +1626,18 @@ export const en: Dictionary = {
|
|||||||
logoutAllDevicesHeading: "Log out all devices",
|
logoutAllDevicesHeading: "Log out all devices",
|
||||||
logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.",
|
logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.",
|
||||||
logout: "Log out",
|
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: {
|
privacy: {
|
||||||
heading: "Privacy",
|
heading: "Privacy",
|
||||||
downloadDataHeading: "Download my data",
|
downloadDataHeading: "Download my data",
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export interface Dictionary {
|
|||||||
backLabel: string;
|
backLabel: string;
|
||||||
batchesHeading: string;
|
batchesHeading: string;
|
||||||
productsHeading: string;
|
productsHeading: string;
|
||||||
glassesAvailable: (remaining: number, total: number) => string;
|
glassesAvailable: (remaining: number) => string;
|
||||||
};
|
};
|
||||||
chargePage: {
|
chargePage: {
|
||||||
verifiedBatch: string;
|
verifiedBatch: string;
|
||||||
@@ -729,15 +729,15 @@ export interface Dictionary {
|
|||||||
intro: string;
|
intro: string;
|
||||||
button: string;
|
button: string;
|
||||||
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => 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;
|
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||||
pending: (count: number) => string;
|
pending: (count: number) => string;
|
||||||
discontinued: (count: number) => string;
|
discontinued: (count: number) => string;
|
||||||
protectedDrafts: (count: number) => string;
|
protectedDrafts: (count: number) => string;
|
||||||
|
stockConflicts: (count: number) => string;
|
||||||
lastRun: (when: string) => string;
|
lastRun: (when: string) => string;
|
||||||
neverRun: string;
|
neverRun: string;
|
||||||
error: string;
|
error: string;
|
||||||
batchLoadError: string;
|
|
||||||
};
|
};
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
@@ -1167,7 +1167,13 @@ export interface Dictionary {
|
|||||||
navOrders: { title: string; description: string };
|
navOrders: { title: string; description: string };
|
||||||
navAddresses: { title: string; description: string };
|
navAddresses: { title: string; description: string };
|
||||||
navWishlist: { 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 };
|
navAdmin: { title: string; description: string };
|
||||||
newsletterHeading: string;
|
newsletterHeading: string;
|
||||||
newsletterSubscribed: string;
|
newsletterSubscribed: string;
|
||||||
@@ -1177,6 +1183,18 @@ export interface Dictionary {
|
|||||||
logoutAllDevicesHeading: string;
|
logoutAllDevicesHeading: string;
|
||||||
logoutAllDevicesDescription: string;
|
logoutAllDevicesDescription: string;
|
||||||
logout: 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: {
|
privacy: {
|
||||||
heading: string;
|
heading: string;
|
||||||
downloadDataHeading: string;
|
downloadDataHeading: string;
|
||||||
|
|||||||
+120
-39
@@ -1,13 +1,14 @@
|
|||||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, vi, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
getApiCoreConnection,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreShopData,
|
||||||
fetchApiCoreBottlingBatches,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreConnection,
|
||||||
} from "./apicore-client";
|
} 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 {
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -16,16 +17,92 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("apicore-client", () => {
|
const feed = {
|
||||||
beforeEach(() => {
|
contractVersion: 1,
|
||||||
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
|
generatedAt: "2026-09-27T10:00:00.000Z",
|
||||||
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
|
harvests: [
|
||||||
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
|
{
|
||||||
|
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(() => {
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
test("verlangt einen API-Schluessel — Benutzer und Passwort reichen nicht mehr", () => {
|
||||||
vi.unstubAllEnvs();
|
// Arrange: nur die alte Konfiguration.
|
||||||
vi.unstubAllGlobals();
|
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 () => {
|
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
|
||||||
@@ -36,45 +113,49 @@ describe("apicore-client", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Act & Assert
|
// 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
|
// 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);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
// Act
|
// Act & Assert
|
||||||
await fetchApiCoreHarvests(session);
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
// Assert
|
|
||||||
expect(fetchMock).toHaveBeenCalled();
|
|
||||||
for (const call of fetchMock.mock.calls) {
|
|
||||||
expect((call[1] as RequestInit).signal).toBeInstanceOf(AbortSignal);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
|
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
|
||||||
// Arrange: ApiCore antwortet mit 500 — eine leere Liste daraus zu machen,
|
// Arrange
|
||||||
// hiesse fuer den Sync "es gibt nichts", und darauf darf nichts aufbauen.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
|
||||||
|
|
||||||
// Act & Assert
|
// 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 () => {
|
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
|
||||||
// Arrange: Schleuderungen ok, Chargen ohne Pflichtfeld lotNumber.
|
// Arrange
|
||||||
vi.stubGlobal(
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
|
||||||
"fetch",
|
|
||||||
vi.fn(async (url: string) =>
|
|
||||||
url.endsWith("/extractions")
|
|
||||||
? jsonResponse([])
|
|
||||||
: jsonResponse([{ id: "b1", date: "2026-08-01", bestBeforeDate: "2028-08-01", jarCount: 10 }])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act & Assert
|
// 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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+142
-228
@@ -1,47 +1,28 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
* HTTP-Client fuer ApiCore (separates Projekt, NestJS-Backend mit eigener
|
||||||
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
* Datenbank). Wabenhain liest dort ausschliesslich die dafuer abgesicherte
|
||||||
* die die Rohdaten fuer den Admin-Sync bereitstellt
|
* Shop-Schnittstelle GET /shop-sync: eine Feld-Allowlist aus Ernte-Fakten,
|
||||||
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
|
* Artikelstamm und Abfuellungen, ohne interne Notizen.
|
||||||
*
|
*
|
||||||
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
* Zugang NUR per API-Schluessel (in ApiCore unter Verwaltung →
|
||||||
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
* Automatisierung → API-Schluessel angelegt, Berechtigung "shop-sync:read").
|
||||||
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
|
* Der Schluessel wird direkt als Bearer-Token gesendet: kein Benutzerkonto,
|
||||||
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
|
* kein Passwort, kein Login, in ApiCore jederzeit widerrufbar. Einen
|
||||||
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
* Rueckfall auf Benutzer/Passwort oder allgemeine Lese-Endpunkte gibt es
|
||||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
* bewusst nicht mehr.
|
||||||
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
|
||||||
* mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
|
|
||||||
* Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
|
|
||||||
* "bottling:read" und optional "apiaries:read" (nur fuer Standortnamen).
|
|
||||||
*
|
*
|
||||||
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
* APICORE_API_URL z.B. "https://apicore.example.org/api/v1"
|
||||||
* "api" laut ApiCore-Backend/src/app.config.ts)
|
* APICORE_API_KEY der Schluessel, Format ack_<8>_<43>
|
||||||
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
|
||||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
|
||||||
*
|
*
|
||||||
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
|
* Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError —
|
||||||
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
* der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an.
|
||||||
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
|
||||||
*
|
*
|
||||||
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
|
* Fuer Wabenhains "Charge" (model Batch) ist ApiCores Abfuellung
|
||||||
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
|
* (BottlingBatch) das Gegenstueck, nicht die Rohhonig-Charge: lotNumber/code,
|
||||||
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
|
* date/fillDate, bestBeforeDate, jarCount/glassCount.
|
||||||
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
|
|
||||||
* den eigenen Service-Zugang auszusperren.
|
|
||||||
*
|
|
||||||
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
|
|
||||||
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
|
|
||||||
* dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch"
|
|
||||||
* (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen
|
|
||||||
* Felder passen fast 1:1: lotNumber/code, date/fillDate,
|
|
||||||
* bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte
|
|
||||||
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
|
|
||||||
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
|
|
||||||
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ApiCoreConfigError extends Error {}
|
export class ApiCoreConfigError extends Error {}
|
||||||
@@ -49,11 +30,13 @@ export class ApiCoreRequestError extends Error {}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore,
|
* Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore,
|
||||||
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt — der Knopf
|
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt.
|
||||||
* dreht sich, und es gibt weder Ergebnis noch Fehlermeldung.
|
|
||||||
*/
|
*/
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
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 {
|
function describeFetchFailure(error: unknown): string {
|
||||||
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
|
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
|
||||||
// a DOMException, which is not an Error subclass in every runtime.
|
// 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";
|
return error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginResponseSchema = z.union([
|
/** Adresse und Schluessel fuer ApiCore. */
|
||||||
z.object({ accessToken: z.string().min(1) }),
|
export interface ApiCoreConnection {
|
||||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
|
|
||||||
export interface ApiCoreSession {
|
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
readonly token: string;
|
readonly apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfig(): { baseUrl: string; email: string; password: string } {
|
export function getApiCoreConnection(): ApiCoreConnection {
|
||||||
const baseUrl = process.env.APICORE_API_URL;
|
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
|
||||||
const email = process.env.APICORE_SYNC_EMAIL;
|
const apiKey = process.env.APICORE_API_KEY?.trim();
|
||||||
const password = process.env.APICORE_SYNC_PASSWORD;
|
if (!baseUrl || !apiKey) {
|
||||||
if (!baseUrl || !email || !password) {
|
|
||||||
throw new ApiCoreConfigError(
|
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 };
|
if (!API_KEY_PATTERN.test(apiKey)) {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new ApiCoreConfigError(
|
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
|
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
|
||||||
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
|
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
|
||||||
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
|
* Versionswechsel dazu und werden hier ignoriert.
|
||||||
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
|
|
||||||
* ApiCore-Version.
|
|
||||||
*/
|
*/
|
||||||
async function getJson<T>(
|
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
|
||||||
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() });
|
|
||||||
|
|
||||||
const harvestSchema = z.object({
|
const harvestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
apiaryId: z.string(),
|
apiaryId: z.string(),
|
||||||
|
apiaryName: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
honeyType: z.string().nullable().optional(),
|
honeyType: z.string().nullable(),
|
||||||
honeySuperCount: z.number().nullable().optional(),
|
honeySuperCount: z.number().nullable(),
|
||||||
grossWeight: z.number().nullable().optional(),
|
grossWeight: z.number().nullable(),
|
||||||
netWeight: z.number().nullable().optional(),
|
netWeight: z.number().nullable(),
|
||||||
notes: z.string().nullable().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
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
|
* ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell:
|
||||||
* Abweichungen vom dortigen Prisma-Modell:
|
|
||||||
*
|
*
|
||||||
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
|
* - `status` wird als freier String gelesen, nicht als Enum: kaeme in ApiCore
|
||||||
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
|
* ein weiterer Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort
|
||||||
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
|
* verwerfen, statt nur den einen unbekannten Artikel zu ignorieren. Siehe
|
||||||
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
|
* isSellableStatus().
|
||||||
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
|
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore keine Einheit.
|
||||||
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
|
* Angenommen wird GRAMM, weil ApiCore dieselbe Groesse an der Abfuellung
|
||||||
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
|
* (jarSize) mit Werten wie 500 fuehrt und in der eigenen Oberflaeche als
|
||||||
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
|
* "g" ausgibt. Das uebernommene Gewicht ist am importierten Entwurf im Admin
|
||||||
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
|
* sichtbar und aenderbar, bevor das Produkt verkauft werden kann.
|
||||||
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
|
|
||||||
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
|
|
||||||
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
|
|
||||||
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
|
|
||||||
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
|
|
||||||
*/
|
*/
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -233,82 +120,109 @@ const productSchema = z.object({
|
|||||||
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
|
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere wird bewusst als "nicht
|
||||||
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
|
* verkaufsfaehig" behandelt: ein unbekannter Status darf nicht dazu fuehren,
|
||||||
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
|
* dass ein Artikel ungeprueft als Shop-Entwurf auftaucht.
|
||||||
* ungeprueft als Shop-Entwurf auftaucht.
|
|
||||||
*/
|
*/
|
||||||
export function isSellableStatus(status: string): boolean {
|
export function isSellableStatus(status: string): boolean {
|
||||||
return status === "ACTIVE";
|
return status === "ACTIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
|
const bottlingSchema = z.object({
|
||||||
return getJson(session, "/products", z.array(productSchema), "Produkte");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const extractionSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
// ApiCores GET /extractions liefert je Schleuderung die verknuepften
|
|
||||||
// Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die
|
|
||||||
// uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht.
|
|
||||||
harvests: z.array(z.object({ harvestId: z.string() })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bottlingBatchSchema = z.object({
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
lotNumber: z.string(),
|
lotNumber: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
bestBeforeDate: z.string(),
|
bestBeforeDate: z.string(),
|
||||||
|
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
|
||||||
|
jarSize: z.number().nullable(),
|
||||||
jarCount: z.number(),
|
jarCount: z.number(),
|
||||||
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
|
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
|
||||||
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
|
productId: z.string().nullable(),
|
||||||
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
|
harvestIds: z.array(z.string()),
|
||||||
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
|
|
||||||
// verwerfen.
|
|
||||||
jarSize: z.number().nullable().optional(),
|
|
||||||
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
|
|
||||||
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
|
|
||||||
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
|
|
||||||
productId: z.string().nullable().optional(),
|
|
||||||
honeyBatch: z.object({
|
|
||||||
id: z.string(),
|
|
||||||
extractionId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
/** Eine Abfuellung, wie der Sync sie braucht. */
|
||||||
|
export interface ApiCoreBottlingBatchWithHarvestId {
|
||||||
export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch {
|
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
|
* ApiCore-ID der ersten (aeltesten) Ernte hinter der Abfuellung, oder null.
|
||||||
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
|
* Eine Schleuderung kann mehrere Ernten buendeln, Wabenhains Batch.harvestId
|
||||||
* Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest
|
* ist dagegen ein einzelner Fremdschluessel — der Admin kann die Zuordnung
|
||||||
* ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein
|
* in Wabenhain jederzeit korrigieren.
|
||||||
* einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste
|
|
||||||
* verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit
|
|
||||||
* auf die tatsaechlich passende Ernte korrigieren.
|
|
||||||
*/
|
*/
|
||||||
harvestSourceId: string | null;
|
harvestSourceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreBottlingBatches(
|
const shopFeedSchema = z.object({
|
||||||
session: ApiCoreSession
|
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
|
||||||
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
harvests: z.array(harvestSchema),
|
||||||
const [extractions, batches] = await Promise.all([
|
products: z.array(productSchema),
|
||||||
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
|
bottlings: z.array(bottlingSchema),
|
||||||
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
const firstHarvestByExtraction = new Map<string, string | null>(
|
export interface ApiCoreShopData {
|
||||||
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
harvests: ApiCoreHarvest[];
|
||||||
);
|
products: ApiCoreProduct[];
|
||||||
|
bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
||||||
return batches.map((batch) => ({
|
}
|
||||||
...batch,
|
|
||||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
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 {
|
||||||
|
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 { describe, test, expect } from "vitest";
|
||||||
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
|
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
|
||||||
import {
|
import {
|
||||||
slugForApiCoreSource,
|
slugForApiCoreSource,
|
||||||
buildHarvestDescription,
|
buildHarvestDescription,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
slugifyProductName,
|
slugifyProductName,
|
||||||
productSlugCandidates,
|
productSlugCandidates,
|
||||||
|
harvestFactChanges,
|
||||||
|
batchFactChanges,
|
||||||
} from "./apicore-mapping";
|
} from "./apicore-mapping";
|
||||||
|
|
||||||
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
||||||
@@ -24,9 +26,7 @@ function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHarvest(
|
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
|
||||||
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
|
|
||||||
): ApiCoreHarvestWithApiaryName {
|
|
||||||
return {
|
return {
|
||||||
id: "cm-harvest-1",
|
id: "cm-harvest-1",
|
||||||
apiaryId: "cm-apiary-1",
|
apiaryId: "cm-apiary-1",
|
||||||
@@ -35,7 +35,6 @@ function makeHarvest(
|
|||||||
honeySuperCount: null,
|
honeySuperCount: null,
|
||||||
grossWeight: null,
|
grossWeight: null,
|
||||||
netWeight: null,
|
netWeight: null,
|
||||||
notes: null,
|
|
||||||
apiaryName: "Streuobstwiese",
|
apiaryName: "Streuobstwiese",
|
||||||
...overrides,
|
...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", () => {
|
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -272,3 +260,70 @@ describe("isSellableStatus", () => {
|
|||||||
expect(isSellableStatus("")).toBe(false);
|
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
|
* 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
|
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
|
||||||
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
|
* 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[] = [];
|
const parts: string[] = [];
|
||||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
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);
|
const lot = slugifyProductName(lotNumber);
|
||||||
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
|
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_GKP_PASSWORD: "w",
|
||||||
DHL_ACCOUNT_NUMBER: "12345678901234",
|
DHL_ACCOUNT_NUMBER: "12345678901234",
|
||||||
APICORE_API_URL: "https://api.example.org/api",
|
APICORE_API_URL: "https://api.example.org/api",
|
||||||
APICORE_SYNC_EMAIL: "[email protected]",
|
APICORE_API_KEY: "ack_secret",
|
||||||
APICORE_SYNC_PASSWORD: "pw",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("readIntegrationStatus", () => {
|
describe("readIntegrationStatus", () => {
|
||||||
@@ -47,7 +46,16 @@ describe("readIntegrationStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never returns a configured value", () => {
|
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", () => {
|
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: "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: "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: "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 {
|
function stripeMode(key: string): "live" | "test" | null {
|
||||||
|
|||||||
Reference in new issue
Block a user