Compare commits

..
7 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 70ede77dde feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem
Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei
Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden
koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein
spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten
oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt.

Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert:

  invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf

Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein
abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der
relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere
Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar
macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war
ohnehin das falsche Konzept.

INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR:
app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad
erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen.
Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft
weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung.

Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn
keine da ist — also bei Rechnungen aus der Zeit davor.

Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer
optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben
unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung
trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere
die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und
ein Wiederholungslauf aenderte daran nichts.

Sicherung: der taegliche CronJob packt das Verzeichnis als
rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das
Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass
wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten
einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt
der Sicherungs-Pod lautlos in "Pending".

Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig,
aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine
Umsatzsteuer aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 04:12:44 +02:00
Nick SzaunigandClaude Opus 5 c525735285 feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.

In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.

Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.

Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.

Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.

Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:24:02 +02:00
Nick SzaunigandClaude Opus 5 95f5252366 fix: gleichnamige Produkte aus verschiedenen Jahrgaengen brechen das Anlegen nicht mehr ab
Product.slug ist eindeutig und zugleich die oeffentliche Adresse
(/honig/<slug>). Ohne eigene Angabe wird er aus dem Namen gebildet. Bei
jahrgangsweise gefuehrtem Honig kollidiert das zwangslaeufig: "Waldhonig"
ergibt in jedem Jahr denselben Slug "waldhonig".

Bisher schlug das als unbehandelte Prisma-Ausnahme (P2002) durch. Statt
einer Meldung erschien die Fehlerseite des Admin-Bereichs, aus der nicht
hervorgeht, dass lediglich die Adresse belegt ist — und das eingegebene
Formular war verloren.

Jetzt wird der Slug vorher geprueft und die Eingabe mit einem konkreten
Vorschlag abgelehnt ("waldhonig-2026"). Das Formular hat fuer genau diesen
Fall bereits ein eigenes Slug-Feld.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:14:15 +02:00
Nick SzaunigandClaude Opus 5 479ace1b88 docs: Konfigurationsvorlagen vollstaendig und uebernahmefaehig machen
01-configmap.yaml und 02-secret.example.yaml sind jetzt so geschrieben, dass
sie sich als Produktivkonfiguration uebernehmen lassen: in drei Abschnitte
gegliedert (ANPASSEN / Vorgaben / optional), jeder Wert mit der Angabe, was
ohne ihn nicht funktioniert. Anzupassen sind genau zwei Werte in der
ConfigMap und die mit "CHANGE-ME" markierten im Secret; alles Uebrige traegt
eine brauchbare Vorgabe oder darf leer bleiben.

Beide Dateien decken zusammen jede Umgebungsvariable ab, die die Anwendung
liest, und jeder Schluessel steht in genau einer der beiden. Bisher war das
Handarbeit und driftete: GOOGLE_PLACES_API_KEY lag in der unverschluesselten
ConfigMap, APICORE_API_URL unnoetig im Secret.

Neu ist deshalb src/lib/k8s-manifest-env.test.ts. Der Test liest die
process.env-Zugriffe unter src/ und die Schluessel der beiden Manifeste und
prueft vier Zusagen: keine gelesene Variable fehlt, kein Schluessel steht
doppelt (sonst entschiede allein die Reihenfolge der envFrom-Eintraege,
welcher Wert gewinnt), kein Schluessel ist tot, und die committete
Secret-Vorlage enthaelt keine ausgefuellten Werte.

Beim ersten Lauf hat der Test zwei Dinge gefunden. ADMIN_EMAIL und
ADMIN_PASSWORD liest nicht die App, sondern prisma/seed.ts — das ist
legitim und steht jetzt begruendet in einer Ausnahmeliste, zusammen mit
DB_PASSWORD fuer das Datenbank-StatefulSet. STRIPE_PUBLISHABLE_KEY dagegen
wird von keiner einzigen Code-Stelle gelesen: der Checkout laeuft
vollstaendig serverseitig ueber eine Stripe-Checkout-Session. Der Schluessel
ist aus Secret-Vorlage, .env.example, docker-compose.yml und README
entfernt, mit Hinweis, wofuer man ihn braeuchte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:06:18 +02:00
Nick SzaunigandClaude Opus 5 990a16e654 chore: Manifeste an den laufenden Cluster angleichen
Die Manifeste in k8s/ trugen noch Platzhalter, waehrend im Cluster laengst
die echten Werte stehen. Wer sie unveraendert anwendet, ueberschreibt damit
funktionierende Einstellungen.

- Domain: shop.example.com -> wabenhain.com, in 06-ingress.yaml (host und
  tls.hosts) und in 01-configmap.yaml (NEXT_PUBLIC_SITE_URL). Beide Stellen
  muessen uebereinstimmen; aus NEXT_PUBLIC_SITE_URL entstehen Sitemap,
  Canonical-Tags und jeder Link in jeder Bestellbestaetigung.

- GOOGLE_PLACES_API_KEY von der ConfigMap ins Secret. Das ist ein
  abrechenbarer Zugangsschluessel, und eine ConfigMap ist unverschluesselt
  und in der Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige
  GOOGLE_PLACE_ID ist oeffentlich und bleibt in der ConfigMap. Der Cluster
  handhabt das bereits so.

- APICORE_API_URL vom Secret in die ConfigMap: eine Adresse ist kein
  Geheimnis. Auch das entspricht dem Cluster.

- VIDEO_TRANSCODE_THREADS in die ConfigMap aufgenommen, damit die
  Threadbegrenzung des Video-Transcodes (siehe vorheriger Commit) auch ueber
  das Deployment einstellbar ist.

Gegenprobe: die Vereinigung aller Schluessel aus ConfigMap und Secret deckt
jetzt genau die Umgebungsvariablen ab, die der Code ueber process.env liest
- keine fehlt. Die beiden Ueberhaenge sind beabsichtigt: DB_PASSWORD liest
der Postgres-StatefulSet, STRIPE_PUBLISHABLE_KEY haelt die Konfiguration
vollstaendig fuer eine kuenftige clientseitige Stripe-Einbindung.

Der Hinweistext der Go-Live-Pruefung nannte shop.example.com als
ausgelieferte Vorgabe; das stimmt nicht mehr. Die Pruefung selbst ist
unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 02:20:34 +02:00
Nick SzaunigandClaude Opus 5 b1384c3695 fix: Video-Upload startet den Pod nicht mehr neu
Ein Video ueber 50 MB im Admin hochzuladen, liess den Pod mitten im Upload
neu starten. Ursache war nicht die Dateigroesse, sondern dass ffmpeg sich
nach den Kernen des KNOTENS bemessen hat statt nach dem CPU-Anteil des Pods.

x264 waehlt seine Threadzahl standardmaessig als das 1,5-fache der vom
Betriebssystem gemeldeten Kerne. In einem Container sind das die Kerne des
Knotens: auf einer 16-Kern-Maschine rund 24 Encoder-Threads mit je eigenen
Bildpuffern, bei 4K-Material rund 12 MB je Bild. Das hatte zwei Folgen, die
von aussen beide gleich aussehen:

1. Der Speicherbedarf sprengte das Speicherlimit des Pods, der Kernel
   beendete den Container (OOMKilled).
2. ffmpeg belegte alle Kerne, der Node-Prozess kam nicht mehr zum Zug und
   beantwortete die Liveness-Probe nicht rechtzeitig. Kubelet wertete das
   als haengenden Prozess und startete den Container neu.

Dekoder-, Filter- und Encoder-Threads sind jetzt gedeckelt (Vorgabe 2, ueber
VIDEO_TRANSCODE_THREADS auf bis zu 8 anhebbar). Der Verbrauch ist damit
konstant und unabhaengig davon, auf welchem Knoten der Pod laeuft. Der
"-threads"-Deckel steht bewusst auch VOR "-i": ffmpeg ordnet Optionen der
naechsten Datei zu, ein Deckel erst hinter "-i" wuerde nur den Encoder
begrenzen und das Dekodieren eines 4K-Videos unbegrenzt lassen.

Zusaetzlich "-nostdin" und stdio ["ignore","ignore","pipe"]: der
Kindprozess bekam von spawn() eine Standardeingabe, die niemand bedient,
und eine unbeachtete stdout-Pipe, die volllaufen kann. Beides konnte
ffmpeg bis zum Zeitlimit blockieren.

Die Argumentliste ist als buildFfmpegArgs() ausgelagert und getestet, damit
die Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten.

Beide Probes bekommen ausserdem timeoutSeconds: 5. Ohne den Eintrag gilt
Kubernetes' Vorgabe von einer Sekunde — zu knapp, solange sich der
Node-Prozess die CPU mit einem laufenden Transcode teilt. k8s/README.md
beschreibt die Diagnose fuer den Fall, dass im Cluster noch eine aeltere,
von Hand gepflegte Fassung des Deployments laeuft.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 01:01:37 +02:00
Nick SzaunigandClaude Opus 5 78c19f1b73 feat: Produkte aus ApiCore synchronisieren
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.

ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.

Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.

Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.

Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 18:03:05 +02:00
36 changed files with 2197 additions and 326 deletions

No files matched your search

+47 -11
View File
@@ -21,8 +21,13 @@ MYSQL_PASSWORD=change-me
MYSQL_DATABASE=wabenhain MYSQL_DATABASE=wabenhain
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys) # Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
#
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der
# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel
# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements —
# solange es die nicht gibt, waere er Konfiguration, die nichts tut.
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint # Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
@@ -81,16 +86,20 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>" SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore- # ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen # -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates # "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key- # beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten, # separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen # API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen), # dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync) # Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe # harvests:read Ernten
# src/lib/apicore-client.ts. # 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.
APICORE_API_URL= APICORE_API_URL=
APICORE_SYNC_EMAIL= APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD= APICORE_SYNC_PASSWORD=
@@ -189,3 +198,30 @@ ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath # PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath
# zeigen. # zeigen.
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# Ablage der Rechnungs-PDFs, nach Jahr und Kunde sortiert:
# invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
# Lokal reicht der Default "./invoices" (per .gitignore ausgeschlossen).
#
# MUSS ein anderes Verzeichnis sein als UPLOAD_DIR: auf die Uploads zeigt
# eine oeffentliche Route (app/uploads/[...path]), die JEDE Datei darunter
# ohne Anmeldung ausliefert. Laegen Rechnungen dort, koennte jede Person, die
# den Pfad erraet, fremde Rechnungen samt Anschrift herunterladen.
#
# In Produktion ein eigenes, gesichertes Volume: Rechnungen unterliegen in
# Deutschland zehn Jahren Aufbewahrungspflicht (§ 147 AO), und das PDF liegt
# NUR hier — die Datenbank kennt lediglich Nummer, Betrag und Pfad. Siehe
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
INVOICE_DIR=./invoices
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
#
# NICHT hochsetzen, um den Upload zu beschleunigen, ohne gleichzeitig das
# Speicherlimit des Pods zu pruefen: x264 haelt pro Thread eigene Bildpuffer,
# bei 4K-Material rund 12 MB je Bild. Ohne Deckel richtet sich ffmpeg nach den
# Kernen des KUBERNETES-KNOTENS statt nach dem CPU-Anteil des Pods — auf einem
# grossen Knoten sprengt das sowohl den Speicher des Pods als auch die
# Liveness-Probe, und der Container wird mitten im Upload neu gestartet.
# Ausfuehrliche Begruendung in src/lib/video-transcode.ts.
VIDEO_TRANSCODE_THREADS=
+3
View File
@@ -53,3 +53,6 @@ next-env.d.ts
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in # lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis # Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
/uploads /uploads
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
/invoices
+57 -1
View File
@@ -39,6 +39,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**) - Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**)
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In - **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten) - **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten - **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo - `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite. - **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
@@ -141,7 +142,7 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
## Stripe ## Stripe
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`). 1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet.
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen. 2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden. 3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.
@@ -189,6 +190,36 @@ Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloud
Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`. Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`.
## ApiCore-Synchronisierung
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
| ApiCore | Wabenhain | Knopf |
|---------|-----------|-------|
| `Harvest` (Ernte) | `Harvest` | `/admin/ernten` → „ApiCore-Synchronisierung" |
| `BottlingBatch` (Abfüllung) | `Batch` (Charge) | derselbe Knopf, läuft direkt nach den Ernten |
| `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) | `/admin/produkte` → „ApiCore-Synchronisierung" |
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
- **Ernten** entstehen als unveröffentlichte Entwürfe. Eine bereits veröffentlichte, früher synchronisierte Ernte bleibt unangetastet — sonst würde ein erneuter Lauf eine Freigabeentscheidung stillschweigend zurücknehmen.
- **Produkte: ein Jahrgang je Abfüllung.** In ApiCore ist „Waldhonig 500 g" *ein* Katalogartikel, an dem jedes Jahr eine weitere Abfüllung hängt. In Wabenhain gehört ein Produkt dagegen zu genau einer Charge — und das ist richtig so, denn jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt; die Bewertung des Waldhonigs von 2026 sagt über den von 2027 nichts aus. Aus einem ApiCore-Artikel werden deshalb **mehrere** Wabenhain-Produkte, eines je Abfüllung. Sie tragen denselben Namen, unterscheiden sich aber in der Adresse: `waldhonig-2026`, `waldhonig-2027`. Wurde ein Artikel im selben Jahr mehrfach abgefüllt, hängt die Losnummer hinten an.
Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben.
Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand startet bei 0, denn wie viele Gläser in den Webshop gehen und wie viele in Hofladen oder Wiederverkauf, entscheidet Wabenhain.
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
**Reihenfolge:** die Charge muss schon da sein, sonst gäbe es nichts, woran der Jahrgang hängt. Erst „Ernten" synchronisieren, dann „Produkte" — fehlende Chargen meldet der Lauf als „wartet auf den Ernten-Sync", nicht als Fehler.
- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
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`.
## Vollständiger Docker-Betrieb ## Vollständiger Docker-Betrieb
```bash ```bash
@@ -223,6 +254,31 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen. Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
## Rechnungen
Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (`api/checkout/webhook`) die Rechnung, legt sie als PDF ab und hängt sie an die Bestellbestätigung. Ohne bestätigte Zahlung gibt es keine Rechnung — fehlt `STRIPE_WEBHOOK_SECRET`, bleibt jede Bestellung auf „NEU" stehen.
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
```
invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
```
Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig.
**`INVOICE_DIR` muss ein anderes Verzeichnis sein als `UPLOAD_DIR`.** Auf die Uploads zeigt mit `app/uploads/[...path]` eine öffentliche Route, die jede Datei darunter ohne Anmeldung ausliefert. Lägen Rechnungen dort, könnte jede Person, die den Pfad errät, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine Route; der Abruf läuft ausschließlich über `/rechnung/<nummer>/pdf` und prüft, ob die Rechnung der angemeldeten Person gehört (Admins sehen alle).
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump. Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume.
> Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS).
**Noch offen** und vor dem Live-Gang zu klären:
- Die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer.
- Das PDF weist **keine Umsatzsteuer** aus — weder Satz und Betrag noch den Hinweis nach § 19 UStG bei Kleinunternehmerregelung. Welches von beidem nötig ist, hängt vom Steuerstatus der Imkerei ab.
## Health-Endpunkte ## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe: Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
+3 -1
View File
@@ -104,8 +104,10 @@ services:
# MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306). # MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306).
DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql} DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql}
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
# Nur der geheime Schluessel: der Checkout laeuft vollstaendig
# serverseitig, ein veroeffentlichbarer pk_-Schluessel wird nirgends
# gelesen (siehe .env.example).
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an, # Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst # siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
+86 -20
View File
@@ -1,30 +1,96 @@
# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Keys) gehoeren # Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Schluessel)
# in 02-secret.yaml (aus 02-secret.example.yaml kopiert), NIE hierher. # gehoeren in 02-secret.yaml, NIE hierher: eine ConfigMap ist unverschluesselt
# und in der Cluster-Oberflaeche im Klartext lesbar.
#
# ANPASSEN vor dem ersten Ausrollen: NEXT_PUBLIC_SITE_URL und SMTP_FROM. Alle
# uebrigen Werte sind sinnvolle Vorgaben und koennen so bleiben; die leeren
# ("") gehoeren zu optionalen Funktionen, die dann einfach ruhen.
#
# Zusammen mit 02-secret.yaml deckt diese Datei JEDE Umgebungsvariable ab, die
# die Anwendung liest. Die Vollstaendigkeit wird automatisch geprueft, siehe
# src/lib/k8s-manifest-env.test.ts — kommt im Code eine neue Variable dazu,
# faellt das beim naechsten "pnpm test" auf, nicht erst im Betrieb.
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: wabenhain-config name: wabenhain-config
namespace: wabenhain namespace: wabenhain
data: data:
# "postgresql" oder "mysql" — muss zum Service passen, den du unter # ---------------------------------------------------------------------
# 03-postgres.yaml/03-mysql.yaml tatsaechlich ausrollst, UND zum # ANPASSEN
# DATABASE_URL-Host in 02-secret.yaml. Ein einzelnes Image traegt beide # ---------------------------------------------------------------------
# Oeffentliche Adresse des Shops. Einzige Quelle fuer Sitemap,
# Canonical-Tags, JSON-LD und JEDEN Link in JEDER E-Mail (src/lib/url.ts).
# Muss zum "host" in 06-ingress.yaml passen — mit https, ohne
# abschliessenden Schraegstrich.
NEXT_PUBLIC_SITE_URL: "https://wabenhain.com"
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
SMTP_FROM: "Wabenhain <[email protected]>"
# ---------------------------------------------------------------------
# VORGABEN — koennen so bleiben
# ---------------------------------------------------------------------
NODE_ENV: "production"
# "postgresql" oder "mysql" — muss zu dem Datenbank-Manifest passen, das du
# tatsaechlich ausrollst (03-postgres.yaml bzw. 03-mysql.yaml), UND zum
# Schema in DATABASE_URL im Secret. Ein einzelnes Image traegt beide
# Prisma-Client-Varianten, siehe src/lib/prisma.ts. # Prisma-Client-Varianten, siehe src/lib/prisma.ts.
DATABASE_PROVIDER: "postgresql" DATABASE_PROVIDER: "postgresql"
NODE_ENV: "production"
# Oeffentliche URL des Shops (fuer absolute Links in E-Mails/Sitemaps/CSP). # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
NEXT_PUBLIC_SITE_URL: "https://shop.example.com" # implizites TLS.
SMTP_PORT: "587" SMTP_PORT: "587"
SMTP_FROM: "Wabenhain <[email protected]>"
# Optional, siehe README-Abschnitte "Google-Rezensionen"/"CAPTCHA" — leer # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
# lassen, wenn nicht genutzt, die App faellt automatisch zurueck. # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
GOOGLE_PLACES_API_KEY: "" # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
GOOGLE_PLACE_ID: "" # Fehlt der Wert, schreibt die App nach "./uploads" relativ zum
NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" # Arbeitsverzeichnis: das trifft im Image zufaellig denselben Ort, ist aber
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production". # nirgends zugesichert. Deshalb hier ausdruecklich setzen.
DHL_ENVIRONMENT: "sandbox"
# Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos,
# siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des
# "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst
# weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example.
UPLOAD_DIR: "/app/uploads" UPLOAD_DIR: "/app/uploads"
# Ablage der Rechnungs-PDFs, nach Jahr und Kunde sortiert:
# /app/invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
# Muss zum mountPath des "invoices"-Volumes in 05-app-deployment.yaml
# passen. BEWUSST ein anderes Verzeichnis als UPLOAD_DIR: auf die Uploads
# zeigt eine oeffentliche Route, auf dieses Verzeichnis keine. Siehe
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
INVOICE_DIR: "/app/invoices"
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
# dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt
# eigene Bildpuffer — bei 4K-Material rund 12 MB je Bild. Zu viele Threads
# bringen den Pod beim Video-Upload um. Siehe src/lib/video-transcode.ts.
VIDEO_TRANSCODE_THREADS: "2"
# "sandbox", bis die DHL-Versandmarken getestet sind, danach "production".
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
DHL_ENVIRONMENT: "sandbox"
# ---------------------------------------------------------------------
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
# ---------------------------------------------------------------------
# Oeffentlicher Teil von Cloudflare Turnstile (CAPTCHA). Der geheime
# Gegenpart TURNSTILE_SECRET_KEY steht im Secret — beide setzen oder beide
# leer lassen. Leer = kein CAPTCHA-Widget, die Formulare bleiben nutzbar.
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
# Google-Eintrag, aus dem die echten Rezensionen auf der Startseite kommen.
# Die Place-ID ist oeffentlich (sie steckt in jeder Google-Maps-Adresse des
# Eintrags) und darf deshalb hier stehen; der abrechenbare
# GOOGLE_PLACES_API_KEY liegt im Secret. Leer = statische Beispiel-Stimmen.
GOOGLE_PLACE_ID: ""
# Adresse der ApiCore-API fuer den Stammdaten-Sync (Ernten, Produkte,
# Chargen — siehe src/lib/apicore-client.ts), mit dem globalen Praefix
# "api", z.B. "https://apicore.example.org/api". Nur die Adresse; die
# Zugangsdaten des Sync-Kontos liegen im Secret. Leer = der Sync-Knopf im
# Admin meldet einen Konfigurationsfehler.
APICORE_API_URL: ""
+106 -41
View File
@@ -1,19 +1,17 @@
# Vorlage OHNE echte Geheimnisse — analog zu .env.example. Kopieren nach # Geheime Konfiguration. Vorlage OHNE echte Werte — kopieren nach
# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), Werte # 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), ausfuellen,
# ausfuellen, dann "kubectl apply -f k8s/02-secret.yaml". # dann "kubectl apply -f k8s/02-secret.yaml".
# #
# Alternative ohne Datei mit Klartext-Secrets auf der Platte: # ANPASSEN: jeder Wert, der "CHANGE-ME" enthaelt. Die leeren Werte ("")
# kubectl create secret generic wabenhain-secrets -n wabenhain \ # gehoeren zu optionalen Funktionen und koennen leer bleiben — die App faellt
# --from-literal=DATABASE_URL='postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public' \ # dann nachvollziehbar zurueck (siehe Kommentar an der jeweiligen Stelle) und
# --from-literal=DB_PASSWORD='CHANGE-ME' \ # stuerzt nicht ab. Nichts hier darf FEHLEN: ein fehlender und ein leerer
# --from-literal=SESSION_SECRET="$(openssl rand -base64 32)" \ # Schluessel sind fuer die App zwar dasselbe, aber ein vollstaendiges Secret
# --from-literal=ADMIN_EMAIL='[email protected]' \ # macht sofort sichtbar, welche Funktionen es ueberhaupt gibt.
# --from-literal=ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | head -c 28)" \ #
# --from-literal=STRIPE_SECRET_KEY='' \ # Gegenprobe nach dem Ausrollen: "pnpm preflight" meldet jeden Wert, der fuer
# --from-literal=STRIPE_PUBLISHABLE_KEY='' \ # den Live-Betrieb fehlt, als Blocker — mit Begruendung, was ohne ihn kaputt
# --from-literal=STRIPE_WEBHOOK_SECRET='' \ # geht.
# --from-literal=SMTP_HOST='' --from-literal=SMTP_USER='' --from-literal=SMTP_PASSWORD='' \
# --from-literal=TURNSTILE_SECRET_KEY=''
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -21,48 +19,115 @@ metadata:
namespace: wabenhain namespace: wabenhain
type: Opaque type: Opaque
stringData: stringData:
# ---------------------------------------------------------------------
# PFLICHT — ohne diese Werte ist kein Live-Betrieb moeglich
# ---------------------------------------------------------------------
# Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus # Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus
# 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306 # 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306
# (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen. # (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen.
# WICHTIG: das Passwort hier MUSS URL-encodiert sein (Sonderzeichen wie #
# @ : / % # ? & sonst ungueltig bzw. falsch geparst) — DB_PASSWORD unten # WICHTIG: das Passwort MUSS hier URL-encodiert sein — Sonderzeichen wie
# bleibt dagegen roh/unencodiert. Encodieren z.B. mit: # @ : / % # ? & werden sonst als Trennzeichen der URL gelesen, und die
# Verbindung schlaegt mit einer irrefuehrenden Meldung fehl. DB_PASSWORD
# weiter unten bleibt dagegen roh. Encodieren mit:
# node -e "console.log(encodeURIComponent('DEIN-PASSWORT'))" # node -e "console.log(encodeURIComponent('DEIN-PASSWORT'))"
DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public" DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public"
# Dasselbe Passwort wie oben in DATABASE_URL — wird vom Postgres-/MySQL-
# StatefulSet (je nachdem, welches du anwendest) zum Anlegen des # Dasselbe Passwort wie in DATABASE_URL, aber UNENCODIERT. Das
# Datenbank-Nutzers verwendet. # Datenbank-StatefulSet legt damit den Datenbanknutzer an (03-postgres.yaml
# bzw. 03-mysql.yaml), und die Sicherung in 07-backup-cronjob.yaml meldet
# sich damit an. Die App selbst liest diesen Wert nicht — sie kennt nur
# DATABASE_URL.
DB_PASSWORD: "CHANGE-ME" DB_PASSWORD: "CHANGE-ME"
# Mindestens 32 Zeichen, z.B.: openssl rand -base64 32
SESSION_SECRET: "CHANGE-ME-min-32-zeichen-zufaelliger-string" # Signiert die Login-Cookies. Mindestens 32 Zeichen, unvorhersehbar.
# Unvorhersehbar waehlen (nicht "admin@..."), siehe README "Admin-Zugang" — # Erzeugen mit: openssl rand -base64 32
# sonst koennte sich jemand die erwartete Adresse vorregistrieren. # Wird dieser Wert bekannt, ist jede Sitzung faelschbar, auch die eines
ADMIN_EMAIL: "[email protected]" # Admins. Ihn zu aendern meldet alle Kundinnen und Kunden ab, mehr nicht.
SESSION_SECRET: "CHANGE-ME-mindestens-32-zeichen-zufaellig"
# Erstes Admin-Konto, angelegt beim Start durch "prisma db seed".
# Unvorhersehbare Adresse waehlen, NICHT "admin@..." oder "info@...":
# sonst kann sich jemand die erwartete Adresse vorher selbst registrieren
# und das Konto belegen (siehe README "Admin-Zugang").
ADMIN_EMAIL: "[email protected]"
# Mindestens 12 Zeichen. Dieses Konto darf Rueckerstattungen ausloesen und
# alle Kundendaten einsehen. Erzeugen mit:
# openssl rand -base64 24 | tr -d '=+/' | head -c 28
ADMIN_PASSWORD: "CHANGE-ME" ADMIN_PASSWORD: "CHANGE-ME"
STRIPE_SECRET_KEY: ""
STRIPE_PUBLISHABLE_KEY: "" # Stripe im LIVE-Modus (sk_live_...). Mit einem sk_test-Schluessel sehen
STRIPE_WEBHOOK_SECRET: "" # Bestellungen erfolgreich aus, ohne dass Geld fliesst.
SMTP_HOST: "" STRIPE_SECRET_KEY: "CHANGE-ME"
SMTP_USER: ""
SMTP_PASSWORD: "" # Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben
# auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.
# Stammt aus dem Webhook-Endpunkt im Stripe-Dashboard, der auf
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
# Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts):
# keine Bestellbestaetigung, keine Versandmeldung, keine
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
SMTP_HOST: "CHANGE-ME"
SMTP_USER: "CHANGE-ME"
SMTP_PASSWORD: "CHANGE-ME"
# ---------------------------------------------------------------------
# EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
# ---------------------------------------------------------------------
# Cloudflare Turnstile (CAPTCHA) fuer Registrierung, Newsletter und
# Firmenanfrage. Leer = kein CAPTCHA; die Formulare bleiben nutzbar, aber
# bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege.
# Der oeffentliche Gegenpart NEXT_PUBLIC_TURNSTILE_SITE_KEY steht in
# 01-configmap.yaml — beide setzen oder beide leer lassen.
TURNSTILE_SECRET_KEY: "" TURNSTILE_SECRET_KEY: ""
# ---------------------------------------------------------------------
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
# ---------------------------------------------------------------------
# Google-Places-API fuer echte Google-Rezensionen auf der Startseite.
# Leer = die Startseite zeigt die statischen Beispiel-Stimmen.
#
# Der Schluessel gehoert hierher und NICHT in 01-configmap.yaml: er ist
# abrechenbar, und eine ConfigMap ist unverschluesselt und in der
# Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige GOOGLE_PLACE_ID
# ist dagegen oeffentlich und steht dort.
GOOGLE_PLACES_API_KEY: ""
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler, statt still nichts zu tun.
#
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login —
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen
# echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read,
# extractions:read, bottling:read und optional apiaries:read. Die Adresse
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml.
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
# .env.example fuer die vollstaendige Einrichtungsanleitung. Leer lassen, # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
# bis vorhanden — der Button zeigt dann "noch nicht konfiguriert" statt # "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
# zu crashen. # oder Echtbetrieb, steuert DHL_ENVIRONMENT in 01-configmap.yaml.
DHL_API_KEY: "" DHL_API_KEY: ""
DHL_API_SECRET: "" DHL_API_SECRET: ""
DHL_GKP_USERNAME: "" DHL_GKP_USERNAME: ""
DHL_GKP_PASSWORD: "" DHL_GKP_PASSWORD: ""
DHL_ACCOUNT_NUMBER: "" DHL_ACCOUNT_NUMBER: ""
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. ENTRA_REDIRECT_URI muss auf die echte, oeffentliche # Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
# Domain zeigen (z.B. https://shop.example.com/api/auth/entra/callback). # ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
# entsprechen und auf die echte Domain zeigen:
# https://wabenhain.com/api/auth/entra/callback
ENTRA_TENANT_ID: "" ENTRA_TENANT_ID: ""
ENTRA_CLIENT_ID: "" ENTRA_CLIENT_ID: ""
ENTRA_CLIENT_SECRET: "" ENTRA_CLIENT_SECRET: ""
ENTRA_REDIRECT_URI: "" ENTRA_REDIRECT_URI: ""
# ApiCore-Ernten-Sync (Admin -> Ernten -> Sync), siehe .env.example.
APICORE_API_URL: ""
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
+30
View File
@@ -38,3 +38,33 @@ spec:
resources: resources:
requests: requests:
storage: 5Gi storage: 5Gi
---
# Ablage der Rechnungs-PDFs (INVOICE_DIR, siehe 01-configmap.yaml und
# src/lib/invoice-storage.ts), sortiert nach Jahr und Kunde:
# /app/invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
#
# BEWUSST EIN EIGENES VOLUME, nicht ein Unterordner von "uploads":
# app/uploads/[...path]/route.ts liefert JEDE Datei unterhalb von UPLOAD_DIR
# ohne Anmeldung aus. Laegen die Rechnungen dort, koennte jede Person, die den
# Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt
# herunterladen. Auf dieses Volume zeigt keine oeffentliche Route.
#
# Zweiter Grund fuer die Trennung: die Aufbewahrungsfristen sind
# verschieden. Produktfotos lassen sich ersetzen; Rechnungen unterliegen in
# Deutschland zehn Jahren Aufbewahrungspflicht (§ 147 AO). Dieses Volume
# sichert der CronJob in 07-backup-cronjob.yaml taeglich mit.
#
# 2Gi sind reichlich bemessen: eine Rechnung ist wenige Kilobyte gross, das
# reicht fuer weit mehr Bestellungen, als die Aufbewahrungsfrist umfasst.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-invoices
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
# storageClassName: "dein-storage-class"
resources:
requests:
storage: 2Gi
+22
View File
@@ -77,6 +77,12 @@ spec:
# (fehlende Volume-Referenz). # (fehlende Volume-Referenz).
- name: uploads - name: uploads
mountPath: /app/uploads mountPath: /app/uploads
# Rechnungsablage, getrennt von den Uploads — auf dieses
# Verzeichnis zeigt bewusst KEINE oeffentliche Route. Muss zu
# INVOICE_DIR in 01-configmap.yaml passen, ausfuehrliche
# Begruendung in 04-uploads-pvc.yaml.
- name: invoices
mountPath: /app/invoices
# Prueft die Datenbank mit (siehe app/api/health/ready/route.ts). # Prueft die Datenbank mit (siehe app/api/health/ready/route.ts).
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem # Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
# Load-Balancing, startet ihn aber nicht neu. # Load-Balancing, startet ihn aber nicht neu.
@@ -86,6 +92,11 @@ spec:
port: 3000 port: 3000
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
# Ohne diesen Eintrag gilt Kubernetes' Vorgabe von EINER Sekunde.
# Waehrend ein Admin-Video umgerechnet wird, teilt sich der
# Node-Prozess die CPU mit ffmpeg — eine Antwort kann dann laenger
# als eine Sekunde brauchen, ohne dass irgendetwas kaputt waere.
timeoutSeconds: 5
# Bewusst der DB-FREIE Endpunkt (siehe app/api/health/route.ts): # Bewusst der DB-FREIE Endpunkt (siehe app/api/health/route.ts):
# eine Liveness-Probe an der Datenbank wuerde aus einer # eine Liveness-Probe an der Datenbank wuerde aus einer
# voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil # voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil
@@ -106,6 +117,14 @@ spec:
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
failureThreshold: 5 failureThreshold: 5
# Siehe Begruendung an der Readiness-Probe oben. Hier wiegt es
# schwerer: eine fehlgeschlagene LIVENESS-Probe laesst Kubernetes
# den Container neu starten. Genau das passierte beim
# Admin-Video-Upload, solange die Probe mit der Vorgabe von einer
# Sekunde gegen "/" lief — also gegen die Startseite samt
# Datenbankabfragen und vollem React-Rendering, waehrend ffmpeg
# gleichzeitig die CPU belegte.
timeoutSeconds: 5
# Bemessen nach dem teuersten Vorgang der Anwendung, dem # Bemessen nach dem teuersten Vorgang der Anwendung, dem
# Admin-Video-Upload: Node laedt den multipart-Body beim # Admin-Video-Upload: Node laedt den multipart-Body beim
# request.formData() vollstaendig in den Speicher, und die Datei # request.formData() vollstaendig in den Speicher, und die Datei
@@ -145,6 +164,9 @@ spec:
- name: uploads - name: uploads
persistentVolumeClaim: persistentVolumeClaim:
claimName: wabenhain-uploads claimName: wabenhain-uploads
- name: invoices
persistentVolumeClaim:
claimName: wabenhain-invoices
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+2 -2
View File
@@ -80,10 +80,10 @@ spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- shop.example.com - wabenhain.com
secretName: wabenhain-tls secretName: wabenhain-tls
rules: rules:
- host: shop.example.com - host: wabenhain.com
http: http:
paths: paths:
- path: / - path: /
+28
View File
@@ -105,14 +105,25 @@ spec:
| gzip -9 > "${target}.partial" | gzip -9 > "${target}.partial"
mv "${target}.partial" "${target}" mv "${target}.partial" "${target}"
# Rechnungsablage mitsichern (INVOICE_DIR, siehe
# 04-uploads-pvc.yaml). Die Datenbank kennt von einer
# Rechnung nur Nummer, Betrag und Pfad — das ausgelieferte
# PDF liegt ausschliesslich auf dem Volume. Ein
# Datenbank-Dump allein ersetzt den Beleg also NICHT.
invoices="/backup/rechnungen-${stamp}.tar.gz"
tar -czf "${invoices}.partial" -C /invoices .
mv "${invoices}.partial" "${invoices}"
# Angefangene Dateien frueherer Abbrueche mit aufraeumen. # Angefangene Dateien frueherer Abbrueche mit aufraeumen.
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
find /backup -name 'rechnungen-*.tar.gz.partial' -mtime +1 -delete
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE # 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
# langfristige Archivierung der Rechnungen (§ 147 AO, # langfristige Archivierung der Rechnungen (§ 147 AO,
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig # zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
# ausgelagerte Sicherungen nutzen. # ausgelagerte Sicherungen nutzen.
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete
echo "Sicherung abgeschlossen:" echo "Sicherung abgeschlossen:"
ls -lh /backup ls -lh /backup
@@ -125,7 +136,24 @@ spec:
volumeMounts: volumeMounts:
- name: backup - name: backup
mountPath: /backup mountPath: /backup
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
- name: invoices
mountPath: /invoices
readOnly: true
volumes: volumes:
- name: backup - name: backup
persistentVolumeClaim: persistentVolumeClaim:
claimName: wabenhain-db-backup claimName: wabenhain-db-backup
# ACHTUNG bei mehr als einem Knoten: wabenhain-invoices ist
# ReadWriteOnce (04-uploads-pvc.yaml). Ein solches Volume laesst
# sich nur von Pods auf DEMSELBEN Knoten einhaengen. Landet dieser
# Sicherungs-Pod auf einem anderen Knoten als der App-Pod, bleibt
# er in "Pending" haengen — sichtbar nur, wenn man nachsieht,
# denn ein Pending-Pod meldet keinen Fehler.
#
# Auf einem Ein-Knoten-Cluster ist das kein Thema. Bei mehreren
# Knoten das PVC auf ReadWriteMany umstellen (NFS/SMB/CephFS) —
# dann greift die Einschraenkung nicht mehr.
- name: invoices
persistentVolumeClaim:
claimName: wabenhain-invoices
+59 -3
View File
@@ -50,9 +50,11 @@ anwendbar. Nummerierung = empfohlene Reihenfolge.
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf 6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
`git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen `git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen
(`:latest` passt fuer den jeweils neuesten `main`-Build). In (`:latest` passt fuer den jeweils neuesten `main`-Build). Die Domain steht
`06-ingress.yaml` `shop.example.com` durch die echte Domain ersetzen in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
(zweimal: `host:` und `tls.hosts`). (`NEXT_PUBLIC_SITE_URL`) bereits als `wabenhain.com` — beide Stellen
muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die
Links in allen Bestellbestaetigungen woandershin.
## Deployment ## Deployment
@@ -165,6 +167,60 @@ vorliegen, und zeigt sonst eine Warnung mit Anzahl und Beispielpfaden
(`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst (`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst
dann, wenn Kundschaft im Shop leere Flaechen sieht. dann, wenn Kundschaft im Shop leere Flaechen sieht.
## Server startet beim Video-Upload neu
Symptom: Ein Video im Admin hochladen, und der Pod startet mittendrin neu. Kleine
Bilder gehen problemlos durch.
Ursache ist fast immer, dass im Cluster noch eine ältere, von Hand gepflegte
Fassung des Deployments läuft und nicht die aus diesem Verzeichnis. Zuerst den
Ist-Zustand feststellen:
```bash
# Abbruchgrund des letzten Containerlaufs:
kubectl -n wabenhain get pods -l app=wabenhain-app \
-o jsonpath='{.items[0].status.containerStatuses[0].lastState.terminated.reason}{"\n"}'
# Die drei Werte, auf die es ankommt:
kubectl -n wabenhain get deploy wabenhain-app \
-o jsonpath='limit={.spec.template.spec.containers[0].resources.limits.memory}{"\n"}liveness={.spec.template.spec.containers[0].livenessProbe.httpGet.path}{"\n"}timeout={.spec.template.spec.containers[0].livenessProbe.timeoutSeconds}{"\n"}'
```
Erwartet wird `limit=1Gi`, `liveness=/api/health`, `timeout=5`. Steht dort
`512Mi`, `/` oder ein leerer Timeout, ist genau das die Ursache — es gibt zwei
Wege, wie das den Container umbringt:
- **`liveness=/`**: Die Startseite ist `force-dynamic`, fragt die Datenbank ab
und rendert React vollständig. Ohne eigenen `timeoutSeconds` gilt Kubernetes'
Vorgabe von **einer Sekunde**. Während ffmpeg das Video umrechnet, teilt sich
der Node-Prozess die CPU — die Seite antwortet nicht rechtzeitig, die Probe
schlägt fehl, und nach `failureThreshold × periodSeconds` startet kubelet den
Container neu. `/api/health` ist stattdessen datenbankfrei und billig.
- **`limit=512Mi`**: Node-Server und ffmpeg teilen sich dasselbe Speicherbudget.
Bei einem 4K-Video reicht das nicht, der Kernel beendet den Container
(`reason=OOMKilled`).
Behoben wird beides, indem die Fassung aus diesem Verzeichnis ausgerollt wird:
```bash
kubectl apply -f k8s/05-app-deployment.yaml
kubectl -n wabenhain rollout status deploy/wabenhain-app
```
Wird das Deployment über die Rancher-Oberfläche gepflegt, überschreibt das
nächste Speichern dort ein `kubectl apply` wieder. Dann die Werte in Rancher
selbst nachziehen (Speicherlimit, beide Probe-Pfade, `timeoutSeconds`) oder das
Manifest als Quelle übernehmen.
Die Anwendungsseite ist unabhängig davon abgesichert: ffmpeg läuft mit
gedeckelter Threadzahl (`VIDEO_TRANSCODE_THREADS`, Vorgabe 2), damit sein
Speicher- und CPU-Bedarf nicht mehr von der Größe des Knotens abhängt — siehe
`src/lib/video-transcode.ts`.
**Zusätzlich prüfen:** Hat das Deployment gar kein `uploads`-Volume, ist ein
erfolgreicher Upload beim nächsten Neustart trotzdem weg. Siehe
„Bilder/Videos verschwinden nach jedem Redeploy" oben.
## Sicherung und Wiederherstellung ## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine `07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
@@ -0,0 +1,12 @@
-- Herkunftsschluessel fuer den ApiCore-Produkt-Sync: ID der Abfuellung
-- (BottlingBatch), aus der dieses Produkt entstanden ist. Nullable, weil
-- manuell angelegte Produkte keine Herkunft in ApiCore haben — alle
-- bestehenden Zeilen bleiben damit unveraendert gueltig.
--
-- Eindeutig, damit ein wiederholter Sync-Lauf dieselbe Abfuellung nicht ein
-- zweites Mal als Produkt anlegt.
-- AlterTable
ALTER TABLE "products" ADD COLUMN "apicoreBottlingId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "products_apicoreBottlingId_key" ON "products"("apicoreBottlingId");
@@ -0,0 +1,9 @@
-- Die Spalte hiess "pdfUrl", wurde aber von keiner Code-Stelle gelesen. Eine
-- URL waere auch das falsche Konzept: das Rechnungs-PDF liegt bewusst NICHT
-- unter einem oeffentlich abrufbaren Pfad, sondern in einem eigenen,
-- gesicherten Verzeichnis (INVOICE_DIR, siehe src/lib/invoice-storage.ts).
--
-- Reines Umbenennen: der Typ bleibt TEXT und nullable, und da nie etwas in
-- die Spalte geschrieben wurde, aendert sich an keiner Zeile ein Wert.
-- AlterTable
ALTER TABLE "invoices" RENAME COLUMN "pdfUrl" TO "pdfPath";
+28 -1
View File
@@ -209,6 +209,22 @@ model Product {
slug String @unique slug String @unique
name String name String
category ProductCategory @default(HONIG) category ProductCategory @default(HONIG)
/// ApiCore-ID der ABFUELLUNG (BottlingBatch), aus der dieses Produkt
/// synchronisiert wurde; null bei manuell angelegten Produkten.
///
/// Warum die Abfuellung und nicht das ApiCore-Produkt: dort ist "Waldhonig
/// 500 g" EIN Katalogartikel, an dem jedes Jahr eine neue Abfuellung haengt.
/// In Wabenhain gehoert ein Produkt dagegen zu genau einer Charge, weil hier
/// jeder Jahrgang eigenen Preis, eigenen Verkaufstext, eigenes
/// Geschmacksprofil und eigene Bewertungen hat. Ein ApiCore-Artikel wird
/// deshalb zu MEHREREN Wabenhain-Produkten, eines je Jahrgang.
///
/// Dient dem Sync als Idempotenz-Schluessel: daran erkennt ein erneuter
/// Lauf, dass diese Abfuellung schon importiert wurde. Bewusst NICHT ueber
/// den Slug geloest — der ist die oeffentliche Adresse und soll lesbar
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
/// richten.
apicoreBottlingId String? @unique
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet) /// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String? harvestId String?
batchId String? batchId String?
@@ -508,7 +524,18 @@ model Invoice {
invoiceNumber String @unique invoiceNumber String @unique
issuedAt DateTime @default(now()) issuedAt DateTime @default(now())
totalCents Int totalCents Int
pdfUrl String? /// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts.
///
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
/// aus Jahr und Namen neu berechnet, damit eine spaetere Aenderung an der
/// Pfadbildung keine bereits abgelegten Belege unauffindbar macht.
///
/// Hiess vorher "pdfUrl" und wurde nirgends gelesen — eine URL waere auch
/// das falsche Konzept gewesen: das Dokument liegt bewusst NICHT unter
/// einem oeffentlich abrufbaren Pfad.
pdfPath String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+1 -1
View File
@@ -81,7 +81,7 @@ must(
must( must(
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter", "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.includes("example.org"), !siteUrl.includes("example.com") && !siteUrl.includes("example.org"),
'k8s/01-configmap.yaml und k8s/06-ingress.yaml liefern "shop.example.com" als Vorgabe aus. Bleibt der Wert stehen, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.' 'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.'
); );
// --- Zahlung --------------------------------------------------------------- // --- Zahlung ---------------------------------------------------------------
+370 -119
View File
@@ -5,96 +5,104 @@ 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,
fetchApiCoreHarvests, fetchApiCoreHarvests,
fetchApiCoreProducts,
fetchApiCoreBottlingBatches, fetchApiCoreBottlingBatches,
isSellableStatus,
ApiCoreConfigError, ApiCoreConfigError,
ApiCoreRequestError, ApiCoreRequestError,
type ApiCoreSession,
type ApiCoreHarvestWithApiaryName, type ApiCoreHarvestWithApiaryName,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId, type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client"; } from "@/lib/apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
/** /**
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr * Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich * den Shop: Ernten, Produkte und Chargen.
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen.
*/
function slugFor(sourceId: string): string {
return `apicore-${sourceId}`;
}
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
*/
function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}
/**
* Zieht Ernten UND Chargen aus ApiCore.
* *
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished: * Durchgaengiges Prinzip fuer alle drei: ApiCore ist die Quelle der ROHDATEN,
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe * Wabenhain bleibt die Quelle aller VERKAUFS-Entscheidungen. Ein Sync-Lauf
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte * darf deshalb nie etwas ueberschreiben, das eine Mitarbeiterin im Admin
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine * bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend * ein Mensch freigibt.
* zuruecksetzen.
* *
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in * - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu * false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein * synchronisierte Ernten bleiben unberuehrt.
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren * - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben — * Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter * bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt * Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen. * gepflegt werden und die es in ApiCore gar nicht gibt.
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte * - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach * und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon * erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
* verfuegbar sind. * ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
* Lagerbestand.
* *
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B. * Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") * "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische * ApiCore-Losnummer als Batch.code.
*
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
* aequivalenten Taxonomien — es findet bewusst keine automatische
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim * Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
* Review korrigiert werden. * Review korrigiert werden.
*/ */
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let sourceHarvests: ApiCoreHarvestWithApiaryName[]; interface SyncCounts {
try { created: number;
sourceHarvests = await fetchApiCoreHarvests(); updated: number;
} catch (error: unknown) { skipped: number;
const message = failed: number;
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError }
? error.message
: error instanceof Error
? error.message
: "Unbekannter Fehler";
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
}
let created = 0; /**
let updated = 0; * Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
let skipped = 0; * siehe syncProducts().
let failed = 0; */
interface ProductSyncCounts {
created: number;
skipped: number;
failed: number;
/**
* Abfuellungen, deren Charge in Wabenhain noch fehlt. Kein Fehler, sondern
* eine Reihenfolge: zuerst der Ernten-Sync, danach noch einmal hier.
*/
pending: number;
/** Artikel, die ApiCore nicht mehr als verkaufsfaehig fuehrt. */
discontinued: number;
}
for (const source of sourceHarvests) { function describeError(error: unknown): string {
const slug = slugFor(source.id); if (error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError) return error.message;
if (error instanceof Error) return error.message;
return "Unbekannter Fehler";
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const source of sources) {
const slug = slugForApiCoreSource(source.id);
const date = new Date(source.date); const date = new Date(source.date);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
failed++; counts.failed++;
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`); console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
continue; continue;
} }
@@ -102,9 +110,8 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try { try {
const existing = await prisma.harvest.findUnique({ where: { slug } }); const existing = await prisma.harvest.findUnique({ where: { slug } });
if (existing?.isPublished) { if (existing?.isPublished) {
skipped++; counts.skipped++;
continue; continue;
} }
@@ -113,64 +120,203 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
type, type,
harvestDate: date, harvestDate: date,
location: source.apiaryName ?? "Altenmünster", location: source.apiaryName ?? "Altenmünster",
description: buildDescription(source), description: buildHarvestDescription(source),
}; };
if (existing) { if (existing) {
await prisma.harvest.update({ where: { slug }, data }); await prisma.harvest.update({ where: { slug }, data });
updated++; counts.updated++;
} else { } else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } }); await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
created++; counts.created++;
} }
} catch (error: unknown) { } catch (error: unknown) {
failed++; counts.failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten // z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer // im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision. // den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`); console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
} }
} }
await logAdminAction(admin, { return counts;
action: "harvest.sync_apicore", }
entityType: "Harvest",
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
});
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist // ---------------------------------------------------------------------------
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der // Produkte
// Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten // ---------------------------------------------------------------------------
// trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite
// verloren zu gehen.
let batchCreated = 0;
let batchSkipped = 0;
let batchFailed = 0;
let batchLoadError: string | null = null;
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = []; /**
try { * Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig:
sourceBatches = await fetchApiCoreBottlingBatches(); *
} catch (error: unknown) { * - `isActive: false` haelt es aus Shop, Suche und Checkout heraus.
batchLoadError = * - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis:
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError * ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine
? error.message * Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus
: error instanceof Error * diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich
? error.message * zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit
: "Unbekannter Fehler"; * Preis 0 zu verkaufen (admin/actions/products.ts, api/checkout/route.ts).
* - Kategorie HONIG als Vorgabe: ApiCore kennt keine Shop-Kategorien, und
* eine Imkerei-Warenwirtschaft fuehrt ueberwiegend Honig. Kerzen oder
* Propolis stellt der Admin beim Review um.
* - Geschmacksprofil, Konsistenz und Bilder bleiben auf den Schema-Vorgaben:
* dafuer gibt es in ApiCore keine Entsprechung, das ist Shop-Redaktion.
*
* Ein schon vorhandenes Produkt wird NIE aktualisiert, auch kein Entwurf.
* Genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
* sind die, die der Admin vor der Freischaltung von Hand nachzieht — ein
* Update wuerde diese Arbeit bei jedem weiteren Lauf wieder wegwerfen, und
* am Entwurf ist nicht erkennbar, ob schon jemand daran war. Soll ein
* Produkt wirklich frisch aus ApiCore kommen, loescht man es in Wabenhain
* und synchronisiert erneut. Dieselbe Regel gilt aus demselben Grund fuer
* Chargen.
*
* EIN PRODUKT JE JAHRGANG, nicht je ApiCore-Artikel. Der Sync laeuft deshalb
* ueber die ABFUELLUNGEN, nicht ueber den Artikelkatalog:
*
* In ApiCore ist "Waldhonig 500 g" ein einziger Katalogartikel, an dem jedes
* Jahr eine weitere Abfuellung haengt. In Wabenhain gehoert ein Produkt
* dagegen zu genau einer Charge — und das ist richtig so, denn jeder Jahrgang
* hat eigenen Preis, eigenen Verkaufstext, eigenes Geschmacksprofil und
* eigene Bewertungen. Honig ist ein Naturprodukt; die Bewertung des
* Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.
*
* Aus einem ApiCore-Artikel werden hier also mehrere Wabenhain-Produkte,
* eines je Abfuellung. Wiedererkannt werden sie ueber Product.apicoreBottlingId
* — nicht ueber den Slug: der ist die oeffentliche Adresse und soll lesbar
* bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
* richten.
*
* Reihenfolge: die Charge muss bereits in Wabenhain sein (Ernten-Sync), sonst
* gaebe es nichts, woran der Jahrgang haengt. Fehlt sie, wird die Abfuellung
* als "pending" gezaehlt statt als Fehler — der naechste Lauf holt sie nach.
*/
async function syncProducts(
articles: ApiCoreProduct[],
bottlings: ApiCoreBottlingBatchWithHarvestId[]
): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
const articleById = new Map(articles.map((article) => [article.id, article]));
for (const bottling of bottlings) {
// Eine Abfuellung ohne Artikel ist in ApiCore erlaubt (z.B. Rohware fuer
// den Direktverkauf ab Hof). Daraus laesst sich kein Shop-Produkt bilden:
// es gaebe weder Namen noch Artikelnummer.
if (!bottling.productId) continue;
const article = articleById.get(bottling.productId);
if (!article) {
counts.failed++;
console.error(
`[apicore-sync] Abfüllung ${bottling.lotNumber} verweist auf den Artikel ${bottling.productId}, den ApiCore nicht ausliefert — übersprungen.`
);
continue;
}
try {
const existing = await prisma.product.findUnique({
where: { apicoreBottlingId: bottling.id },
select: { id: true },
});
if (existing) {
counts.skipped++;
continue;
}
// Ein in ApiCore ausgelaufener Artikel bekommt keinen NEUEN Jahrgang im
// Shop. Bereits importierte Jahrgaenge bleiben unberuehrt und weiter
// verkaeuflich: solange Glaeser im Regal stehen, ist "nicht mehr
// produziert" kein Grund, den Abverkauf abzubrechen. Diese Entscheidung
// trifft der Admin, nicht der Sync.
if (!isSellableStatus(article.status)) {
counts.discontinued++;
continue;
}
const batch = await prisma.batch.findUnique({
where: { code: bottling.lotNumber },
include: { harvest: { select: { id: true, year: true } } },
});
if (!batch) {
counts.pending++;
continue;
}
const slug = await firstFreeSlug(
productSlugCandidates(article.name, batch.harvest.year, bottling.lotNumber)
);
if (!slug) {
counts.failed++;
console.error(
`[apicore-sync] Für "${article.name}" (${batch.harvest.year}, Los ${bottling.lotNumber}) ist keine freie Adresse frei — Produkt bitte von Hand anlegen.`
);
continue;
}
await prisma.product.create({
data: {
slug,
apicoreBottlingId: bottling.id,
name: article.name,
description: buildProductDescription(article, batch.harvest.year),
// Die Glasgroesse der Abfuellung schlaegt die Angabe am Artikel:
// sie sagt, was tatsaechlich in dieses Glas kam.
weightGrams: deriveJarWeightGrams(bottling.jarSize, article),
category: "HONIG",
harvestId: batch.harvest.id,
batchId: batch.id,
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
// Bewusst 0 statt bottling.jarCount: wie viele Glaeser in den
// Webshop gehen und wie viele in Hofladen, Wiederverkauf oder
// Rueckstellmuster, entscheidet Wabenhain — nicht die Abfuellmenge.
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(
`[apicore-sync] Abfüllung ${bottling.lotNumber} konnte nicht als Produkt importiert werden: ${describeError(error)}`
);
}
} }
for (const source of sourceBatches) { return counts;
}
/**
* Erste noch freie Adresse aus den Vorschlaegen, oder null, wenn alle belegt
* sind. Bewusst ohne Hochzaehlen: eine Adresse wie "waldhonig-2026-2" sagt
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
* hinschauen muss.
*/
async function firstFreeSlug(candidates: string[]): Promise<string | null> {
for (const slug of candidates) {
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
if (!taken) return slug;
}
return null;
}
// ---------------------------------------------------------------------------
// Chargen
// ---------------------------------------------------------------------------
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<SyncCounts> {
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const source of sources) {
if (!source.harvestSourceId) { if (!source.harvestSourceId) {
batchFailed++; counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`); console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
continue; continue;
} }
const fillDate = new Date(source.date); const fillDate = new Date(source.date);
const bestBeforeDate = new Date(source.bestBeforeDate); const bestBeforeDate = new Date(source.bestBeforeDate);
if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) { if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) {
batchFailed++; counts.failed++;
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`); console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
continue; continue;
} }
@@ -178,48 +324,153 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
try { try {
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } }); const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
if (existing) { if (existing) {
batchSkipped++; counts.skipped++;
continue; continue;
} }
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } }); const harvest = await prisma.harvest.findUnique({
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
});
if (!harvest) { if (!harvest) {
batchFailed++; counts.failed++;
console.error( console.error(
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.` `[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
); );
continue; continue;
} }
const glassCount = Math.max(Math.round(source.jarCount), 0);
await prisma.batch.create({ await prisma.batch.create({
data: { data: {
code: source.lotNumber, code: source.lotNumber,
harvestId: harvest.id, harvestId: harvest.id,
fillDate, fillDate,
bestBeforeDate, bestBeforeDate,
glassCount: Math.max(Math.round(source.jarCount), 0), glassCount,
remainingGlasses: Math.max(Math.round(source.jarCount), 0), remainingGlasses: glassCount,
}, },
}); });
batchCreated++; counts.created++;
} catch (error: unknown) { } catch (error: unknown) {
batchFailed++; counts.failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${describeError(error)}`);
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
} }
} }
return counts;
}
/**
* Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht
* fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen
* Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
* verschwinden.
*
* Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit
* je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und
* Ernte angelegt (siehe syncProducts).
*/
async function loadAndSyncBatches(
session: ApiCoreSession
): Promise<{ counts: SyncCounts; loadError: string | null }> {
let sources: ApiCoreBottlingBatchWithHarvestId[];
try {
sources = await fetchApiCoreBottlingBatches(session);
} catch (error: unknown) {
return {
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
loadError: describeError(error),
};
}
return { counts: await syncBatches(sources), loadError: null };
}
// ---------------------------------------------------------------------------
// Server Actions
// ---------------------------------------------------------------------------
/**
* Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen
* Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte
* Ernte als Pflicht-Fremdschluessel schon bereitsteht.
*/
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
try {
session = await openApiCoreSession();
sourceHarvests = await fetchApiCoreHarvests(session);
} catch (error: unknown) {
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
}
const harvests = await syncHarvests(sourceHarvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
});
const batches = await loadAndSyncBatches(session);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "batch.sync_apicore", action: "batch.sync_apicore",
entityType: "Batch", entityType: "Batch",
detail: batchLoadError detail: batches.loadError
? `Chargen konnten nicht geladen werden: ${batchLoadError}` ? `Chargen konnten nicht geladen werden: ${batches.loadError}`
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`, : `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`,
}); });
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect( redirect(
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` + `/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` + `&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "") (batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "")
);
}
/**
* Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an,
* also einen eigenen Jahrgang (siehe syncProducts).
*
* Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die
* Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier
* NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre
* Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das
* als "wartet auf den Ernten-Sync" statt als Fehler.
*/
export async function syncProductsFromApiCoreAction(): Promise<void> {
const admin = await requireAdmin();
let session: ApiCoreSession;
let articles: ApiCoreProduct[];
let bottlings: ApiCoreBottlingBatchWithHarvestId[];
try {
session = await openApiCoreSession();
[articles, bottlings] = await Promise.all([
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
} catch (error: unknown) {
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
}
const products = await syncProducts(articles, bottlings);
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
detail:
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
`${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`,
});
revalidatePath("/admin/produkte");
redirect(
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
`&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}`
); );
} }
+41 -2
View File
@@ -9,6 +9,24 @@ import { adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
* gepflegt ist. `adminProductNumbersSchema` erlaubt 0 bewusst weiterhin:
* ein Entwurf ohne Preis muss speicherbar bleiben — insbesondere die aus
* ApiCore importierten Produkte, die genau so entstehen (ApiCore fuehrt
* keine Verkaufspreise, siehe apicore-sync.ts). Verboten ist nur die
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
* Produkt bestellbar.
*/
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
if (isActive && priceCents <= 0) {
fail(
redirectPath,
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
);
}
}
export async function createProductAction(formData: FormData): Promise<void> { export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-"); const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
@@ -27,12 +45,30 @@ export async function createProductAction(formData: FormData): Promise<void> {
} }
const numbers = parsedNumbers.data; const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS"; const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien // davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen. // ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
const isHoney = category === "HONIG"; const isHoney = category === "HONIG";
// Product.slug ist eindeutig UND zugleich die oeffentliche Adresse
// (/honig/<slug>). Ohne eigene Angabe wird er aus dem Namen gebildet — bei
// jahrgangsweise gefuehrtem Honig kollidiert das zwangslaeufig: "Waldhonig"
// ergibt in jedem Jahr denselben Slug "waldhonig". Ohne diese Behandlung
// schlug das als unbehandelte Prisma-Ausnahme (P2002) durch, und statt
// einer Meldung erschien die Fehlerseite des Admin-Bereichs — aus der nicht
// hervorgeht, dass lediglich die Adresse belegt ist.
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
if (slugTaken) {
fail(
"/admin/produkte/neu",
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
);
}
const product = await prisma.product.create({ const product = await prisma.product.create({
data: { data: {
slug, slug,
@@ -50,7 +86,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on", isActive,
inventory: { create: { quantity: numbers.quantity } }, inventory: { create: { quantity: numbers.quantity } },
}, },
}); });
@@ -84,6 +120,9 @@ export async function updateProductAction(formData: FormData): Promise<void> {
} }
const numbers = parsedNumbers.data; const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const previousInventory = await prisma.inventory.findUnique({ const previousInventory = await prisma.inventory.findUnique({
where: { productId: id }, where: { productId: id },
select: { quantity: true }, select: { quantity: true },
@@ -103,7 +142,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on", isActive,
inventory: { inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } }, upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
}, },
+7 -1
View File
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { q, status } = await props.searchParams; const { q, status, error } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
@@ -38,6 +38,12 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<div> <div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm"> <label className="text-sm">
+7
View File
@@ -10,6 +10,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) { export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { id } = await props.params; const { id } = await props.params;
const { error } = await props.searchParams;
const rawProduct = await prisma.product.findUnique({ const rawProduct = await prisma.product.findUnique({
where: { id }, where: { id },
include: { harvest: true, batch: true, inventory: true }, include: { harvest: true, batch: true, inventory: true },
@@ -35,6 +36,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
) )
: null} : null}
</p> </p>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required /> <Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
+8 -1
View File
@@ -5,8 +5,9 @@ import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() { export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [harvests, batches] = await Promise.all([ const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }), prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
@@ -16,6 +17,12 @@ export default async function AdminProductNewPage() {
<div className="max-w-xl"> <div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} /> <BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required /> <Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} /> <Field name="slug" label={t.adminProductNewPage.slugLabel} />
+44 -1
View File
@@ -2,11 +2,13 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { syncProductsFromApiCoreAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductsPage() { export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams;
const products = await prisma.product.findMany({ const products = await prisma.product.findMany({
include: { harvest: true, batch: true, inventory: true }, include: { harvest: true, batch: true, inventory: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@@ -21,6 +23,30 @@ export default async function AdminProductsPage() {
{t.adminProductsPage.newProduct} {t.adminProductsPage.newProduct}
</Link> </Link>
</div> </div>
{synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
</p>
) : null}
{/* "pending" ist kein Fehler, sondern eine Reihenfolge: die Charge des
Jahrgangs kommt ueber den Ernten-Sync. Deshalb als Hinweis, nicht rot. */}
{Number(pending ?? 0) > 0 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.adminProductsPage.syncPending(Number(pending))}
</p>
) : null}
{Number(discontinued ?? 0) > 0 ? (
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
{t.adminProductsPage.syncDiscontinued(Number(discontinued))}
</p>
) : null}
{syncError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminProductsPage.syncError} ({syncError})
</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -50,6 +76,13 @@ export default async function AdminProductsPage() {
<td className="p-4">{product.inventory?.quantity ?? 0}</td> <td className="p-4">{product.inventory?.quantity ?? 0}</td>
<td className="p-4"> <td className="p-4">
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive} {product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
{/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten
(admin/actions/products.ts). Aus ApiCore importierte
Entwuerfe sehen sonst wie ein normales inaktives Produkt
aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */}
{product.priceCents <= 0 ? (
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null}
</td> </td>
<td className="p-4"> <td className="p-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline"> <Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
@@ -64,6 +97,16 @@ export default async function AdminProductsPage() {
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p> <p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
) : null} ) : null}
</div> </div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-3xl">
<h2 className="text-lg text-forest mb-4">{t.adminProductsPage.syncHeading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.adminProductsPage.syncIntro}</p>
<form action={syncProductsFromApiCoreAction}>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductsPage.syncButton}
</button>
</form>
</div>
</div> </div>
); );
} }
+16
View File
@@ -102,6 +102,22 @@ export async function POST(request: Request) {
); );
} }
// Preis 0 ist kein Geschenk, sondern ein noch nicht gepflegter Preis. Aus
// ApiCore importierte Produkte kommen genau so an (siehe
// admin/actions/apicore-sync.ts: ApiCore fuehrt keine Verkaufspreise), und
// auch ein manuell angelegtes Produkt kann versehentlich ohne Preis aktiv
// geschaltet werden. Die Aktivierung im Admin weist das bereits ab; diese
// Pruefung ist die zweite Haelfte davon und faengt Altbestaende sowie
// direkte Datenbank-Aenderungen ab, die nie durch das Admin-Formular
// gelaufen sind. Sonst waere die Bestellung tatsaechlich fuer 0 Euro
// abgeschlossen worden.
if (products.some((product) => product.priceCents <= 0)) {
return NextResponse.json(
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
{ status: 400 }
);
}
const orderItemsData = input.items.map((cartItem) => { const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!; const product = products.find((p) => p.id === cartItem.productId)!;
return { return {
+74 -2
View File
@@ -6,9 +6,59 @@ import { sendMail } from "@/lib/mail";
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates"; import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe"; import type Stripe from "stripe";
/**
* Erzeugt das Rechnungs-PDF, legt es nach Jahr und Kunde sortiert ab und
* vermerkt den Pfad an der Rechnung. Gibt das Dokument zurueck, damit es der
* Bestellbestaetigung angehaengt werden kann — oder null, wenn das nicht
* geklappt hat.
*
* Bewusst mit eigener Fehlerbehandlung statt im umgebenden try/catch: dort
* fuehrt jeder Fehler zu einem 500er an Stripe und damit zu einem erneuten
* Zustellversuch. Fuer eine volle Platte oder ein nicht eingehaengtes Volume
* waere das die falsche Antwort — die Zahlung ist erfolgt, die Bestellung
* steht, und die Rechnung laesst sich jederzeit nachtraeglich abrufen
* (/rechnung/<nummer>/pdf erzeugt sie dann neu). Ein Wiederholungslauf
* wuerde daran nichts aendern, nur die Bestaetigungsmail verzoegern.
*
* Gerendert wird aus derselben Quelle wie der spaetere Abruf
* (getInvoiceByNumber), damit abgelegtes, versendetes und spaeter
* angezeigtes Dokument garantiert dasselbe sind.
*/
async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | null> {
try {
const invoice = await getInvoiceByNumber(invoiceNumber);
if (!invoice) return null;
const pdf = await generateInvoicePdf({
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
order: invoice.order,
});
const relativePath = invoiceRelativePath({
year: invoice.issuedAt.getFullYear(),
lastName: invoice.order.lastName,
firstName: invoice.order.firstName,
invoiceNumber: invoice.invoiceNumber,
});
await storeInvoicePdf(relativePath, pdf);
await prisma.invoice.update({ where: { id: invoice.id }, data: { pdfPath: relativePath } });
return pdf;
} catch (error: unknown) {
console.error(`[invoice] ${invoiceNumber} konnte nicht abgelegt werden:`, error);
return null;
}
}
/** /**
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf * Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
* /api/checkout/webhook mit den Events "checkout.session.completed", * /api/checkout/webhook mit den Events "checkout.session.completed",
@@ -150,7 +200,7 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
}, },
}); });
await prisma.invoice.upsert({ const invoice = await prisma.invoice.upsert({
where: { orderId: order.id }, where: { orderId: order.id },
create: { create: {
orderId: order.id, orderId: order.id,
@@ -160,6 +210,8 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
update: {}, update: {},
}); });
const invoicePdf = await storeInvoiceDocument(invoice.invoiceNumber);
const { subject, html, text } = await orderConfirmationEmail({ const { subject, html, text } = await orderConfirmationEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale, locale: isLocale(order.locale) ? order.locale : defaultLocale,
orderNumber: order.orderNumber, orderNumber: order.orderNumber,
@@ -181,7 +233,27 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
giftMessage: order.giftMessage, giftMessage: order.giftMessage,
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`, trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
}); });
await sendMail({ to: order.email, subject, html, text }); await sendMail({
to: order.email,
subject,
html,
text,
// Die Rechnung liegt der Bestaetigung bei, statt nur im Konto zu
// warten: Gastbestellungen haben gar kein Konto, und ein Beleg, den
// man suchen muss, wird nicht abgeheftet. Fehlt das PDF (siehe
// storeInvoiceDocument), geht die Bestaetigung trotzdem raus.
...(invoicePdf
? {
attachments: [
{
filename: `${invoice.invoiceNumber}.pdf`,
content: invoicePdf,
contentType: "application/pdf",
},
],
}
: {}),
});
} catch (error: unknown) { } catch (error: unknown) {
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den // Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er // Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
+19 -7
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data"; import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
export async function GET( export async function GET(
@@ -23,13 +24,24 @@ export async function GET(
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 }); return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
} }
const pdf = await generateInvoicePdf({ // Zuerst das abgelegte Dokument: eine Rechnung ist ein Beleg, und der muss
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale, // spaeter genauso aussehen wie das, was die Kundschaft per E-Mail bekommen
invoiceNumber: invoice.invoiceNumber, // hat. Ein Neu-Rendern wuerde nach einer Aenderung an Firmendaten oder am
issuedAt: invoice.issuedAt, // Layout ein ABWEICHENDES Dokument unter derselben Rechnungsnummer
totalCents: invoice.totalCents, // ausliefern (siehe lib/invoice-storage.ts).
order: invoice.order, //
}); // Neu erzeugt wird nur, wenn keine Datei da ist: bei Rechnungen aus der
// Zeit vor dieser Ablage, oder wenn das Ablegen seinerzeit fehlschlug.
const stored = invoice.pdfPath ? await readStoredInvoicePdf(invoice.pdfPath) : null;
const pdf =
stored ??
(await generateInvoicePdf({
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
order: invoice.order,
}));
return new NextResponse(new Uint8Array(pdf), { return new NextResponse(new Uint8Array(pdf), {
headers: { headers: {
+13 -1
View File
@@ -903,7 +903,7 @@ export const de: Dictionary = {
create: "Anlegen", create: "Anlegen",
syncHeading: "ApiCore-Synchronisierung", syncHeading: "ApiCore-Synchronisierung",
syncIntro: syncIntro:
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte.", "Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte. Die Produkte selbst kommen über den eigenen Sync-Knopf unter „Produkte“.",
syncButton: "Jetzt synchronisieren", syncButton: "Jetzt synchronisieren",
syncResult: (created, updated, skipped, failed) => syncResult: (created, updated, skipped, failed) =>
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`, `Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
@@ -945,6 +945,18 @@ export const de: Dictionary = {
statusInactive: "Inaktiv", statusInactive: "Inaktiv",
edit: "Bearbeiten", edit: "Bearbeiten",
noProductsYet: "Noch keine Produkte angelegt.", noProductsYet: "Noch keine Produkte angelegt.",
syncHeading: "ApiCore-Synchronisierung",
syncIntro:
"Legt für jede Abfüllung aus ApiCore ein eigenes Produkt an — also einen Jahrgang. „Waldhonig 2026“ und „Waldhonig 2027“ sind damit getrennte Produkte mit eigenem Preis, eigenem Text und eigenen Bewertungen; die Adresse trägt die Jahreszahl. Importierte Produkte sind zunächst deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Erst wenn Preis, Gewicht und Beschreibung gepflegt sind, lässt sich ein Produkt aktiv schalten. Vorhandene Produkte werden nur übersprungen, nie überschrieben. Die zugehörige Charge muss schon vorhanden sein — also zuerst den Ernten-Sync ausführen.",
syncButton: "Jetzt synchronisieren",
syncResult: (created, skipped, failed) =>
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"} angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
syncDiscontinued: (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 Jahrgänge bleiben unverändert im Verkauf, damit der Restbestand abverkauft werden kann.`,
syncPending: (count) =>
`${count} Abfüllung${count === 1 ? "" : "en"} wartet${count === 1 ? "" : "en"} noch auf die zugehörige Charge. Zuerst unter „Ernten“ synchronisieren, dann hier erneut.`,
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
draftHint: "Entwurf: kein Preis gepflegt",
}, },
adminProductEditPage: { adminProductEditPage: {
backToProducts: "Zurück zu Produkten", backToProducts: "Zurück zu Produkten",
+13 -1
View File
@@ -903,7 +903,7 @@ export const en: Dictionary = {
create: "Create", create: "Create",
syncHeading: "ApiCore sync", syncHeading: "ApiCore sync",
syncIntro: syncIntro:
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest.", "Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest. The products themselves have their own sync button under “Products”.",
syncButton: "Sync now", syncButton: "Sync now",
syncResult: (created, updated, skipped, failed) => syncResult: (created, updated, skipped, failed) =>
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`, `Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
@@ -945,6 +945,18 @@ export const en: Dictionary = {
statusInactive: "Inactive", statusInactive: "Inactive",
edit: "Edit", edit: "Edit",
noProductsYet: "No products created yet.", noProductsYet: "No products created yet.",
syncHeading: "ApiCore sync",
syncIntro:
"Creates one product per bottling run from ApiCore — that is, per vintage. “Waldhonig 2026” and “Waldhonig 2027” are separate products with their own price, copy and reviews; the address carries the year. Imported products start out inactive and without a price, because ApiCore does not hold sales prices. A product can only be activated once price, weight and description have been filled in. Existing products are only ever skipped, never overwritten. The matching batch has to exist already — so run the harvest sync first.",
syncButton: "Sync now",
syncResult: (created, skipped, failed) =>
`Products: ${created} new vintage${created === 1 ? "" : "s"} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
syncDiscontinued: (count) =>
`${count} bottling run${count === 1 ? "" : "s"} belong${count === 1 ? "s" : ""} to an article ApiCore no longer carries, so no new vintage was created for ${count === 1 ? "it" : "them"}. Vintages already in the shop stay on sale so remaining stock can be sold through.`,
syncPending: (count) =>
`${count} bottling run${count === 1 ? " is" : "s are"} still waiting for the matching batch. Sync under “Harvests” first, then run this again.`,
syncError: "Sync with ApiCore failed.",
draftHint: "Draft: no price set",
}, },
adminProductEditPage: { adminProductEditPage: {
backToProducts: "Back to products", backToProducts: "Back to products",
+8
View File
@@ -647,6 +647,14 @@ export interface Dictionary {
statusInactive: string; statusInactive: string;
edit: string; edit: string;
noProductsYet: string; noProductsYet: string;
syncHeading: string;
syncIntro: string;
syncButton: string;
syncResult: (created: number, skipped: number, failed: number) => string;
syncDiscontinued: (count: number) => string;
syncPending: (count: number) => string;
syncError: string;
draftHint: string;
}; };
adminProductEditPage: { adminProductEditPage: {
backToProducts: string; backToProducts: string;
+159 -90
View File
@@ -3,8 +3,8 @@ import { z } from "zod";
/** /**
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter * HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB), * .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt * die die Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts). * (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
* *
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT * Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen * (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
@@ -13,9 +13,9 @@ import { z } from "zod";
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt * interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen * (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto * automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional * mit ausschliesslich Lese-Berechtigungen empfohlen statt eines echten
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten * Admin-Zugangs: "harvests:read", "products:read", "extractions:read",
* Admin-Zugangs. * "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. "http://localhost:3001/api" (globaler Prefix
@@ -23,10 +23,15 @@ import { z } from "zod";
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos * APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos * APICORE_SYNC_PASSWORD Passwort des Service-Kontos
* *
* Ohne diese drei Variablen wirft fetchApiCoreHarvests()/ * Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
* fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der * ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die * dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
* Aktion stillschweigend nichts tun zu lassen. *
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
* den eigenen Service-Zugang auszusperren.
* *
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT * Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor * ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
@@ -37,8 +42,6 @@ import { z } from "zod";
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId * ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle * -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft. * Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
* Das erfordert zusaetzlich die Berechtigungen "extractions:read" und
* "bottling:read" fuer das Sync-Service-Konto.
*/ */
export class ApiCoreConfigError extends Error {} export class ApiCoreConfigError extends Error {}
@@ -49,23 +52,10 @@ const loginResponseSchema = z.union([
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }), z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
]); ]);
const apiarySchema = z.object({ id: z.string(), name: z.string() }); /** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
export interface ApiCoreSession {
const harvestSchema = z.object({ readonly baseUrl: string;
id: z.string(), readonly token: string;
apiaryId: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
} }
function getConfig(): { baseUrl: string; email: string; password: string } { function getConfig(): { baseUrl: string; email: string; password: string } {
@@ -80,7 +70,9 @@ function getConfig(): { baseUrl: string; email: string; password: string } {
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password }; return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
} }
async function login(baseUrl: string, email: string, password: string): Promise<string> { export async function openApiCoreSession(): Promise<ApiCoreSession> {
const { baseUrl, email, password } = getConfig();
let res: Response; let res: Response;
try { try {
res = await fetch(`${baseUrl}/auth/login`, { res = await fetch(`${baseUrl}/auth/login`, {
@@ -104,48 +96,139 @@ async function login(baseUrl: string, email: string, password: string): Promise<
"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)." "Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
); );
} }
return parsed.data.accessToken; return { baseUrl, token: parsed.data.accessToken };
}
/**
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
*/
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
let res: Response;
try {
res = await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
}
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({
id: z.string(),
apiaryId: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
} }
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */ /** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> { async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
try { try {
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } }); const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
if (!res.ok) return new Map(); return new Map(apiaries.map((a) => [a.id, a.name]));
const parsed = z.array(apiarySchema).safeParse(await res.json());
if (!parsed.success) return new Map();
return new Map(parsed.data.map((a) => [a.id, a.name]));
} catch { } catch {
return new Map(); return new Map();
} }
} }
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> { export async function fetchApiCoreHarvests(
const { baseUrl, email, password } = getConfig(); session: ApiCoreSession
const token = await login(baseUrl, email, password); ): Promise<ApiCoreHarvestWithApiaryName[]> {
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
let res: Response; const apiaryNames = await fetchApiaryNames(session);
try { return harvests.map((harvest) => ({
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
}
const parsed = z.array(harvestSchema).safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
}
const apiaryNames = await fetchApiaryNames(baseUrl, token);
return parsed.data.map((harvest) => ({
...harvest, ...harvest,
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null, apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
})); }));
} }
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
* Abweichungen vom dortigen Prisma-Modell:
*
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
*/
const productSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
sku: z.string().nullable().optional(),
packagingSize: z.number().nullable().optional(),
weight: z.number().nullable().optional(),
salesUnit: z.string().nullable().optional(),
status: z.string(),
});
export type ApiCoreProduct = z.infer<typeof productSchema>;
/**
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
* ungeprueft als Shop-Entwurf auftaucht.
*/
export function isSellableStatus(status: string): boolean {
return status === "ACTIVE";
}
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
return getJson(session, "/products", z.array(productSchema), "Produkte");
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chargen (BottlingBatch, siehe Modul-Kommentar oben) // Chargen (BottlingBatch, siehe Modul-Kommentar oben)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -164,6 +247,16 @@ const bottlingBatchSchema = z.object({
date: z.string(), date: z.string(),
bestBeforeDate: z.string(), bestBeforeDate: z.string(),
jarCount: z.number(), jarCount: z.number(),
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
// verwerfen.
jarSize: z.number().nullable().optional(),
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
productId: z.string().nullable().optional(),
honeyBatch: z.object({ honeyBatch: z.object({
id: z.string(), id: z.string(),
extractionId: z.string(), extractionId: z.string(),
@@ -185,43 +278,19 @@ export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch
harvestSourceId: string | null; harvestSourceId: string | null;
} }
export async function fetchApiCoreBottlingBatches(): Promise<ApiCoreBottlingBatchWithHarvestId[]> { export async function fetchApiCoreBottlingBatches(
const { baseUrl, email, password } = getConfig(); session: ApiCoreSession
const token = await login(baseUrl, email, password); ): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const authHeader = { Authorization: `Bearer ${token}` }; const [extractions, batches] = await Promise.all([
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
let extractionsRes: Response; getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
let batchesRes: Response; ]);
try {
[extractionsRes, batchesRes] = await Promise.all([
fetch(`${baseUrl}/extractions`, { headers: authHeader }),
fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }),
]);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
}
if (!extractionsRes.ok) {
throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`);
}
if (!batchesRes.ok) {
throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`);
}
const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json());
if (!parsedExtractions.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen.");
}
const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json());
if (!parsedBatches.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen.");
}
const firstHarvestByExtraction = new Map<string, string | null>( const firstHarvestByExtraction = new Map<string, string | null>(
parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null]) extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
); );
return parsedBatches.data.map((batch) => ({ return batches.map((batch) => ({
...batch, ...batch,
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null, harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
})); }));
+262
View File
@@ -0,0 +1,262 @@
import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
deriveJarWeightGrams,
slugifyProductName,
productSlugCandidates,
} from "./apicore-mapping";
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
return {
id: "cm-product-1",
name: "Sommertracht 500 g",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
...overrides,
};
}
function makeHarvest(
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
): ApiCoreHarvestWithApiaryName {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Lindenhonig",
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
apiaryName: "Streuobstwiese",
...overrides,
};
}
describe("slugForApiCoreSource", () => {
test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => {
// Arrange
const sourceId = "cm-product-1";
// Act
const slug = slugForApiCoreSource(sourceId);
// Assert
expect(slug).toBe("apicore-cm-product-1");
});
test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => {
// Arrange: derselbe Datensatz, in ApiCore umbenannt.
const before = makeProduct({ name: "Sommertracht 500 g" });
const after = makeProduct({ name: "Sommertracht cremig, 500 g" });
// Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen.
expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id));
});
});
describe("buildHarvestDescription", () => {
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
// Arrange
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
);
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
const source = makeHarvest();
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toContain("Bitte Beschreibung ergänzen.");
});
});
describe("buildProductDescription", () => {
test("uebernimmt ApiCores Beschreibung und nennt Artikelnummer und Verkaufseinheit", () => {
// Arrange
const source = makeProduct({ description: "Cremig gerührt.", sku: "WH-500", salesUnit: "Glas" });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Artikelnummer WH-500 · Verkaufseinheit Glas). Cremig gerührt."
);
});
test("setzt eine Aufforderung ein, statt einen leeren Verkaufstext zu erzeugen", () => {
// Arrange: ApiCores description ist eine interne Notiz und oft leer.
const source = makeProduct({ description: " " });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toContain("Bitte Produktbeschreibung für den Shop ergänzen.");
});
});
describe("deriveWeightGrams", () => {
test("nimmt das Gewicht des Artikels", () => {
// Arrange
const source = makeProduct({ weight: 500, packagingSize: 250 });
// Act & Assert: weight schlaegt packagingSize.
expect(deriveWeightGrams(source)).toBe(500);
});
test("faellt auf die Gebindegroesse zurueck, wenn kein Gewicht gepflegt ist", () => {
// Arrange
const source = makeProduct({ weight: null, packagingSize: 250 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(250);
});
test("rundet auf volle Gramm", () => {
// Arrange
const source = makeProduct({ weight: 499.6 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(500);
});
test("gibt 0 zurueck statt zu raten, wenn ApiCore keine Menge kennt", () => {
// Arrange
const source = makeProduct();
// Act & Assert: 0 ist in Wabenhain kein speicherbares Gewicht und zwingt
// damit zur manuellen Pflege, bevor das Produkt verkauft werden kann.
expect(deriveWeightGrams(source)).toBe(0);
});
test("behandelt 0 und negative Werte wie eine fehlende Angabe", () => {
// Arrange
const zero = makeProduct({ weight: 0 });
const negative = makeProduct({ weight: -500 });
// Act & Assert
expect(deriveWeightGrams(zero)).toBe(0);
expect(deriveWeightGrams(negative)).toBe(0);
});
});
describe("slugifyProductName", () => {
test("macht aus einem Namen eine adresstaugliche Form", () => {
expect(slugifyProductName("Waldhonig")).toBe("waldhonig");
expect(slugifyProductName("Waldhonig, cremig")).toBe("waldhonig-cremig");
});
test("schreibt Umlaute aus, statt sie zu verschlucken", () => {
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
// "Bluetenhonig" ein "bl-tenhonig".
expect(slugifyProductName("Blütenhonig")).toBe("bluetenhonig");
expect(slugifyProductName("Süßer Honig")).toBe("suesser-honig");
});
});
describe("productSlugCandidates", () => {
test("stellt den Jahrgang voran, weil er die Jahrgaenge unterscheidet", () => {
// Arrange & Act
const candidates = productSlugCandidates("Waldhonig", 2026, "FH-26-01");
// Assert: erste Wahl ist die lesbare Adresse mit Jahreszahl.
expect(candidates[0]).toBe("waldhonig-2026");
});
test("haelt die Losnummer als Ausweichadresse bereit", () => {
// Arrange & Act & Assert: deckt den Fall ab, dass derselbe Artikel im
// selben Jahr mehrfach abgefuellt wurde.
expect(productSlugCandidates("Waldhonig", 2026, "FH-26-02")).toEqual([
"waldhonig-2026",
"waldhonig-2026-fh-26-02",
]);
});
test("zwei Jahrgaenge desselben Namens kollidieren nicht", () => {
// Arrange & Act
const first = productSlugCandidates("Waldhonig", 2026, "FH-26-01")[0];
const second = productSlugCandidates("Waldhonig", 2027, "FH-27-01")[0];
// Assert: genau das ist der Grund fuer die Jahreszahl — Product.slug ist
// eindeutig, und ohne sie waere der zweite Jahrgang nicht anlegbar.
expect(first).not.toBe(second);
});
test("faellt bei einem Namen ohne verwertbare Zeichen nicht auf eine leere Adresse zurueck", () => {
// Arrange & Act & Assert: ein leerer Slug waere eine ungueltige URL.
expect(productSlugCandidates("***", 2026, "")).toEqual(["produkt-2026"]);
});
});
describe("deriveJarWeightGrams", () => {
test("nimmt die Glasgroesse der Abfuellung", () => {
// Arrange
const source = makeProduct({ weight: 250 });
// Act & Assert: die Abfuellung sagt, was tatsaechlich in dieses Glas kam
// — der Artikel nur, was ueblicherweise vorgesehen ist.
expect(deriveJarWeightGrams(500, source)).toBe(500);
});
test("faellt auf die Angaben am Artikel zurueck, wenn die Abfuellung keine Groesse fuehrt", () => {
// Arrange
const source = makeProduct({ weight: 250 });
// Act & Assert
expect(deriveJarWeightGrams(null, source)).toBe(250);
expect(deriveJarWeightGrams(0, source)).toBe(250);
});
test("gibt 0 zurueck, wenn weder Abfuellung noch Artikel eine Menge kennen", () => {
expect(deriveJarWeightGrams(null, makeProduct())).toBe(0);
});
});
describe("buildProductDescription mit Jahrgang", () => {
test("nennt den Jahrgang zuerst", () => {
// Arrange
const source = makeProduct({ description: "Kräftig.", sku: "WH-500" });
// Act
const description = buildProductDescription(source, 2026);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Jahrgang 2026 · Artikelnummer WH-500). Kräftig."
);
});
});
describe("isSellableStatus", () => {
test("erkennt ACTIVE als verkaufsfaehig", () => {
expect(isSellableStatus("ACTIVE")).toBe(true);
});
test("behandelt DISCONTINUED und unbekannte Status als nicht verkaufsfaehig", () => {
// Arrange & Act & Assert: ein kuenftiger, hier noch unbekannter
// ApiCore-Status darf nicht versehentlich als Shop-Entwurf durchrutschen.
expect(isSellableStatus("DISCONTINUED")).toBe(false);
expect(isSellableStatus("SEASONAL")).toBe(false);
expect(isSellableStatus("")).toBe(false);
});
});
+133
View File
@@ -0,0 +1,133 @@
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
/**
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
* admin/actions/apicore-sync.ts: jene Datei traegt "use server" und darf
* deshalb ausschliesslich async Server Actions exportieren — dort waeren
* genau die Entscheidungen, die man pruefen moechte (Was wird aus einem
* Produkt ohne Beschreibung? Was aus einem fehlenden Gewicht?), nicht
* einzeln testbar.
*/
/**
* Stabiler, eindeutiger Slug aus der ApiCore-ID — zugleich der
* Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob
* ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf
* Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den
* manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen
* neuen Slug und wuerde ein zweites Mal importiert.
*/
export function slugForApiCoreSource(sourceId: string): string {
return `apicore-${sourceId}`;
}
/**
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
*/
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
if (source.notes?.trim()) parts.push(source.notes.trim());
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
return `Automatisch aus ApiCore importiert. ${detail}`;
}
/**
* Wabenhains Product.description ist ein Pflichtfeld und steht im Shop als
* Verkaufstext. ApiCores Beschreibung ist dagegen eine interne Notiz und oft
* leer.
*
* Sie wird deshalb zwar uebernommen, aber immer mit einem Herkunftshinweis
* versehen: der Text bleibt als Entwurf erkennbar, solange ihn niemand
* ersetzt hat. Ist gar nichts da, tritt an seine Stelle eine ausdrueckliche
* Aufforderung statt eines leeren Feldes — ein leerer Verkaufstext waere im
* Shop nicht von einem vergessenen zu unterscheiden.
*/
export function buildProductDescription(source: ApiCoreProduct, vintageYear?: number): string {
const own = source.description?.trim();
const facts: string[] = [];
if (vintageYear != null) facts.push(`Jahrgang ${vintageYear}`);
if (source.sku?.trim()) facts.push(`Artikelnummer ${source.sku.trim()}`);
if (source.salesUnit?.trim()) facts.push(`Verkaufseinheit ${source.salesUnit.trim()}`);
const detail = own || "Bitte Produktbeschreibung für den Shop ergänzen.";
const suffix = facts.length > 0 ? ` (${facts.join(" · ")})` : "";
return `Automatisch aus ApiCore importiert${suffix}. ${detail}`;
}
/**
* Gewicht in Gramm. Bevorzugt ApiCores `weight` (Gewicht des Artikels),
* ersatzweise `packagingSize` (Gebindegroesse) — zur Einheiten-Annahme
* "Gramm" siehe den Kommentar an productSchema in apicore-client.ts.
*
* Fehlt beides, wird bewusst 0 zurueckgegeben statt geraten. 0 ist in
* Wabenhain kein gueltiges Gewicht (adminProductNumbersSchema verlangt
* mindestens 1 g), das importierte Produkt laesst sich also gar nicht
* abspeichern, ohne dass jemand ein echtes Gewicht eintraegt — genau die
* gewuenschte Bremse, denn aus dem Gewicht ergibt sich der Grundpreis je
* Kilogramm, der in Deutschland verpflichtend anzugeben ist.
*/
export function deriveWeightGrams(source: ApiCoreProduct): number {
const raw = source.weight ?? source.packagingSize;
if (raw == null || !Number.isFinite(raw) || raw <= 0) return 0;
return Math.round(raw);
}
/**
* Gewicht eines Glases aus dieser Abfuellung.
*
* Bevorzugt `jarSize` der Abfuellung gegenueber den Angaben am
* ApiCore-Artikel: die Abfuellung sagt, was TATSAECHLICH in dieses Glas
* gefuellt wurde, der Artikel nur, was ueblicherweise vorgesehen ist. Wird
* ein Jahrgang einmal in 250-g- statt 500-g-Glaeser gefuellt, steht das an
* der Abfuellung und nirgends sonst.
*
* Zur Einheit "Gramm" siehe den Kommentar an productSchema in
* apicore-client.ts: ApiCore fuehrt jarSize mit Werten wie 500 und gibt sie
* in der eigenen Oberflaeche als "g" aus.
*/
export function deriveJarWeightGrams(
jarSize: number | null | undefined,
source: ApiCoreProduct
): number {
if (jarSize != null && Number.isFinite(jarSize) && jarSize > 0) return Math.round(jarSize);
return deriveWeightGrams(source);
}
/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
export function slugifyProductName(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Moegliche oeffentliche Adressen fuer ein synchronisiertes Produkt, in
* absteigender Wuenschbarkeit — der Sync nimmt die erste noch freie.
*
* Der Jahrgang gehoert in die Adresse, weil genau er die Produkte
* unterscheidet: "Waldhonig" heisst jedes Jahr gleich, ist aber jedes Jahr
* ein anderes Erzeugnis mit eigenem Preis und eigenen Bewertungen. Ohne
* Jahreszahl kollidierte der zweite Jahrgang mit dem ersten (Product.slug ist
* eindeutig).
*
* Die Losnummer als zweite Stufe deckt den selteneren Fall ab, dass ein
* Artikel im selben Jahr mehrfach abgefuellt wurde. Faellt auch die weg,
* entscheidet der Aufrufer — hier wird bewusst nicht hochgezaehlt: eine
* Adresse wie "waldhonig-2026-2" sagt niemandem etwas.
*/
export function productSlugCandidates(name: string, year: number, lotNumber: string): string[] {
const base = slugifyProductName(name) || "produkt";
const withYear = `${base}-${year}`;
const lot = slugifyProductName(lotNumber);
return lot ? [withYear, `${withYear}-${lot}`] : [withYear];
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect } from "vitest";
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
return {
year: 2026,
lastName: "Mustermann",
firstName: "Erika",
invoiceNumber: "RE-2026-A3F9C1B2",
...overrides,
};
}
describe("invoiceRelativePath", () => {
test("sortiert nach Jahr, dann Kunde, dann Rechnungsnummer", () => {
// Arrange & Act
const relative = invoiceRelativePath(makeInput());
// Assert
expect(relative).toBe("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
});
test("schreibt Umlaute im Kundenordner aus", () => {
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
// "Mueller" ein "m-ller" und damit unleserliche Ordner.
expect(invoiceRelativePath(makeInput({ lastName: "Müller", firstName: "Jörg" }))).toBe(
"2026/mueller-joerg/RE-2026-A3F9C1B2.pdf"
);
});
test("legt Rechnungen derselben Person im selben Ordner ab", () => {
// Arrange
const first = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0001" }));
const second = invoiceRelativePath(makeInput({ invoiceNumber: "RE-2026-0002" }));
// Act & Assert: genau das ist der Zweck der Ablage — alle Belege einer
// Person eines Jahres beieinander.
expect(first.split("/").slice(0, 2)).toEqual(second.split("/").slice(0, 2));
expect(first).not.toBe(second);
});
test("trennt Jahrgaenge voneinander", () => {
// Arrange & Act
const older = invoiceRelativePath(makeInput({ year: 2025 }));
const newer = invoiceRelativePath(makeInput({ year: 2026 }));
// Assert: die Aufbewahrungsfrist laeuft je Jahr ab, ein Jahrgang muss
// sich als ganzes Verzeichnis archivieren lassen.
expect(older.startsWith("2025/")).toBe(true);
expect(newer.startsWith("2026/")).toBe(true);
});
test("faellt auf 'unbekannt' zurueck, statt ein leeres Pfadsegment zu erzeugen", () => {
// Arrange & Act & Assert: "2026//RE-....pdf" waere ein kaputter Pfad.
expect(invoiceRelativePath(makeInput({ lastName: "***", firstName: "###" }))).toBe(
"2026/unbekannt/RE-2026-A3F9C1B2.pdf"
);
});
});
describe("resolveInvoicePath", () => {
test("loest innerhalb des Ablageverzeichnisses auf", () => {
// Arrange & Act
const resolved = resolveInvoicePath("2026/mustermann-erika/RE-2026-A3F9C1B2.pdf");
// Assert
expect(resolved.startsWith(INVOICE_DIR)).toBe(true);
});
test("weist Pfade ab, die das Verzeichnis verlassen", () => {
// Arrange & Act & Assert: der Pfad kommt zwar aus der eigenen Datenbank,
// ist aber irgendwann einmal aus Eingaben entstanden — "../" darf nie
// hinausfuehren.
expect(() => resolveInvoicePath("../../etc/passwd")).toThrow();
expect(() => resolveInvoicePath("2026/../../../geheim.pdf")).toThrow();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Ablage der erzeugten Rechnungs-PDFs.
*
* BEWUSST NICHT unter UPLOAD_DIR: app/uploads/[...path]/route.ts liefert
* JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Eine Rechnung dort
* abzulegen hiesse, dass jede Person, die den Pfad erraet, fremde Rechnungen
* samt Anschrift und Bestellinhalt herunterladen kann. Rechnungen bekommen
* deshalb ein eigenes Verzeichnis, auf das keine oeffentliche Route zeigt.
*
* Warum ueberhaupt als Datei und nicht bei jedem Abruf neu erzeugt: eine
* Rechnung ist ein Beleg. Wuerde sie jedes Mal neu gerendert, saehe dieselbe
* Rechnungsnummer nach einer Aenderung an Firmendaten oder am Layout anders
* aus als das, was die Kundschaft per E-Mail bekommen hat. Abgelegt wird
* deshalb genau das Dokument, das auch versendet wurde.
*
* Verzeichnis ueber INVOICE_DIR einstellbar; in Kubernetes zeigt das auf ein
* eigenes, gesichertes Volume (siehe k8s/04-uploads-pvc.yaml und den
* Sicherungs-CronJob 07-backup-cronjob.yaml).
*/
export const INVOICE_DIR = path.resolve(
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
);
/**
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
* wuerde aus "Mueller" ein "m-ller".
*/
function slugifyNamePart(value: string): string {
return value
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export interface InvoicePathInput {
/** Kalenderjahr der Rechnung — die oberste Ebene der Ablage. */
year: number;
lastName: string;
firstName: string;
invoiceNumber: string;
}
/**
* Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert:
*
* 2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
*
* Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein
* abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren.
*
* Bekannte Grenze: zwei verschiedene Personen mit identischem Namen teilen
* sich einen Ordner. Das ist Absicht — ein angehaengtes technisches Kuerzel
* machte die Ablage fuer Menschen schlechter durchsuchbar, und die
* Rechnungsnummer im Dateinamen bleibt ohnehin eindeutig.
*
* Faellt ein Name vollstaendig weg (nur Sonderzeichen), tritt "unbekannt" an
* seine Stelle, damit nie ein leeres Pfadsegment entsteht.
*/
export function invoiceRelativePath(input: InvoicePathInput): string {
const customer =
[slugifyNamePart(input.lastName), slugifyNamePart(input.firstName)].filter(Boolean).join("-") ||
"unbekannt";
return path.posix.join(String(input.year), customer, `${input.invoiceNumber}.pdf`);
}
/**
* Loest einen relativen Rechnungspfad gegen INVOICE_DIR auf und stellt
* sicher, dass er das Verzeichnis nicht verlaesst.
*
* Dieselbe Absicherung wie bei den Uploads (lib/uploads.ts): der relative
* Pfad stammt zwar aus der eigenen Datenbank, aber ein Pfad aus der Datenbank
* ist kein Beweis fuer einen harmlosen Pfad — er ist irgendwann einmal aus
* Eingaben entstanden. "../" darf hier nie hinausfuehren.
*/
export function resolveInvoicePath(relativePath: string): string {
const resolved = path.resolve(/*turbopackIgnore: true*/ INVOICE_DIR, relativePath);
const withSep = INVOICE_DIR.endsWith(path.sep) ? INVOICE_DIR : INVOICE_DIR + path.sep;
if (!resolved.startsWith(withSep)) {
throw new Error(`Rechnungspfad liegt ausserhalb von INVOICE_DIR: ${relativePath}`);
}
return resolved;
}
/** Legt das PDF ab und gibt den relativen Pfad zurueck, unter dem es liegt. */
export async function storeInvoicePdf(relativePath: string, pdf: Buffer): Promise<string> {
const target = resolveInvoicePath(relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, pdf);
return relativePath;
}
/**
* Liest ein abgelegtes PDF, oder null, wenn es (noch) keines gibt.
*
* Null ist ein regulaerer Fall, kein Fehler: Rechnungen aus der Zeit vor
* dieser Ablage haben keine Datei, und der Abruf erzeugt sie dann eben neu.
*/
export async function readStoredInvoicePdf(relativePath: string): Promise<Buffer | null> {
try {
return await readFile(resolveInvoicePath(relativePath));
} catch {
return null;
}
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, test, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/**
* Haelt die Kubernetes-Manifeste und den Code in Deckung.
*
* Die Manifeste unter k8s/ sind ausdruecklich dafuer gedacht, mit moeglichst
* wenig Anpassung als Produktivkonfiguration uebernommen zu werden. Das
* traegt nur, solange sie VOLLSTAENDIG sind. Ohne diese Pruefung faellt eine
* im Code neu hinzugekommene Umgebungsvariable erst im Betrieb auf, und zwar
* als stiller Rueckfall auf einen Standardwert statt als Fehler: zur Laufzeit
* ist eine fehlende Variable nicht von einer absichtlich leeren zu
* unterscheiden.
*
* Bewusst KEIN YAML-Parser: das Projekt hat keinen als Abhaengigkeit, und
* fuer diesen Zweck genuegt es, die Schluesselzeilen der flachen
* data:/stringData:-Bloecke zu lesen. Die Manifeste sind von Hand gepflegt
* und halten dieses einfache Format ein.
*/
const REPO_ROOT = process.cwd();
const CONFIGMAP = path.join(REPO_ROOT, "k8s", "01-configmap.yaml");
const SECRET = path.join(REPO_ROOT, "k8s", "02-secret.example.yaml");
const SRC = path.join(REPO_ROOT, "src");
/**
* Schluessel eines Manifests: genau zwei Leerzeichen eingerueckt, beginnend
* mit einem Grossbuchstaben. Kommentarzeilen (" # ...") und Metadatenfelder
* (" name: ...") fallen dadurch heraus, ohne Sonderfaelle.
*/
function manifestKeys(file: string): string[] {
return readFileSync(file, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^ {2}([A-Z][A-Z0-9_]*):/.exec(line);
return match ? [match[1]] : [];
});
}
/** Alle process.env-Zugriffe unterhalb von src/ — das ist die Laufzeit der App. */
function envNamesUsedInCode(dir: string): Set<string> {
const found = new Set<string>();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
for (const name of envNamesUsedInCode(full)) found.add(name);
continue;
}
if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith(".test.ts")) continue;
for (const match of readFileSync(full, "utf8").matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) {
found.add(match[1]);
}
}
return found;
}
/**
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
* liest — sonst sammelt sich Konfiguration an, die nichts mehr tut.
*/
const USED_OUTSIDE_THE_APP: Record<string, string> = {
DB_PASSWORD:
"Datenbank-StatefulSet (03-postgres.yaml/03-mysql.yaml) und Sicherung (07-backup-cronjob.yaml); die App kennt nur DATABASE_URL.",
ADMIN_EMAIL: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
ADMIN_PASSWORD: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
};
describe("Kubernetes-Manifeste und Code", () => {
test("die Manifeste liefern jede Variable, die die App liest", () => {
// Arrange
const provided = new Set([...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]);
// Act
const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name)).sort();
// Assert
expect(missing, `In k8s/ fehlen: ${missing.join(", ")}`).toEqual([]);
});
test("kein Schluessel steht doppelt in ConfigMap und Secret", () => {
// Arrange
const config = manifestKeys(CONFIGMAP);
const secret = new Set(manifestKeys(SECRET));
// Act
const inBoth = config.filter((name) => secret.has(name)).sort();
// Assert: stuenden sie in beiden, entschiede allein die Reihenfolge der
// envFrom-Eintraege, welcher Wert gewinnt — eine Falle beim Suchen.
expect(inBoth, `Doppelt vergeben: ${inBoth.join(", ")}`).toEqual([]);
});
test("kein Schluessel in den Manifesten, den niemand liest", () => {
// Arrange
const used = envNamesUsedInCode(SRC);
// Act
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP))
.sort();
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
// als liesse sich damit etwas einstellen.
expect(unused, `Wird nirgends gelesen: ${unused.join(", ")}`).toEqual([]);
});
test("die Secret-Vorlage enthaelt keine ausgefuellten Werte", () => {
// Arrange
const secret = readFileSync(SECRET, "utf8");
// Act
const filledIn = secret
.split("\n")
.flatMap((line) => {
const match = /^ {2}([A-Z][A-Z0-9_]*): "(.*)"$/.exec(line);
if (!match) return [];
const [, key, value] = match;
const isPlaceholder = value === "" || value.includes("CHANGE-ME");
return isPlaceholder ? [] : [key];
})
.sort();
// Assert: die Vorlage wird committet — ein echter Wert waere damit im
// Repository. Platzhalter sind leer oder tragen "CHANGE-ME".
expect(filledIn, `Sieht nach echten Werten aus: ${filledIn.join(", ")}`).toEqual([]);
});
});
+21 -1
View File
@@ -1,10 +1,23 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
export interface MailAttachment {
filename: string;
content: Buffer;
contentType: string;
}
interface SendMailInput { interface SendMailInput {
to: string; to: string;
subject: string; subject: string;
html: string; html: string;
text: string; text: string;
/**
* Optionale Anhaenge, aktuell die Rechnung zur Bestellbestaetigung.
* Bewusst als Buffer und nicht als Pfad: der Aufrufer hat das Dokument
* ohnehin schon im Speicher, und so haengt der Mailversand nicht daran,
* dass das Ablageverzeichnis gerade erreichbar ist.
*/
attachments?: MailAttachment[];
} }
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */ /** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
@@ -66,7 +79,14 @@ export async function sendMail(input: SendMailInput): Promise<void> {
} }
try { try {
await transport.sendMail({ from, to: input.to, subject: input.subject, html: input.html, text: input.text }); await transport.sendMail({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
...(input.attachments?.length ? { attachments: input.attachments } : {}),
});
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
+86
View File
@@ -0,0 +1,86 @@
import { describe, test, expect } from "vitest";
import { buildFfmpegArgs, transcodeThreadCount, MAX_TRANSCODE_THREADS } from "./video-transcode";
/** Alle Werte, die im Argument-Array direkt auf das gesuchte Flag folgen. */
function valuesAfter(args: string[], flag: string): string[] {
return args.flatMap((arg, index) => (arg === flag ? [args[index + 1]] : []));
}
describe("transcodeThreadCount", () => {
test("nutzt die Vorgabe, wenn nichts konfiguriert ist", () => {
// Arrange & Act
const threads = transcodeThreadCount(undefined);
// Assert: eine feste, kleine Zahl — bewusst NICHT die Kernzahl des
// Knotens, siehe Begruendung in video-transcode.ts.
expect(threads).toBe(2);
});
test("uebernimmt einen gueltigen konfigurierten Wert", () => {
expect(transcodeThreadCount("4")).toBe(4);
});
test("faellt bei unsinnigen Werten auf die Vorgabe zurueck", () => {
// Arrange & Act & Assert: eine kaputte Env-Var darf den Upload nicht in
// einen unbegrenzten ffmpeg-Lauf kippen lassen.
expect(transcodeThreadCount("")).toBe(2);
expect(transcodeThreadCount("0")).toBe(2);
expect(transcodeThreadCount("-3")).toBe(2);
expect(transcodeThreadCount("2.5")).toBe(2);
expect(transcodeThreadCount("viele")).toBe(2);
});
test("deckelt nach oben, damit die Obergrenze nicht umgangen werden kann", () => {
expect(transcodeThreadCount("999")).toBe(MAX_TRANSCODE_THREADS);
});
});
describe("buildFfmpegArgs", () => {
test("begrenzt Dekoder-, Filter- und Encoder-Threads auf denselben Wert", () => {
// Arrange
const threads = 2;
// Act
const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", threads);
// Assert: ohne diese Deckel waehlt x264 seine Threadzahl nach den Kernen
// des KNOTENS, nicht nach dem CPU-Anteil des Pods — genau das sprengte
// das Speicherlimit und blockierte die Liveness-Probe.
expect(valuesAfter(args, "-threads")).toEqual(["2", "2"]);
expect(valuesAfter(args, "-filter_threads")).toEqual(["2"]);
});
test("setzt den Dekoder-Deckel vor die Eingabedatei", () => {
// Arrange & Act
const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2);
// Assert: ffmpeg ordnet Optionen der NAECHSTEN Datei zu. Ein "-threads"
// erst nach "-i" gilt nur noch dem Encoder — das Dekodieren eines
// 4K-Videos bliebe unbegrenzt.
const firstThreads = args.indexOf("-threads");
const input = args.indexOf("-i");
expect(firstThreads).toBeGreaterThanOrEqual(0);
expect(firstThreads).toBeLessThan(input);
});
test("laesst ffmpeg nie auf die Standardeingabe warten", () => {
// Arrange & Act & Assert: der Kindprozess bekommt eine Pipe als stdin,
// die niemand bedient — ohne -nostdin kann ffmpeg dort auf
// Tastatureingaben warten und der Upload haengt bis zum Zeitlimit.
expect(buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2)).toContain("-nostdin");
});
test("behaelt die web-optimierenden Einstellungen bei", () => {
// Arrange & Act
const args = buildFfmpegArgs("/tmp/in.mp4", "/tmp/out.mp4", 2);
// Assert: Tonspur weg, auf 1920 begrenzt, faststart — die Gruende stehen
// am Modulkommentar in video-transcode.ts.
expect(args).toContain("-an");
expect(valuesAfter(args, "-vf")).toEqual(["scale='min(1920,iw)':-2"]);
expect(valuesAfter(args, "-movflags")).toEqual(["+faststart"]);
expect(valuesAfter(args, "-crf")).toEqual(["26"]);
expect(valuesAfter(args, "-i")).toEqual(["/tmp/in.mp4"]);
expect(args[args.length - 1]).toBe("/tmp/out.mp4");
});
});
+105 -19
View File
@@ -43,8 +43,102 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
* Reverse-Proxy). * Reverse-Proxy).
*/ */
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> { export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
const args = [ const args = buildFfmpegArgs(
inputPath,
outputPath,
transcodeThreadCount(process.env.VIDEO_TRANSCODE_THREADS)
);
try {
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
await runFfmpeg("ffmpeg", args);
} catch (error: unknown) {
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
// ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
// tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
// durchschlagen, nicht stillschweigend erneut versucht werden.
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
if (!isMissingBinary || !ffmpegStaticPath) throw error;
await runFfmpeg(ffmpegStaticPath, args);
}
}
/**
* Hoechstzahl an Threads, die sich ueber VIDEO_TRANSCODE_THREADS einstellen
* laesst. Der Deckel ist Absicht: die Variable soll den Transcode auf einem
* groesseren Knoten beschleunigen koennen, aber nicht die Begrenzung
* aushebeln, um die es hier ueberhaupt geht.
*/
export const MAX_TRANSCODE_THREADS = 8;
/**
* Vorgabe, wenn nichts konfiguriert ist. Zwei Threads lassen auf jedem
* realistischen Knoten genug Rechenzeit fuer den Node-Prozess uebrig, damit
* der die Liveness-Probe weiter bedienen kann.
*/
const DEFAULT_TRANSCODE_THREADS = 2;
/**
* Liest die gewuenschte Threadzahl aus der Umgebung und faellt bei allem,
* was keine sinnvolle ganze Zahl ist, auf die Vorgabe zurueck.
*
* Bewusst KEINE Ableitung aus os.cpus(): in einem Container meldet das die
* Kerne des KNOTENS, nicht den CPU-Anteil des Pods. Genau diese Verwechslung
* ist die Ursache des Absturzes, den diese Funktion verhindert — sie darf
* nicht durch die Hintertuer zurueckkommen.
*/
export function transcodeThreadCount(raw: string | undefined): number {
const parsed = Number(raw);
if (!raw || !Number.isInteger(parsed) || parsed < 1) return DEFAULT_TRANSCODE_THREADS;
return Math.min(parsed, MAX_TRANSCODE_THREADS);
}
/**
* Baut die ffmpeg-Argumentliste. Ausgelagert und exportiert, damit die
* Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten.
*
* Die drei Thread-Deckel sind der eigentliche Fix gegen den Pod-Absturz beim
* Video-Upload:
*
* x264 waehlt seine Threadzahl sonst als das 1,5-fache der Kerne, die das
* Betriebssystem meldet — in Kubernetes sind das die Kerne des KNOTENS, nicht
* der CPU-Anteil des Pods. Auf einem 16-Kern-Knoten sind das rund 24
* Encoder-Threads mit je eigenen Bildpuffern; bei 4K-Material ist ein
* einzelnes Bild rund 12 MB gross. Zwei Folgen, beide toedlich:
*
* 1. Der Speicherbedarf sprengt das Speicherlimit des Pods, der Kernel
* beendet den Container (OOMKilled) — mitten im Upload.
* 2. ffmpeg belegt alle Kerne, der Node-Prozess kommt nicht mehr zum Zug und
* beantwortet die Liveness-Probe nicht rechtzeitig. Kubelet wertet das als
* haengenden Prozess und startet den Container neu.
*
* Beides sieht von aussen gleich aus: "der Server stuerzt beim Video-Upload
* ab". Mit gedeckelten Threads bleibt der Verbrauch konstant und unabhaengig
* davon, auf welchem Knoten der Pod gerade laeuft.
*
* "-threads" muss VOR "-i" stehen, um auch das Dekodieren zu begrenzen:
* ffmpeg ordnet Optionen jeweils der naechsten Datei zu. Ein "-threads" erst
* hinter "-i" gilt nur dem Encoder, und das Dekodieren eines 4K-Videos
* bliebe unbegrenzt.
*
* "-nostdin" verhindert, dass ffmpeg auf Tastatureingaben wartet: der
* Kindprozess bekommt von spawn() eine Pipe als Standardeingabe, die niemand
* bedient.
*/
export function buildFfmpegArgs(inputPath: string, outputPath: string, threads: number): string[] {
const threadCount = String(threads);
return [
"-y", "-y",
"-nostdin",
// Dekoder- und Filterbegrenzung, siehe Kommentar oben: muss vor "-i" stehen.
"-threads",
threadCount,
"-filter_threads",
threadCount,
"-i", "-i",
inputPath, inputPath,
"-an", "-an",
@@ -52,6 +146,10 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string
"scale='min(1920,iw)':-2", "scale='min(1920,iw)':-2",
"-c:v", "-c:v",
"libx264", "libx264",
// Encoder-Begrenzung (x264). Ohne sie bliebe der Encoder unbegrenzt,
// auch wenn das Dekodieren oben schon gedeckelt ist.
"-threads",
threadCount,
// "veryfast" statt "medium": x264-Presets tauschen Encodier-GESCHWINDIGKEIT // "veryfast" statt "medium": x264-Presets tauschen Encodier-GESCHWINDIGKEIT
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das // gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit // steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
@@ -75,28 +173,16 @@ export async function transcodeVideoForWeb(inputPath: string, outputPath: string
"+faststart", "+faststart",
outputPath, outputPath,
]; ];
try {
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
await runFfmpeg("ffmpeg", args);
} catch (error: unknown) {
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
// ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
// tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
// durchschlagen, nicht stillschweigend erneut versucht werden.
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
if (!isMissingBinary || !ffmpegStaticPath) throw error;
await runFfmpeg(ffmpegStaticPath, args);
}
} }
function runFfmpeg(binary: string, args: string[]): Promise<void> { function runFfmpeg(binary: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(binary, args); // stdin bewusst "ignore": ffmpeg soll dort nie auf eine Eingabe warten
// (zusaetzlich zu -nostdin). stdout ebenfalls "ignore" — die Ausgabe geht
// in eine Datei, ffmpeg schreibt dorthin nichts; eine unbeachtete Pipe
// koennte dagegen volllaufen und den Prozess blockieren. Nur stderr wird
// gelesen, dort steht die Diagnose.
const proc = spawn(binary, args, { stdio: ["ignore", "ignore", "pipe"] });
let stderr = ""; let stderr = "";
const timer = setTimeout(() => { const timer = setTimeout(() => {
proc.kill("SIGKILL"); proc.kill("SIGKILL");