Compare commits

..
182 Commits
Author SHA1 Message Date
Nick.RosenloecherandClaude Opus 5.5 68fe297da6 fix: Ernte-Abos reservieren das naechste Fenster nach laufenden Abos
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m10s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m38s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m14s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 12m1s
Bedarf je Erntefenster = max(geplante Glaeser, laufende Abos x Glaeser),
und nur das naechste Fenster je Angebot. Vorher hielten alle kuenftigen
Fenster ihre geplante Menge sofort fest, und ohne Planung blieben neue
Ernte-Abos unreserviert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:25:18 +02:00
Nick.RosenloecherandClaude Opus 5.5 c4b0212004 chore(k8s): Wabenhain auf Build sha-1d88bac (automatische Honigwahl)
Docker Image bauen und veroeffentlichen / lint (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 3m27s
Docker Image bauen und veroeffentlichen / typecheck (push) Canceled after 3m38s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:07:40 +02:00
Nick.RosenloecherandClaude Opus 5.5 1d88bac52e feat: Patenschafts-Honig automatisch waehlen (nie cremig, neueste Ernte)
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m18s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m32s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m41s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 20m38s
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven,
nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei
gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung
im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:35:04 +02:00
Nick.RosenloecherandClaude Opus 5.5 3ddf0bfa73 chore: kurze Namen fuer die Abo-Angebote im Einrichtungsskript
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m26s
Docker Image bauen und veroeffentlichen / test (push) Successful in 5m17s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 4m13s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 8m55s
Live angelegt am 2026-10-16 (inaktiv, Stripe-Preise price_1URDJE..., price_1URDJF...).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:46:04 +02:00
Nick.RosenloecherandClaude Opus 5.5 f786601309 chore(k8s): Wabenhain auf Build sha-ed17d14 (Bienenpatenschaft), Einrichtungsskript
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m21s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m27s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m19s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 7m44s
Ausgerollt am 2026-10-16, Migrationen und Seed beim Start gelaufen.
scripts/sponsorship-bootstrap.ts verknuepft den Paket-Honig und legt die
Abo-Angebote inaktiv samt Stripe-Preis an.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:26:30 +02:00
Nick.RosenloecherandClaude Opus 5.5 e3b0eb7b05 chore(stripe): Livekonto fuer das Patenschafts-Abo eingerichtet
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m28s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m37s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m27s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 9m3s
Webhook um Abo-Ereignisse ergaenzt, Kundenportal nur fuer die Zahlungsart
angelegt (ID in der ConfigMap), Zahlungsmethoden-Domains registriert.
Dashboard-Schritte ohne API in EINRICHTUNG.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:43:30 +02:00
Nick.RosenloecherandClaude Opus 5.5 ed17d14e46 fix: CI-Zeitgrenze fuer Urkundentests, Cookie-Banner mobil, Hydration im Warenkorb-Effekt
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 3m4s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m13s
Docker Image bauen und veroeffentlichen / test (push) Successful in 4m4s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 13m1s
Die Layout-Matrix der Urkunde lief auf dem CI-Runner in die 5-Sekunden-
Grenze; Tests bekommen 30 s. Die Cookie-Knoepfe stapeln sich unter 430 px,
statt ineinanderzulaufen. Der Tropfen im Warenkorb-Effekt wird immer
gerendert und nur bei reduzierter Bewegung nicht animiert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:06:33 +02:00
Nick SzaunigandClaude Opus 5.5 310c5ecf8c fix: Kuendigungslimits zaehlen nur Fehlversuche, kuerzere Kontingent-Reservierung
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 3m18s
Docker Image bauen und veroeffentlichen / test (push) Failing after 4m28s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m15s
Docker Image bauen und veroeffentlichen / build-and-push (push) Skipped
Passende Kuendigungen werden nicht mehr von fremden Fehlversuchen
ausgesperrt. Offene Abo-Abschluesse blockieren einen Platz nur noch 35
Minuten, die Stripe-Sitzung laeuft nach 32 Minuten ab.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:13:48 +02:00
Nick SzaunigandClaude Opus 5.5 515fca1c16 fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:09:32 +02:00
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00
Nick SzaunigandClaude Opus 5.5 dcb578f062 chore(deps): fontkit, three, react-three und fflate fuer die Bienenpatenschaft
Feste, mindestens zwei Wochen alte Versionen. fontkit misst die Schriften
der Urkunde, three/@react-three rendern die 3D-Ansicht im Konto, fflate
packt den Download aller Dateien.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:55:58 +02:00
Nick SzaunigandClaude Opus 5.5 db50bcf381 docs: Ist-Stand des Stripe-Livekontos in der Patenschafts-Spec
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m16s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 1m56s
Docker Image bauen und veroeffentlichen / test (push) Successful in 2m16s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 7m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:13:12 +02:00
Nick SzaunigandClaude Opus 5.5 86f2145115 docs: Bienenpatenschaft mit SEPA-Schutz, Stripe-Einrichtung und Abo-Stufen je Monat
Docker Image bauen und veroeffentlichen / lint (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / typecheck (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Abo-Stufen 1 bis 24 je bezahltem Monat, danach VIP. SEPA fuer Abos mit
Neukunden-Frist, Verzug und Sperre nach Rueckbuchung. Stripe-Einrichtung
erst im Testmodus per Skript, Livemodus nur nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:04:27 +02:00
Nick SzaunigandClaude Opus 5.5 4bcf90a8d9 docs: Bienenpatenschaft um Abo, Kontopflicht, 3D-Ansicht, Medaillen und Uebertragung erweitern
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m0s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 1m48s
Docker Image bauen und veroeffentlichen / test (push) Successful in 2m8s
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 17m57s
Teilt den Umfang in fuenf Teilprojekte mit eigener Planung. Abo mit
Lieferung erst nach bezahlten Monaten, Honigkontingent und
Reservierung, Kuendigungsbutton; Medaillen je Patenschaft, Stammkunden-
und Abo-Stufen bis VIP; befristete Uebertragungslinks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:03:17 +02:00
Nick SzaunigandClaude Opus 5.5 96c101804d docs: README, k8s/README und EINRICHTUNG auf Stand main (2026-10-16)
Docker Image bauen und veroeffentlichen / lint (push) Successful in 1m41s
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 1m42s
Docker Image bauen und veroeffentlichen / test (push) Successful in 2m40s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 8m21s
README: Abschnitt Projektstand (Issues #34, #33), veraltete Aussagen zu
Platzhaltern und Rechnungsnummern korrigiert, CI-Gate, Rollout per set
image, Client-IP hinter Cloudflare, VAT_RATE_PERCENT_SONSTIGES, Teststand.
k8s/README: Image-Pinning, Abweichungen Repo/Cluster, Sicherung auf der
Storage Box, SMB 3.1.1 mit seal. EINRICHTUNG: APICORE_WEBHOOK_SECRET,
ERP_EXPORT_TOKEN, Abschnitt Neuen Build ausrollen. .env.example:
DATABASE_POOL_SIZE.

Refs #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 08:01:07 +02:00
Nick SzaunigandClaude Opus 5.5 24df111495 docs: Spezifikation Bienenpatenschaft mit automatischer Urkunde
Docker Image bauen und veroeffentlichen / lint (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / typecheck (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Ersetzt den bisherigen Implementierungsauftrag nach den verbindlichen
Vorgaben vom 2026-10-16: drei Pakete, keine Besuche oder laufenden
Inhalte, Urkunde als deterministisch erzeugte PDF mit Vorschau aus
derselben Layoutberechnung, idempotente Erzeugung nach Zahlung,
Revisionen und Druckablauf im Admin.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 07:45:54 +02:00
Nick SzaunigandClaude Opus 5.5 35e7c84767 fix(ci): typecheck erzeugt die Next-Routentypen selbst
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 1m53s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m3s
Docker Image bauen und veroeffentlichen / test (push) Successful in 1m53s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 5m44s
In der CI fehlt .next/types; tsc fand die globalen PageProps/LayoutProps
nicht (TS2304). "next typegen" vor tsc erzeugt sie ohne Build.
Lokal mit geloeschtem .next/types geprueft.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 09:10:13 +02:00
Nick SzaunigandClaude Opus 5.5 85d1a46f64 ci: Qualitaets-Gate vor dem Image-Build
Docker Image bauen und veroeffentlichen / typecheck (push) Failing after 1m56s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 2m17s
Docker Image bauen und veroeffentlichen / test (push) Successful in 3m10s
Docker Image bauen und veroeffentlichen / build-and-push (push) Skipped
Der Workflow baute bisher bei jedem Push auf main ungeprueft ein Image.
Jetzt laufen lint (inkl. pnpm audit ab high), typecheck und test auf dem
isolierten Runner (ci-isolated, ohne Docker-Socket und Clusternetz);
build-and-push startet nur, wenn alle drei gruen sind. Muster und
Runner-Pruefskript aus dem VyCept-Repo (docs/BETRIEB.md 11b).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 09:02:45 +02:00
Nick SzaunigandClaude Opus 5.5 835323e466 fix(deps): High-/Critical-Advisories schliessen, typecheck-Script
pnpm audit --prod meldete 1 critical und 8 high:
- next 16.3.5 -> 16.3.8 (critical), eslint-config-next mitgezogen
- nodemailer 9 -> 10.1.0 (Bruch nur: Node >= 20; Image laeuft auf Node 22)
- sharp >= 0.35.5 und source-map-js >= 1.2.2 per Override (transitiv ueber next)
- mariadb >= 3.5.4 (direkt und per Override)

Neues Script "typecheck" (Prisma-Clients erzeugen, tsc --noEmit) fuer das CI-Gate.
Lokal geprueft: lint, typecheck, 674 Tests, next build gruen; audit ohne high.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 09:02:44 +02:00
Nick SzaunigandClaude Opus 5.5 63d90ae8b3 chore(k8s): Wabenhain auf Build sha-0d254db (Client-IP-Haertung)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 7m47s
Live seit 2026-10-12: Rate-Limits je Besucher (CF-Connecting-IP nur von
Cloudflare-Edges und nur oeffentlich), IPv6 je /64, X-Real-IP nur ausserhalb
der Produktion.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 16:03:20 +02:00
Nick SzaunigandClaude Opus 5.5 0d254db867 fix: Client-IP-Ermittlung härten (CF-Connecting-IP, X-Real-IP)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m59s
CF-Connecting-IP wird nur noch als öffentliche Unicast-Adresse akzeptiert
(kein 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16,
240/4 "Pseudo IPv4", 255.255.255.255, ::, ::1, fc00::/7, fe80::/10 sowie
IPv4-gemappte Formen); sonst gilt die Gegenstelle aus X-Forwarded-For.

Der X-Real-IP-Fallback gilt nur noch außerhalb von Produktion. In
Produktion ohne X-Forwarded-For wird "unknown" geliefert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:19:21 +02:00
Nick SzaunigandClaude Opus 5.5 97f0e033e0 fix(k8s): Ingress nur noch ueber Cloudflare erreichbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Middleware cloudflare-only (ipAllowList: Cloudflare-IPs und private Netze)
vor die HTTPS-Umleitung gesetzt, wie live im Cluster seit 2026-10-12.
Direkte Zugriffe auf den Origin an Cloudflare vorbei bekommen 403.
Die Middleware selbst liegt in rke2-cluster/security.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:14:59 +02:00
Nick SzaunigandClaude Opus 5.5 5a8ff27090 fix(security): Rate-Limits zaehlen IPv6 je /64-Netz
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 6m47s
Seit CF-Connecting-IP gilt, sieht der Limiter echte IPv6-Adressen. Ein
Anschluss bekommt meist ein ganzes /64 und koennte durch Adresswechsel
jedes Limit umgehen (Login, 2FA, Checkout). getClientIp liefert IPv6 deshalb
als /64-Praefix, IPv4-gemappte Adressen wie IPv4 (wie VyCept, #94).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:59:09 +02:00
Nick SzaunigandClaude Opus 5.5 946745c064 fix(security): Rate-Limits je Besucher statt je Cloudflare-Edge
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
wabenhain.com laeuft ueber Cloudflare. Der letzte X-Forwarded-For-Eintrag
ist dann die Cloudflare-Edge, die sich viele Besucher teilen: Login-, 2FA-
und Checkout-Limits trafen alle Besucher derselben Edge gemeinsam, ein
einzelner konnte andere aussperren.

resolveClientIp nimmt CF-Connecting-IP, wenn die Gegenstelle eine
Cloudflare-Edge ist; sonst wie bisher den letzten Eintrag (beim
Direktzugriff ist CF-Connecting-IP faelschbar und zaehlt nicht).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:54:01 +02:00
Nick SzaunigandClaude Opus 5.5 dfa5c97491 fix(k8s): Wabenhain-Image per Tag und Digest pinnen
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Sicherheitsreview: ein Tag ist in der Registry ueberschreibbar. Tag plus
Digest bleibt lesbar und unveraenderlich; Cluster auf denselben Stand
gebracht, kubectl diff leer.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:41:34 +02:00
Nick SzaunigandClaude Opus 5.5 eda2c11fe3 chore(k8s): Wabenhain auf Build sha-4827c8f (E-Rechnung, Storno, CRM-Export)
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Ausgerollt am 2026-10-12 nach Sicherung wabenhain-db-backup-pre-einvoice;
Migration 20261012010000_cancellation_invoices angewendet.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:39:59 +02:00
Nick SzaunigandClaude Opus 5.5 4827c8fa61 fix(invoice): Befunde aus dem Sicherheitsreview
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 9m45s
- E-Rechnung: in XML verbotene Steuerzeichen aus Namen/Anschriften entfernen,
  sonst waere das eingebettete XML bei einem einzigen Zeichen ungueltig.
- Rechnungskopie: genau eine Adresse; eine Liste ([email protected],[email protected]) wird beim
  Speichern abgelehnt und nie an nodemailer gegeben.
- Hinweis bei fehlgeschlagener Stornorechnung korrigiert (ein erneutes
  Stornieren ist nicht moeglich, die Stripe-Erstattung holt sie nach).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:57:29 +02:00
Nick SzaunigandClaude Opus 5.5 2995890b07 chore(k8s): App-Deployment auf festen Build gepinnt, wie im Cluster
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Image per Digest statt :latest, imagePullPolicy IfNotPresent, Pod- und
Container-securityContext (runAsNonRoot 1001, seccomp RuntimeDefault, keine
Rechteausweitung, alle Capabilities entfernt). kubectl diff gegen den Cluster
ist leer. Neue Builds per kubectl set image ...:sha-<commit>.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:48:00 +02:00
Nick SzaunigandClaude Opus 5.5 4b15ca0b8d fix(invoice): Rechnung ohne PDF/A statt Fehler, wenn Schriften fehlen
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Fehlt assets/fonts im Container, entsteht das PDF mit Standardschriften
und eingebettetem XML, nur nicht als PDF/A; der Fehler steht laut im Log.
Eine Rechnung darf nie an den Schriften scheitern.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:24:25 +02:00
Nick SzaunigandClaude Opus 5.5 051eedaf7f feat(erp): Kunden und Firmenanfragen fuer das CRM in ERPNext
Zwei nur lesende Endpunkte mit ERP_EXPORT_TOKEN:
- GET /api/erp/kunden: Kundenkonten (ohne Admins, ohne Passwort/2FA) mit
  Standardadresse; ERPNext fuehrt sie als Privatkunden.
- GET /api/erp/anfragen: Firmenanfragen; ERPNext fuehrt die Firma als
  B2B-Kunden mit Ansprechpartner und je Anfrage eine Chance.
Seitenweise ueber den Cursor "nach"; ERPNext holt jeweils die ganze Liste
und erkennt so auch geloeschte Konten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:22:48 +02:00
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:19:42 +02:00
Nick SzaunigandClaude Opus 5.5 508ff24aa7 feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:32:44 +02:00
Nick SzaunigandClaude Opus 5.5 f28e5e9d84 feat(k8s): Sicherung und Hintergrund-Jobs lauffaehig machen
- Werkzeug-Image wabenhain-tools (Dockerfile "AS tools", Muster wie
  artista-modelcamp-tools): Im App-Image fehlten tsconfig.json und Pakete
  wie stripe, Bestandsabgleich und Bestellfreigabe brachen mit
  "Cannot find module" ab. Die CI baut es neben dem App-Image.
- Datenbank-Sicherung: PV auf der Storage Box (Customer/Wabenhain), der
  Unterordner db-backup entsteht per subPath. Vorher blieb der PVC ohne
  StorageClass haengen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 08:15:51 +02:00
Nick SzaunigandClaude Opus 5.5 b246c9ff4e feat(erp): read-only invoice export for ERPNext
ERPNext (collab.vycept.com) now picks up every shop invoice with its PDF,
books it as a Wabenhain sales invoice and matches it to the Stripe payment
via the PaymentIntent, so fees, revenue and the invoice document sit on one
booking.

- GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>: invoices oldest first,
  with order, payment intent, customer address (no phone, no notes), items
  and the VAT split per rate exactly as printed (lib/vat.ts), including the
  shipping and discount share per rate, so ERPNext books the same cents.
- GET /api/erp/rechnungen/<RE-YYYY-NNNN>/pdf: the stored invoice PDF.
- Bearer token ERP_EXPORT_TOKEN (min. 32 chars, timing-safe compare);
  without it the export is off. Failed attempts are rate limited.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:39:11 +02:00
Nick SzaunigandClaude Opus 5.5 7698d4cef3 chore(k8s): CPU- und Speicher-Reservierung entfernen
Requests stehen explizit auf 0 statt zu fehlen, weil Kubernetes einen
fehlenden Request auf das Limit setzt und dann noch mehr reserviert.
Die Limits bleiben als Obergrenze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:40 +02:00
Nick SzaunigandClaude Opus 5.5 93b589ff04 feat(stock): keep shop stock in sync with ApiCore, write sales back
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:16:54 +02:00
Nick SzaunigandClaude Opus 5.5 641afa1ae4 fix(harvests): allow several harvests of the same type per year
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.

- Drop the (year, type) unique index. The slug is now the only unique,
  public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
  bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
  "apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
  (decomposed umlauts) and writes out umlauts. The migration repairs
  manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
  writers cannot produce duplicates; known slugs are skipped up front to
  keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:25:02 +02:00
Nick SzaunigandClaude Opus 5.5 f1c0da4673 feat(admin): archive tabs for business inquiries and reviews
Inquiries: completed and rejected inquiries move to an "Archiv" tab;
resetting the status brings them back. Reviews: new "Ablehnen" action
(Review.rejectedAt) instead of only deleting; the archive shows decided
reviews in two columns, accepted and rejected, and either decision can
be reversed. Pending counters in the sidebar and dashboard exclude
rejected reviews, and the product page tells the author their review was
not published instead of "still being checked".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:13:07 +02:00
Nick SzaunigandClaude Opus 5.5 866d3726d1 fix(apicore-sync): explain that HTTP 403 can mean an IP allowlist block
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m46s
ApiCore API keys can now be limited to source addresses; a 403 then
usually means this server's address is not on the key's allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:42:23 +02:00
Nick SzaunigandClaude Opus 5.5 5c6d99d0a3 docs(einrichtung): ApiCore sync uses an API key, not a service account
The setup guide still listed APICORE_SYNC_EMAIL/APICORE_SYNC_PASSWORD,
which the client no longer reads since 3bc0693.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:09:53 +02:00
Nick SzaunigandClaude Opus 5.5 f71bcb2b67 feat(inhalte): text placeholder {{USt-IdNr}} for the VAT ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
Admin texts can now contain the placeholder {{USt-IdNr}}. Wherever
content is read (lib/content.ts), it is replaced with the VAT ID
maintained under Admin > Einstellungen > Firmendaten, so every text
follows a change automatically. The placeholder is matched case- and
space-insensitively; an empty VAT ID inserts nothing, and unknown
placeholders stay visible so typos show up. Images, videos, select
fields and the source field itself are never rewritten.

The default imprint text uses the placeholder instead of a hard-coded
number, and the settings page lists the available placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:59:04 +02:00
Nick SzaunigandClaude Opus 5.5 77bdd9958c feat(rechnung): branded invoice with VAT ID and per-rate grouping
The invoice PDF and the invoice page now share one branded layout:
logo (uploaded brand logo, otherwise the drawn honeycomb mark) and
wordmark in the header, honey accents, a forest-green table header,
a meta box with invoice number, date, order number and VAT ID, and a
footer with address, contact, VAT ID and page numbers on every page.

A mixed cart (e.g. honey at 7 % and a beeswax candle at 19 %) lists
its items in one block per VAT rate with a subtotal each. Next to the
totals, a VAT table shows net, VAT and gross per rate plus a sum row.
Long invoices continue on further pages with the table header repeated.

The VAT identification number is a new company field
(company.vatId), editable in Admin > Inhalte, defaulting to the
number already stated in the imprint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:49:59 +02:00
Nick SzaunigandClaude Opus 5.5 1daef79026 feat(cart): open the cart drawer only after the drip has run down
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
The drawer used to slide in 180 ms after the honey drop landed and
covered the glow and the drip on the cart icon. It now waits until the
drip has finished (about 1.4 s). Without a visible flight (reduced
motion, icon off-screen) it still opens almost at once. The drip
timing lives in cart-flight.tsx so both files share one source.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:34:38 +02:00
Nick SzaunigandClaude Opus 5.5 15a457f66e feat(ernten): show only the remaining jars per batch
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m6s
The batch list on a harvest page no longer states the batch's total
size ("12 von 300 Gläsern verfügbar"). It now reads "Es sind noch 12
Gläser verfügbar", with dedicated wording for a single jar and for a
sold-out batch, which reads scarcer and nudges customers to buy.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:23:20 +02:00
Nick SzaunigandClaude Opus 5.5 55ae867f68 fix(cart): keep the cart icon unchanged while it glows
The landing glow was a larger, filled basket with a thicker stroke and
a blurred halo, so the icon looked bigger during the effect. The glow
is now an exact copy of the icon (same size, same stroke width, no
fill, no scaling) that only changes colour to honey for a moment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:57:52 +02:00
Nick SzaunigandClaude Opus 5.5 e19827d6af feat(cart): honey glow and drip when the drop lands in the cart
After the flying honey drop reaches the header cart icon, the basket
now lights up twice in honey colour with a warm halo, and a small
honey drop gathers at its lower edge, stretches and runs down. With
reduced motion only a single calm glow remains, without the drip.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:45:54 +02:00
Nick SzaunigandClaude Opus 5.5 3107a02588 feat(konto): settings page for security and privacy
Adds /konto/einstellungen, which gathers two-factor authentication,
"sign out on all devices", the Erntepost consent, the data export
(Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place.
The account overview now links there through a "Settings" card
instead of listing these blocks inline. Server actions are unchanged
apart from redirecting failures and revalidating the new page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 21:20:37 +02:00
Nick SzaunigandClaude Opus 5.5 3bc0693e4d refactor(apicore-sync): API key and shop feed only, no password login
The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:05:11 +02:00
Nick SzaunigandClaude Opus 5.5 55be1fe881 feat(apicore-sync): authenticate with an ApiCore API key
APICORE_API_KEY replaces the service-account login: the key is sent as
the bearer token directly, needs no password and no MFA exemption, may
only read the shop feed and can be revoked in ApiCore at any time. A
malformed key is rejected with a clear message before any request.
Email/password stays as fallback for ApiCore versions without keys.

The system status treats the key or the legacy login as configured.
README, .env.example and the k8s secret template describe the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 01:24:26 +02:00
Nick SzaunigandClaude Opus 5.5 5642f0c4af feat(apicore-sync): read from ApiCore's dedicated shop feed
The sync now fetches everything from ApiCore's GET /shop-sync in one call:
an allowlisted feed (harvest facts, article master data, bottlings) that
never contains internal notes and needs only the single permission
"shop-sync:read" instead of read access to harvests, extractions etc.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:30:33 +02:00
Nick SzaunigandClaude Opus 5.5 d38f4303ff feat(apicore-sync): field-level ownership between ApiCore and the shop
ApiCore stays the source of truth for beekeeping facts, Wabenhain for
everything sales-related; both remain separate packages talking only over
ApiCore's HTTP API.

- Batches: lot number, fill date, best-before date and filled jar count
  now follow ApiCore on every run (before: created once, corrections never
  arrived). remainingGlasses - the shop's own stock - is never written; if
  it exceeds a corrected fill quantity, the run reports it instead of
  silently adjusting.
- Batches are matched by the new Batch.apicoreBottlingId instead of the lot
  number, so correcting a lot number in ApiCore no longer creates a second
  batch. Existing synced batches are adopted once via their lot number.
- Harvests: date and year always follow ApiCore, also when published or
  edited; type, location and text stay as maintained in the admin.
- Products stay entirely shop-owned after creation (weight drives the
  legally required unit price, so it is never changed automatically).
- Admin panel: batch update count and stock-conflict notice; intro text
  states which system owns which fields.

Migration is additive (nullable unique column).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:14:35 +02:00
Nick SzaunigandClaude Opus 5.5 45ca0ea745 test(session): tamper inside the signature, not its last character
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
The last base64url character of a 32-byte HMAC signature partly carries
padding bits that decoders ignore, so the tampered token was sometimes
still valid and the test failed at random. Changing a character in the
middle of the signature always alters the signature bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 0482fd490e fix(apicore-sync): keep internal notes out of the shop and protect edited drafts
- Harvest descriptions no longer include ApiCore's internal harvest notes.
  The description becomes public on "Veröffentlichen", so a draft published
  without a careful read could have carried operational notes into the shop.
- A repeated sync overwrote unpublished harvest drafts, undoing the admin's
  corrections (season instead of honey type, location, sales text). Drafts
  with an admin audit entry are now left alone and reported as protected.
- Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted
  the connection but never answered left the admin action hanging.
- The sync panel shows the time of the last complete run.
- Client tests: timeout, failed fetch is never treated as an empty source,
  malformed responses are rejected as a whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 e0b47faf45 feat(orders): reopen cancelled orders only after confirmation; accurate sales
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Cancelled orders:
- Moving an order out of STORNIERT is possible again, but only after a
  warning is confirmed in the status form (OrderStatusForm); without
  the confirmation the server changes nothing.
- Reopening books the counter-entry to the cancellation: goods that the
  cancellation restocked are deducted again (atomically, only if enough
  stock is left, otherwise the order stays cancelled). A later second
  cancellation therefore restocks +1 again instead of leaving +2.
- The warning states that no new payment is collected.

Dashboard figures:
- One definition of a counted sale (lib/sales.ts): not unpaid (NEU),
  not cancelled, no refunded return. Used for revenue today/month, sold
  glasses, average cart, top products/categories, revenue trend and
  repeat customers.
- "Verkaufte Gläser" previously summed every order item, including
  cancelled orders and refunded returns.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 01:14:12 +02:00
Nick SzaunigandClaude Opus 5.5 dc1523aafd fix(orders): cancelled orders cannot be reopened (stock restocked twice)
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).

STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
  cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
  message and moves non-cancel statuses with an atomic
  "status <> STORNIERT" update, so a cancellation from a second tab
  cannot be undone in between
- the order list and detail page no longer offer the status select for
  cancelled orders

Existing stock that was restocked twice has to be corrected by hand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:45:31 +02:00
Nick SzaunigandClaude Opus 5.5 de82ad0233 feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.

System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
  Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
  whether each is set up and the names of missing variables, never a
  value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
  smtp.office365.com:587 and login.microsoftonline.com:443

/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:07:34 +02:00
Nick SzaunigandClaude Opus 5.5 b5311fae83 fix(mail): connect to Microsoft 365 over IPv4 and check network paths
The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.

- The shop now resolves smtp.office365.com via IPv4 only and connects
  to that address; the TLS certificate is still verified against
  smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
  sticks.
- The email test first probes smtp.office365.com:587 and
  login.microsoftonline.com:443 over IPv4 and reports each result, so a
  blocked port (hoster firewall, NetworkPolicy) is distinguishable from
  a configuration error.

Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 22:41:49 +02:00
Nick SzaunigandClaude Opus 5.5 2943021fe9 fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:

- The admin accepts the entry as written (plain or obfuscated with
  [at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
  only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
  mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
  contact page and every email in legal texts (Impressum, Datenschutz,
  Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
  with a fresh random key; the browser builds the clickable mailto link
  after load (components/protected-email.tsx).

Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:35:50 +02:00
Nick SzaunigandClaude Opus 5.5 12675f381a docs: step-by-step setup guide for secrets, SSO and Microsoft 365 mail
docs/EINRICHTUNG.md walks from nothing to a running shop: tools and
access, generating database/session/admin values, the Entra ID app
registration used for both sign-in and mail (redirect URI, client
secret, SMTP.SendAsApp with admin consent, enterprise app object id),
unlocking the shared mailbox in Exchange Online, Stripe keys and webhook
events, optional services, a complete secret/config map table, rollout,
verification, maintenance (secret expiry) and troubleshooting keyed to
the messages of the admin email test. Linked from README and k8s/README.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:21:01 +02:00
Nick SzaunigandClaude Opus 5.5 11910dd6dd refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00
Nick SzaunigandClaude Opus 5.5 0620e9d79a feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:17:33 +02:00
Nick SzaunigandClaude Opus 5.5 302a18baa6 feat(mail): SMTP diagnosis and admin email test for Microsoft 365
Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:

- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
  instructions: security defaults, SMTP AUTH disabled for tenant or
  mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
  the mailbox), wrong credentials, basic-auth retirement, network, TLS.
  The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
  signed-in admin only (no free recipient, rate limited, audit logged)
  and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
  never sent unencrypted, strips a trailing newline from secrets (a
  common kubectl/base64 mistake) and logs the diagnosed cause.

Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:48:40 +02:00
Nick SzaunigandClaude Opus 5.5 cef2248a45 fix(security): escape untrusted values in transactional email HTML
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.

All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:42:49 +02:00
Nick SzaunigandClaude Opus 5.5 dcfad4e011 feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 4ab1a7edae feat(legal): readable legal pages and corrected legal texts
Legal pages rendered admin text as bare paragraphs. LegalPage now has a
honeycomb header, a sticky table of contents with scroll tracking (a
collapsible one on mobile), anchor links, a reading progress bar and a
revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives
lists, cookie/provider entries with labelled facts and safe links from
plain text, so the pages stay editable in the admin without markup.

Legal corrections (German law as of 2026):
- Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu
  Art. 246a EGBGB): recipient name/address/e-mail filled from company
  data, deadline rule, return period, refund details, diminished value
  and the model withdrawal form. The incomplete notice extended the
  withdrawal period to up to 12 months and 14 days. Honey is no longer
  declared perishable.
- Impressum: TMG references replaced by DDG; editorial contact gains an
  address.
- Datenschutz: removes the self-hosted vs. hosting-provider
  contradiction, adds PayPal/Klarna, reviews, back-in-stock
  notifications, storage period, no automated decisions, withdrawal of
  consent and the Art. 21 objection notice; newsletter double opt-in.
- AGB: payment methods and delivery area point to the live shipping
  page; adds contract-text and input-correction information.
- Cookie policy: storage period and provider for every entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 07:02:12 +02:00
Nick SzaunigandClaude Opus 5.5 86cc0ad310 feat(ui): Seite "Fuer Unternehmen" mit Leben - Glaeser, Biene, Zielgruppen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m30s
- Hero: Titel gross auf dem Bienenfoto, darunter "Fuer ..." mit
  wechselnder Zielgruppe (Unternehmen, Kanzleien, Praxen, Hotels,
  Agenturen, Handwerksbetriebe - die Woerter aus dem Untertitel), eine
  Biene fliegt durchs Bild, Knopf springt zur Anfrage.
- Die drei Pakete wieder als Karten, jetzt mit gezeichnetem Honigglas:
  Klassik mit Banderole, Geschenk im Karton mit Schleife, Individuell mit
  Stift und Funkeln. Beim Hover hebt sich das Glas, die Schleife wippt.
  Ein Klick springt zum Formular und waehlt das Paket dort vor
  (?paket=, nur bekannte Schluessel).
- Anfrage: links Ueberschrift und das Glaeser-Foto (mitlaufend, leichte
  Parallaxe), rechts das Formular.

Neu im Woerterbuch nur Ueberschrift, Knopftext und die Zielgruppenliste;
keine neuen Aussagen. Dunkelmodus und reduzierte Bewegung beruecksichtigt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:35:29 +02:00
Nick SzaunigandClaude Opus 5.5 1ff5f8c722 docs: list every cookie and storage entry in the cookie policy
The policy only named wabenhain_session, although the site sets ten
cookies and three localStorage entries. § 25 TDDDG requires the
information for every access to the user's device, so each entry now
states purpose, provider, storage period, flags and legal basis.

Also adds the legal-basis section (§ 25 Abs. 1/2 TDDDG, Art. 6 DSGVO),
third-party cookies (Stripe __stripe_mid/__stripe_sid, Microsoft,
Cloudflare Turnstile), browser controls, data subject rights, withdrawal
under Art. 7(3) DSGVO and a revision date. Corrects the claim that the
cart never leaves the device: it is sent when an order is submitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:34:55 +02:00
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +02:00
Nick SzaunigandClaude Opus 5.5 b77516b9f5 feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
  "Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
  Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
  die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
  umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
  Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
  leicht mit (Zoom und Parallax, nur transform; components/parallax-media).

Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:57:45 +02:00
Nick SzaunigandClaude Opus 5.5 d52a762e8a fix: Produkt-Formular ueberschreibt keinen zwischenzeitlich gebuchten Bestand
Das Speichern schrieb den Formularwert immer fest in den Lagerbestand.
Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch
wieder in den Bestand, ohne dass es je zurueckkam.

- Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded)
- Feld unveraendert: Bestand wird nicht angefasst
- Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand
  entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert
  und der aktuelle Wert angezeigt
- Fehlt quantityLoaded (alter Tab), muss neu geladen werden

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:55:36 +02:00
Nick SzaunigandClaude Opus 5.5 5848eb6ad5 fix: Bestand bei Storno nach erstatteter Retoure nicht doppelt zurueckbuchen
Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.

- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
  (needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
  erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 03:12:37 +02:00
Nick SzaunigandClaude Opus 5.5 fdb6456207 feat(ui): Leerer Warenkorb zeigt verfuegbare Honige
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Bisher stand dort nur ein Satz und ein Knopf zum Shop. Jetzt steht der
Hinweis als Spalte links, daneben bis zu zwei aktuell verfuegbare
Produkte (Honig zuerst) - dasselbe Muster wie "Aktuelle Ernte" auf der
Startseite. Nutzt die bereits geladenen availableProducts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:14:52 +02:00
Nick SzaunigandClaude Opus 5.5 0fb176add8 feat(ui): Microsoft-Anmeldung nur wenn eingerichtet, in der Anmelde-Spalte
Der Knopf stand unter beiden Spalten (Anmelden und Konto erstellen),
obwohl er zur Anmeldung gehoert, und erschien auch ohne eingerichtete
Entra-Konfiguration - der Klick endete dann in der Meldung "SSO ist noch
nicht konfiguriert". Jetzt steht er unter dem Anmeldeformular und nur,
wenn isEntraConfigured() zutrifft. Die serverseitige Pruefung in der
Login-Route bleibt als zweite Absicherung.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:13:19 +02:00
Nick SzaunigandClaude Opus 5.5 774bc9cf00 feat(ui): Pakete fuer Firmenkunden als Auswahl-Karten im Formular
Die drei Pakete standen doppelt: als Deko-Kacheln (mit grossem
Waben-Wasserzeichen) ueber dem Formular und darin noch einmal als
Auswahlliste. Jetzt waehlt man sie direkt im Formular als Karten mit
sichtbarem Auswahl- und Fokuszustand; die Kacheln entfallen. Feldname
und Werte (package = KLASSIK/GESCHENK/INDIVIDUELL) sind unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:11:29 +02:00
Nick SzaunigandClaude Opus 5.5 b588975317 feat(ui): Seitenabschluesse, Wissens-Verzeichnis, Kopfzeile ohne Ueberlauf
- Imkerei, Herkunft und Ernten endeten mit dem letzten Absatz direkt vor
  der Fusszeile. Neuer PageOutro: zwei naechste Wege als grosse
  Serif-Links mit Kurztext (Ernten/Shop bzw. Herkunft/Shop).
- Kopfzeile: bei 1280 px lief sie 54 px ueber (horizontale Scrollleiste),
  weil die nicht umbrechbare Slogan-Zeile unter dem Logo schon ab xl
  erschien. Jetzt erst ab 2xl; geprueft 1024 bis 1920 px ohne Ueberlauf.
- Wissen: Inhaltsverzeichnis statt zehn gleicher Bloecke mit je einem
  "Weiterlesen" - ganze Zeile ist der Link, Titel und Kurztext
  nebeneinander. Ungenutzter Schluessel readMore entfernt.
- Produktseite: bei ein, zwei Empfehlungen steht die Ueberschrift als
  erste Rasterspalte statt ueber einer einzelnen verlorenen Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:05:25 +02:00
Nick SzaunigandClaude Opus 5.5 908afebacb feat(ui): Produktbild ab Tablet im Hochformat
Die Galerie lief im Querformat 4:3 neben einer langen Detailspalte und
wirkte daneben schmaechtig. Ab md jetzt 4:5, das Glas traegt die Seite;
auf dem Handy bleibt 4:3, damit der Kaufen-Knopf oben bleibt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:47:47 +02:00
Nick SzaunigandClaude Opus 5.5 7c305a3fce feat(ui): Bild-Platzhalter im Stil des Glas-Etiketts
Fehlt ein Foto, zeigte die Seite einen Farbverlauf mit Diagonallinien -
ein generisches Muster, das auf Produkt- und Inhaltskacheln wie
unfertige Vorlage wirkte. Jetzt: ruhige Markenflaeche mit leisem
Wabenraster, das gezeichnete Waben-Zeichen und darunter der Name in
gesperrten Serif-Versalien, wie auf dem Etikett. In kleinen Groessen
(Warenkorb, Galerie-Vorschau) bleibt per Container Query nur das Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:46:59 +02:00
Nick SzaunigandClaude Opus 5.5 7e467a9ddf feat(ui): Startseite mit eigenstaendigen Abschnitten statt Schablonen-Zickzack
- Aktuelle Ernte: bei ein, zwei Produkten steht die Ueberschrift als
  eigene Spalte daneben, statt ueber einem halb leeren Dreierraster.
- Herkunft: Foto randlos ueber die halbe Seitenbreite, Text daneben,
  ohne Rahmen.
- Philosophie: typografische Reihe mit Trennlinien und grossen
  Serif-Titeln statt vier Icon-Karten.
- Region: breites Bild, der Text liegt als Tafel ueberlappend darueber.
- Geschaeftskunden: kompakte Tafel mit randlosem Bild statt eines dritten
  grossen Bild/Text-Wechsels.

Inhalte, Texte und Content-Schluessel bleiben unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:45:06 +02:00
Nick SzaunigandClaude Opus 5.5 1fa8ca246a refactor(ui): Kicker-Zeilen ueber Ueberschriften entfernt
Jede zweite Ueberschrift trug eine kleine Versalien-Zeile mit Wabenpunkt
("• STIMMEN", "• AKTUELL VERFUEGBAR", "• KUNDENKONTO" ...). Auf 29 Seiten
und Abschnitten wiederholt las sich das wie eine Vorlage. SectionHeading
rendert sie nicht mehr, die Prop ist entfernt; die Ueberschrift traegt den
Abschnitt allein.

Die eyebrow-Texte, die weiterhin als Seitentitel, Bildbeschriftung oder
Breadcrumb dienen, bleiben; 18 danach ungenutzte Eintraege sind aus Typ und
beiden Woerterbuechern entfernt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:41:48 +02:00
Nick SzaunigandClaude Opus 5.5 a4581d3467 fix(ui): Weg vom Admin zur Website, Anfuehrungszeichen nicht mehr ueber den Sternen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Admin-Leiste (Desktop und Mobilmenue): Link "Zur Website" oberhalb von
  "Angemeldet als". Der Shop-Kopf ist im Admin ausgeblendet, bisher fuehrte
  von dort kein sichtbarer Weg zurueck.
- Startseite, Kundenstimmen: das grosse Schriftzeichen "„" sass auf der
  Grundlinie und ragte mit leading-[0.6] in die Sternebewertung. Ersetzt
  durch ein gezeichnetes SVG mit fester Hoehe und Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:22:24 +02:00
Nick SzaunigandClaude Opus 5.5 52ce37e216 fix: Weiterleitungen innerhalb der Seite relativ statt auf die Produktionsdomain
Sechs Route-Handler (Hinweis-Weiterleitung, E-Mail-Bestaetigung,
Newsletter bestaetigen/abmelden, Entra-Login und -Callback) bauten ihr
Weiterleitungsziel mit getBaseUrl() absolut zusammen. Ohne gesetztes
NEXT_PUBLIC_SITE_URL (lokal, Staging, Vorschau-Host) sprang man damit auf
https://wabenhain.com, und die gerade gesetzte Flash-Meldung ging auf dem
fremden Host verloren - aufgefallen, als die Admin-Pruefung nach
/api/hinweis umleitete.

Neuer Helfer lib/redirect.ts (redirectWithinSite) mit relativem
Location-Header, analog zu redirect() in Server Actions. Er lehnt alles
ausser Pfaden dieser Seite ab ("//host", "/\host", absolute URLs), damit
daraus keine offene Weiterleitung werden kann. getSiteUrl() bleibt fuer
Adressen, die die Seite verlassen (E-Mails, Stripe, Entra-redirect_uri,
Sitemap, Canonicals).

proxy.ts bleibt bei new URL("/konto", request.url) (eigener Host): der
Proxy verlangt einen absoluten Location-Header, ein relativer endet dort
in "Invalid URL" - als Kommentar festgehalten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:11:44 +02:00
Nick SzaunigandClaude Opus 5.5 4f442ec06d feat(admin): Logo und Favicon per Bild-Upload austauschbar
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).

- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
  Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
  app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
  lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
  Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
  neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
  mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.

Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:02:18 +02:00
Nick SzaunigandClaude Opus 5.5 ae2726b0ee refactor(admin): ApiCore-Synchronisierung nur noch einmal auf dem Dashboard
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Derselbe "Alles synchronisieren"-Knopf stand auf /admin/produkte und
/admin/ernten und sah dadurch wie zwei verschiedene Abgleiche aus. Er
sitzt jetzt einmal auf dem Dashboard, das Ergebnis des Laufs erscheint
dort oben. Die Action springt standardmaessig nach /admin zurueck; die
alten Ziele bleiben in der Whitelist erlaubt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:34:18 +02:00
Nick SzaunigandClaude Opus 5.5 11eccec01b feat(ui): Polish-Durchgang Admin und Mobil, Erstattung nur mit Rueckfrage
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.

Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.

Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:24:55 +02:00
Nick SzaunigandClaude Opus 5.5 b7a75dddca feat(admin): Formulare fuer Gutscheine, Versand, Benutzer, Ernten und Chargen vereinheitlicht
Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.

Benutzer bearbeiten: Sicherheitsbereich mit Status-Badges, Passwort-Reset
als Sekundaerknopf, Sperren/Loeschen in eigenem abgesetzten Bereich.
AdminField/AdminSelect koennen jetzt min und einen Hinweistext.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:10 +02:00
Nick SzaunigandClaude Opus 5.5 a09057d960 feat(admin): Listen mit Status-Badges, Leerzustaenden und klaren Aktionen
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).

Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:51:50 +02:00
Nick SzaunigandClaude Opus 5.5 95b4b5953d fix(admin): Kategorie und Slug beim Produkt-Anlegen serverseitig pruefen
Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:43:54 +02:00
Nick SzaunigandClaude Opus 5.5 c125e91941 feat(admin): Produkt anlegen gegliedert wie Produkt bearbeiten
Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:41:59 +02:00
Nick SzaunigandClaude Opus 5.5 76ebe7d042 feat(admin): Bestelldetail mit Zeitleiste, Produktformular gegliedert
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste
  (dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand
- Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und
  Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld
  isActive), Speicherleiste bleibt beim Scrollen unten erreichbar
- Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger
  Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 19:48:29 +02:00
Nick SzaunigandClaude Opus 5.5 c95c713717 feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
  Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
  offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
  Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
  tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
  "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
  mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
  #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
  2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
  selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
  Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:45:24 +02:00
Nick SzaunigandClaude Opus 5.5 15ddceaa39 feat(ui): Fehlerseite mit gerissener Wabe
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:30:29 +02:00
Nick SzaunigandClaude Opus 5.5 e6455b05e6 feat(ui): Admin-Seitenleiste mit Wabenmarkierung statt Seitenbalken
Aktive Seite mit Honig-Wabe statt farbigem border-left, feines Wabenraster
in der Seitenleiste (liegt per isolate/-z-10 unter dem Menue).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:28:40 +02:00
Nick SzaunigandClaude Opus 5.5 578ec767b2 feat(ui): Checkout mit Schrittanzeige und Lieferart-Karten
- Schrittanzeige "Deine Daten -> Bezahlen" mit Waben (rein anzeigend,
  gesteuert weiter ueber den bestehenden Zahlungszustand)
- Lieferart als Karten mit Symbol, Auswahl mit Waben-Haekchen, aria-pressed
- Bestelluebersicht abgehoben, Namen und Gesamtsumme in Serifenschrift
- Keine Aenderung an Bezahllogik oder Formularfeldern

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:27:35 +02:00
Nick SzaunigandClaude Opus 5.5 d4bb6ec1cf feat(ui): Adressen, Retoure, Suche und Cookie-Hinweis
- Adressen im Zweierraster, Standardadresse mit Honigrahmen und Wabenmarke,
  neues Adressformular in der Formular-Karte
- Retoure: Positionen als Liste mit Waben, Formular in der Karte
- Suche: Vorschlaege gleiten gestaffelt herein, Suchbegriff hervorgehoben,
  Lupe wird zum Schliessen-Symbol; das Feld legt sich ueber die Navigation
  statt sie zu verschieben (vorher: waagerechter Scrollbalken)
- Cookie-Hinweis als schwebende Karte; "Nur notwendige" und "Alle
  akzeptieren" gleich gewichtet (vorher war Akzeptieren Waldgruen auf
  Waldgruen und kaum als Knopf erkennbar)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:24:07 +02:00
Nick SzaunigandClaude Opus 5.5 baebf78ee4 feat(ui): Sendungsverfolgung im Wabenstil
- DHL-Verlauf: neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
  unterwegs), aeltere als leere Waben, Eintraege erscheinen gestaffelt
- Bestellung verfolgen: Suchformular in der Formular-Karte, Ergebnis als
  abgehobene Karte

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:19:42 +02:00
Nick SzaunigandClaude Opus 5.5 ee854bd77b feat(ui): Formular-Karten, Backup-Codes als Kacheln, Gutschein zum Kopieren
- Gemeinsame FormPanel-Karte fuer Passwort- und Zwei-Faktor-Seiten
- Zwei-Faktor-Code: grosses, zentriertes Ziffernfeld
- Backup-Codes als nummerierte Kacheln statt langer Liste
- Newsletter-Bestaetigung: Willkommensrabatt als Gutschein mit
  gestricheltem Rand und Kopier-Knopf (bestaetigt "Kopiert")

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:17:58 +02:00
Nick SzaunigandClaude Opus 5.5 76be1292ed feat(ui): Markenseiten Herkunft, Imkerei, Unternehmen, Kontakt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
- Herkunft: atmendes Titelbild mit Wabenraster, Ueberschrift als Karte,
  die ins Bild ragt; Aspekte als Liste mit Waben
- Imkerei: Bild mit Honigrahmen, erster Absatz als Einstieg
- Fuer Unternehmen: Pakete als Karten mit Waben-Wasserzeichen, das beim
  Hover einrueckt; Anfrage mit mitlaufender Ueberschrift neben dem Formular
- Kontakt: E-Mail gross auf Wald-Karte mit Wabenraster, Abholung daneben

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:14:19 +02:00
Nick SzaunigandClaude Opus 5.5 dfda94ddfc feat(ui): Leseerlebnis fuer Wissen und rechtliche Seiten
- Artikel: Lesefortschritt als feine Honiglinie am oberen Rand, Initiale
  in Serifenschrift und Honig, groesserer Titel, angenehme Zeilenlaenge
- Wissen-Uebersicht und "Weitere Artikel" als ruhige Liste mit grossen
  Serifen-Titeln, Unterstrich und gezeichnetem Pfeil statt "→"
- Rechtliche Seiten mit klarerer Ueberschriften-Hierarchie

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:09:22 +02:00
Nick SzaunigandClaude Opus 5.5 e75392c75b feat(ui): Warenkorb-Seite mit Fortschritt zum Mengenrabatt
- Weg zum Mengenrabatt als sich fuellender Balken (gleiche Schwelle wie
  bisher der Texthinweis), erreichter Rabatt als Bestaetigung mit Wabe
- Positionen gleiten herein und beim Entfernen hinaus, Entfernen-Knopf
  als gezeichnetes Symbol statt Textzeichen
- Artikelnamen und Preise in Serifenschrift, Gesamtsumme tickt bei jeder
  Aenderung, Zusammenfassung laeuft beim Scrollen mit

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:06:53 +02:00
Nick SzaunigandClaude Opus 5.5 467984d027 feat(ui): Ernten, Chargen-Nachweis und aktive Navigation
- Ernten: Jahrgang als grosse Serifenzahl mit auslaufender Linie, Karten
  heben sich mit Honigschimmer, Pfeil und Unterstrich
- Ernte-Detail: grosser Titel, mitlaufendes Bild, Fuellstandsbalken je
  Charge (verfuegbare von abgefuellten Glaesern)
- Chargen-Seite (QR-Code auf dem Glas): Echtheitssiegel stempelt sich ein,
  Zeitleiste mit Waben und sich fuellender Linie, Werte in Serifenschrift
- Produktverweise heben sich, gezeichneter Pfeil statt Textzeichen
- Fix: aktive Seite in der Kopfzeile zeigte den Unterstrich in Textfarbe
  statt in Honig (unlayered CSS ueberstimmte die Utility-Klasse)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:04:18 +02:00
Nick SzaunigandClaude Opus 5.5 1c5282e84f fix: fehlenden STRIPE_PUBLISHABLE_KEY im Server-Log melden
Bisher fiel der fehlende Schluessel nur in der Kasse auf ("Zahlung gerade
nicht verfuegbar"), nie im Log des Pods.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:01:00 +02:00
Nick SzaunigandClaude Opus 5.5 064ca3ff6d feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue),
  einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten;
  weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck
  (steht still, solange Warenkorb oder Menue offen sind)
- Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen
- Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl
- Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein
  Haekchen in eine Wabe, Fehler werden als Alert angesagt
- 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:05:01 +02:00
Nick SzaunigandClaude Opus 5.5 4ee5bafe20 feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.

- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
  Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
  alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
  der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
  Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
  Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
  Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 08:01:18 +02:00
Nick SzaunigandClaude Opus 5.5 6ac7cc5d1f feat(ui): Kundenkonto im Wabenstil
- Konto-Kacheln: Icon in einer Wabe, die sich beim Hover mit Honig fuellt,
  einlaufender Pfeil, Serifen-Titel
- Bestell-Statusleiste: Waben statt Punkte, erledigte Abschnitte fliessen
  nacheinander voll, aktueller Schritt pulsiert sanft
- Status-Badge mit pulsierendem Punkt bei laufenden Bestellungen
- Leere Zustaende mit gestrichelter Wabe und Serifen-Titel
- Formularfelder: einheitlicher Honig-Schein beim Fokus
- Groessere Abschnittstitel im Konto, Bestellsummen in Serifen-Preisschrift
- Nur CSS-Animationen (Komponenten rendern auch auf Admin-Serverseiten);
  reduzierte Bewegung schaltet Puls und Fuellen ab

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 07:53:02 +02:00
Nick SzaunigandClaude Opus 5.5 3de17ca070 feat(ui): Startseite mutiger gestaltet
- Hero: deutlich groessere Headline, langsam atmendes Bild, Wabenraster,
  Honigtropfen als Scroll-Hinweis, Hauptaktion in Honig
- "Sechs Schritte" als Weg: eine Honiglinie fuellt sich beim Scrollen und
  laesst die Waben der Schritte nacheinander aufleuchten (waagerecht ab lg,
  senkrecht auf dem Handy; reduzierte Bewegung: sofort gefuellt)
- Philosophie mit Waben statt Nummern, Verantwortung als geteiltes Layout
  mit mitlaufender Ueberschrift
- Bilder mit versetztem Honigrahmen, Wabenraster auf dunklen Flaechen,
  grosse Anfuehrungszeichen bei den Kundenstimmen
- Knopf-Varianten "inverted"/"accent" fuer dunkle Flaechen ersetzen
  !-Overrides, die den Fuell-Effekt ausgehebelt haetten
- Fix: Ueberschrift der Kundenstimmen war Waldgruen auf Waldgruen

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 07:48:08 +02:00
Nick SzaunigandClaude Opus 5.5 3cb639c154 feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet
- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
  Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
  geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
  wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
  gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
  Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
  Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
  Summe, gestalteter leerer Zustand

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:56:03 +02:00
Nick SzaunigandClaude Opus 5.5 839bbff595 feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)
Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:36:36 +02:00
Nick SzaunigandClaude Opus 5.5 fb9c2353c6 feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit
die aktiven Listen nur zeigen, was noch zu tun ist:
- Bestellungen: versendet/abgeholt/storniert, keine offene Retoure,
  seit einem Monat nicht bearbeitet
- Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet
- Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert)

Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle
5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind.
Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand
archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen
landen im Audit-Log.

Neue Felder archivedAt/archivedManually samt Index an orders,
return_requests und products (Migration 20260925010000_archive_fields).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:05:10 +02:00
Nick SzaunigandClaude Opus 5.5 01eaca39dc feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.

- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
  Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
  damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 03:27:58 +02:00
Nick SzaunigandClaude Opus 5 2659db64bd docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s
Haelt die freigegebene Loesung fest: Versandstaffeln haengen kuenftig an der
Produktkategorie statt an einer einzigen globalen Mengenstaffel, weil ein
Honigglas deutlich mehr Versandkosten verursacht als eine Kerze. Bei
gemischten Warenkoerben gilt die teuerste Kategorie, nicht die Summe.

Ausserdem wird die Hoechstbestellmenge je Position im Adminportal pflegbar
(globaler Standardwert plus optionale Uebersteuerung je Produkt) statt als
widerspruechliche Konstanten im Stepper und im Checkout-Schema zu stehen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:16:48 +02:00
Nick SzaunigandClaude Opus 5 c35d61bfd6 feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Stornieren gab es bereits, Loeschen nicht — abgebrochene Checkouts und
Testbestellungen blieben dauerhaft in der Liste stehen.

Loeschbar ist bewusst nur, was nie ein Geschaeftsvorfall war: keine Zahlung
erfasst, keine Rechnung erzeugt, Status NEU oder STORNIERT. Eine bezahlte
Bestellung und ihre Rechnung sind Buchungsbelege (§ 147 AO, § 257 HGB) und
muessen jahrelang vorgelegt werden koennen; ein Loeschknopf darauf wuerde
nicht aufraeumen, sondern Unterlagen vernichten. Dafuer gibt es den Storno:
er erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.

Die Regel steht in lib/order-delete.ts als reine Logik ohne Datenbankzugriff
— dieselbe Trennung wie bei shipping-calc.ts. Server Action und Admin-Seite
teilen sie sich, statt die Bedingung zweimal zu formulieren und irgendwann
auseinanderzulaufen, und sie laesst sich ohne DATABASE_URL testen (13 neue
Faelle, u.a. dass bei Rechnung UND Zahlung die Rechnung als Grund gemeldet
wird).

Drei Dinge, die ein blosses delete() falsch gemacht haette:

- LAGERBESTAND: bei NEU liegt die Ware reserviert und wird zurueckgebucht,
  bei STORNIERT ist das beim Stornieren bereits geschehen. Dort erneut
  gutzuschreiben wuerde den Bestand erhoehen, ohne dass je ein Glas zurueckkam.
- ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
  Stripe-Sitzung haben. Wird sie geloescht, ohne die Sitzung zu schliessen,
  kann Minuten spaeter noch bezahlt werden — der Webhook findet dann keine
  Bestellung mehr, und das Geld liegt bei Stripe, ohne dass im Shop etwas
  davon zu sehen waere. Die Sitzung wird deshalb VOR dem Loeschen geschlossen;
  gelingt das nicht sicher, wird gar nicht geloescht. Ist bei Stripe
  zwischenzeitlich bezahlt worden, verweigert die Aktion das Loeschen.
- WETTLAUF: die Bedingungen stehen zusaetzlich in der WHERE-Klausel des
  DELETE. Zwischen Pruefung und Loeschen koennte der Webhook eine Zahlung
  eingetragen haben; die Pruefung sieht nur einen Schnappschuss, die
  WHERE-Klausel wird atomar gegen den aktuellen Zeilenstand ausgewertet.

Der Audit-Log-Eintrag ist ausfuehrlicher als bei anderen Aktionen und traegt
Bestellnummer, Status, Betrag, E-Mail und Positionsanzahl — der Datensatz ist
danach weg, dieser Eintrag ist die einzige verbleibende Spur.

Die Oberflaeche zeigt den Knopf am Seitenende in einem abgesetzten Bereich,
damit er nicht neben dem Statusfeld liegt, mit Browser-Rueckfrage ueber
ConfirmForm. Ist die Bestellung nicht loeschbar, steht dort statt des Knopfes
der Grund und der Verweis auf den Storno. Die Bestellliste zeigt jetzt auch
Erfolgsmeldungen, nicht nur Fehler.

tsc: 0 Fehler. vitest: 372/372 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 02:05:43 +02:00
Nick SzaunigandClaude Opus 5 256e823c4f fix: Erntepost-Eingabefeld war auf 34 px zusammengedrueckt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m13s
Regression aus dem vorigen Commit (Turnstile per Prop statt process.env):
sobald das CAPTCHA auf der Newsletter-Anmeldung tatsaechlich rendert, sprengt
es die Eingabezeile.

Das Formular war "flex flex-col sm:flex-row gap-3 max-w-lg" ohne flex-wrap,
und das Widget lag als dritter Eintrag in derselben Zeile. Ab 640 px teilten
sich damit Eingabefeld, Knopf und ein rund 300 px breites CAPTCHA eine Zeile
von 512 px. Auf wabenhain.com bei 1440 px nachgemessen: Eingabefeld 34x71,
Knopf 174x71, CAPTCHA 300x71.

Solange kein Sitekey ankam, fiel das nicht auf — das Widget erschien nie, die
Zeile hatte nur zwei Eintraege. Der Fehler lag also schon laenger im Markup
und wurde erst sichtbar, als das CAPTCHA wie vorgesehen erschien.

Jetzt bilden Eingabefeld und Knopf eine eigene Zeile, das CAPTCHA steht
darunter. Die Firmenanfrage ist nicht betroffen, sie stapelt ueber space-y-5.

Dazu zwei Einstellungen am Widget selbst, die von Anfang an gefehlt haben:

- language: Turnstile folgte der BROWSER-Sprache statt der Seitensprache. Auf
  der deutschen Seite stand englisch "Verify you are human".
- theme: "light": Turnstile folgte dem Farbschema des Betriebssystems. Wer es
  auf "dunkel" stehen hat, bekam einen schwarzen Kasten mitten in einen
  Abschnitt, den diese Seite ausschliesslich in Creme und Waldgruen zeichnet.
  Die Seite hat keinen Dunkelmodus, "auto" ist hier also immer falsch.

Beides betrifft alle Formulare, nicht nur den Newsletter.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:44:09 +02:00
Nick SzaunigandClaude Opus 5 b9f92b5c0d feat: Rechtstexte nennen alle Empfaenger, denen tatsaechlich Daten zugehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m11s
Die Datenschutzerklaerung beschrieb Hosting, Bestellung, Konto, Newsletter,
Kontaktformular und Google-Rezensionen — drei Empfaenger fehlten, obwohl
Daten an sie gehen. Abgeglichen wurde gegen die Content-Security-Policy in
next.config.ts: sie listet abschliessend, wohin der Browser ueberhaupt
Verbindungen aufbauen darf, und ist damit ein belastbares Inventar.

Neu aufgenommen:

- Cloudflare Turnstile als eigener Abschnitt 8. Das Skript laedt auf jeder
  Seite mit Formular aus dem Browser des Besuchers nach; IP-Adresse und
  Geraetemerkmale gehen dabei an Cloudflare in den USA. Bisher stand davon
  nirgends etwas — weder in der Datenschutzerklaerung noch unter "Externe
  Inhalte" in der Cookie-Richtlinie, wo Stripe bereits korrekt beschrieben
  ist. Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO, Drittlandtransfer ueber
  EU-US Data Privacy Framework.
- DHL in Abschnitt 4: Name und Lieferanschrift, bei Packstationen zusaetzlich
  die Standortnummer, und bei der Packstationssuche im Bestellvorgang der
  eingegebene Ort.
- Microsoft Entra ID in Abschnitt 5. Die Anmeldung damit ist kundenseitig auf
  /konto verlinkt, tauchte aber in keinem Rechtstext auf.

Die bisherigen Abschnitte 8 bis 10 ruecken auf 9 bis 11.

Ausserdem zwei Korrekturen am Bestand:

- Der Verweis auf die EU-Plattform zur Online-Streitbeilegung entfaellt in
  Impressum UND AGB. Die ODR-Verordnung ist aufgehoben, die Plattform
  eingestellt; ein Link auf einen abgeschalteten Dienst ist schlechter als
  keiner. Die Erklaerung nach § 36 Abs. 1 Nr. 1 VSBG bleibt und ist jetzt
  ausdruecklich als solche benannt.
- Die Cookie-Richtlinie zitierte das TTDSG. Das Gesetz heisst seit Mai 2024
  TDDDG.

Unveraendert bleibt der Cookie-Banner: er bietet eine Auswahl, die derzeit
nichts schaltet, weil keine einwilligungspflichtigen Cookies gesetzt werden —
das ist Absicht, damit spaeter hinzukommende Cookies den Mechanismus schon
vorfinden.

Alle Texte auch auf Englisch. Kein Ersatz fuer eine anwaltliche Pruefung.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:29:18 +02:00
Nick SzaunigandClaude Opus 5 3393de2286 fix: Hero-Text war ueber hellem Bildmaterial nicht lesbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Ortszeile ueber der Ueberschrift stand in honey-light auf einem Foto mit
25 % Grundabdunklung plus einem to-tr-Verlauf, der die Textspalte bei rund
44 % Gesamtdeckung liegen liess. Gegen ein helles Motiv (Holz, Himmel)
gerechnet ergab das:

    Ortszeile    1,34:1   (WCAG AA verlangt 4,5:1 bei 12 px)
    Ueberschrift 2,73:1   (verlangt 3:1 bei grosser Schrift)
    Fussnote     1,90:1

Lighthouse meldet davon nichts und gibt der Seite weiterhin 100 fuer
Accessibility — Kontrast von Text ueber Bildern laesst sich nicht statisch
pruefen, weil der Hintergrund vom hochgeladenen Motiv abhaengt. Genau deshalb
muss die Abdunklung den schlechtesten Fall abdecken und nicht das Bild, das
gerade hinterlegt ist.

Drei Aenderungen, alle nachgerechnet:

- Verlauf ab md waagerecht (85 % -> 70 % -> 0) statt diagonal. Die Textspalte
  endet bei etwa 45 % Breite und liegt damit ganz im dunklen Teil, waehrend
  die rechte Bildhaelfte unangetastet bleibt.
- Auf schmalen Schirmen senkrecht, weil der Text dort ueber die volle Breite
  laeuft und rechts sonst in den durchsichtigen Teil geriete.
- Neuer Farbton honey-pale (#e4cfa4) fuer die Ortszeile; honey-light haelt
  4,5:1 selbst bei 89 % Abdunklung nicht (4,14:1). Fussnote von cream/60 auf
  cream/80.

Ergebnis in der Textspalte: Ortszeile 4,81:1 bis 6,97:1, Ueberschrift 6,41:1
bis 9,28:1, Fussnote 4,80:1 bis 6,59:1 — AA auf allen Breiten.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:04:36 +02:00
Nick SzaunigandClaude Opus 5 368985c0cf fix: Newsletter und Firmenanfrage waeren mit aktivem CAPTCHA unabsendbar gewesen
Beide Formulare lasen NEXT_PUBLIC_TURNSTILE_SITE_KEY direkt aus process.env,
und zwar im Modulrumpf einer Client Component. NEXT_PUBLIC_-Variablen werden
dort zur BUILD-Zeit ins Bundle eingebacken. Gesetzt wird der Wert hier aber
erst zur Laufzeit (k8s/01-configmap.yaml) — im Docker-Build existiert er nicht
und wird auch nicht als Build-Arg hereingereicht.

Der Wert war damit dauerhaft undefined, das Widget erschien nie. Solange
TURNSTILE_SECRET_KEY ebenfalls leer ist, faellt das nicht auf:
verifyTurnstileToken gibt ohne Secret bewusst true zurueck. Sobald das Secret
aber gesetzt wird — also genau beim Scharfschalten des Schutzes —, verlangt
der Server einen Token, den der Browser nirgends erzeugen kann. Newsletter-
Anmeldung und Firmenanfrage haetten dann jede Eingabe abgewiesen, ohne dass
irgendwo ein Fehler sichtbar wird.

Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im Server
Component und reichen ihn als Prop weiter — dort wirkt die Laufzeit-Variable.
Diese beiden folgen jetzt demselben Muster.

Bewusst so und nicht ueber ein Docker-Build-Arg geloest: ein Build-Arg wuerde
den Schluessel ins Image einbacken, womit ein Schluesselwechsel einen Neubau
braeuchte und die ConfigMap fuer diese beiden Stellen wirkungslos bliebe. Ein
einziger Weg fuer alle Formulare ist weniger fehleranfaellig als zwei.

Hinweis fuer den Betrieb: der Schutz ist damit noch nicht aktiv. Dafuer
muessen NEXT_PUBLIC_TURNSTILE_SITE_KEY (ConfigMap) UND TURNSTILE_SECRET_KEY
(Secret) gesetzt sein — beide oder keiner.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 04:04:36 +02:00
Nick SzaunigandClaude Opus 5 5870c60c50 feat: Ruecksendekosten sind geregelt und ausgewiesen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
Bisher sagte kein Text des Shops, wer die Kosten einer Ruecksendung traegt.
Nach § 357 Abs. 6 BGB traegt sie dann der VERKAEUFER: die Belehrung des
Kaeufers ist die Voraussetzung dafuer, sie ihm auferlegen zu duerfen. Ohne
sie war jede Retoure auf eigene Rechnung.

Die Regelung steht bewusst an zwei Stellen, nicht an einer:

- Widerrufsbelehrung: nur der amtliche Mustersatz "Du traegst die
  unmittelbaren Kosten der Ruecksendung der Waren." (Anlage 1 zu Art. 246a § 1
  Abs. 2 EGBGB), unveraendert uebernommen. Eine Abweichung vom Muster kostet
  dessen Gesetzlichkeitsfiktion — die Belehrung waere dann angreifbar.

- Versand und Zahlung, neuer Abschnitt "Rücksendung": derselbe Grundsatz in
  Alltagssprache plus die Ausnahme fuer mangelhafte Ware. Geht ein Mangel auf
  die Produktion oder auf uns als Verkaeufer zurueck, tragen wir die Kosten
  vollstaendig.

Die Ausnahme gehoert NICHT in die Widerrufsbelehrung: sie folgt aus der
Gewaehrleistung (§ 439 Abs. 2 BGB) und gilt unabhaengig davon, ob widerrufen
wird. Beides dort zu vermischen wuerde zwei verschiedene Rechte als eines
darstellen.

Im Product-Schema kommt damit returnFees hinzu, das bisher aus genau diesem
Grund fehlte — ReturnFeesCustomerResponsibility, der Wert fuer "Kaeufer traegt
die Ruecksendung". Er braucht keinen Betrag, was hier auch nicht ginge: die
Ruecksendung laeuft ueber den Kaeufer, nicht ueber unser Versandkonto. Der
guenstigere Sonderfall (Mangel) bleibt im Schema unabgebildet; schema.org
kennt dafuer kein Feld, und ihn generell zuzusagen waere falsch.

Beide Texte auch auf Englisch.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:51:59 +02:00
Nick SzaunigandClaude Opus 5 cc267ad8d4 fix: ohne JavaScript blieb der gesamte Seiteninhalt unter dem Hero unsichtbar
Hero, ScrollReveal und ProductCard starten mit opacity 0 und werden per
motion/react eingeblendet. Diese Startwerte stehen als inline-style bereits im
server-gerenderten HTML — auf wabenhain.com an 38 Elementen nachgezaehlt. Laeuft
kein JavaScript, blendet sie niemand wieder ein: der Besucher sieht das
Hero-Bild und darunter eine leere Seite. Kein Teilausfall, sondern der
komplette Inhalt.

Fuer Suchmaschinen war das unkritisch, der Text steht im HTML. Fuer Menschen
mit deaktiviertem JavaScript war es ein Totalausfall.

Die drei Bausteine tragen jetzt data-reveal, und ein noscript-Block im
Root-Layout hebt darueber die Startwerte auf. Das Attribut statt eines
Treffers auf [style*="opacity:0"] ist der Kern der Sache: Warenkorb-Schublade,
Menue und Cookie-Banner blenden ueber AnimatePresence ebenfalls mit opacity 0
ein, sollen ohne JavaScript aber verborgen BLEIBEN. Sie tragen das Attribut
nicht.

!important ist hier notwendig und nicht nachlaessig — einen inline-style
ueberschreibt sonst nichts.

Grenze der Loesung: noscript deckt "JavaScript deaktiviert" ab. Laedt das
Bundle dagegen und schlaegt erst spaeter fehl, greift es nicht; dafuer waere
ein Zeitgeber noetig, der die Animation im Normalfall stoeren wuerde.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:51:51 +02:00
Nick SzaunigandClaude Opus 5 c5e4978fad fix: Produkt ohne Geschmacksnoten bekam "Waldhonig — . 500 g" als Beschreibung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m5s
Die Beschreibung der Produktseite setzt Name, Geschmacksnoten und Fuellmenge
zusammen und rechnet dabei fest damit, dass Noten gepflegt sind. Sind sie es
nicht — ein freiwilliges Admin-Feld —, blieb zwischen Gedankenstrich und
Fuellmenge ein einzelner Punkt ohne Inhalt stehen.

Auf wabenhain.com an "Waldhonig" beobachtet: description und og:description
lauteten "Waldhonig — . 500 g aus eigener Imkerei in Altenmünster." Das ist
genau der Text, den Google als Snippet anzeigt und den jede geteilte Vorschau
uebernimmt.

Derselbe Fall liess unter der Ueberschrift einen leeren Absatz stehen, der
nur Abstand zwischen Produktnamen und Bewertung erzeugte.

Beides faellt jetzt weg, wenn keine Noten gepflegt sind; mit Noten bleibt die
Ausgabe unveraendert.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:31:35 +02:00
Nick SzaunigandClaude Opus 5 7dd42e35bc perf: Produktbilder werden als AVIF ausgeliefert
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m43s
next/image lieferte bisher nur WebP (die Next.js-Vorgabe). Am Hero-Bild der
Startseite (wabenhain.com, 1920x1280) nachgemessen, mit genau den
Einstellungen, die Next.js selbst verwendet — WebP q75 gegen AVIF q47
(image-optimizer.js skaliert die Qualitaet mit 50/80) bei effort 3:

    Breite   WebP        AVIF
      640     21,2 kB     12,0 kB   -43 %
      828     30,2 kB     17,4 kB   -42 %
     1080     44,4 kB     26,5 kB   -40 %
     1920    105,0 kB     65,7 kB   -37 %

Auf einer Shop-Uebersicht mit einem Dutzend Kacheln ist das der groesste
einzelne Posten der Seite. Browser ohne AVIF-Unterstuetzung bekommen
unveraendert WebP, ganz alte das Original; die Auswahl trifft Next.js anhand
des Accept-Headers.

Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in derselben
Messung durchgehend gleichauf oder schneller als WebP (1920 px: 60 ms gegen
122 ms). minimumCacheTTL steigt trotzdem von den voreingestellten vier Wochen
auf ein Jahr — nicht wegen der Kodierdauer, sondern weil hochgeladene Bilder
unveraenderlich sind: lib/uploads.ts legt jede Datei unter einem eigenen
Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL. Ein
kurzes TTL wirft hier nur brauchbare Ergebnisse weg.

Dazu zwei ausdrueckliche Festschreibungen, die das bisherige Verhalten
dokumentieren statt es zu aendern:

- qualities: [75] entspricht der Vorgabe von Next.js 16, die andere Werte
  bereits ablehnt (gegen die laufende Seite geprueft: ?q=42 antwortet mit
  400). Ausgeschrieben, damit eine spaetere Erweiterung der Liste eine
  bewusste Entscheidung ist und nicht unbemerkt Kodier-Last je Wert oeffnet.
- dangerouslyAllowSVG: false entspricht ebenfalls der Vorgabe. Ausgeschrieben,
  weil ein SVG ein Dokument ist, das Skripte enthalten kann, und hier
  same-origin ausgeliefert wuerde.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:12:44 +02:00
Nick SzaunigandClaude Opus 5 73c070f11e feat: Produktseiten liefern Google Versandkosten, Ruecknahme und Verkaeufer
Das Product-Schema der Produktseite enthielt Preis, Verfuegbarkeit und
Bewertung, aber keine der Angaben, die Google fuer ein vollstaendiges
Produkt-Suchergebnis erwartet. Praktische Folge: der Preis wurde nach einiger
Zeit als moeglicherweise veraltet eingestuft und ausgeblendet, und
Versandkosten schaetzte Google selbst — bei einem Shop, dessen Versandstaffel
ohnehin auf der Seite steht.

Neu im Offer-Block:

- shippingDetails mit der tatsaechlichen Staffel fuer ein Glas, also demselben
  "ab"-Preis, der auf der Seite ausgewiesen ist. Das Liefergebiet kommt aus
  der Admin-Einstellung (Deutschland oder EU), nicht aus einer festen Liste.
- hasMerchantReturnPolicy mit dem gesetzlichen Widerrufsrecht: vierzehn Tage
  ab Erhalt, Rueckgabe per Versand. returnFees fehlt bewusst — wer die Kosten
  der Ruecksendung traegt, sagt die Widerrufsbelehrung nicht, und eine
  Annahme an dieser Stelle waere eine Zusage gegenueber Google, die der Shop
  nirgends macht.
- priceValidUntil, itemCondition sowie seller als Verweis auf das
  Store-Schema aus dem Root-Layout, statt Name und Anschrift ein zweites Mal
  auszuschreiben.
- weight als Fuellmenge in Gramm — der Wert, ueber den Kaeufer
  Lebensmittelangebote vergleichen.

Ausserdem ein eigener openGraph-Block. Ohne ihn erbte jede Produktseite den
Block aus dem Root-Layout unveraendert: ein geteilter Honig-Link zeigte in
WhatsApp, Facebook oder Slack Titel und Text der STARTSEITE, egal welches
Produkt verlinkt war. Das Feld "images" bleibt bewusst leer, damit die
gestaltete Vorschaukarte aus opengraph-image.tsx erhalten bleibt — ein
Eintrag dort haette sie durch das rohe Produktfoto ersetzt.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:06:02 +02:00
Nick SzaunigandClaude Opus 5 a8990a872a fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex
robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler.
Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo
ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als
inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine
Informationen verfuegbar".

Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per
robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag
nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus
dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein
noindex.

Jetzt greifen beide Mechanismen zusammen statt gegeneinander:

- lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links
  weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre
  Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie,
  Bildindexierung und Textausschnitt.
- Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate,
  der Warenkorb noIndex.
- Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues
  konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt
  feldweise, ein eigener title in einer Unterseite loescht den Wert also
  nicht.
- robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler
  wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das
  noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen
  die Produktbilder, die in der Bildersuche erwuenscht sind.

Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot
und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie
die host-Angabe in robots.txt.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 03:05:54 +02:00
Nick SzaunigandClaude Opus 5 bffcce127d fix: "Passwort vergessen" schob sich ueber das CAPTCHA
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m41s
Im Anmeldeformular standen CAPTCHA, Absenden-Knopf und der
"Passwort vergessen"-Link in EINER Flex-Zeile ohne Umbruch:

    <div className="flex items-center justify-between gap-4">

Das Turnstile-Widget ist ein iframe mit FESTER Breite von rund 300 px und
schrumpft nicht mit. Die Zeile steht zudem in einer der beiden Spalten
der Kontoseite, hat also nur die halbe Breite zur Verfuegung. Der Platz
reichte daher nie — die Elemente schoben sich uebereinander, und zwar
nicht nur auf schmalen Bildschirmen.

Jetzt steht das Widget auf einer eigenen Zeile, Knopf und Link darunter
in einer Zeile mit "flex-wrap". Das Registrierungsformular daneben hat es
schon immer so gemacht; die beiden sind damit endlich gleich aufgebaut.

Damit das an keiner Stelle wiederkommen kann, traegt das Widget selbst
jetzt "w-fit max-w-full overflow-x-auto". Das ist kein Schoenheitsmittel,
sondern eine Zusicherung: der iframe bleibt in jedem Fall innerhalb
seines Kastens. Ist der zu schmal, laesst sich der Inhalt seitlich
schieben, statt das Layout zu zerlegen — unabhaengig davon, wie eine
kuenftige Seite ihn einbettet.

Die uebrigen sieben Einbindungen (Registrierung, Passwort vergessen,
Passwort zuruecksetzen, Zwei-Faktor-Code, Produktseite, Firmenanfrage,
Newsletter, Checkout) habe ich geprueft: dort steht das Widget bereits
auf einer eigenen Zeile.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 02:52:15 +02:00
Nick SzaunigandClaude Opus 5 7f9ed7262f refactor: Liefergebiet bekommt einen eigenen Abschnitt "Versand"
Die Einstellung lag unter "Firmendaten" — zwischen Firmenname, Slogan und
Anschrift. Dort sucht sie niemand: eine Versandregel ist keine
Stammdatenangabe. Genau das ist beim ersten Suchen auch passiert.

Jetzt ein eigener Abschnitt "Versand" (englisch "Shipping") unter
Admin → Inhalte → Texte, direkt hinter den Firmendaten. Der
Feldtyp-Vertrag der Registry bekommt dafuer die Gruppe "shipping".

Rein organisatorisch — Schluessel, Wert und Verhalten bleiben gleich, ein
bereits gepflegter Wert wandert einfach mit.

tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-20 02:16:06 +02:00
Nick SzaunigandClaude Opus 5 1bdf4df04a feat: Gebiete mit Zollpflicht vom Versand ausgenommen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m7s
Vorgabe war: nur dorthin liefern, wo keine Zollabwicklung noetig ist. Die
EU-Einstellung erfuellt das auf Laenderebene — die Union ist eine
Zollunion, eine Sendung von Deutschland nach Frankreich braucht keine
Zollanmeldung. Der Laendercode allein genuegt aber nicht.

Einige Gebiete gehoeren zu einem EU-Mitgliedstaat, liegen aber ausserhalb
des Zoll- und/oder Steuergebiets der Union. Dorthin ist jede Sendung eine
Ausfuhr mit Zollinhaltserklaerung, Handelsrechnung und moeglichen
Einfuhrabgaben beim Empfaenger. Zwei davon liegen in DEUTSCHLAND:

  Helgoland (27498) und Büsingen am Hochrhein (78266)

Sie waren also selbst bei der Einstellung "Nur Deutschland" betroffen.
Dazu kommen Kanarische Inseln, Ceuta, Melilla, die franzoesischen
Ueberseegebiete, Livigno, Campione d'Italia, Åland und der Berg Athos.

Erkennbar sind sie nur an der Postleitzahl. findCustomsExcludedArea
prueft sie je Land, denn dieselbe Ziffernfolge bedeutet anderswo etwas
anderes: "22" ist in Finnland Åland, in Italien die Provinz Como. Die
Checkout-Route weist solche Anschriften mit einer Meldung ab, die das
Gebiet benennt und auf die Abholung verweist.

Azoren und Madeira sind bewusst NICHT ausgenommen: sie gehoeren voll zum
Zoll- und Steuergebiet, dorthin gilt der normale Binnenversand. Ein Test
haelt das fest, damit sie nicht versehentlich mitgesperrt werden.

Die Hinweistexte auf "Versand und Zahlung" nennen die Ausnahmen jetzt
ausdruecklich — in beiden Einstellungen und beiden Sprachen.

8 neue Tests in shipping-region.test.ts, 1 in der Checkout-Route
("weist Helgoland ab, obwohl es in Deutschland liegt").

tsc: 0 Fehler. vitest: 359/359 gruen (zuvor 350). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:03:10 +02:00
Nick SzaunigandClaude Opus 5 6d7a77c8dd feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:

1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
   Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
   Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
   Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
   einzigen Eintrag waere eine Attrappe.

2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
   ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
   laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
   Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
   Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
   schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
   geht (Adresse im Gebiet oder Abholung).

3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
   passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
   dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.

Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.

"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.

Technisch dazugekommen:
  - lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
    shipping-calc.ts), 11 Tests.
  - content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
    ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
    wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
    saehe, dass die Umstellung nicht gegriffen hat.
  - Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
    Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
  - 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
    Drittland trotzdem abgewiesen).

tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:59:15 +02:00
Nick SzaunigandClaude Opus 5 29f59adc6b feat: Kommando zum Nachschlagen der Google-Place-ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m37s
Die Place-ID steht nicht in der Google Cloud Console — dort gibt es nur
den API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss
ueber die Places API nachgeschlagen werden.

  pnpm places:find "Wabenhain Altenmünster"

gibt Name, Anschrift und GOOGLE_PLACE_ID je Treffer aus.

Das Kommando ist zugleich die schnellste Probe auf die Einrichtung:
kommt eine ID heraus, sind "Places API (New)", Abrechnung und Schluessel
richtig gesetzt und die Startseite kann die Rezensionen abrufen. Kommt
eine Ablehnung, nennt sie die drei tatsaechlich moeglichen Ursachen
(API nicht aktiviert, keine Abrechnung, OAuth-Client statt
API-Schluessel bzw. Referrer-Beschraenkung) — statt wie Google pauschal
auf den Schluessel zu zeigen.

Die Feldmaske fordert nur id, displayName und formattedAddress an: die
API rechnet nach angeforderten Feldern ab.

tsc: 0 Fehler. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:32:05 +02:00
Nick SzaunigandClaude Opus 5 6639da130e fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.

Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.

Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.

Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.

Zwei Verhaltensaenderungen, beide unvermeidbar:
  - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
    Rezensionen in Googles eigener Reihenfolge.
  - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
    Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
    Zuordnung zur Quelle.

README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.

Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.

tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:16:19 +02:00
Nick SzaunigandClaude Opus 5 9b10a9f0c5 perf: Checkout haelt gleichzeitigem Andrang stand
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.

1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft

Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.

Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.

2. Zu enger Verbindungspool, zu knappe Transaktionsfristen

Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.

Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.

Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.

3. Drei Regressionstests

Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.

tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:10:41 +02:00
Nick SzaunigandClaude Opus 5 cbc81caa1a fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s
Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.

Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:

    const cacheKey = id || src;
    // Script has already loaded
    if (cacheKey && LoadCache.has(cacheKey)) {
      return;
    }

Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:

  - die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
    zwei Widgets — eines davon war immer tot;
  - jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
    statt ueber F5.

Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".

Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.

Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:

  - "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
    Containern sucht;
  - turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
  - ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
    laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
    Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
    abschicken.

Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.

Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.

tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 05:46:14 +02:00
Nick SzaunigandClaude Opus 5 e5cae9b49d perf: Firmendaten und Social-Links je Request nur noch einmal laden
getCompanyInfo() wurde bei JEDEM Seitenaufruf mehrfach ausgefuehrt: das
Root-Layout braucht es zweimal (einmal in generateMetadata, einmal fuer
das LocalBusiness-Schema im Rumpf), dazu kommt fast jede Seite mit einem
eigenen Aufruf. Jeder davon war eine eigene Datenbankabfrage ueber
dieselben zehn Schluessel — dreimal dasselbe Ergebnis pro gerenderter
Seite, auf jeder Seite des Shops.

Beide Funktionen sind jetzt in React-cache() gehuellt. Die
Deduplizierung gilt je Request und ist KEIN serverweiter
Zwischenspeicher: eine Aenderung im Admin-Bereich wirkt unveraendert
sofort, es gibt nichts zu invalidieren. Beide Funktionen nehmen keine
Argumente, damit greift die Deduplizierung zuverlaessig — cache()
unterscheidet Aufrufe anhand ihrer Argumente.

Geprueft: "next build" laeuft durch, tsc 0 Fehler, vitest 328/328 gruen,
eslint 0 Warnungen.

Nicht geaendert, aber geprueft: die Admin-Listen ohne "take" (Chargen,
Ernten, Gutscheine, Inhalte, Produkte, Versandstaffeln) betreffen
ausnahmslos kleine, langsam wachsende Tabellen — eine Begrenzung waere
dort Vorratsarbeit. Die Listen auf den wachsenden Tabellen (Bestellungen,
Bewertungen, Audit-Log, Retouren, Anfragen, Benutzer) sind bereits
begrenzt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:47:43 +02:00
Nick SzaunigandClaude Opus 5 2bbb38c31a fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes
SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei
Admin-Konten.

requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu,
wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter
jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein
zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine
vollstaendige Session an und lieferte damit ein Flag, das "geprueft"
behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die
Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des
Entra-Mandanten, die dieser Code nicht nachpruefen kann.

Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben
TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand:
twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer
KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen.

PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard.

Die Startseite des Admin-Bereichs lud bei jedem Aufruf

  1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus
     fuenf Zeilen "Top-Produkte" zu rechnen, und
  2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl
     ("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma
     koenne kein HAVING. Es kann.

Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem
Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je
Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht
mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die
Primaerschluessel der tatsaechlich verkauften Produkte.

PERFORMANCE — fehlende Indizes auf order_items.

PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als
MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten
dadurch zwei:

  - productId: hasCustomerPurchased (lib/reviews.ts) prueft den
    "verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine
    angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan.
  - batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict
    gegen dieselbe Tabelle.

Migration 20260919010000_order_item_fk_indexes.

tsc: 0 Fehler. vitest: 328/328 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 04:22:53 +02:00
Nick SzaunigandClaude Opus 5 3e81896194 fix: Session-Pruefung akzeptierte Token anderer Zwecke
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".

Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:

  signTwoFactorChallenge("customer_123")
  -> verifySessionToken(...) ergab
     { customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }

Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.

Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.

verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.

Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.

Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.

tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 02:30:47 +02:00
Nick SzaunigandClaude Opus 5 5170e477bc fix: leere Telefonnummer wird nirgends mehr angezeigt
Ohne im Admin-Bereich gepflegte Nummer griff bisher der Rueckfallwert aus
lib/site-config.ts — und der war eine Platzhalternummer
("+49 (0) 000 000000"). Sie stand damit tatsaechlich auf der Kontaktseite
und im Impressum. Eine Nummer, unter der niemand erreichbar ist, ist
schlimmer als gar keine Angabe.

Der Rueckfallwert ist jetzt leer, und beide Stellen lassen die Zeile bei
leerem Feld weg:

- kontakt/page.tsx rendert das Absatz-Element gar nicht erst.
- impressum/page.tsx nimmt die Telefonzeile aus dem Kontakt-Abschnitt.
  § 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
  nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.

Der DHL-Versand war bereits darauf vorbereitet: buildDhlContact setzt
"phone: address.phone || undefined" und laesst das Feld im Aufruf
entsprechend weg (lib/dhl.ts).

Die Go-Live-Pruefung warnt weiterhin bei einer EINGETRAGENEN
Platzhalternummer, aber nicht mehr bei einem leeren Feld — leer ist ab
sofort eine zulaessige Entscheidung, und der Hinweistext sagt das.

tsc: 0 Fehler. vitest: 316/316 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 20:54:54 +02:00
Nick SzaunigandClaude Opus 5 dbf146c19f test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten
bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene
Haken fuer Werte, die gar nicht gesetzt waren.

Tests fuer api/checkout/route.ts (10 Faelle)
  Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes
  Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die
  beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung
  aufgeloest und OHNE orderNumber geantwortet; laesst sich die
  Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per
  Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird.

Tests fuer api/checkout/webhook/route.ts (12 Faelle)
  Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt
  Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und
  Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine
  stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung,
  fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung.

Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt.

Go-Live-Pruefung: falsch-positive Haken beseitigt
  Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert
  einfach — eine leere Zeichenkette enthaelt nun einmal kein
  "example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist
  die echte Domain" als bestanden, direkt neben dem Blocker
  "NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt
  sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste
  Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber
  genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen
  Folgeregeln bei fehlendem Wert entfallen.

  Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung
  prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der
  ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal
  erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf
  im Pod.

tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 17:50:38 +02:00
Nick SzaunigandClaude Opus 5 1fa8dc9ce2 fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 16:57:18 +02:00
Nick SzaunigandClaude Opus 5 dda0a66c6c fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT"
stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein
echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche
nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen
Vorgänge.

Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein
Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch
nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen
sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf.

Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed.
Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert
(SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout
abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als
"STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim
bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man
selbst abgebrochen hat, verwirrt mehr als sie hilft.

releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter.
Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es
entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts.
Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt,
fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter
keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen,
sonst wäre der Bestand danach nicht mehr zurückzubuchen.

Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt
als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen
auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen,
indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer
Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter
den Händen wegzunehmen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:13:35 +02:00
Nick SzaunigandClaude Opus 5 a3f91b83fc fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.

Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.

Drei Schichten dagegen, jede für sich wirksam:

1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
   lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
   gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
   30 Minuten" abgewiesen werden.

2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
   eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
   steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
   also die offene Zahlung einer fremden Person abbrechen. Der Token wird
   nach Gebrauch aus der Adresszeile entfernt.

   {CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
   success_url und return_url dokumentiert, nicht für cancel_url — darauf
   baut hier nichts.

3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
   genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
   ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
   Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
   blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
   aus wie jede andere offene.

Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.

Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.

Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.

Weiter in diesem Zug:

- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
  Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
  Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
  bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
  Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
  versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.

- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
  hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
  dann meldet die Prüfung "ungeprüft" statt "in Ordnung".

- Packstation-Bestellungen waren vollständig kaputt: die Muster für
  Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
  Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
  Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
  Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
  Richtige sagte. Drei Tests halten das jetzt fest.

- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
  Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
  einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
  auf.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 02:07:22 +02:00
Nick SzaunigandClaude Opus 5 e525db84ef feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.

Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.

Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.

Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.

Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 01:26:18 +02:00
Nick SzaunigandClaude Opus 5 7fe1a01550 fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s
Die Anfrage an die Places API setzte "fields=review". Richtig ist "reviews"
(Plural). Google antwortet auf den falschen Wert mit INVALID_REQUEST, die
Funktion gab daraufhin null zurueck, und die Startseite fiel auf die
statischen Beispiel-Stimmen zurueck.

Nach aussen sah das voellig normal aus — eine Bewertungen-Sektion mit
Zitaten und Sternen war ja da. Nur waren es nie die echten. Genau deshalb
blieb der Tippfehler unbemerkt.

Damit das nicht wieder passieren kann, meldet die Anbindung jetzt, WARUM sie
nichts liefert. Vorher fuehrten alle Fehlerfaelle stumm zum selben Bild:

  [google-reviews] Places API meldet "REQUEST_DENIED": ...

REQUEST_DENIED (Schluessel ungueltig oder Places API im Projekt nicht
aktiviert), NOT_FOUND (Place-ID stimmt nicht) und OVER_QUERY_LIMIT
(Kontingent erschoepft oder keine Abrechnung hinterlegt) sind damit von
aussen unterscheidbar.

Weitere Korrekturen:

- Zeitlimit von fuenf Sekunden. Ohne das haengt die Startseite an einer
  nicht antwortenden fremden API — dieselbe Absicherung wie beim
  Passwort-Leak-Abgleich.
- Rezensionen verlinken auf ihre Quelle bei Google. Die Nutzungsbedingungen
  der Places API verlangen die Zuordnung; bisher stand ein fremdes Zitat
  unbelegt auf der Seite. author_url und profile_photo_url wurden aus der
  Antwort schlicht verworfen.
- Rezensionen ohne Text werden uebersprungen. Reine Sternebewertungen sind
  in der Antwort enthalten und waeren als Zitat eine leere Sprechblase; "text"
  ist laut Google optional, war hier aber als Pflichtfeld typisiert.

README: Einrichtung, Bedeutung der Statuscodes und der Hinweis, dass Google
den genutzten place/details-Endpunkt als Legacy fuehrt — bleibt
REQUEST_DENIED trotz gueltigem Schluessel bestehen, ist die Umstellung auf
die Places API (New) faellig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:17:13 +02:00
Nick SzaunigandClaude Opus 5 33580b3290 feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem
CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt
offen, die am meisten hergeben:

- Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten
  bzw. des sechsstelligen Codes.
- Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein
  Weg herauszufinden, welche Adressen registriert sind.
- Checkout — automatisierte Bestellungen blockieren Lagerbestand und
  eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests
  brauchbar macht.
- Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse
  entgegen und loest spaeter Versand an sie aus.

Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte
Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der
Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld
— er schickt kein Formular ab.

Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine
Seite den Feldnamen anders geschrieben, waere das Token leer angekommen —
und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere
der Schutz dort lautlos ausgefallen.

Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein
BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung
nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen
Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein,
waehrend jedes Formular offensteht.

Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein
GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein
Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt
rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse.
Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben
ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches
Raetsel kostete nur Bedienbarkeit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:07:12 +02:00
Nick SzaunigandClaude Opus 5 6135c1f9c0 fix(security): Rechnungsablage kann nicht mehr ins oeffentliche Upload-Verzeichnis geraten
app/uploads/[...path] liefert JEDE Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus — das ist fuer Produktfotos gewollt. Dass Rechnungen nicht
darunter liegen, war bisher aber allein eine Frage der richtigen
Konfiguration: ein INVOICE_DIR="/app/uploads/rechnungen" haette genuegt,
damit fremde Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar
sind, der den Pfad erraet.

Das waere im Betrieb nicht aufgefallen, denn funktioniert haette weiterhin
alles: Ablegen, Versenden und Abrufen ueber /rechnung/<nummer>/pdf haetten
sich genauso verhalten wie vorher. Nur haette daneben eine zweite,
ungeschuetzte Tuer zu denselben Dateien gestanden.

Drei Sicherungen statt einer Anmerkung in der Dokumentation:

- lib/invoice-storage.ts wirft beim Laden des Moduls, wenn INVOICE_DIR
  innerhalb von UPLOAD_DIR liegt. Lieber startet die Anwendung nicht, als
  dass sie Belege oeffentlich ausliefert.
- "pnpm preflight" meldet denselben Fall als Blocker, also bereits vor dem
  Deploy statt erst beim Start.
- Ein Test haelt die Trennung fest, damit sie nicht beim naechsten Umbau
  unbemerkt verloren geht.

Der uebrige Sicherheitsdurchgang ueber die Aenderungen dieser Sitzung ergab
keine ausnutzbaren Befunde: der Admin-Bereich ist dreifach abgesichert
(Proxy, Layout, jede Server Action einzeln), jeweils autoritativ gegen die
Datenbank und mit erzwungener Zwei-Faktor-Authentifizierung; das returnTo
des Sync-Knopfes wird gegen eine feste Liste geprueft; Pfade aus der
Datenbank werden weiterhin gegen ihr Wurzelverzeichnis validiert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:29:54 +02:00
Nick SzaunigandClaude Opus 5 92ac826baa feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.

syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.

Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.

Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.

Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:26:09 +02:00
Nick SzaunigandClaude Opus 5 8f0f06fd13 feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht
Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht.
Neue Server Action deleteProductAction samt Knopf in der Produktliste,
im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog,
Audit-Log-Eintrag, Pruefung vor dem Loeschen.

Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit
onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen
und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen
Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits
gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht
(§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht",
sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt
aus dem Shop und der Beleg bleibt.

Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder
bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste
Datei ist harmloser als ein totes Bild an anderer Stelle.

Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der
Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas
darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft"
bzw. "in Verwendung").

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:10:09 +02:00
Nick SzaunigandClaude Opus 5 dc13bd071b fix: abgebrochene Video-Uploads werden als das gemeldet, was sie sind
"Video-Upload: unerwarteter Fehler. Error: aborted { code: ECONNRESET }"
verdeckte die eigentliche Aussage: nicht die Verarbeitung ist gescheitert,
die Datei ist gar nicht erst vollstaendig angekommen. Der Fall landete in
der allgemeinen Auffanglinie und sah damit aus wie ein Programmfehler.

Ein Abbruch der Verbindung wird jetzt erkannt und mit Mengenangabe geloggt:

  [upload-video] Verbindung abgebrochen nach 41943040 von angekuendigten
  78643200 Bytes.

Genau diese Zahlen trennen die beiden moeglichen Ursachen. Bricht es immer
bei derselben BYTEZAHL ab, liegt eine Groessengrenze auf dem Weg. Bricht es
nach derselben ZEIT ab, ist es eine Zeitgrenze — und davon liegen mehrere
hintereinander, allen voran Node selbst: dessen requestTimeout beendet einen
Request nach fuenf Minuten und laesst sich nicht konfigurieren. Das ist die
harte Obergrenze fuer die Dauer eines Uploads, unabhaengig davon, dass
MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt.

Statt eines 500ers antwortet die Route mit 408 und einer Meldung, die zum
Wiederholen und zum vorherigen Verkleinern raet. Die Antwort erreicht die
Kundschaft meist nicht mehr — die Verbindung ist ja weg —, ist aber richtig
fuer den Fall, dass nur eine Zwischenstation aufgegeben hat.

k8s/README.md bekommt dazu einen Abschnitt mit der Tabelle der Zeitgrenzen
und dem ffmpeg-Aufruf, mit dem sich ein Hero-Video vorab auf genau das Mass
bringen laesst, auf das der Server es ohnehin umrechnet. Was darueber
hinausgeht, wird nur uebertragen, um anschliessend weggerechnet zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 17:38:25 +02:00
Nick SzaunigandClaude Opus 5 425fd76bc1 docs: fsGroup wirkt bei SMB/NFS nicht — Rechte gehoeren in die mountOptions
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Der Kommentar am fsGroup-Feld behauptete, Kubernetes ordne damit JEDES
gemountete Volume der Gruppe 1001 zu. Das stimmt nur fuer blockbasierten
Speicher.

Bei SMB/CIFS und NFS wird fsGroup ignoriert: diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachtraeglich aendern koennte. Ohne
uid/gid in den mountOptions erscheint dort alles als root:root, und der als
uid 1001 laufende Container scheitert bei jedem Upload mit EACCES — also
genau die Meldung "Die Datei konnte nicht gespeichert werden", ohne dass am
Deployment etwas falsch aussieht.

k8s/README.md bekommt dafuer einen eigenen Abschnitt: Diagnose im laufenden
Pod, die noetigen mountOptions (uid=1001, gid=1001 zusaetzlich zu
dir_mode/file_mode) und der Hinweis, dass Aenderungen daran erst beim
naechsten Einhaengen greifen, ein Rollout-Restart also dazugehoert.

Ebenfalls vermerkt: noperm umgeht die Rechtepruefung, statt den Besitz
richtigzustellen — es ersetzt uid/gid nicht.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:59:03 +02:00
Nick SzaunigandClaude Opus 5 625b73a8c8 feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz
haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein
Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
Bienenwachskerze nicht.

Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt:

1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer
   Ausnahmen, die sich nicht aus der Kategorie ergeben.
2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS.
3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert.

Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen
Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt.
Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0
bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen
eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem
leeren Feld eine 0 gemacht.

Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im
selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und
Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der
Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt
das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig
auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent
verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen
exakt den Rechnungsbetrag ergibt.

Der Satz wandert von der Rechnung an die BESTELLPOSITION
(OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis:
wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen
unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz
je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte
entstand in der vorigen Migration und wurde nie in Betrieb beschrieben.

VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der
sicherere ist — als Lebensmittel waeren 7 richtig, als
Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen
vermerkt.

Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand"
gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die
Saetze ohne globalen Zustand testen lassen, und greift auf die
Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine
Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:54:26 +02:00
Nick SzaunigandClaude Opus 5 7c59283ede feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.

DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.

Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.

Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.

Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.

Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.

Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 06:32:12 +02:00
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
Nick SzaunigandClaude Opus 5 c61ae92390 feat: nicht dauerhaftes Upload-Verzeichnis wird erkannt, bevor Daten verloren gehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Ergaenzt den Abgleich aus c8ddcec um die Pruefung, die frueher anschlaegt.
checkUploadFiles() erkennt fehlende Dateien — also erst, NACHDEM sie weg
sind. checkUploadVolume() erkennt die Ursache schon vorher: liegt
UPLOAD_DIR im Container statt auf einem gemounteten Volume, sind alle
Bilder und Videos beim naechsten Redeploy verloren, auch wenn gerade noch
jede Datei da ist.

Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat
eine andere als sein uebergeordnetes Verzeichnis. Bewusst eine Heuristik
und damit nur ein Hinweis, keine Blockade — ein Bind-Mount innerhalb
desselben Dateisystems kann dieselbe Kennung tragen. Ausserhalb von
NODE_ENV=production immer "gemountet", weil UPLOAD_DIR lokal als
"./uploads" im Projektverzeichnis liegt und dort sonst ein Daueralarm
entstuende.

Die Funktion liegt in lib/uploads.ts statt in lib/upload-health.ts:
Letzteres importiert Prisma und laesst sich ohne DATABASE_URL nicht
testen — dieselbe Ueberlegung wie bei lib/form-fields.ts in ff365c3. Als
reine Funktion neben UPLOAD_DIR ist sie direkt pruefbar, und dort gehoert
sie fachlich ohnehin hin.

Vier Tests: kein Alarm ausserhalb der Produktion, ein gewoehnliches
Verzeichnis wird in Produktion als nicht dauerhaft erkannt, ein fehlendes
Verzeichnis erzeugt keinen Fehlalarm, und der gepruefte Pfad kommt mit
zurueck, damit die Warnung ihn benennen kann.

Das Admin-Dashboard zeigt beide Befunde getrennt: die Vorwarnung als
Hinweis, fehlende Dateien als Fehler.

Nicht enthalten und auch nicht moeglich: das Anwenden der Manifeste im
Cluster. Auf dieser Maschine gibt es weder kubectl noch eine kubeconfig;
die beiden kubectl-Befehle aus k8s/README.md muss jemand mit
Cluster-Zugriff einmalig ausfuehren.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:09:31 +02:00
Nick SzaunigandClaude Opus 5 c8ddcec04c feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.

Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.

Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.

lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.

Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.

Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:06:33 +02:00
Nick SzaunigandClaude Opus 5 bdce9b6a0a feat: Impressum mit den echten Inhaberdaten ausgefuellt
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen,
Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden
Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug
"[Name wie oben]" und nennt jetzt ebenfalls den Namen.

Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war
eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer
vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich
selbst widersprochen.

Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die
Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine
frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin
aenderbar, ohne dass dafuer ein Deploy noetig waere.

Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig —
der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche
Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt.

Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten
werden dir vor Abschluss der Bestellung transparent angezeigt." Das war
der Ersatztext (tierFallback), der nur erschien, solange keine
Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht
leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ
und Verwendung entfernt, nicht nur geleert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:02:45 +02:00
Nick SzaunigandClaude Opus 5 9dd71ffb9a feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.

Aufbau:

- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
  (GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
  anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
  Header, kein OAuth und kein Geschaeftskundenvertrag. Die
  Packstation-Auswahl funktioniert damit auch, solange die
  Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
  DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
  (15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
  dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
  ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
  20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
  ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
  Packstation nur bei Versand, nicht bei Abholung.

Zwei Entscheidungen, die Folgearbeit ersparen:

- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
  Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
  Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
  Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
  das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
  Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
  Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
  auf eine Anschrift, die es nicht gibt.

Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.

Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.

Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 00:37:00 +02:00
Nick SzaunigandClaude Opus 5 ff365c3cb1 feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und
Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in
der allein die Reihenfolge entschied, welches Bild wohin kommt.

Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom
Standort pflegen wollte, bekam es auf Position 0 — also in die linke
Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt,
war im Formular ausserdem nirgends erkennbar.

Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im
Ernten-Formular:

  Bild 1 — linke Kachel auf der Chargenseite
  Bild 2 — rechte Kachel auf der Chargenseite (Standort)

Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen
eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und
werden in dieser Reihenfolge uebermittelt.

Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt
und filtert leere Werte heraus, wodurch ein leerer erster Slot
verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler,
der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als ""
stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt
weiterhin [] statt ["", ""]).

Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im
Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit
"??" haette dieser leere String als gueltiger Wert gegolten und das
Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]).

strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden
aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben
unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen
Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als
reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt
pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts.

Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt
offen), mit Gegenprobe, dass strArray denselben Fall weiterhin
zusammenrutschen laesst.

Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die
Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 21:10:12 +02:00
Nick SzaunigandClaude Opus 5 979d39e677 feat: taegliche Datenbanksicherung mit dokumentierter Wiederherstellung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m57s
Es gab bisher keinerlei Sicherung: Bestellungen, Rechnungen,
Kundenkonten und Chargendaten lagen in einem einzelnen
PersistentVolume ohne Netz. Ein Speicherfehler, ein versehentliches
"kubectl delete pvc" oder ein misslungenes Cluster-Update haetten alles
davon unwiederbringlich geloescht. Rechnungen unterliegen in
Deutschland zusaetzlich einer zehnjaehrigen Aufbewahrungspflicht
(§ 147 AO) — ein Datenverlust waere hier nicht nur aergerlich, sondern
ein Rechtsproblem.

Neu: 07-backup-cronjob.yaml — naechtlicher pg_dump auf ein eigenes
Volume, gzip-komprimiert, 30 Tage Aufbewahrung, concurrencyPolicy
Forbid. Fehlgeschlagene Laeufe werden laenger aufgehoben als
erfolgreiche: nur an ihnen laesst sich per "kubectl logs"
nachvollziehen, warum eine Sicherung ausblieb.

Zwei Details, an denen Sicherungsskripte typischerweise scheitern:

- "set -o pipefail". Ohne das liefert "pg_dump | gzip" den Exit-Code von
  gzip, und gzip gelingt auch dann, wenn pg_dump vorher abgebrochen ist.
  Der Job galte als erfolgreich, im Verzeichnis laege eine
  abgeschnittene Datei, und das faellt erst im Ernstfall auf. Beide
  Varianten mit einem absichtlich abbrechenden Dump geprueft: ohne
  pipefail Exit 0 samt vollstaendig aussehender .sql.gz, mit pipefail
  Exit 1 und keine .sql.gz.
- Schreiben unter ".partial" und erst danach umbenennen, damit ein
  mitten im Lauf abgebrochener Job keine Datei hinterlaesst, die wie
  eine vollstaendige Sicherung aussieht. Liegengebliebene .partial-Reste
  raeumt derselbe Lauf mit auf.

k8s/README.md: neuer Abschnitt "Sicherung und Wiederherstellung" mit
Ansehen, manuellem Ausloesen, Herausziehen aus dem Cluster und dem
vollstaendigen Wiederherstellungsweg (App auf 0 skalieren, Dump
einspielen, wieder hochskalieren) — samt dem Hinweis, dass eine nie
getestete Sicherung keine Sicherung ist. Der Abschnitt "Bekannte
Grenzen" nannte "ohne Replikation/Backup-Automation"; das stimmt jetzt
nur noch zur Haelfte und ist entsprechend praezisiert: die Sicherung
liegt weiterhin im selben Cluster und ersetzt keine Replikation.

Alle Manifeste gegen einen YAML-Parser geprueft.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:21:19 +02:00
Nick SzaunigandClaude Opus 5 21ac8bbc6a fix: Go-Live-Pruefung liest den ausgelieferten Text statt der Quelltext-Vorlage
Die Pruefung der Rechtstexte war falsch gebaut: sie suchte
[PLATZHALTER]-Markierungen in src/i18n/dictionaries/de.ts und en.ts. Diese
Dateien sind aber nur der RUECKFALLWERT. Impressum, Datenschutz, AGB,
Widerrufsbelehrung und die Telefonnummer sind im Admin-Bereich unter
"Inhalte" pflegbar (lib/content-registry.ts, Tabelle SiteContent) — genau
wie die uebrigen Firmendaten.

Zwei Folgen davon:

- Falsch-positiv: Wer sein Impressum im Admin-Bereich ausfuellt — ohne
  Deploy, so wie vorgesehen —, bekam trotzdem dauerhaft einen Blocker
  gemeldet, der sich ueber die Oberflaeche gar nicht schliessen liess.
- Falsch-negativ: Wer einen Platzhaltertext erst im Admin eintraegt, kam
  ungeprueft durch.

Das Skript ist deshalb von .mjs auf .ts gewechselt (ueber tsx, wie die
Seed-Skripte) und fragt jetzt getContentValue() ab — also genau den Text,
den Besucher auf /impressum tatsaechlich zu sehen bekommen, je
Sprachfassung einzeln. Neben dem Impressum werden nun auch Datenschutz,
AGB und Widerrufsbelehrung geprueft; der Befund benennt praezise, welcher
Text in welcher Sprache noch offen ist, statt pauschal eine Datei zu
nennen.

Ist die Datenbank vom ausfuehrenden Rechner aus nicht erreichbar, meldet
das Skript diese Punkte ausdruecklich als UNGEPRUEFT (Warnung) — es raet
weder "in Ordnung" noch "blockiert". Beide Wege gegen die lokale
Datenbank geprueft: mit Verbindung meldet es "Impressum (de), Impressum
(en)" als einzigen offenen Rechtstext, ohne Verbindung die Warnung samt
Ursache.

Kein Top-Level-await: das Projekt hat kein "type": "module", tsx
uebersetzt nach CommonJS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:17:09 +02:00
Nick SzaunigandClaude Opus 5 a78c1cd235 fix: hochgeladene Bilder ueberleben einen Redeploy
Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist
an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy
und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-,
Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade —
die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass
irgendwo ein Fehler aufgetaucht waere.

Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei)
mit einem PersistentVolumeClaim, den das Deployment jetzt bindet.
storageClassName ist bewusst auskommentiert, damit das Manifest ohne
Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt.

Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard
"RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein
ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue
Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating
haengen, und das im k8s/README.md dokumentierte
"kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet
wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie
zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher
gehaltenen Rate-Limits teilen wuerden.

Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell:
claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der
mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein.

preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com"
geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie
stehen, zeigten Sitemap, Canonicals und jeder Link in jeder
Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen
HTTPS-/localhost-Pruefungen angeschlagen haetten.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:13:06 +02:00
Nick SzaunigandClaude Opus 5 91aee0d609 fix: deaktivierte Produkte sind nicht mehr bestellbar
Ein Produkt, das im Admin-Bereich auf "Inaktiv" gesetzt wird, verschwand
zwar aus der Shop-Uebersicht und aus der Sitemap — seine Detailseite blieb
aber vollstaendig bestellbar. Erreichbar war sie weiterhin ueber
Direktlink, Lesezeichen, alte Newsletter, "Zuletzt angesehen" und den
Google-Index. getProductBySlug filtert isActive nicht, und die Seite
pruefte es nicht nach.

Auch der Checkout selbst pruefte es nicht: getProductsByIds filtert
bewusst nicht danach (es wird auch fuer bereits bestellte, inzwischen
deaktivierte Produkte genutzt, siehe lib/data.ts), und die Route verliess
sich allein auf den Abgleich der Anzahl gefundener Produkte. Die
Warenkorb-Oberflaeche zeigt ein deaktiviertes Produkt ueber
/api/shop/cart-stock zwar mit Bestand 0 an — aber das ist nur der Client.
Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert wurde,
kam ungeprueft durch: die Bestellung wurde angelegt und tatsaechlich
bezahlt, obwohl die Imkerei den Artikel gerade aus dem Verkauf genommen
hatte.

Behoben an beiden Stellen:

- Produktseite: isPurchasable = isActive && Bestand > 0 steuert jetzt
  Bestellbutton, Sticky-Leiste und die schema.org-Verfuegbarkeit. Die
  Seite bleibt bewusst erreichbar statt 404 zu liefern — Bestellhistorie
  und Sendungsverfolgung verlinken hierher zum Bewerten, und wer eine
  ausgelaufene Saisonernte gekauft hat, soll sie weiter bewerten koennen.
  Stattdessen "robots: noindex", damit sie nicht ueber Google als
  vermeintlich bestellbares Produkt gefunden wird.
- Die "Benachrichtige mich"-Anmeldung erscheint nur noch bei einem aktiven,
  aber ausverkauften Produkt. Die Benachrichtigung haengt am Lagerbestand
  (lib/stock-notifications.ts) — bei einem dauerhaft eingestellten Produkt
  waere sie ein Versprechen, das nie eingeloest wird.
- Checkout-Route: lehnt Bestellungen mit deaktivierten Produkten
  serverseitig ab, ueber dieselbe Fehlermeldung wie ein nicht mehr
  vorhandenes Produkt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:08:15 +02:00
Nick SzaunigandClaude Opus 5 746c12ae68 chore: ungenutzte create-next-app-Platzhaltergrafiken entfernen
next.svg, vercel.svg, globe.svg, window.svg und file.svg stammen
unveraendert aus dem Projektgeruest und werden nirgends referenziert
(weder in src/ noch in e2e/). Sie wurden bisher in jedes Docker-Image
mitgebaut und waren unter der eigenen Domain oeffentlich abrufbar — ein
Next.js- und ein Vercel-Logo auf einer Imkerei-Seite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:37 +02:00
Nick SzaunigandClaude Opus 5 c9ec2b4998 feat: Go-Live-Pruefung "pnpm preflight"
Die letzten offenen Punkte vor dem Live-Gang sind Inhaber-Aufgaben, keine
Code-Aufgaben — und genau die Sorte, die still durchrutscht, weil der Shop
mit ihnen trotzdem startet. Das Skript macht daraus eine Pruefliste, die
mit Exit-Code 1 abbricht, solange ein Blocker offen ist, und laesst sich
damit unveraendert als Schritt in die Deploy-Pipeline haengen.

Geprueft wird, was einen oeffentlichen Shop unbemerkt unbrauchbar macht:

- SESSION_SECRET/ADMIN_EMAIL/ADMIN_PASSWORD auf Laenge und darauf, dass
  noch der Beispielwert aus .env.example darin steht (ein bekanntes
  Signiergeheimnis macht jede Sitzung faelschbar).
- NEXT_PUBLIC_SITE_URL auf HTTPS und kein localhost — dieser Wert landet
  in jeder Bestellbestaetigung, in sitemap.xml und in allen Canonicals.
- Stripe-Schluessel und Webhook-Secret vorhanden, und ob versehentlich noch
  ein sk_test-Schluessel gesetzt ist. Ohne Webhook-Secret wird keine
  Zahlung bestaetigt: Bestellungen bleiben auf "NEU", ohne Rechnung und
  ohne Bestaetigungsmail.
- SMTP vollstaendig — fehlt es, landen E-Mails nur im Server-Log statt beim
  Kunden (lib/mail.ts).
- Impressum ohne [PLATZHALTER]/[PLACEHOLDER]-Markierungen. Bewusst beide
  Markierungen: die deutsche Fassung nutzt die eine, die englische die
  andere — eine Pruefung auf nur eine liesse die jeweils andere
  Sprachfassung mit Luecken online gehen. Ein unvollstaendiges Impressum
  nach § 5 TMG ist in Deutschland abmahnfaehig.

Bewusst ohne Datenbankzugriff, damit die Pruefung auch von einer Maschine
ohne Zugang zur Produktionsdatenbank laeuft. Felder, die zusaetzlich im
Admin-Bereich unter "Inhalte" pflegbar sind (Telefonnummer, Anschrift),
sind deshalb Warnung statt Blocker.

README: neue Abschnitte "Vor dem Live-Gang" und "Health-Endpunkte"; in der
Tech-Stack-Tabelle ausserdem die Zeile "GSAP + ScrollTrigger" korrigiert —
GSAP wurde laengst durch motion/react ersetzt und steht weder in
package.json noch im Quellcode.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:37 +02:00
Nick SzaunigandClaude Opus 5 c50e5e0e08 fix: alle 14 bekannten Schwachstellen in Produktionsabhaengigkeiten schliessen
"pnpm audit --prod" meldete 14 Advisories, davon 2 kritisch. Danach: keine.

Direkte Abhaengigkeiten angehoben:
- next 16.3.0 -> 16.3.5        (2x KRITISCH, gepatcht ab 16.3.3)
- eslint-config-next entsprechend
- nodemailer ^9.0.5 -> ^9.1.1  (HIGH + MODERATE: Empfaengerdomain liess sich
                                ueber falsch geparste RFC-5322-Kommentare
                                umbiegen — betrifft jede Transaktionsmail)
- sharp ^0.35.3 -> ^0.35.4     (HIGH)
- prisma/@prisma/client und beide Adapter ^7.9.1 -> ^7.10.0

Drei verbleibende Advisories liessen sich nur transitiv unter der
prisma-CLI aufloesen, deshalb als Overrides in pnpm-workspace.yaml
(dieselbe Stelle und dasselbe Muster wie die bereits vorhandenen
nanoid-/mariadb-Eintraege): mysql2 >=3.23.1, fast-uri >=3.1.6,
deepmerge-ts >=8.0.0.

deepmerge-ts ist ein Hauptversionssprung und haengt unter @prisma/config —
also genau im Pfad von "prisma migrate deploy", das der Container laut
Dockerfile-CMD bei JEDEM Start ausfuehrt. Deshalb nach dem Anheben
"prisma validate" und "prisma generate" (beide Provider) geprueft, dazu
Typecheck, Lint, 206 Tests und ein vollstaendiger Produktions-Build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:25 +02:00
Nick SzaunigandClaude Opus 5 18d1bd28bf fix: Produkt- und Shop-Links in E-Mails ueber dieselbe Domain-Quelle
Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:18 +02:00
Nick SzaunigandClaude Opus 5 4b76247648 fix: Health-Endpunkte trennen Liveness von Readiness
Readiness- und Liveness-Probe zeigten beide auf "/" — eine
force-dynamic-Seite samt Datenbankabfragen und vollem React-Rendering, alle
10 bzw. 30 Sekunden.

Der teure Teil ist dabei nicht das eigentliche Problem. Entscheidend ist,
dass damit auch die LIVENESS-Probe an der Datenbank haengt: faellt die DB
voruebergehend aus, wertet Kubernetes den Container als tot und startet ihn
neu — woraufhin der neue Container laut Dockerfile-CMD zuerst
"prisma migrate deploy" und "prisma db seed" gegen genau die gestoerte
Datenbank faehrt. Aus einer voruebergehenden Stoerung wird so eine
Neustart-Schleife.

Jetzt zwei Endpunkte mit klar getrennter Aufgabe:

- /api/health       — nur "Prozess antwortet", ohne Datenbankzugriff.
                      Liveness-Probe.
- /api/health/ready — zusaetzlich "SELECT 1" gegen die Datenbank, sonst 503.
                      Readiness-Probe und Docker-Healthcheck.

Faellt die Readiness-Probe aus, nimmt Kubernetes den Pod nur aus dem
Load-Balancing und startet ihn nicht neu — genau das richtige Verhalten.
"SELECT 1" laeuft unveraendert auf PostgreSQL und MySQL/MariaDB und
beruehrt keine Tabelle, ist also unabhaengig vom Migrationsstand. Die
Fehlerdetails gehen nur ins Server-Log, nicht in die oeffentliche Antwort.

docker-compose.yml bekommt denselben Readiness-Endpunkt als Healthcheck,
mit start_period fuer den Migrations-/Seed-Lauf vor dem Serverstart.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:06 +02:00
Nick SzaunigandClaude Opus 5 d3020cd4da fix: Sitemap, Canonicals und JSON-LD folgen der echten Domain
Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten
NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes
"https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte
"https://wabenhain.com" im Produkt-JSON-LD).

Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging,
Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host
— zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle
strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft
eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain
weist die Indexierung aktiv von der echten Seite weg.

Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit
siteConfig.domain als Rueckfallebene, damit eine vergessene
Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen
Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in
E-Mail-Code eingebuergerten Namen und liefert denselben Wert.

robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte):
ohne das rendert Next.js die Datei beim Build statisch vor und backt den
Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist
die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem
Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build
geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt
alle dem zur Laufzeit gesetzten Wert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:00:59 +02:00
Nick Szaunig 96bb3d9c50 fix: Video-Upload als roher Body statt multipart — beseitigt die 502-Ursache
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist
ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy
vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten
im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher
verfolgte Timeout-Spur war damit falsch.

Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die
eigentliche Ursache: request.formData() liest den GESAMTEN Body in den
Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige
Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt
(arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis
100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das
Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess
(OOMKilled), und der Proxy meldet 502.

Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data.
Damit laesst sich request.body direkt auf die Platte durchreichen, und der
Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld
wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die
Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil
Content-Length nur eine Ankuendigung des Clients ist.

Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9
Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB —
also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief.

Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast
immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in
die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert —
502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes
Zeitlimit.
2026-09-15 02:27:11 +02:00
Nick Szaunig e8f0bd4f55 fix: Ingress auf Traefik umstellen — die nginx-Annotationen waren wirkungslos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m6s
Der Zielcluster nutzt Traefik, nicht ingress-nginx. Saemtliche Annotationen
der Form nginx.ingress.kubernetes.io/* ignoriert Traefik ersatzlos und
still: sie erzeugen keinen Fehler, wirken aber auch nicht. Das betraf das
Upload-Limit (proxy-body-size) ebenso wie die Zeitlimits, und
ingressClassName zeigte auf "nginx".

Damit aendert sich auch die Diagnose des fehlgeschlagenen Video-Uploads.
Belegt aus Traefiks Quelltext (pkg/config/static/static_config.go,
SetDefaults) und der Referenzdokumentation:

- readTimeout des EntryPoints: Default 60s, beschrieben als "maximum
  duration for reading the entire request, including the body". Er umfasst
  also die gesamte Uebertragung des Uploads. Bei 20-100 MB ueber eine
  normale Upload-Leitung ist das schnell ueberschritten — 80 MB bei
  10 Mbit/s sind rund 64 Sekunden. Laeuft er ab, kappt Traefik die
  Verbindung und antwortet mit einer eigenen HTML-Fehlerseite. Das ist die
  wahrscheinlichste Quelle des "Unexpected token '<'" im Browser.
- responseHeaderTimeout: von SetDefaults NICHT gesetzt, bleibt 0, und
  "If zero, no timeout exists". Die Dauer des ffmpeg-Transcodings ist bei
  Traefik also NICHT begrenzt — anders als zuvor angenommen.
- Body-Groesse: Traefik erzwingt keine Obergrenze (begrenzt ist nur
  maxHeaderBytes mit 1 MB fuer die Header). Ein Gegenstueck zu nginx'
  proxy-body-size wird nicht gebraucht.

readTimeout gehoert zur statischen EntryPoint-Konfiguration und laesst sich
nicht per Ingress-Annotation setzen. Das Manifest dokumentiert deshalb
ausfuehrlich, welche Einstellung in der Traefik-Installation noetig ist
(Helm: ports.websecure.transport.respondingTimeouts.readTimeout: 600s).

Weiter angepasst: ingressClassName auf "traefik", HTTPS-Umleitung ueber eine
Traefik-Middleware statt der nginx-Annotation, und der Hinweis zu
X-Forwarded-For nennt jetzt Traefiks forwardedHeaders.trustedIPs — davon
haengt ab, ob lib/rate-limit.ts dem Header trauen darf. Die Kommentare in
lib/uploads.ts und lib/csrf.ts verwiesen auf die entfernten
nginx-Einstellungen und sind entsprechend berichtigt.
2026-09-14 22:48:13 +02:00
Nick Szaunig 72d36f4716 perf: Header-Bild der Imkerei-Seite sofort laden
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m1s
Ein Audit ueber alle bildfuehrenden Seiten zeigte /imkerei als einzige
Ausnahme: dort war kein Bild priorisiert. Das Bild steht neben der h1 im
ersten Viewport und ist auf breiten Bildschirmen das groesste Element der
Seite, wurde aber doppelt zurueckgehalten — durch das voreingestellte
Lazy-Loading und durch ScrollReveal, das bei opacity 0 startet und erst
nach der Hydration einblendet.

Es rendert jetzt direkt und mit priority, also loading="eager" und
fetchPriority="high". Die Textspalte daneben behaelt ihre Einblendung, die
kostet nichts. Damit verhaelt sich die Seite wie /herkunft und
/unternehmen, die ihr Header-Bild ohnehin schon ausserhalb von ScrollReveal
rendern.

Stand des Audits danach, im Produktions-Build gemessen: /, /herkunft,
/unternehmen und /imkerei liefern je genau ein Header-Bild mit
loading="eager" und fetchPriority="high", alle uebrigen Bilder lazy, und
auf keiner Seite geht noch ein hochgeladenes Bild am Bildoptimierer vorbei.
2026-09-14 20:59:43 +02:00
Nick Szaunig 0a44fecbe1 perf: Warenkorb-Miniaturen ueber den Bildoptimierer laden
Die letzte Stelle, an der ein hochgeladenes Bild noch als rohes <img>
ausgeliefert wurde. Der Warenkorb lud damit fuer JEDE Position das
vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION Kantenlaenge, also
durchaus ein paar hundert Kilobyte — nur um es auf 64 bzw. 80 Pixel zu
verkleinern. Bei mehreren Positionen summierte sich das spuerbar, gerade im
Mobilfunknetz, und betraf sowohl die Warenkorb-Schublade als auch die
Warenkorbseite.

Der bisherige eslint-disable-Kommentar begruendete das rohe <img> mit einer
"noch unbekannten externen Bildquelle". Das trifft nicht zu: das Bild kommt
aus Product.images[0] und liegt damit immer same-origin unter /uploads/...
(beim Speichern geprueft von isValidUploadPath in lib/uploads.ts).
next/image ist dafuer zustaendig und liefert jetzt eine 80px-Variante statt
der Originaldatei.

Beide Verwendungsstellen bekommen dieselbe leichte Eckenrundung wie die
uebrigen Bilder; vorher war die Schublade rounded-sm und die Warenkorbseite
gar nicht gerundet.
2026-09-14 20:55:50 +02:00
Nick Szaunig 02d493dede fix: verbleibende Lint-Befunde ausraeumen (Lint jetzt ohne Beanstandung)
Vier offene Befunde standen einem sauberen Produktionslauf im Weg:

- recently-viewed-section und site-chrome riefen setState direkt im
  Effektrumpf auf, um einen Leerzustand herzustellen ("keine angesehenen
  Produkte" bzw. "Eingabe kuerzer als zwei Zeichen"). Beides ergibt sich
  unmittelbar aus vorhandenen Werten und wird jetzt beim Rendern
  abgeleitet — das spart je einen zusaetzlichen Renderdurchlauf. Bei der
  Suche verschwindet die Vorschlagsliste dadurch sofort beim Leeren des
  Feldes statt erst nach dem naechsten Effektlauf.

- STAT_KEYS im Admin-Dashboard wurde ausschliesslich als Typ verwendet,
  landete als `as const`-Array aber trotzdem im Bundle. Ersetzt durch
  einen Typ-Alias.

- Ein DHL-Test importierte DhlApiError, ohne es zu benutzen, und pruefte
  nur den Meldungstext — er haette damit jeden beliebigen anderen Fehler
  mit passendem Text durchgehen lassen. Er prueft jetzt zusaetzlich den
  Fehlertyp, wie seine beiden Geschwistertests.

Stand danach: eslint ohne Beanstandung (vorher 2 Fehler, 2 Warnungen),
tsc ohne Fehler, 201 Tests gruen, Produktions-Build erfolgreich.
2026-09-14 20:37:01 +02:00
Nick Szaunig 107cbadf79 perf: Header-Medien sofort und in passender Groesse laden
Drei Gruende, warum Hero-Bild und -Video spuerbar spaet erschienen:

1. Das Standbild des Hero-Videos steckte im poster-Attribut. poster nimmt
   nur eine fertige URL und laedt die hochgeladene Datei damit in voller
   gespeicherter Groesse — am Bildoptimierer vorbei, ohne Ruecksicht auf
   das Geraet. Es liegt jetzt als eigene ContentImage-Ebene unter dem
   Video, laeuft also durch next/image. Gemessen: auf einem 500px breiten
   Viewport kommt statt der vollen Datei die 640px-Variante als WebP an.
   Das Video darueber ist transparent, bis es eigene Bilddaten hat — der
   Uebergang bleibt unsichtbar.

2. Die aeusserste Hero-Section startete bei opacity 0 und wurde erst per
   Animation nach der Hydration sichtbar. Das groesste Element im ersten
   Viewport wurde damit um die gesamte JS-Ladezeit zurueckgehalten. Die
   Animation dort ist entfernt; der gestaffelte Aufbau der Textspalte
   bleibt unveraendert.

3. next/image erhielt weiterhin die Prop "priority". Die ist seit
   Next.js 16 abgekuendigt (siehe node_modules/next/dist/docs, Changelog
   v16.0.0) und erzeugt nur noch einen <link rel="preload">, setzt am
   <img> selbst aber weder fetchpriority noch loading — im Trace kam das
   Hero-Bild deshalb trotz "priority" mit Prioritaet "Low" an. Die
   Dokumentation nennt dafuer ausdruecklich loading="eager" und
   fetchPriority="high" als Ersatz; ContentImage bildet seine unveraendert
   heissende priority-Prop jetzt darauf ab, die Produktgalerie ebenso.
   Danach bestand die LCP-Discovery-Pruefung von Chrome vollstaendig,
   vorher scheiterte sie an fetchpriority.

Ausserdem bekommen Inhaltsbilder leicht abgerundete Ecken (8px), Platzhalter
und echtes Bild gleichermassen, damit die Form beim Pflegen eines Bildes
nicht umspringt. Randlose Flaechen (Hero ueber die volle Fensterbreite)
bleiben bewusst eckig — dort saesse die Rundung an der Fensterkante.

Geprueft im Produktions-Build gegen Chrome: CLS 0.00, kein horizontaler
Ueberlauf bei schmalem Viewport, Video spielt (readyState 4), Hero-Bild
loading="eager" + fetchpriority="high", uebrige Bilder lazy.
2026-09-14 20:36:53 +02:00
Nick Szaunig 2a16b3f962 fix: Ingress-Zeitlimits und Speicherbudget fuer den Video-Upload setzen
Zwei Einstellungen im Deployment liessen einen Video-Upload scheitern,
bevor die Anwendung ueberhaupt antworten konnte — beide Male mit einer
HTML-Fehlerseite, die im Browser als "Unexpected token '<'" ankam:

1. Der Ingress trug keinerlei Timeout-Annotation. ingress-nginx wartet
   dann nur 60 Sekunden je Phase und liefert danach eine eigene
   HTML-504-Seite. Ein Video-Upload braucht regelmaessig laenger: erst
   muessen bis zu 100 MB ueber die Leitung, danach rechnet ffmpeg das
   Video fuers Web um. proxy-read-timeout und proxy-send-timeout stehen
   jetzt auf 600s — bewusst oberhalb des harten ffmpeg-Zeitlimits von 5
   Minuten, damit im Zweifel die Anwendung selbst mit einer verwertbaren
   JSON-Meldung abbricht statt nginx die Verbindung zu kappen.

2. Das Speicherlimit von 512Mi war fuer einen 100-MB-Upload zu knapp:
   request.formData() laedt den multipart-Body vollstaendig in den
   Speicher, und der Kernel beendet den Prozess bei Ueberschreitung
   (OOMKilled). Limit auf 1Gi, Request auf 512Mi. Der CPU-Request steigt
   von 100m auf 500m, weil ffmpeg CPU-gebunden ist und mit 100m ein
   Vielfaches der Zeit braucht — bewusst weiterhin ohne CPU-Limit, das
   wuerde den Transcode auch auf einem freien Knoten drosseln.

Der Kommentar nennt zusaetzlich das davon unabhaengige Origin-Zeitlimit
eines vorgeschalteten CDN (bei Cloudflare 100 Sekunden, Fehler 524), das
sich hier nicht beeinflussen laesst.
2026-09-14 16:15:25 +02:00
Nick Szaunig 713bf48157 fix: Video-Upload haelt das Video nicht mehr mehrfach im Arbeitsspeicher
Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig
in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus,
und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem
Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von
512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel
beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt
davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als
"Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien.

Die Datei wandert jetzt streamend auf die Platte (file.stream() ->
createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das
Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile()
als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit
EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume
liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der
ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf
Dateipfaden statt auf Buffern.

Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler
ab und antwortet auch dann mit JSON: eine durchschlagende Exception
liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine
Meldung gewinnen kann.

Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB,
Auslieferung inkl. Range-Anfrage (206) korrekt.
2026-09-14 16:15:17 +02:00
Nick Szaunig b11edd7751 fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser

  Unexpected token '<', "<!DOCTYPE "... is not valid JSON

und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.

Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.

readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.

Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
2026-09-14 00:36:10 +02:00
Nick Szaunig be5fb32a4a perf: Admin-Bildvorschauen ueber next/image statt als rohes <img>
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Die Vorschaukacheln in admin/inhalte, admin/produkte und admin/ernten
luden das vollstaendig gespeicherte Bild, um es auf 80x80 bzw. in einer
Vierer-Rasterkachel darzustellen. Bei einem Produktformular mit mehreren
Bildern summierte sich das zu mehreren Megabyte allein fuer Miniaturen.

Der urspruengliche Kommentar ("next/image lohnt hier nicht") stimmte, als
Uploads klein waren; gespeichert wird inzwischen bis MAX_IMAGE_DIMENSION
Kantenlaenge. Beide Felder rendern die Vorschau jetzt ueber next/image
mit passendem sizes, der Optimierer liefert damit genau die gebrauchte
Groesse. Layout und Bedienung bleiben unveraendert.
2026-09-13 23:16:35 +02:00
Nick Szaunig cd8e307faf perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen
Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts:

1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen
   Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt
   sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die
   Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab.
   Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet
   Bereichsanfragen jetzt mit 206 samt Content-Range, meldet
   Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416.

2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den
   RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro
   gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte,
   inklusive Content-Length (vorher chunked, ohne Groessenangabe).

3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload
   den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304
   auf If-None-Match.

4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag
   mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses
   Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren.
   reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange
   Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie
   hochskaliert).

Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter
dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales
Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig
ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei,
die sich zu Milliarden Pixeln entpackt) bekommt sharp mit
MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die
Byte-Grenze allein nicht abdeckt.

Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt,
jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von
180 ms auf 65 ms.

Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das
neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
2026-09-13 23:16:23 +02:00
Nick Szaunig 0920d33721 fix: Video-Upload scheiterte, weil ffmpeg-static mitgebuendelt wurde
Jeder Video-Upload ohne System-ffmpeg im PATH — also jede lokale
Entwicklungsumgebung unter Windows/macOS — endete mit 400 "Das Video
konnte nicht verarbeitet werden.". Im Server-Log stand der eigentliche
Grund:

  spawn \ROOT\node_modules\.pnpm\[email protected]\...\ffmpeg.exe ENOENT

ffmpeg-static exportiert den Pfad zu seiner mitgelieferten Binary
__dirname-relativ. Wird das Paket vom Bundler mitgebuendelt, zeigt sein
__dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt auf das
echte Verzeichnis in node_modules — genau dieselbe Ursache, aus der
pdfkit hier schon als serverExternalPackage gefuehrt wird.

ffmpeg-static daher ebenfalls in serverExternalPackages aufgenommen; der
Kommentar erklaert die Ursache jetzt einmal fuer beide Pakete. Der
Standalone-Build kopiert das Paket damit unveraendert nach
node_modules (verifiziert).
2026-09-13 23:15:39 +02:00
Nick Szaunig abdebfc477 fix: Admin-Upload scheiterte an der CSRF-Pruefung (403 "Ungueltige Anfrage.")
Die Same-Origin-Pruefung verglich den Origin-Header gegen
new URL(request.url).host. Next.js rekonstruiert request.url aber aus der
internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
Browser tatsaechlich adressiert hat — reproduzierbar liefert request.url
"localhost:3999", waehrend der Browser "127.0.0.1:3999" aufgerufen hat.

Damit schlug jeder Admin-Upload mit 403 fehl, sobald die Seite unter einem
anderen Namen als dieser internen Adresse erreicht wurde: oeffentliche
Domain hinter dem Reverse-Proxy, LAN-IP, oder schlicht 127.0.0.1 statt
localhost. Der 2025 nachgeruestete Fallback auf NEXT_PUBLIC_SITE_URL half
nur, wenn diese Variable gesetzt ist und exakt passt — in .env fehlt sie.

Der Origin wird jetzt gegen alle plausiblen eigenen Hosts geprueft, in
dieser Reihenfolge: X-Forwarded-Host (vom eigenen Proxy gesetzt, Caddy
verwirft einen vom Client mitgeschickten Wert), Host (was der Browser
adressiert hat — derselbe Vergleich, den Next.js fuer Server Actions
intern anstellt), request.url und zuletzt NEXT_PUBLIC_SITE_URL.

Der CSRF-Schutz bleibt dabei vollstaendig: eine fremde Seite kann bei
einem Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen — den
Host setzt der Browser aus der Ziel-URL, eigene Header liessen sich nur
per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
Endpunkt nicht beantwortet. Ein gefaelschter POST traegt also weiterhin
zwangslaeufig einen fremden Origin und wird abgewiesen (durch Test und
manuelle Gegenprobe belegt).
2026-09-13 23:15:28 +02:00
656 changed files with 64944 additions and 4805 deletions

No files matched your search

+143 -22
View File
@@ -9,6 +9,11 @@ DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=p
# MySQL/MariaDB stattdessen: # MySQL/MariaDB stattdessen:
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain" # DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
# Optional: Groesse des Datenbank-Verbindungspools PRO Instanz (Standard 20,
# siehe src/lib/db-adapter.ts). Beim Hochskalieren mitziehen, damit
# Instanzen x Poolgroesse plus CronJobs unter max_connections bleiben.
# DATABASE_POOL_SIZE=20
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container) # Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
POSTGRES_USER=wabenhain POSTGRES_USER=wabenhain
POSTGRES_PASSWORD=change-me POSTGRES_PASSWORD=change-me
@@ -21,10 +26,21 @@ 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)
#
# Serverseitiger Schluessel fuer Checkout-Sessions, Erstattungen und Webhooks.
# Empfohlen: ein eingeschraenkter Schluessel (rk_...) statt des vollen sk_...
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Oeffentlicher Schluessel fuer das eingebettete Zahlungsformular auf /checkout.
# Bewusst OHNE NEXT_PUBLIC_: wird zur Laufzeit gelesen, nicht beim Build
# eingebrannt. Muss zum Modus des geheimen Schluessels passen (test/live).
STRIPE_PUBLISHABLE_KEY=pk_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
# Optional: Kundenportal-Konfiguration (bpc_...) fuer "Zahlungsart aendern" im
# Abo, angelegt von `pnpm tsx scripts/stripe-setup.ts --apply`. Leer = die App
# findet sie ueber ihre Metadaten; fehlt sie ganz, bietet das Konto "Zahlungsart
# aendern" nicht an (die Stripe-Standardkonfiguration wird nie benutzt).
STRIPE_PORTAL_CONFIGURATION_ID=
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth # Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die # geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
@@ -57,7 +73,11 @@ SITE_ADDRESS=
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite # Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
# automatisch die statischen Beispiel-Stimmen als Fallback. # automatisch die statischen Beispiel-Stimmen als Fallback.
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API". # API-Key: Google-Cloud-Projekt mit aktivierter "Places API (New)" — NICHT
# die gleichnamige alte API, die in neuen Projekten nicht mehr aktivierbar
# ist. Ausserdem noetig: Abrechnung am Projekt aktiv, und der Wert muss ein
# API-Schluessel sein (ein OAuth-Client funktioniert nicht). Keine
# Referrer-Beschraenkung setzen — der Abruf erfolgt serverseitig.
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id # Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
GOOGLE_PLACES_API_KEY= GOOGLE_PLACES_API_KEY=
GOOGLE_PLACE_ID= GOOGLE_PLACE_ID=
@@ -69,31 +89,40 @@ GOOGLE_PLACE_ID=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, # Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt # Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup. # App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures # zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im # "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die
# SMTP-Modus). # ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer
SMTP_HOST= # lokale Entwicklung ohne Mail-Setup.
SMTP_PORT=587 # SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe
# Adresse tragen.
SMTP_USER= SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>" SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore- # ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen # Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates # /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key- # Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten, # "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen # widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen), # Details siehe src/lib/apicore-client.ts.
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync)
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe
# src/lib/apicore-client.ts.
APICORE_API_URL= APICORE_API_URL=
APICORE_SYNC_EMAIL= APICORE_API_KEY=
APICORE_SYNC_PASSWORD= # Bestandsabgleich: der Schluessel braucht zusaetzlich "Verkaeufe und
# Rueckbuchungen des Shops buchen" (shop-sync:write). In ApiCore einen Webhook
# fuer "stock.changed" auf https://<shop>/api/apicore/webhook anlegen und das
# dort angezeigte Secret hier eintragen. Ohne Secret wird der Webhook
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
APICORE_WEBHOOK_SECRET=
# Rechnungsexport fuer die Buchhaltung (ERPNext, collab.vycept.com): ERPNext
# holt damit stuendlich alle Rechnungen samt PDF ab und ordnet sie der
# Stripe-Zahlung zu (GET /api/erp/rechnungen, nur lesend, siehe
# src/lib/erp-export.ts). Mindestens 32 Zeichen, z. B. `openssl rand -hex 32`;
# derselbe Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus.
ERP_EXPORT_TOKEN=
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) — # DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im # ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
@@ -123,6 +152,24 @@ APICORE_SYNC_PASSWORD=
# im Zweifel im DHL-Developer-Portal unter der App nachschauen): # im Zweifel im DHL-Developer-Portal unter der App nachschauen):
# DHL_GKP_USERNAME=user-valid # DHL_GKP_USERNAME=user-valid
# DHL_GKP_PASSWORD=SandboxPasswort2023! # DHL_GKP_PASSWORD=SandboxPasswort2023!
# HINWEIS: Fuer die PACKSTATION-SUCHE im Checkout reicht DHL_API_KEY allein.
# Sie laeuft ueber die DHL Location Finder API (lib/dhl-locations.ts), einen
# eigenen Dienst mit eigener Authentifizierung — nur der API-Key im Header,
# kein OAuth und kein Geschaeftskundenvertrag. Ist der Key gesetzt, bietet der
# Checkout "An eine Packstation" als Lieferziel an; fehlt er, erscheint die
# Option gar nicht erst. Die uebrigen DHL_*-Werte braucht erst das Erstellen
# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
# cached Ergebnisse 15 Minuten.
# SENDUNGSVERFOLGUNG auf /bestellung-verfolgen: laeuft ueber die DHL Shipment
# Tracking – Unified API (lib/dhl-tracking.ts), ebenfalls nur mit API-Key.
# Die DHL-App muss dafuer im Developer Portal zusaetzlich das Produkt
# "Shipment Tracking - Unified" abonniert haben. Ist es in einer EIGENEN App
# freigeschaltet, deren Key in DHL_TRACKING_API_KEY eintragen — sonst wird
# DHL_API_KEY verwendet. Ohne freigeschalteten Key zeigt die Seite wie bisher
# nur Sendungsnummer + Link zu dhl.de. Ergebnisse werden pro Sendung
# 10 Minuten (zugestellt: 12 Stunden) gecached, um das Kontingent zu schonen.
DHL_TRACKING_API_KEY=
DHL_API_KEY= DHL_API_KEY=
DHL_API_SECRET= DHL_API_SECRET=
DHL_GKP_USERNAME= DHL_GKP_USERNAME=
@@ -155,6 +202,11 @@ DHL_ENVIRONMENT=sandbox
# als Unternehmensapplikation (wird beim App-Registration-Schritt # als Unternehmensapplikation (wird beim App-Registration-Schritt
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis # automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
# (z.B. nur bestimmte Gruppen) einschraenken. # (z.B. nur bestimmte Gruppen) einschraenken.
# 6. Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben): dafuer
# die Anwendungsberechtigung "Office 365 Exchange Online -> SMTP.SendAsApp"
# mit Administratorzustimmung ergaenzen und in Exchange das Postfach
# freigeben (README "E-Mail-Versand"). Laeuft das Secret ab, sind
# Anmeldung UND Mailversand betroffen.
ENTRA_TENANT_ID= ENTRA_TENANT_ID=
ENTRA_CLIENT_ID= ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET= ENTRA_CLIENT_SECRET=
@@ -180,3 +232,72 @@ 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
# Ablage der Urkunden-PDFs der Bienenpatenschaft (lib/sponsorship/storage.ts).
# Aus demselben Grund wie INVOICE_DIR NIE unterhalb von UPLOAD_DIR — eine
# Urkunde traegt Namen und eine persoenliche Widmung. Die Anwendung startet
# nicht, wenn das Verzeichnis im Upload-Verzeichnis liegt. In Kubernetes ein
# Unterordner des gesicherten Rechnungs-Volumes (siehe k8s/01-configmap.yaml).
CERTIFICATE_DIR=./certificates
# Hintergrundaufgaben im App-Prozess (src/instrumentation.ts): Urkunden
# nachholen, Abo-Lieferungen anlegen, Verzug pruefen. "off" schaltet sie ab,
# z.B. fuer eine zweite Instanz, die nur Seiten ausliefern soll.
BACKGROUND_JOBS=on
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
# 19 = voller Satz
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
# Steuerzeilen den Pflichthinweis nach § 19 UStG
#
# Die Shop-Preise sind BRUTTOPREISE (gegenueber Verbrauchern ist der
# Gesamtpreis inkl. Umsatzsteuer anzugeben, § 1 PAngV). Die Steuer wird daraus
# HERAUSGERECHNET, nicht aufgeschlagen — der Endpreis bleibt genau der, den die
# Kundschaft im Warenkorb gesehen und bezahlt hat.
#
# Der Satz wird an jeder Rechnung festgeschrieben: eine Aenderung hier wirkt
# nur auf kuenftige Rechnungen, bereits ausgestellte behalten ihren Satz.
VAT_RATE_PERCENT=7
# Satz je Kategorie — der Steuersatz haengt an der Art der Ware. Fehlt hier
# ein Wert, gilt VAT_RATE_PERCENT von oben. Ein am einzelnen Produkt
# gepflegter Satz (Admin -> Produkte) schlaegt beides.
#
# PROPOLIS bitte pruefen: als Lebensmittel waeren 7% richtig, als
# Nahrungsergaenzungs- oder Kosmetikprodukt 19%. Voreingestellt ist der volle
# Satz, weil er im Zweifel der sicherere ist.
VAT_RATE_PERCENT_HONIG=7
VAT_RATE_PERCENT_KERZEN=19
VAT_RATE_PERCENT_PROPOLIS=19
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher)
VAT_RATE_PERCENT_SONSTIGES=19
# Bienenpatenschaft (Leistung, Regelsatz; Honiganteil eigene Position).
VAT_RATE_PERCENT_PATENSCHAFT=19
# 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=
+99
View File
@@ -3,14 +3,89 @@ name: Docker Image bauen und veroeffentlichen
# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>") # Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>")
# und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell # und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell
# ueber "Run workflow" in der Gitea-Weboberflaeche ebenfalls startbar. # ueber "Run workflow" in der Gitea-Weboberflaeche ebenfalls startbar.
#
# Qualitaets-Gate: Ein Image entsteht nur, wenn lint (inkl. audit), typecheck
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
concurrency:
group: docker-build-${{ gitea.ref }}
cancel-in-progress: true
env:
NEXT_TELEMETRY_DISABLED: "1"
jobs: jobs:
lint:
runs-on: ci-isolated
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
- run: pnpm lint
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
- run: pnpm audit --prod --audit-level high
typecheck:
runs-on: ci-isolated
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
# postinstall erzeugt beide Prisma-Clients (Postgres + MySQL), die tsc braucht.
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
- run: pnpm typecheck
test:
runs-on: ci-isolated
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Runner-Isolation pruefen
run: bash scripts/ci-runner-isolation.sh
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: { node-version: "22" }
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/pnpm
~/.cache/pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
# Unit- und Komponententests (jsdom), ohne Datenbank.
- run: pnpm test
build-and-push: build-and-push:
needs: [lint, typecheck, test]
runs-on: docker-builder runs-on: docker-builder
env: env:
# Fest hinterlegt statt ${{ gitea.server_url }}: das hat in manchen # Fest hinterlegt statt ${{ gitea.server_url }}: das hat in manchen
@@ -77,6 +152,16 @@ jobs:
type=sha,format=short type=sha,format=short
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Metadaten Werkzeuge
id: meta-tools
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}-tools
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
# Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres + # Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres +
# MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe # MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe
# src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile- # src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile-
@@ -86,8 +171,22 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: runner
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Werkzeug-Image fuer die CronJobs (Dockerfile "AS tools"); nutzt die
# gemeinsamen Build-Stufen aus dem Cache des ersten Builds.
- name: Werkzeuge bauen und pushen
uses: docker/build-push-action@v6
with:
context: .
target: tools
push: true
tags: ${{ steps.meta-tools.outputs.tags }}
labels: ${{ steps.meta-tools.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+6
View File
@@ -53,3 +53,9 @@ 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
# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR).
/certificates
+31 -2
View File
@@ -52,6 +52,27 @@ RUN pnpm add --save-prod \
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \ @prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 @prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3
# --- Werkzeuge (CronJobs) -------------------------------------------------
# Eigenes Image <repo>-tools fuer die Skripte unter scripts/, die als
# CronJob laufen (Bestandsabgleich mit ApiCore, Freigabe haengender
# Bestellungen), nach dem Muster von artista-modelcamp-tools. Das runner-
# Image enthaelt nur den Standalone-Output: dort fehlen tsconfig.json (der
# Pfad-Alias "@/") und Pakete wie stripe, die Skripte brechen dort mit
# "Cannot find module" ab. Steht VOR runner, damit ein Build ohne --target
# weiterhin das App-Image liefert.
FROM base AS tools
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./
ENV PATH="/app/node_modules/.bin:${PATH}"
USER 1001
CMD ["tsx", "scripts/apicore-stock-sync.ts"]
# --- Runtime -------------------------------------------------------------- # --- Runtime --------------------------------------------------------------
FROM base AS runner FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -67,13 +88,21 @@ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Eingebettete Schriften der Rechnungs-PDFs (PDF/A-3, lib/einvoice/facturx-pdf.ts
# liest sie zur Laufzeit aus process.cwd()/assets/fonts).
COPY --from=builder /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY prisma.config.ts ./ COPY prisma.config.ts ./
COPY scripts ./scripts COPY scripts ./scripts
COPY src/lib/db-adapter.ts ./src/lib/db-adapter.ts # Das ganze lib-Verzeichnis statt einzelner Dateien: neben prisma/seed.ts
COPY src/lib/password-breach-check.ts ./src/lib/password-breach-check.ts # laeuft im Container auch scripts/release-stale-orders.ts (CronJob, siehe
# k8s/07-backup-cronjob.yaml), das ueber lib/order-release.ts weitere Module
# nachzieht. Einzeln aufgezaehlt faellt so ein Lauf erst im Cluster mit
# "Cannot find module" auf. Die Dateien liegen nur herum — der Next.js-Server
# nutzt ausschliesslich seinen gebuendelten Standalone-Output.
COPY src/lib ./src/lib
COPY --from=builder /app/src/generated ./src/generated COPY --from=builder /app/src/generated ./src/generated
# Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe # Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe
# node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort # node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort
+347 -31
View File
@@ -2,6 +2,19 @@
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe. Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
## Projektstand
Was gebaut, geprüft, gemergt und ausgerollt ist, was offen ist und was nur der Inhaber erledigen kann, steht in den Gitea-Issues. Einstieg ist das Übersichts-Issue **[Projektstand und Übersicht](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/34)**, die Aufgaben des Inhabers sammelt **[Inhaber-Aufgaben](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/33)**.
Kurzfassung (Stand 2026-10-16):
- Produktiv läuft der Build `sha-0d254db` (Client-IP-Härtung vom 2026-10-12). Die drei neueren Commits auf `main` (Abhängigkeits-Updates, Qualitäts-Gate in der CI) sind gebaut, aber noch nicht ausgerollt.
- Im Cluster laufen App, Datenbank und die tägliche Sicherung `wabenhain-db-backup`. Die CronJobs `wabenhain-release-stale-orders` und `wabenhain-apicore-stock` sind angelegt, aber ausgesetzt (`suspend: true`).
- Für den Bestandsabgleich mit ApiCore fehlt im Cluster-Secret `APICORE_WEBHOOK_SECRET`, für die ERPNext-Schnittstelle `ERP_EXPORT_TOKEN`.
- Offene Spezifikation: [Versandstaffel je Kategorie](docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md) (freigegeben, nicht umgesetzt).
## Tech-Stack ## Tech-Stack
| Bereich | Technologie | | Bereich | Technologie |
@@ -9,11 +22,10 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
| Sprache | TypeScript | | Sprache | TypeScript |
| Framework | Next.js 16 (App Router) + React 19 | | Framework | Next.js 16 (App Router) + React 19 |
| Styling | Tailwind CSS v4 | | Styling | Tailwind CSS v4 |
| Scroll-/Hero-Animationen | GSAP + ScrollTrigger | | Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) |
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" | | Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) | | ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
| Payment | Stripe Checkout | | Payment | Stripe Checkout, eingebettet (`ui_mode: "embedded_page"`) |
| Hosting | Docker / Node.js | | Hosting | Docker / Node.js |
## Was ist enthalten ## Was ist enthalten
@@ -37,10 +49,11 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`) - Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular) - Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
- Honigwissen mit allen 9 im Briefing genannten Artikeln - Honigwissen mit allen 9 im Briefing genannten Artikeln
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**) - Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Cookie-Richtlinie, Versand und Zahlung sowie die elektronische Widerrufsfunktion „Vertrag widerrufen“ (`/vertrag-widerrufen`, Eingänge unter Admin → Widerrufe). Die Texte sind ausformuliert und im Admin unter „Inhalte“ pflegbar; `pnpm preflight` meldet verbliebene Platzhalter. Eine anwaltliche Prüfung steht noch aus.
- **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)
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" 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 Microsoft 365 mit OAuth 2.0 (dieselbe App wie die Microsoft-Anmeldung), 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.
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45&nbsp;KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten. - **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45&nbsp;KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
@@ -57,8 +70,8 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
### Bewusst vereinfacht ### Bewusst vereinfacht
- **Bilder**: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt. - **Bilder**: Fotos für Produkte, Ernten, Chargen, Inhalte, Logo und das Hero-Video lädt der Admin hoch (siehe `k8s/README.md` zu Speicher und Video-Upload). Solange zu einer Fläche kein Foto hinterlegt ist, erscheint ein Platzhalter im Stil des Glas-Etiketts.
- Rechtstexte sind Platzhalter und **müssen** vor Live-Gang anwaltlich geprüft werden. - Rechtstexte sind ausformuliert, **müssen** aber anwaltlich geprüft werden — das kann kein Skript feststellen.
## Admin-Zugang ## Admin-Zugang
@@ -142,24 +155,76 @@ 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-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
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 `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna) ### Eingebetteter Checkout
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
**Apple Pay / Google Pay**: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, `stripe.checkout.sessions.create(...)` ohne `ui_mode: "embedded"`) laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains) ist laut Stripe nur für Elements bzw. die *eingebettete* Checkout-Zahlungsseite nötig, **nicht** für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain `checkout.stripe.com`) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code. Ablauf:
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
### Zahlungsmethoden (PayPal, Karte, SEPA-Lastschrift, Klarna)
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um PayPal, SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das: SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall). - `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
- `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus). - `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus).
- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt. - `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren. - `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf.
Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. Zwei weitere Events betreffen Geld, das den Shop wieder verlässt, **ohne dass hier jemand etwas angeklickt hat**:
- `charge.refunded` hält eine Erstattung fest, die von Hand im Stripe-Dashboard ausgelöst wurde. Ohne dieses Abonnement stand die Zahlung im Shop weiter auf `SUCCEEDED` und jede Auswertung zählte einen Umsatz mit, den es nicht mehr gab. Eine **Teilerstattung** setzt den Status bewusst *nicht* auf `REFUNDED` (der Restbetrag ist weiterhin eingezogen), sondern wird nur protokolliert. Der Bestellstatus und der Lagerbestand bleiben in beiden Fällen unangetastet: ob eine Erstattung Storno, Retoure oder Kulanz bedeutet, steht am Event nicht.
- `charge.dispute.created` vermerkt eine Rückbuchung (Chargeback) in den Notizen der Bestellung und schreibt sie ins Server-Log. Die Stellungnahme gegenüber Stripe ist **fristgebunden** — vorher war eine Rückbuchung ausschließlich im Stripe-Dashboard zu sehen.
Für einen produktiven Webhook-Endpoint müssen alle sechs Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. `pnpm preflight` prüft das Abonnement.
> **`checkout.session.expired` ist nicht optional.** Ohne dieses Abonnement bleibt der beim Checkout reservierte Bestand jedes abgebrochenen Kaufs dauerhaft blockiert. Falls es doch einmal fehlt oder der Endpunkt nicht erreichbar war, fängt der CronJob `wabenhain-release-stale-orders` (siehe unten) das ab.
### Abgebrochener Checkout
Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafür: jeder nicht zu Ende geführte Checkout hält Ware fest, die niemand bezahlt hat. Drei Wege geben sie wieder frei:
| Wann | Was passiert |
|------|--------------|
| „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
Der letzte Fall war eine Lücke: schlug `stripe.checkout.sessions.create` fehl, blieb die Bestellung auf `NEU` **ohne** `stripeCheckoutSessionId` stehen. Damit griff keiner der drei Wege darüber — der Abbruch-Klick braucht die Sitzungs-ID, der CronJob ebenso, und ein Stripe-Event kann es ohne Sitzung nicht geben. Der reservierte Bestand blieb dauerhaft blockiert, und die Oberfläche schickte die Kundschaft dabei auf die Erfolgsseite („wir melden uns"), obwohl es keinen Weg gab, diese Bestellung je zu bezahlen. Heute wird stattdessen ein Fehler angezeigt, der ausdrücklich sagt, dass nichts abgebucht wurde. Lässt sich die Sitzungs-ID nach dem Anlegen nicht speichern, wird die Sitzung sofort wieder geschlossen und dieselbe Auflösung ausgelöst — eine Bestellung auf `NEU` ohne Sitzungs-ID kann so nicht mehr entstehen. Der CronJob räumt Altbestände aus der Zeit davor trotzdem ab.
**Solange die Zahlung nicht bestätigt ist, erscheint die Bestellung nicht im Kundenkonto.** `getOrdersForCustomer` blendet den Status `NEU` aus (`lib/account-data.ts`). Vorher stand ein gerade abgebrochener Kauf dort bis zu 45 Minuten lang mit Bestellnummer und Summe in der Liste und sah aus wie ein zustande gekommener Kauf. Bezahlte Bestellungen sind davon nicht betroffen — der Webhook setzt sie binnen Sekunden auf `BEZAHLT`. In der Bestellverfolgung (`/bestellung-verfolgen`, auch für Gastbestellungen) bleibt eine `NEU`-Bestellung auffindbar, trägt dort aber die Kennzeichnung **„Zahlung ausstehend"** statt eines nichtssagenden „Neu".
**Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu.
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
### Stornierung im Admin-Bereich
Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIERT**, passiert dreierlei in dieser Reihenfolge (`admin/actions/orders.ts`):
1. Status setzen und Lagerbestand zurückbuchen — atomar in einer Transaktion, damit zwei offene Tabs den Bestand nicht doppelt gutschreiben.
2. **War die Bestellung bezahlt, wird der volle Betrag über die Stripe Refunds API erstattet** und `Payment.status` auf `REFUNDED` gesetzt. Vorher unterblieb das: die Bestellung galt als storniert, die Ware lag wieder im Regal, und das Geld blieb beim Shop. Teilerstattungen laufen weiterhin über den Retouren-Bereich, wo sie hingehören.
3. **War die Bestellung noch `NEU`, wird die offene Stripe-Sitzung geschlossen.** Sonst konnte die Kundschaft Minuten später noch bezahlen — der Webhook fand die Bestellung dann nicht mehr auf `NEU`, brach ab, und das Geld lag bei Stripe, ohne dass im Shop irgendetwas davon zu sehen war.
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
## Versandkosten ## Versandkosten
@@ -167,29 +232,143 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
## Google-Rezensionen ## Google-Rezensionen
Die „Bewertungen"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig: Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
1. In der [Google Cloud Console](https://console.cloud.google.com/) ein Projekt anlegen (oder ein bestehendes verwenden) und darin die **„Places API"** aktivieren. **Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren.
2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als `GOOGLE_PLACES_API_KEY` in `.env` eintragen. Empfehlenswert: den Key auf die Places API einschränken.
3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id) ermitteln und als `GOOGLE_PLACE_ID` eintragen.
Die Rezensionen werden serverseitig eine Stunde gecacht (`src/lib/google-reviews.ts`), um API-Kosten gering zu halten. **Einrichtung — alle vier Schritte sind nötig:**
1. **API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht.
2. **Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab.
3. **API-Schlüssel erzeugen:** *APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe.
4. **Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap).
**Die Place-ID steht nicht in der Cloud Console.** Sie gehört zum Eintrag bei Google Maps, nicht zum Projekt. Mit gesetztem Schlüssel liefert sie dieses Kommando:
```bash
pnpm places:find "Wabenhain Altenmünster"
```
Es gibt Name, Anschrift und `GOOGLE_PLACE_ID=…` je Treffer aus — und ist zugleich die schnellste Probe auf die Einrichtung: kommt eine ID heraus, stimmen API, Abrechnung und Schlüssel. Andernfalls benennt es die Ursache. Ohne Schlüssel geht es auch über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters.
**Fehler werden im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
| Meldung im Log | Bedeutung |
|----------------|-----------|
| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
| `billing … not enabled` | Kein Rechnungskonto am Projekt |
| `HTTP 404` | Die Place-ID passt zu keinem Eintrag |
| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich |
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe.
> **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge.
## E-Mail-Versand ## E-Mail-Versand
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren. Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten.
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam. Ob der Versand funktioniert, zeigt **Admin → Einstellungen → System & Tests**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache.
**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[email protected]`) — braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. `SMTP_USER` und die Adresse in `SMTP_FROM` sind dieses Postfach.
2. **Berechtigung an der SSO-App:** Entra Admin Center → App-Registrierungen → die SSO-App → API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“.
3. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die SSO-App → **Objekt-ID** (nicht die der App-Registrierung).
4. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
```powershell
Install-Module ExchangeOnlineManagement # einmalig
Connect-ExchangeOnline
New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID aus Schritt 3> -DisplayName "Wabenhain Shop"
Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 3> -AccessRights FullAccess
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
```
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten.
5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → Einstellungen → System & Tests**.
6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an.
**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen.
## CAPTCHA (Cloudflare Turnstile) ## CAPTCHA (Cloudflare Turnstile)
Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar: **Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
| Formular | Missbrauch, der damit abgewehrt wird |
|----------|--------------------------------------|
| Login | Durchprobieren erbeuteter Zugangsdaten |
| Zwei-Faktor-Code | Durchprobieren des sechsstelligen Codes |
| Passwort vergessen / zurücksetzen | Mailversand an fremde Adressen, Abfragen welche Adressen registriert sind |
| Registrierung | Massenhaft angelegte Konten |
| Checkout | Blockieren von Lagerbestand, Kartentests über die Zahlungsstrecke |
| Newsletter | Eintragen fremder Adressen |
| Firmenanfrage | Spam über das Kontaktformular |
| Verfügbarkeits-Benachrichtigung | Mailversand an fremde Adressen |
Nicht geschützt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile wäre nach dem ersten Aufruf verbraucht. Sie ist stattdessen rate-limitiert und verlangt Bestellnummer **und** zugehörige E-Mail-Adresse.
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig). 1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen. 2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
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:
**Ein Knopf für alles:** „Alles synchronisieren" steht sowohl unter `/admin/ernten` als auch unter `/admin/produkte` und löst denselben Vorgang aus — es geht danach nur auf die Seite zurück, von der er kam.
| Schritt | ApiCore | Wabenhain |
|---------|---------|-----------|
| 1 | `Harvest` (Ernte) | `Harvest` |
| 2 | `BottlingBatch` (Abfüllung) | `Batch` (Charge) |
| 3 | `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) |
Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine Charge braucht ihre Ernte als Pflicht-Fremdschlüssel, ein Produkt entsteht je Abfüllung und braucht die zugehörige Charge. Genau deshalb steckt sie im Ablauf und nicht im Kopf der bedienenden Person — getrennt ausgelöst musste man sie kennen und zweimal klicken, sonst meldete der erste Produkt-Lauf reihenweise fehlende Chargen. Nebenbei werden die Abfüllungen nur noch einmal von ApiCore geladen statt zweimal.
**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 kommt aus ApiCore (siehe „Bestandsabgleich" unten): der Shop verkauft aus dem gesamten Restbestand der Abfüllung.
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.
Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt.
- **Chargen**: Losnummer, Abfüll- und MHD-Datum und abgefüllte Menge kommen aus ApiCore. `remainingGlasses` zeigt den Rest laut ApiCore (Bestandsabgleich).
- **Hergestellte Waren** (Kerzen, Propolis-Tinkturen, Salben, … — in ApiCore Artikel der Art „Hergestellte Ware" mit Herstellungschargen): **ein** Shop-Produkt je Artikel, nicht je Charge. Neue Artikel entstehen als deaktivierte Entwürfe ohne Preis; schon vorhandene Shop-Produkte verknüpft man stattdessen im Produkt-Formular („ApiCore-Artikel") — am besten vor dem ersten Sync, sonst entsteht ein zusätzlicher Entwurf.
- 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 `Harvest.apicoreHarvestId` wiedererkannt, Chargen über `Batch.apicoreBottlingId`, Honig-Produkte über `Product.apicoreBottlingId`, hergestellte Waren über `Product.apicoreProductId`.
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
### Bestandsabgleich
**ApiCore führt den Restbestand je Charge; Shop und ApiCore zeigen dieselbe Zahl.** Design: `ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md`.
- **Verkauft = bezahlt.** Beim Bezahlen geht je Position ein Verkauf an ApiCore; Storno einer bezahlten Bestellung und erstattete Retoure buchen zurück — auf genau die Chargen, aus denen verkauft wurde. Unbezahlte Bestellungen erreichen ApiCore nie.
- **Warteschlange:** Jede Buchung landet in `apicore_stock_events`, in derselben Transaktion wie der Statuswechsel, und wird danach gesendet. Ist ApiCore nicht erreichbar, verkauft der Shop weiter; der CronJob `wabenhain-apicore-stock` (alle 10 Minuten, `pnpm stock:apicore-sync`) reicht nach. Jede Buchung trägt eine feste ID — ApiCore bucht sie nur einmal.
- **Shop-Bestand** verknüpfter Produkte = ApiCore-Rest − noch nicht bestätigte Buchungen − Reservierungen unbezahlter Bestellungen. Das Bestandsfeld im Admin ist für sie gesperrt; Entnahmen (Hofladen, Eigenbedarf, Bruch) und Inventur bucht man in ApiCore.
- **Entnahmen in ApiCore** kommen per Webhook `stock.changed` sofort an (`POST /api/apicore/webhook`, HMAC-signiert). Der Webhook nennt nur, *was* sich geändert hat — die Zahlen holt der Shop selbst über die API.
- **Chargen-Zuordnung:** Welche Charge(n) ApiCore einem Verkauf zugeordnet hat, steht an der Bestellposition (`OrderItem.apicoreAllocations`) — für Rückverfolgung und Rückruf.
- **Dashboard:** Buchungen, die länger als 15 Minuten hängen oder von ApiCore abgelehnt wurden, sowie aktive, noch nicht verknüpfte Produkte erscheinen unter „Zu tun".
**Einrichtung zusätzlich zum Sync:**
1. In ApiCore dem Wabenhain-API-Schlüssel die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen" (`shop-sync:write`) geben.
2. In ApiCore unter Automatisierung → Webhooks einen Webhook für `stock.changed` auf `https://<shop>/api/apicore/webhook` anlegen und dessen Secret als `APICORE_WEBHOOK_SECRET` setzen.
3. CronJob `wabenhain-apicore-stock` aus `k8s/07-backup-cronjob.yaml` anwenden.
4. Kerzen/Propolis-Produkte in ApiCore als „Hergestellte Ware" mit Herstellungscharge (aktueller Bestand) anlegen, in Wabenhain verknüpfen, dann synchronisieren.
## Vollständiger Docker-Betrieb ## Vollständiger Docker-Betrieb
```bash ```bash
@@ -204,9 +383,138 @@ docker compose exec app pnpm db:seed:demo # optional: Beispieldaten zum Auspro
Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`). Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`).
## Vor dem Live-Gang
```bash
pnpm preflight
```
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, keinen Mailversand und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
```bash
kubectl exec -n wabenhain deploy/wabenhain-app -- pnpm preflight
```
Der Exit-Code ist dabei derselbe (1, solange ein Blocker offen ist), der Aufruf lässt sich also unverändert als letzter Schritt in die Deploy-Pipeline hängen.
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
- `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags.
- Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail.
- Mailversand eingerichtet (`SMTP_USER` + `ENTRA_*`) — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung.
- Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig.
Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt).
Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert".
Unabhängig davon **müssen** die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) 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-0001.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 auf die Storage Box (30 Tage Aufbewahrung). Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume. Die zehnjährige Aufbewahrung nach § 147 AO deckt das nicht ab. Vorgesehen ist dafür, dass ERPNext jede Rechnung samt PDF über `/api/erp/rechnungen` übernimmt; im Cluster fehlt dafür Stand 2026-10-16 noch `ERP_EXPORT_TOKEN` (siehe „Projektstand“).
> Bei **mehr als einem Knoten**: im Repo ist das PVC `wabenhain-invoices` als `ReadWriteOnce` angelegt und ließe sich nur von Pods auf demselben Knoten einhängen; landet der Sicherungs-Pod woanders als die App, bleibt er ohne Fehlermeldung in „Pending“. Im Cluster sind `wabenhain-invoices` und `wabenhain-uploads` deshalb bereits `ReadWriteMany` auf SMB (Stand 2026-10-16); das Repo-Manifest weicht davon ab.
### Umsatzsteuer
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`, `VAT_RATE_PERCENT_SONSTIGES` (weitere Bienenprodukte wie Salben oder Wachstücher, Vorgabe in `k8s/01-configmap.yaml`: 19).
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
| Wert | Auf der Rechnung |
|------|------------------|
| `7` | Nettobetrag + „zzgl. 7 % Umsatzsteuer" |
| `19` | Nettobetrag + „zzgl. 19 % Umsatzsteuer" |
| `0` | „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." anstelle der Steuerzeilen |
> **`VAT_RATE_PERCENT_PROPOLIS` bitte prüfen.** Voreingestellt sind 19 %, weil der volle Satz im Zweifel der sicherere ist. Als Lebensmittel wären 7 % richtig, als Nahrungsergänzungs- oder Kosmetikprodukt 19 % — das hängt von der konkreten Aufmachung ab.
**Mehrere Sätze auf einer Rechnung** sind der Normalfall, sobald Honig und Kerze im selben Paket liegen. Die Rechnung weist dann je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt werden im Verhältnis der Warenwerte auf die Sätze verteilt — der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung; gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt das Hare-Niemeyer-Verfahren, damit dabei keine Cent verschwinden oder entstehen.
Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrieben — Teil des Snapshots wie der Preis. Wird ein Produkt später umkategorisiert oder ändern sich die Sätze, bleiben bestehende Rechnungen unverändert.
**Die Shop-Preise sind Bruttopreise.** Gegenüber Verbraucher:innen ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser Betrag steht in `Product.priceCents` und `Order.totalCents`. Die Steuer wird deshalb **herausgerechnet, nicht aufgeschlagen** — ein Aufschlag würde den Endpreis verändern und von dem abweichen, was im Warenkorb stand. Gerechnet wird auf der Gesamtsumme, damit sich Rundungen einzelner Positionen nicht aufsummieren; der Steuerbetrag ergibt sich als Rest, sodass Netto + Steuer immer exakt den Bruttobetrag ergibt. Der Versand trägt den Satz der Ware (umsatzsteuerliche Nebenleistung).
`pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`.
### Rechnungsnummer
Die Nummer hat die Form `RE-<Jahr>-<laufende Nummer>`, zum Beispiel `RE-2026-0001`. § 14 Abs. 4 Nr. 4 UStG verlangt eine **fortlaufende** Nummer — eine bloß eindeutige genügt nicht, weil sich mit ihr bei einer Betriebsprüfung nicht zeigen ließe, dass keine Rechnung fehlt. Die Zählung beginnt in jedem Jahr wieder bei `0001`; das ist zulässig, solange das Jahr Teil der Nummer ist.
Die laufende Nummer kommt aus der Tabelle `invoice_counters` (eine Zeile je Jahr) und wird zusammen mit der Rechnung in **einer** Transaktion vergeben. Eine Vergabe nach dem Muster „höchste vorhandene Nummer + 1“ wäre anfällig dafür, dass zwei gleichzeitig eintreffende Stripe-Webhooks dieselbe Nummer lesen und zweimal vergeben; die Zeilensperre beim Hochzählen schließt das aus.
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine Rechnung existiert. Stripe stellt denselben Webhook bei ausbleibender Bestätigung erneut zu — ohne diese Prüfung verbrauchte jeder Wiederholungsversuch eine Nummer und riße eine Lücke in die Folge.
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
### E-Rechnung
Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang).
- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten.
- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image.
- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log.
- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig.
### Rechnungskopie
Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie.
### Stornorechnung
Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST-<Jahr>-<laufende Nummer>` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log).
ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`.
### Kunden und Firmenanfragen im CRM (ERPNext)
Zwei weitere nur lesende Endpunkte mit demselben `ERP_EXPORT_TOKEN` (`lib/erp-crm-export.ts`):
| Endpunkt | Inhalt | In ERPNext |
|---|---|---|
| `GET /api/erp/kunden?nach=<id>&limit=<1-200>` | Kundenkonten (ohne Admins, ohne Passwort/2FA), Standardadresse | Kunde in der Gruppe **Privat** mit Kontakt und Adresse |
| `GET /api/erp/anfragen?nach=<id>&limit=<1-200>` | Firmenanfragen der Geschäftskunden-Seite | Kunde (Firma) in der Gruppe **Gewerblich** mit Ansprechpartner, je Anfrage eine Chance |
ERPNext holt bei jedem Lauf die vollständige Liste (Cursor `nach` = letzte ID der vorigen Seite, `next` in der Antwort). Ein im Shop gelöschtes Konto ohne Rechnung verschwindet dadurch auch aus ERPNext; mit Rechnung bleibt der Kunde wegen der Aufbewahrungspflicht stehen.
## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
| Endpunkt | Prüft | Verwendung |
|---|---|---|
| `/api/health` | nur, ob der Node-Prozess antwortet — **kein** Datenbankzugriff | Liveness-Probe |
| `/api/health/ready` | zusätzlich `SELECT 1` gegen die Datenbank, antwortet sonst mit 503 | Readiness-Probe, Docker-Healthcheck |
Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der Datenbank hängt, macht aus einer vorübergehenden DB-Störung eine Neustart-Schleife, weil der neu startende Container laut `Dockerfile`-CMD zuerst `prisma migrate deploy` gegen genau diese gestörte Datenbank fährt. Die Readiness-Probe darf dagegen ruhig fehlschlagen — Kubernetes nimmt den Pod dann nur aus dem Load-Balancing, statt ihn zu töten. Vorher zeigten beide Probes auf `/`, also alle 10 bzw. 30 Sekunden auf eine `force-dynamic`-Seite samt DB-Abfragen und vollem React-Rendering.
## Kubernetes / Gitea CI ## Kubernetes / Gitea CI
`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md). `.gitea/workflows/docker-build.yml` prüft bei jedem Push auf `main` zuerst `pnpm lint`, `pnpm audit --prod --audit-level high`, `pnpm typecheck` und `pnpm test` (Runner `ci-isolated`). Nur wenn alle grün sind, baut der Job `build-and-push` (Runner `docker-builder`) zwei Images und veröffentlicht sie in der Gitea-eigenen Container-Registry: `wabenhain` (App) und `wabenhain-tools` (Skripte für die CronJobs), jeweils mit den Tags `latest` und `sha-<kurzer Commit>`. Der App-Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig.
Die Pipeline wendet **keine** Manifeste an und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt; ein neuer Build geht per `kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>` in Betrieb, danach wird `image:` in `k8s/05-app-deployment.yaml` nachgezogen. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig. Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
@@ -218,36 +526,37 @@ Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten. - **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart). - **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen. - **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen. - **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Ist dieser letzte Eintrag eine Cloudflare-Edge, gilt stattdessen `CF-Connecting-IP`; IPv6-Adressen zählen je /64-Netz (`lib/rate-limit.ts`). Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage. - **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen. - **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details. - **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
- **Abhängigkeits-Sicherheitsscan**: `pnpm audit` vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive `nanoid`-DoS-Lücke über `pnpm-workspace.yaml`s `overrides` gepatcht). - **Abhängigkeits-Sicherheitsscan**: Die CI bricht ab, sobald `pnpm audit --prod --audit-level high` eine Lücke ab „high“ in Laufzeit-Abhängigkeiten findet; ohne grünen Audit entsteht kein Image. Lokal mit `pnpm audit` prüfen (siehe „Nützliche Befehle“ unten). Zuletzt geschlossen wurden High-/Critical-Advisories in Commit `835323e` (2026-10-14).
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS. - **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill. - **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters. - **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist. - **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen. - **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen. - **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren). - **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren); hinter Cloudflare aus `CF-Connecting-IP`, siehe Rate-Limiting oben.
- **Nur über Cloudflare erreichbar**: Der Ingress hängt an der Middleware `wabenhain-cloudflare-only` (nur Cloudflare-Adressen und private Netze), direkte Zugriffe auf den Origin beantwortet Traefik mit 403. Die Middleware liegt nicht in diesem Repo, sondern in den Cluster-Manifesten (siehe Kommentar in `k8s/06-ingress.yaml`).
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen. - **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) wie einen fehlgeschlagenen Zahlungseinzug. - **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank. - **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`. - **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestellnummern (`WH-<Jahr>-<8 Hex-Zeichen>`) kommen aus `crypto.randomBytes`; Rechnungsnummern sind dagegen fortlaufend (siehe „Rechnungsnummer“).
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`. - **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF). - Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
## Datenmodell ## Datenmodell
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema: Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `Payment`, `Shipment`, `Pickup`, `Invoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `NewsletterSubscriber`, `BusinessInquiry`. `Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `ApiCoreStockEvent`, `Payment`, `Shipment`, `Pickup`, `InvoiceCounter`, `Invoice`, `CancellationCounter`, `CancellationInvoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `SiteContent`, `NewsletterSubscriber`, `BusinessInquiry`, `WithdrawalDeclaration`.
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben. Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
## Tests ## Tests
- **Unit-/Integrationstests** (`src/**/*.test.ts`, [Vitest](https://vitest.dev/)): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — `lib/shipping-calc.ts`, `lib/discount-calc.ts`, `lib/format.ts` sowie die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen. - **Unit-/Integrationstests** (`src/**/*.test.ts(x)`, [Vitest](https://vitest.dev/), Stand 2026-10-16: 65 Testdateien, die meisten unter `src/lib`): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — unter anderem Versand- und Rabattberechnung, Umsatzsteuer, Rechnung und E-Rechnung, Checkout-, Abbruch- und Webhook-Route, Bestandsabgleich mit ApiCore, Sessions, CSRF, Client-IP und die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen; die CI führt sie bei jedem Push aus.
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. - **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. Die CI führt sie bisher **nicht** aus.
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen. Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
@@ -259,6 +568,13 @@ Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bu
pnpm dev # Entwicklungsserver pnpm dev # Entwicklungsserver
pnpm build # Produktions-Build pnpm build # Produktions-Build
pnpm lint # ESLint pnpm lint # ESLint
pnpm typecheck # TypeScript ohne Ausgabe (erzeugt vorher Prisma-Clients und Next-Routentypen)
pnpm test # Vitest (Unit-/Komponententests)
pnpm test:e2e # Playwright gegen eine laufende Instanz
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
pnpm orders:release-stale # haengende, bei Stripe abgelaufene Bestellungen freigeben (wie CronJob)
pnpm stock:apicore-sync # Bestandsabgleich mit ApiCore nachholen (wie CronJob)
pnpm places:find "Name Ort" # Google-Place-ID nachschlagen
pnpm db:studio # Prisma Studio (DB-GUI) pnpm db:studio # Prisma Studio (DB-GUI)
pnpm db:migrate # neue Migration erstellen/anwenden (Dev) pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+102
View File
@@ -0,0 +1,102 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -4
View File
@@ -79,6 +79,16 @@ services:
required: false required: false
expose: expose:
- "3000" - "3000"
# Prueft denselben Endpunkt wie die Kubernetes-Readiness-Probe (siehe
# k8s/05-app-deployment.yaml): nicht nur "Prozess laeuft", sondern "App
# erreicht die Datenbank". start_period deckt den Migrations-/Seed-Lauf
# ab, den der Container laut Dockerfile-CMD vor dem Serverstart ausfuehrt.
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
volumes: volumes:
# Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt # Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt
# /app/uploads bereits mit passendem Besitzer fuer den non-root # /app/uploads bereits mit passendem Besitzer fuer den non-root
@@ -94,9 +104,12 @@ 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}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
# 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
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]" # OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
@@ -107,11 +120,14 @@ services:
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY} GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY}
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID} GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID}
SMTP_HOST: ${SMTP_HOST} # Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe
SMTP_PORT: ${SMTP_PORT:-587} # App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example).
SMTP_USER: ${SMTP_USER} SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>} SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>}
ENTRA_TENANT_ID: ${ENTRA_TENANT_ID}
ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID}
ENTRA_CLIENT_SECRET: ${ENTRA_CLIENT_SECRET}
ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
NODE_ENV: production NODE_ENV: production
+504
View File
@@ -0,0 +1,504 @@
# Wabenhain einrichten — von null bis live
Diese Anleitung führt durch alles, was außerhalb des Codes eingerichtet werden muss, damit der Shop in Kubernetes vollständig läuft: Geheimnisse (Secrets), Microsoft-Anmeldung, Mailversand über Microsoft 365, Stripe und die optionalen Dienste. Sie setzt nichts voraus, was nicht hier steht.
**Zeitbedarf:** etwa 2 Stunden für Pflicht und Mailversand, plus Wartezeiten bei Microsoft (bis zu 30 Minuten) und Stripe.
**Reihenfolge:**
1. [Werkzeuge und Zugänge bereitlegen](#1-werkzeuge-und-zugänge)
2. [Grundwerte erzeugen](#2-grundwerte-erzeugen) (Datenbank, Sitzungen, Admin)
3. [Microsoft Entra ID: App-Registrierung](#3-microsoft-entra-id-app-registrierung) (Anmeldung **und** Mailversand)
4. [Microsoft 365: Absender-Postfach freischalten](#4-microsoft-365-absender-postfach-freischalten)
5. [Stripe](#5-stripe)
6. [Optionale Dienste](#6-optionale-dienste)
7. [Secret und ConfigMap ausfüllen](#7-secret-und-configmap-ausfüllen)
8. [Ausrollen](#8-ausrollen)
9. [Prüfen](#9-prüfen)
10. [Wartung: was abläuft und was zu tun ist](#10-wartung)
11. [Fehlerbehebung](#11-fehlerbehebung)
> **Sicherheitsregel für das ganze Dokument:** Echte Werte gehören ausschließlich in `k8s/02-secret.yaml` (per `.gitignore` ausgeschlossen) bzw. direkt ins Cluster. Nie committen, nie in Chats, Tickets oder Screenshots kopieren. Die ConfigMap (`k8s/01-configmap.yaml`) ist unverschlüsselt und im Cluster für jeden mit Lesezugriff im Klartext sichtbar — dort steht nichts Geheimes.
---
## 1. Werkzeuge und Zugänge
**Auf deinem Rechner:**
| Werkzeug | Wofür | Prüfen mit |
|---|---|---|
| `kubectl` mit Zugriff auf das Cluster | Secrets und Manifeste anwenden | `kubectl get nodes` |
| `openssl` | Zufallswerte erzeugen | `openssl version` |
| Node.js | Datenbank-Passwort URL-kodieren | `node --version` |
| PowerShell mit Modul `ExchangeOnlineManagement` | Postfach für den Mailversand freischalten | `Get-Module -ListAvailable ExchangeOnlineManagement` |
Modul einmalig installieren (PowerShell):
```powershell
Install-Module ExchangeOnlineManagement -Scope CurrentUser
```
**Zugänge, die du brauchst:**
| Zugang | Rolle | Wofür |
|---|---|---|
| Microsoft Entra Admin Center (entra.microsoft.com) | Anwendungsadministrator oder Globaler Administrator | App-Registrierung, Administratorzustimmung |
| Microsoft 365 Admin Center (admin.microsoft.com) | Exchange-Administrator | Freigegebenes Postfach anlegen und freischalten |
| Stripe-Dashboard (dashboard.stripe.com) | Administrator | API-Schlüssel, Webhook |
| Gitea (`git.encry.de`) | Schreibrecht am Repository | Image-Build (siehe `k8s/README.md`) |
---
## 2. Grundwerte erzeugen
Diese Werte denkst du dir nicht aus, sondern lässt sie erzeugen. Notiere sie vorübergehend in einem Passwort-Manager, nicht in einer Textdatei.
### Datenbank-Passwort (`DB_PASSWORD` und `DATABASE_URL`)
```bash
openssl rand -base64 32 | tr -d '=+/' | head -c 32
```
Das Passwort steht an **zwei** Stellen im Secret:
- `DB_PASSWORD`: **roh**, genau wie erzeugt.
- `DATABASE_URL`: **URL-kodiert**. Sonderzeichen würden sonst als Trennzeichen der URL gelesen.
```bash
node -e "console.log(encodeURIComponent('HIER-DAS-PASSWORT'))"
```
Ergebnis einsetzen:
`postgresql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-postgres:5432/wabenhain?schema=public`
Bei MySQL statt Postgres: `mysql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-mysql:3306/wabenhain` und in der ConfigMap `DATABASE_PROVIDER: "mysql"`.
### Sitzungsschlüssel (`SESSION_SECRET`)
```bash
openssl rand -base64 32
```
Signiert alle Login-Cookies. Wird er bekannt, lassen sich Sitzungen fälschen — auch die eines Admins. Ihn zu ändern meldet alle nur ab.
### Erstes Admin-Konto (`ADMIN_EMAIL`, `ADMIN_PASSWORD`)
- `ADMIN_EMAIL`: eine **echte, nicht erratbare** Adresse, z. B. `n.rosenloecher-admin@wabenhain.com` — **nicht** `admin@…` oder `info@…`. Sonst könnte jemand diese Adresse vorher als Kundenkonto registrieren. An diese Adresse gehen außerdem die Benachrichtigungen über eingegangene Widerrufe.
- `ADMIN_PASSWORD`:
```bash
openssl rand -base64 24 | tr -d '=+/' | head -c 28
```
Das Konto wird beim ersten Start angelegt. Beim ersten Login verlangt der Admin-Bereich die Einrichtung der Zwei-Faktor-Authentifizierung (Authenticator-App bereithalten).
---
## 3. Microsoft Entra ID: App-Registrierung
**Eine** App-Registrierung erledigt zwei Aufgaben:
- **„Mit Microsoft anmelden“** (Single Sign-on im Kundenkonto)
- **Mailversand** über Microsoft 365 per OAuth 2.0 — ohne Passwort
Eine zweite Unternehmensanwendung ist nicht nötig.
### 3.1 App registrieren
1. **Entra Admin Center** → Identität → Anwendungen → **App-Registrierungen** → **Neue Registrierung**.
2. **Name:** z. B. `Wabenhain Shop`.
3. **Unterstützte Kontotypen:** **„Nur Konten in diesem Organisationsverzeichnis“** (Single Tenant).
4. **Umleitungs-URI:** Plattform **Web**, Adresse **`https://wabenhain.com/api/auth/entra/callback`** — zeichengenau, mit `https`, ohne Schrägstrich am Ende.
5. **Registrieren**.
Auf der Seite **Übersicht** notieren:
| Angezeigt als | Gehört in |
|---|---|
| Anwendungs-ID (Client-ID) | `ENTRA_CLIENT_ID` |
| Verzeichnis-ID (Mandanten-ID) | `ENTRA_TENANT_ID` |
`ENTRA_REDIRECT_URI` ist die Adresse aus Schritt 4: `https://wabenhain.com/api/auth/entra/callback`.
### 3.2 Geheimen Clientschlüssel anlegen
1. In der App → **Zertifikate & Geheimnisse** → **Geheime Clientschlüssel** → **Neuer geheimer Clientschlüssel**.
2. Beschreibung z. B. `Shop 2026`, Ablauf **24 Monate** (Maximum).
3. **Sofort** die Spalte **„Wert“** kopieren → `ENTRA_CLIENT_SECRET`.
> ⚠ **Nicht die „Geheimnis-ID“** kopieren — das ist der häufigste Fehler (Microsoft meldet dann `AADSTS7000215`). Der Wert wird nur dieses eine Mal angezeigt; ist er weg, einen neuen Schlüssel anlegen.
>
> **Ablaufdatum sofort in den Kalender eintragen**, mit Erinnerung vier Wochen vorher. Läuft der Schlüssel ab, fallen Microsoft-Anmeldung **und** Mailversand gleichzeitig aus (siehe [Wartung](#10-wartung)).
### 3.3 API-Berechtigungen
In der App → **API-Berechtigungen**. Es braucht zwei Arten:
| Berechtigung | API | Typ | Wofür |
|---|---|---|---|
| `openid`, `email`, `profile`, `User.Read` | Microsoft Graph | **Delegiert** | Anmeldung („Mit Microsoft anmelden“) |
| `SMTP.SendAsApp` | **Office 365 Exchange Online** | **Anwendung** | Mailversand |
`User.Read` ist meist schon vorhanden. Die übrigen hinzufügen:
**Für die Anmeldung** (falls noch nicht vorhanden):
1. **Berechtigung hinzufügen** → **Microsoft Graph** → **Delegierte Berechtigungen**.
2. `openid`, `email`, `profile` anhaken → **Berechtigungen hinzufügen**.
**Für den Mailversand:**
1. **Berechtigung hinzufügen** → Reiter **„Von meiner Organisation verwendete APIs“**.
2. Nach **Office 365 Exchange Online** suchen und auswählen. (Die Berechtigung liegt **nicht** unter Microsoft Graph.)
3. **Anwendungsberechtigungen** (nicht „Delegierte“) → **`SMTP.SendAsApp`** anhaken → **Berechtigungen hinzufügen**.
**Danach:** **„Administratorzustimmung für &lt;Organisation&gt; erteilen“** klicken und bestätigen. In der Liste muss bei jeder Berechtigung ein grünes **„Erteilt für …“** stehen.
> **Was `SMTP.SendAsApp` erlaubt:** Die App darf per SMTP senden — aber **nur** mit Postfächern, die ihr in Exchange ausdrücklich freigegeben werden (Abschnitt 4). Ohne diese Freigabe kann sie nichts senden.
### 3.4 Objekt-ID der Unternehmensanwendung notieren
1. Entra → Identität → Anwendungen → **Unternehmensanwendungen**.
2. Filter „Anwendungstyp“ auf **Alle Anwendungen** stellen, nach `Wabenhain Shop` suchen, öffnen.
3. Auf der **Übersicht** die **Objekt-ID** kopieren.
> ⚠ Das ist **eine andere Objekt-ID** als die auf der Seite der App-Registrierung. Für Exchange zählt nur die der **Unternehmensanwendung**.
---
## 4. Microsoft 365: Absender-Postfach freischalten
Alle Shop-Mails (Bestellbestätigung, Versandmeldung, Passwort-Reset, Widerrufsbestätigung …) gehen von **einem** Postfach aus, z. B. `hallo@wabenhain.com`.
**Empfohlen: ein freigegebenes Postfach (Shared Mailbox).** Es braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. Die App sendet ohne Anmeldung einer Person.
### 4.1 Freigegebenes Postfach anlegen (falls noch nicht vorhanden)
1. **Microsoft 365 Admin Center** → **Teams und Gruppen** → **Freigegebene Postfächer** → **Freigegebenes Postfach hinzufügen**.
2. Name `Wabenhain`, E-Mail `hallo@wabenhain.com` → **Änderungen speichern**.
3. Optional: Mitglieder hinzufügen, die Antworten der Kundschaft lesen sollen.
### 4.2 App für genau dieses Postfach freischalten
In **PowerShell** (als Exchange-Administrator). Platzhalter ersetzen:
- `<CLIENT-ID>` = `ENTRA_CLIENT_ID` aus 3.1
- `<OBJEKT-ID>` = Objekt-ID der **Unternehmensanwendung** aus 3.4
```powershell
Connect-ExchangeOnline
# 1. App in Exchange bekannt machen
New-ServicePrincipal -AppId <CLIENT-ID> -ObjectId <OBJEKT-ID> -DisplayName "Wabenhain Shop"
# 2. Zugriff NUR auf das Absender-Postfach
Add-MailboxPermission -Identity "[email protected]" -User <OBJEKT-ID> -AccessRights FullAccess
# 3. Authentifiziertes SMTP für dieses Postfach erlauben (auch OAuth läuft darüber)
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
```
**Empfohlene Absicherung:** Authentifiziertes SMTP für alle **anderen** Postfächer abschalten. Die Einstellung am Absender-Postfach aus Schritt 3 hat Vorrang, der Shop sendet weiter.
```powershell
Set-TransportConfig -SmtpClientAuthenticationDisabled $true
```
### 4.3 Kontrolle
```powershell
Get-ServicePrincipal | Where-Object DisplayName -eq "Wabenhain Shop"
Get-MailboxPermission -Identity "[email protected]" | Where-Object { $_.User -like "*Wabenhain*" }
Get-CASMailbox -Identity "[email protected]" | Format-List SmtpClientAuthenticationDisabled # muss False sein
Disconnect-ExchangeOnline -Confirm:$false
```
Änderungen in Exchange brauchen **bis zu 30 Minuten**, bis sie greifen.
**Ergebnis für die Konfiguration:**
| Wert | Wo | Inhalt |
|---|---|---|
| `SMTP_USER` | Secret | `hallo@wabenhain.com` |
| `SMTP_FROM` | ConfigMap | `Wabenhain <hallo@wabenhain.com>` — **dieselbe Adresse** wie `SMTP_USER` |
Server (`smtp.office365.com`), Port (587, STARTTLS) und Anmeldeverfahren (OAuth 2.0) sind fest im Code — dafür gibt es keine Einstellungen. Ein SMTP-Passwort gibt es nicht.
---
## 5. Stripe
Im **Stripe-Dashboard**, **Live-Modus** (Schalter oben rechts, nicht „Testmodus“).
### 5.1 API-Schlüssel
**Entwickler** → **API-Schlüssel**:
| Angezeigt als | Gehört in | Wo |
|---|---|---|
| Veröffentlichbarer Schlüssel (`pk_live_…`) | `STRIPE_PUBLISHABLE_KEY` | ConfigMap (nicht geheim) |
| Geheimer Schlüssel (`sk_live_…`) | `STRIPE_SECRET_KEY` | Secret |
Sicherer als der volle geheime Schlüssel ist ein **eingeschränkter Schlüssel** (`rk_live_…`) mit Schreibrecht auf Checkout Sessions, Refunds und Leserecht auf Charges/Payment Intents.
> Beide Schlüssel müssen aus **demselben Modus** stammen. Mit `sk_test` sehen Bestellungen erfolgreich aus, ohne dass Geld fließt.
### 5.2 Webhook
**Entwickler** → **Webhooks** → **Endpunkt hinzufügen**:
- **URL:** `https://wabenhain.com/api/checkout/webhook`
- **Ereignisse:**
- `checkout.session.completed`
- `checkout.session.async_payment_succeeded`
- `checkout.session.async_payment_failed`
- `checkout.session.expired`
- `charge.refunded`
- `charge.dispute.created`
Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOOK_SECRET` im Secret.
> Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „Neu“, es gibt weder Rechnung noch Bestätigungsmail.
### 5.3 Zahlungsarten
**Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren.
### 5.4 Abo der Bienenpatenschaft
**Stand Livekonto (2026-10-16, eingerichtet):**
- Webhook `https://wabenhain.com/api/checkout/webhook` mit allen zwölf
Ereignissen (die sechs bisherigen plus `charge.dispute.closed`,
`customer.subscription.created/updated/deleted`, `invoice.paid`,
`invoice.payment_failed`). Das Signatur-Geheimnis bleibt dasselbe.
- Kundenportal `bpc_1URCIfEvHaDWQ1U0nD4jVWXc`: nur Zahlungsart ändern, keine
Kündigung, kein Tarifwechsel, keine Kundendaten, keine Rechnungshistorie.
Steht als `STRIPE_PORTAL_CONFIGURATION_ID` in `k8s/01-configmap.yaml`.
- Zahlungsmethoden-Domains `wabenhain.com` und `www.wabenhain.com` registriert
(Apple Pay, Google Pay, PayPal, Link, Klarna im eingebetteten Checkout).
**Nur im Dashboard möglich (Stripe bietet dafür keine API):**
1. Einstellungen → Billing → Abos und E-Mails → Wiederholungsversuche:
Smart Retries an, nach dem letzten Versuch **Abo kündigen**.
2. Einstellungen → Billing → Abos und E-Mails → Kunden-E-Mails: Mails zu
erfolgreichen Zahlungen, anstehenden Verlängerungen und fehlgeschlagenen
Zahlungen **aus** (Wabenhain verschickt eigene Rechnungen und Mails).
3. Einstellungen → Zahlungsmethoden: Google Pay ist in der Konfiguration
„Default“ als nicht verfügbar markiert — dort aktivieren.
Das Abo läuft über Stripe Billing (Checkout im Modus „subscription“). Produkte und Monatspreise der Abo-Angebote legt Wabenhain selbst an, sobald ein Abo-Angebot im Admin gespeichert oder zum ersten Mal abgeschlossen wird — **nicht** von Hand im Dashboard anlegen.
**Einrichtungsskript** (wiederholbar, schreibt ohne `--apply` nichts):
```bash
# Probelauf: zeigt, was fehlt
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
# Schreiben (Testmodus)
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply
# Livekonto: nur nach Freigabe des Inhabers, zusätzlich --live-ok
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply --live-ok
```
Das Skript liest `STRIPE_SECRET_KEY` aus der Umgebung (`.env`) und
1. ergänzt den Webhook-Endpunkt `<BASE_URL>/api/checkout/webhook` um die Abo-Ereignisse (bestehende bleiben): `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.dispute.closed`. Legt es den Endpunkt neu an, steht das Signaturgeheimnis nur im Dashboard → `STRIPE_WEBHOOK_SECRET`.
2. legt eine Kundenportal-Konfiguration an, die **nur das Ändern der Zahlungsart** erlaubt (keine Kündigung, kein Tarifwechsel, keine Kundendaten, kein Rechnungsverlauf). Die App findet sie über die Metadaten `wabenhain=abo-zahlungsart`; alternativ `STRIPE_PORTAL_CONFIGURATION_ID` setzen. Ohne diese Konfiguration bietet das Konto „Zahlungsart ändern“ nicht an — die Standardkonfiguration wird bewusst nie benutzt.
**Nur im Dashboard** (Checkliste, das Skript gibt sie ebenfalls aus):
- [ ] **Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen:** Smart Retries an; nach dem letzten erfolglosen Versuch **Abo kündigen** (nicht „unbezahlt markieren“). Wabenhain beendet das Abo dann über `customer.subscription.deleted`.
- [ ] **… Kunden-E-Mails:** Stripe-Mails zu erfolgreichen Zahlungen/Rechnungen, bevorstehenden Verlängerungen und fehlgeschlagenen Zahlungen **aus** — Wabenhain verschickt eigene Rechnungen (Nummernkreis RE-…) und Mails.
- [ ] **Einstellungen → Zahlungsmethoden:** Karte, Apple Pay, Google Pay und SEPA-Lastschrift aktiv. Im Abo zeigt Checkout nur Zahlarten, die wiederkehrende Zahlungen können. Google Pay prüfen; für Apple Pay/Google Pay im eingebetteten Checkout die Domain registrieren.
- [ ] **Stripe Tax** nur mit aktiver Registrierung einschalten (Preise sind Bruttopreise; ohne Registrierung berechnet Stripe nichts).
**Im Admin:** SEPA-Neukunden-Frist (Vorgabe 56 Tage, 0 schaltet ab), Kontingente und Erntefenster der Abo-Angebote.
**Was automatisch passiert:** Jede bezahlte Monatsrechnung wird eine Wabenhain-Bestellung mit Rechnung und Mail; die erste erzeugt die Urkunde. Nach drei bezahlten Monaten (Ernte-Variante: im Erntefenster nach sechs) legt der Hintergrundlauf alle zehn Minuten eine 0-€-Lieferbestellung an, die in der normalen Pack- und DHL-Liste erscheint. Eine Rückbuchung setzt das Abo in Verzug, sperrt SEPA für das Konto und beendet das Abo nach 14 Tagen ohne Ausgleich. Kündigungen kommen aus dem Konto oder von `/vertraege-kuendigen` (Footer „Verträge hier kündigen“); öffentliche Kündigungen stehen im Audit-Log (`subscription.cancellation_request`) und gehen zusätzlich an `ADMIN_EMAIL`.
---
## 6. Optionale Dienste
Alle folgenden Werte dürfen leer bleiben. Die Funktion ruht dann, der Shop läuft weiter. Ausführliche Anleitungen stehen in `.env.example` im jeweiligen Abschnitt.
| Dienst | Werte | Ohne sie | Einrichtung |
|---|---|---|---|
| **Cloudflare Turnstile** (CAPTCHA) — *dringend empfohlen* | `TURNSTILE_SECRET_KEY` (Secret), `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (ConfigMap) | Keine Bot-Abwehr an Formularen | dash.cloudflare.com → Turnstile → Widget für `wabenhain.com` anlegen. Beide setzen oder beide leer. |
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
| **ApiCore-Synchronisierung** | `APICORE_API_KEY` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | API-Schlüssel aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel, nur Berechtigung `shop-sync:read`; kein Benutzerkonto, kein Passwort |
| **Bestandsabgleich mit ApiCore** | zusätzlich `APICORE_WEBHOOK_SECRET` (Secret), Berechtigung `shop-sync:write` am Schlüssel, CronJob `wabenhain-apicore-stock` | Der Webhook `/api/apicore/webhook` antwortet mit 503, Entnahmen in ApiCore kommen erst mit dem nächsten Abgleich an | ApiCore → Automatisierung → Webhooks: Webhook für `stock.changed` auf `https://wabenhain.com/api/apicore/webhook`, dessen Secret hier eintragen. Einzelheiten im README, Abschnitt „Bestandsabgleich“ |
| **ERPNext-Export** (Rechnungen, Stornorechnungen, Kunden, Firmenanfragen) | `ERP_EXPORT_TOKEN` (Secret), mindestens 32 Zeichen | Die Endpunkte unter `/api/erp/…` lehnen jeden Abruf ab, ERPNext übernimmt nichts | Zufallswert erzeugen (wie `SESSION_SECRET` in Abschnitt 2) und denselben Wert in ERPNext als Token für Wabenhain hinterlegen |
---
## 7. Secret und ConfigMap ausfüllen
### 7.1 Secret (`k8s/02-secret.yaml`)
```bash
cp k8s/02-secret.example.yaml k8s/02-secret.yaml
```
`k8s/02-secret.yaml` öffnen und jeden Wert aus der Tabelle eintragen. Werte in **doppelten Anführungszeichen** lassen (`stringData` — Kubernetes kodiert selbst, **kein** base64 von Hand).
| Schlüssel | Pflicht | Inhalt | Quelle |
|---|---|---|---|
| `DATABASE_URL` | ja | Verbindungs-URL mit **URL-kodiertem** Passwort | Abschnitt 2 |
| `DB_PASSWORD` | ja | Datenbank-Passwort **roh** | Abschnitt 2 |
| `SESSION_SECRET` | ja | ≥ 32 zufällige Zeichen | Abschnitt 2 |
| `ADMIN_EMAIL` | ja | nicht erratbare Admin-Adresse | Abschnitt 2 |
| `ADMIN_PASSWORD` | ja | ≥ 12 Zeichen, zufällig | Abschnitt 2 |
| `STRIPE_SECRET_KEY` | ja | `sk_live_…` bzw. `rk_live_…` | 5.1 |
| `STRIPE_WEBHOOK_SECRET` | ja | `whsec_…` | 5.2 |
| `SMTP_USER` | ja | `hallo@wabenhain.com` | 4 |
| `ENTRA_TENANT_ID` | ja* | Verzeichnis-ID | 3.1 |
| `ENTRA_CLIENT_ID` | ja* | Anwendungs-ID | 3.1 |
| `ENTRA_CLIENT_SECRET` | ja* | **Wert** des Clientschlüssels | 3.2 |
| `ENTRA_REDIRECT_URI` | ja* | `https://wabenhain.com/api/auth/entra/callback` | 3.1 |
| `TURNSTILE_SECRET_KEY` | empfohlen | Turnstile-Secret | 6 |
| `GOOGLE_PLACES_API_KEY` | nein | Google-API-Schlüssel | 6 |
| `APICORE_API_KEY` | nein | ApiCore-API-Schlüssel (`ack_…`) | 6 |
| `APICORE_WEBHOOK_SECRET` | nein | Secret des ApiCore-Webhooks `stock.changed` | 6 |
| `ERP_EXPORT_TOKEN` | nein | Bearer-Token für den ERPNext-Export, ≥ 32 Zeichen | 6 |
| `DHL_*` (6 Werte) | nein | DHL-Zugangsdaten | 6 |
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
**Nicht mehr verwendet** (aus älteren Secrets entfernen): `SMTP_HOST`, `SMTP_PORT`, `SMTP_PASSWORD`, `SMTP_OAUTH_TENANT_ID`, `SMTP_OAUTH_CLIENT_ID`, `SMTP_OAUTH_CLIENT_SECRET`.
### 7.2 ConfigMap (`k8s/01-configmap.yaml`)
Nur diese Werte anpassen, der Rest sind sinnvolle Vorgaben:
| Schlüssel | Inhalt |
|---|---|
| `NEXT_PUBLIC_SITE_URL` | `https://wabenhain.com` — muss zu `host` in `k8s/06-ingress.yaml` passen |
| `SMTP_FROM` | `Wabenhain <hallo@wabenhain.com>` — dieselbe Adresse wie `SMTP_USER` |
| `DATABASE_PROVIDER` | `postgresql` (oder `mysql`, passend zu `DATABASE_URL`) |
| `STRIPE_PUBLISHABLE_KEY` | `pk_live_…` |
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Turnstile-Site-Key (empfohlen) |
| `VAT_RATE_PERCENT*` | Umsatzsteuersätze prüfen (Honig 7 %, Kerzen 19 %, Propolis je nach Einordnung) |
| `DHL_ENVIRONMENT` | `sandbox`, bis DHL getestet ist, dann `production` |
| `GOOGLE_PLACE_ID`, `APICORE_API_URL` | nur bei Nutzung |
---
## 8. Ausrollen
Erstmalig, in dieser Reihenfolge (Details und Hintergründe: `k8s/README.md`):
```bash
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/01-configmap.yaml
kubectl apply -f k8s/02-secret.yaml
# Nur EINE Datenbank, passend zu DATABASE_PROVIDER:
kubectl apply -f k8s/03-postgres.yaml
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml
kubectl apply -f k8s/07-backup-cronjob.yaml
kubectl rollout status deployment/wabenhain-app -n wabenhain
```
Datenbank-Migrationen und das Anlegen des Admin-Kontos laufen beim Start des Pods automatisch.
**Später einen Wert ändern:** Datei anpassen, erneut anwenden und den Pod neu starten — die App liest Secrets und ConfigMap nur beim Start:
```bash
kubectl apply -f k8s/02-secret.yaml # bzw. 01-configmap.yaml
kubectl rollout restart deployment/wabenhain-app -n wabenhain
```
Einzelnen Wert ohne Datei ändern bzw. entfernen:
```bash
kubectl -n wabenhain patch secret wabenhain-secrets --type=merge -p '{"stringData":{"SMTP_USER":"[email protected]"}}'
kubectl -n wabenhain patch secret wabenhain-secrets --type=json -p '[{"op":"remove","path":"/data/SMTP_PASSWORD"}]'
```
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
**Neuen Build ausrollen:** Die Pipeline baut nur Images und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt, ein `rollout restart` startet deshalb nur denselben Build neu. Einen neuen Build setzt man so ein (Digest aus der Gitea-Registry, Paket `wabenhain`, Tag `sha-<kurzer Commit>`):
```bash
kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
kubectl -n wabenhain rollout status deploy/wabenhain-app
```
Danach `image:` in `k8s/05-app-deployment.yaml` auf denselben Wert ziehen. Einzelheiten: `k8s/README.md`, Abschnitt „Bei einem neuen Image“.
---
## 9. Prüfen
Der Reihe nach:
1. **Vollständigkeit der Konfiguration** im laufenden Pod:
```bash
kubectl -n wabenhain exec deploy/wabenhain-app -- node_modules/.bin/tsx scripts/preflight.ts
```
Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung.
2. **Shop erreichbar:** `https://wabenhain.com` öffnen.
3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich.
4. **Mailversand und Anbindungen:** **Admin → Einstellungen → System & Tests**. Oben steht, welche Dienste eingerichtet sind (fehlende Werte werden benannt). Darunter „Test-Mail senden“: zuerst prüft der Test, ob `smtp.office365.com:587` und `login.microsoftonline.com:443` per IPv4 erreichbar sind, dann den Versand. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
6. **Firmendaten:** **Admin → Einstellungen → Firma & Versand** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
---
## 10. Wartung
| Was | Läuft ab / ändert sich | Folge, wenn verpasst | Was tun |
|---|---|---|---|
| **Entra-Clientschlüssel** (`ENTRA_CLIENT_SECRET`) | nach höchstens **24 Monaten** | Microsoft-Anmeldung **und** Mailversand fallen gleichzeitig aus | Vier Wochen vorher neuen Schlüssel anlegen (3.2), Secret ersetzen, Pod neu starten, **E-Mail-Test**, danach alten Schlüssel in Entra löschen |
| **Stripe-Schlüssel** | bei Verdacht auf Abfluss | Fremde könnten Erstattungen auslösen | Im Stripe-Dashboard „Schlüssel rollen“, Secret ersetzen, Pod neu starten |
| **`SESSION_SECRET`** | bei Verdacht auf Abfluss | Sitzungen fälschbar | Neuen Wert erzeugen, ersetzen, neu starten (meldet alle ab) |
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → Einstellungen → System & Tests**.
---
## 11. Fehlerbehebung
### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
| Meldung / Ursache | Lösung |
|---|---|
| „nicht vollständig eingerichtet“ + Liste fehlender Werte | Genannte Werte ins Secret, Pod neu starten |
| `AADSTS7000215` — Client-Secret falsch | In `ENTRA_CLIENT_SECRET` steht die Geheimnis-ID statt des **Werts** → neuen Schlüssel anlegen (3.2) |
| `AADSTS7000222` — Client-Secret abgelaufen | Neuen Schlüssel anlegen (3.2), ersetzen, neu starten |
| `AADSTS700016` — App nicht gefunden | `ENTRA_CLIENT_ID` und `ENTRA_TENANT_ID` gehören nicht zusammen |
| `AADSTS90002` / `900023` — Tenant ungültig | `ENTRA_TENANT_ID` prüfen (Verzeichnis-ID) |
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
| „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false` |
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
| „nicht erreichbar“ (Netzwerk) | Die Netzwerkprüfung im Test zeigt, welcher Weg fehlt. Der Cluster muss ausgehend per IPv4 `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dort die Freigabe von Port 587 beantragen. |
| `ENETUNREACH 2603:…` (IPv6-Adresse) | Stammte aus älteren Versionen, die zufällig eine IPv6-Adresse wählten; der Shop verbindet sich inzwischen nur per IPv4. Tritt es trotzdem auf: aktuelles Image ausrollen. |
Ausführliche Fehler stehen außerdem im Pod-Log:
```bash
kubectl logs deploy/wabenhain-app -n wabenhain | grep "\[mail\]"
```
### Microsoft-Anmeldung
| Symptom | Lösung |
|---|---|
| Knopf „Mit Microsoft anmelden“ fehlt | Einer der vier `ENTRA_*`-Werte fehlt |
| Microsoft meldet „redirect_uri mismatch“ (`AADSTS50011`) | `ENTRA_REDIRECT_URI` und die Umleitungs-URI in der App-Registrierung (3.1) müssen zeichengenau gleich sein |
### Sonstiges
| Symptom | Lösung |
|---|---|
| Pod startet nicht, Log nennt Datenbankfehler | `DATABASE_URL`: Passwort URL-kodiert? Host passend zum Datenbank-Manifest? |
| Bestellungen bleiben auf „Neu“ | Stripe-Webhook (5.2): URL, Ereignisse und `STRIPE_WEBHOOK_SECRET` prüfen; im Stripe-Dashboard unter Webhooks die Zustellversuche ansehen |
| Bilder weg nach Neustart | `k8s/README.md`, Abschnitt „Bilder/Videos verschwinden nach jedem Redeploy“ |
@@ -0,0 +1,132 @@
# Bienenpatenschaft Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bienenpatenschaften als Einmalkauf und Abo verkaufen, mit deterministisch erzeugter Urkunde, Medaillen, 3D-Ansicht im Konto, Übertragung, Treuestufen und Stripe-Abo.
**Architecture:** Eine reine Layout-Engine (`src/lib/certificate`) erzeugt aus Personalisierung, Textbausteinen und Vorlagenversion positionierte Elemente; daraus entstehen PDF (pdfkit) und Bilder (SVG mit Glyphenpfaden → sharp). Einmal-Angebote hängen an je einem `Product` der neuen Kategorie `PATENSCHAFT`, damit Warenkorb, Checkout, Rechnung, ERP-Export und ApiCore-Buchung weiterlaufen; der Honig eines Pakets wird beim Checkout als eigene Position des verknüpften Honigprodukts angelegt (Steueraufteilung, Bestandsreservierung und ApiCore-Abbuchung ohne Sonderweg). Abos laufen über Stripe Billing; jede bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung mit Rechnung, jede fällige Lieferung zu einer 0-€-Bestellung mit Honigpositionen.
**Tech Stack:** Next.js 16 (App Router, Server Actions), Prisma 7 (Postgres + MySQL), pdfkit, fontkit, sharp, three + @react-three/fiber + drei, fflate, Stripe (API 2026-08-26.dahlia), vitest, Playwright.
**Spec:** `docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md`
Abweichung vom Standard „ein Plan je Teilprojekt“: Der Inhaber hat alle fünf Teilprojekte auf einmal freigegeben. Der Plan ist deshalb in Teile A–E gegliedert, jeder Teil ist für sich lauffähig und testbar und wird einzeln committet.
## Global Constraints
- Kein Versprechen von Besuchen, Events, Videos, Fotos, Berichten, Umweltwirkungen – in keinem Text, keiner Mail, keiner Urkunde.
- Urkunde: A4 hochkant (595.28 × 841.89 pt), genau eine Seite, Inhalt ≥ 15 mm (42.52 pt) vom Rand, Rahmen ≥ 12 mm (34.02 pt), kleinste Schrift 8 pt, Fließtext ≥ 11 pt.
- Schriften Cormorant Garamond und Inter, statische TTF unter SIL OFL, mit Lizenzdatei in `assets/fonts/`.
- Farben: Forest `#24352b`, Honey Dark `#8a6531`, Honey `#b88a44`, Sage `#788774`; auf Papier kein Verlauf, keine Folienimitation.
- Eingabelimits (Start): Bienenname 2–24, Name 2–60, Schenkende ≤ 80, Widmung ≤ 280 Zeichen und ≤ 5 Zeilenumbrüche; NFC, Steuerzeichen raus, Glyphenprüfung.
- Keine Pronomen, keine abgeleiteten Anreden, keine Behauptung individueller Betreuung einer Biene.
- Patenschaftsnummer `WH-P-JJJJ-NNNNN`. Vorlagenkennung `<name>@<version>`.
- Patenschaften nur mit angemeldetem, verifiziertem Konto. Gastbestellungen anderer Produkte bleiben.
- PDFs in `CERTIFICATE_DIR` (nie unter `UPLOAD_DIR`), Downloads nur nach Besitzprüfung, fremde IDs → 404.
- Wasserzeichen der Vorschau: „Unverbindliche Vorschau“ / „Preview – not binding“.
- Abo: Lieferanspruch nach 3 (vierteljährlich) bzw. 6 (Ernte) bezahlten Monaten; SEPA-Neukunden-Frist 56 Tage (Admin, 0 = aus); Rückbuchung → `IM_VERZUG`, nach 14 Tagen ohne Ausgleich Ende; Konto mit Rückbuchung → kein SEPA mehr.
- Abo-Stufen 1–24 je bezahltem Monat, danach VIP; Stammkunden-Stufen ab 2/5/10/25 bezahlten Bestellungen; beides im Admin pflegbar.
- Übertragungslinks: 24 h / 7 Tage / 30 Tage, einmalig, widerrufbar, Token ≥ 128 Bit, nur Hash gespeichert.
- Zahlarten nie per `payment_method_types`; Stripe-Livekonto nur über Einrichtungsskript bzw. Stripe-Anbindung ändern.
- Codekommentare und Texte auf Deutsch, Stil wie im Repo (Warum-Kommentare, keine console.log zum Debuggen).
- Agents committen nicht; der Hauptlauf committet zentral.
## Review Focus
1. Zwei Patenschaften desselben Pakets im Warenkorb dürfen nicht verschmelzen → Tests in A7 (Warenkorbschlüssel) und A8 (Checkout mit doppelter productId).
2. Webhook mehrfach oder parallel: genau eine Patenschaft, genau eine Mail → Test in A9.
3. Manipulierte Personalisierung (überlange Widmung, fremde Zeichen, `<script>`): Checkout lehnt ab, Urkunde zeigt nur Text → Tests in A2 und A8.
4. Abo gekündigt, während eine Lieferung fällig wird: keine Lieferung über bezahlte Monate hinaus → Test in D4.
5. Honig knapp, während Abos reserviert sind: Einzelverkauf und Pakete können reservierte Gläser nicht kaufen → Test in D5.
---
## Teil A – Urkunde und Einmal-Patenschaft (Teilprojekt 1)
### Task A1: Schriften, Personalisierungsschema, Textbausteine
**Files:** Create `assets/fonts/CormorantGaramond-{Regular,Italic,SemiBold,SemiBoldItalic}.ttf`, `assets/fonts/Inter-{Regular,Medium}.ttf`, Lizenzdateien; `src/lib/certificate/{schema,text,fonts}.ts`, `src/types/fontkit.d.ts`; Tests `src/lib/certificate/{schema,text}.test.ts`
**Produces:**
- `OCCASIONS = ["SELBST","GEBURTSTAG","WEIHNACHTEN","DANKESCHOEN","GESCHENK"] as const`, `type Occasion`
- `CERTIFICATE_LIMITS = { beeName:{min:2,max:24}, recipientName:{min:2,max:60}, giverNames:{max:80}, dedication:{max:280,maxLineBreaks:5} }`
- `normalizeCertificateText(value: string, opts?: { multiline?: boolean }): string`
- `personalizationSchema` (zod) → `Personalization = { beeName; recipientName; giverNames?; dedication?; occasion: Occasion; startDate: "YYYY-MM-DD" }`; `giverNames` wird bei `SELBST` verworfen
- `CertificateTerm = {kind:"FIXED";start;end} | {kind:"OPEN";start} | {kind:"ENDED";start;end}`, `fixedTermFor(start)` (Ende = Start + 12 Monate − 1 Tag)
- `composeCertificateText({ personalization, locale, term, number }): CertificateText` = `{ title; subtitle; occasionLine|null; leadLine|null; beforeBee; beeName; afterBee; giverLine|null; dedication|null; termLine; numberLine; notice }`
- `loadCertificateFonts(): CertificateFonts` (gecacht; Schlüssel `serif`, `serifItalic`, `serifBold`, `serifBoldItalic`, `sans`, `sansMedium`), `findUnsupportedCharacters(text, fontKeys): string[]`
- [ ] Tests (Schema-Grenzen, „Zoë“, Steuerzeichen, giverNames bei SELBST, 6 Umbrüche, Emoji; Text: eigene/verschenkte Variante, Anlasszeilen, leere Felder ohne Zeile, drei Laufzeitformen de/en, Hinweis wörtlich)
- [ ] Implementieren, `pnpm vitest run src/lib/certificate` grün
### Task A2: Layout-Engine
**Files:** `src/lib/certificate/layout.ts`, `src/lib/certificate/templates/{index,<vorlage>}.ts`; Test `layout.test.ts`
**Produces:**
- `LayoutElement = {kind:"text"; font; size; color; x; y; width; text; align; role} | {kind:"path"; d; stroke?; fill?; strokeWidth?; opacity?} | {kind:"image"; src:"logo"; x; y; width; height}`
- `CertificateLayout = { templateVersion; width: 595.28; height: 841.89; elements }`
- `CertificateTemplate { version; layout(text, fonts): CertificateLayout }`, `CURRENT_TEMPLATE_VERSION`, `getTemplate(version)`
- `layoutCertificate(text, templateVersion?)`, `CertificateLayoutError { field }`, `checkCertificateFits(p, locale)`
- [ ] Testmatrix der Spec: keine Überlappung, Sicherheitszone, Mindestgrößen, Namen ≤ 2 Zeilen, Umbruch nur an Leerzeichen/Bindestrich
- [ ] Implementieren, grün
### Task A3: Renderer und Designvarianten
**Files:** `src/lib/certificate/{render-pdf,render-image}.ts`, `scripts/render-certificate-samples.ts`; Test `render.test.ts`
**Produces:** `renderCertificatePdf(layout, { title, logo? }): Promise<Buffer>`, `renderCertificateSvg(layout, { watermark? }): string` (Glyphenpfade), `renderCertificateImage(layout, { widthPx, watermark?, format }): Promise<Buffer>`, `layoutLines(layout): string[]`
- [ ] Tests: `%PDF`, genau eine Seite, eingebettete Fonts; SVG ohne `<text>`; Wasserzeichen; Bildbreite
- [ ] Drei Vorlagen rendern, ansehen, eine wählen, andere entfernen
### Task A4: Datenmodell
Enums `SponsorshipOfferKind`, `SponsorshipCycle`, `MedalTier`, `SponsorshipStatus`; `ProductCategory.PATENSCHAFT`; `FulfillmentMethod.DIGITAL`; Modelle `SponsorshipOffer`, `Sponsorship`, `SponsorshipRevision`, `SponsorshipCounter`; `OrderItem.personalization`, `OrderItem.sponsorshipOfferId`, `OrderItem.sponsorshipRole`. Migration `prisma/migrations/20261016010000_sponsorship`. `DIGITAL` überall behandeln, `pnpm typecheck` grün.
### Task A5: Angebote im Admin + Startdaten
`src/lib/sponsorship/offers.ts` (`getActiveOffers`, `getOfferBySlug`), Admin `/admin/patenschaften/angebote`, Upsert legt das `Product` (Kategorie `PATENSCHAFT`, Preis = Paketpreis − Honiganteil) mit an; Seed der drei Pakete; `PATENSCHAFT` aus Shop-Listen und Produktseite filtern.
### Task A6: Vorschau-API und Verkaufsseite
`/api/patenschaft/vorschau` (Origin, Rate-Limit 60/10 min, Schema + Fit, WebP 900 px mit Wasserzeichen, `no-store`); `/patenschaft` mit Konfigurator (Einmalig/Abo, Schritte, Zähler, Debounce 400 ms, Bestätigung, Anmeldezwang mit Entwurf in `sessionStorage` `wabenhain-sponsorship-draft`).
### Task A7: Warenkorb
`CartItem.key`, `CartItem.sponsorship?: { offerSlug; personalization }`, Operationen über `key`, `persist` Version 2 mit `migrate`, Patenschaft Menge fest 1. Tests: zwei gleiche Pakete bleiben zwei Positionen; alter Speicherstand wird migriert.
### Task A8: Checkout
`expandSponsorshipItems(...)` in `src/lib/sponsorship/checkout-expand.ts`: Hauptposition ohne Reservierung, Honigposition aus verknüpftem Produkt (`unitPriceCents = honeyShareCentsPerJar`, Menge = Gläser, Steuer des Honigs), Kontopflicht, erneute Prüfung, reine Digitalbestellung → `DIGITAL` ohne Versand, doppelte productIds erlaubt. Tests inkl. Manipulation.
### Task A9: Ausstellung nach Zahlung
`createSponsorshipsForOrder(tx, orderId)` im Claim-Lauf; `issuePendingSponsorships({orderId?, limit?})`; Mail-Claim über `mailSentAt`; Backoff `min(2^n × 5 min, 12 h)`, 8 Versuche → `FEHLER`; `scripts/sponsorship-jobs.ts` + CronJob `wabenhain-sponsorship`; `CERTIFICATE_DIR` mit Startprüfung. Tests: doppelter Aufruf, Fehler beim Rendern, Wiederholung.
### Task A10: Kontobereich und Download
`/konto/patenschaft`, `/konto/patenschaft/[id]`, PDF-Route mit Besitzprüfung, `getOwnedSponsorship(customerId, id)`.
### Task A11: Admin Patenschaften, Druck, Korrektur
Liste/Filter, Detail, Druck-PDF, „gedruckt“, „erneut erzeugen“, „Mail erneut“, `reviseSponsorship(...)` mit Revision und Audit-Log; Aufmerksamkeitsliste.
## Teil B – Medaillen, 3D, Gesamtdownload (Teilprojekt 2)
- B1 `src/lib/medals/`: `renderMedalSvg(input: MedalInput)`, `renderMedalPng(input, sizePx = 2048)`; `MedalInput` = Patenschaft (tier, beeName, year, number) | Stammkunde (level, name) | Abo (months) | VIP.
- B2 Routen `textur.webp`, `medaille.png`, `medaille.svg`, `alles.zip` (fflate) mit Besitzprüfung.
- B3 `src/components/patenschaft-3d/`: `<SponsorshipStage textureUrl backTextureUrl medalSvgUrl tier fallbackImageUrl />`, dynamisch geladen, Wölbung, Reflex, Ziehen mit Nachschwung, Doppelklick wendet, Zoom begrenzt, Neigung nach Tipp, reduced motion, WebGL-Fallback, Tastatur, Pause außerhalb der Sicht.
## Teil C – Übertragung (Teilprojekt 3)
`SponsorshipTransfer`, `createTransferAction(id, validity)`, `revokeTransferAction`, `/patenschaft/uebernehmen/[token]`, Audit-Log, neutrale Fehlerseite. Tests: Ablauf, Widerruf, Doppel-Einlösung, Abo nicht übertragbar.
## Teil D – Abo (Teilprojekt 4)
- D1 Modelle `SponsorshipSubscription`, `SubscriptionPayment`, `SubscriptionDelivery`, `HarvestWindow`; `Customer.stripeCustomerId`, `Customer.sepaBlockedAt`; Einstellung SEPA-Frist (56).
- D2 Stripe Product/Price aus Admin; `POST /api/patenschaft/abo` (Checkout `mode: "subscription"`, eingebettet; `excluded_payment_method_types: ["sepa_debit"]` nur bei Sperre).
- D3 Webhooks Abo/Rechnung/Rückbuchung idempotent; `invoice.paid` → Zahlung + Bestellung `DIGITAL` + Rechnung + Mail; erste Zahlung → Patenschaft (OPEN).
- D4 `computeDeliveryEntitlement(...)`, täglicher Lauf erzeugt 0-€-Lieferbestellungen.
- D5 `availableForSale(productId)` = Bestand − Abo-Reservierung; Kontingent; Deckungsanzeige; Stopp neuer Abos.
- D6 Kündigung im Konto und unter `/vertraege-kuendigen`, `cancel_at_period_end`, Mail, ENDED-Revision bei Wirksamkeit.
- D7 `scripts/stripe-setup.ts` und Livekonto über die Stripe-Anbindung (Webhook-Ereignisse, Kundenportal nur Zahlungsart), Checkliste in `docs/EINRICHTUNG.md`.
## Teil E – Treuestufen (Teilprojekt 5)
`LoyaltyLevel`, Seed (2/5/10/25; Abo 1–24 + VIP), `getCustomerLoyalty(customerId)`, Medaillenleiste, Admin-Pflege. Tests: Erstattung senkt Stufe, Kündigung entfernt Abo-Stufe.
## Abschluss
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` grün
- [ ] Browserprüfung Desktop + 390 px, reduced motion, Tastatur
- [ ] Code-Review und `code-simplifier` auf den Diff
- [ ] Commits, Push, CI grün; Rollout nur nach Freigabe
@@ -0,0 +1,184 @@
# Versandstaffel je Artikelkategorie und konfigurierbare Höchstbestellmenge
Datum: 2026-09-22
Status: freigegeben, Implementierung ausstehend
## Problem
Die Versandkosten richten sich heute nach einer einzigen, global gepflegten
Mengenstaffel: `resolveShippingCostCents` in `src/lib/shipping-calc.ts`
bekommt die Summe aller Stück über alle Positionen hinweg und sucht dazu die
passende Zeile aus `ShippingTier`. Das setzt voraus, dass jedes Stück
ungefähr gleich viel Versandkosten verursacht. Das trifft nicht zu: ein
500-Gramm-Honigglas wiegt ein Vielfaches einer Bienenwachskerze, und eine
Staffel, die für Honig kostendeckend ist, verteuert Kerzenbestellungen ohne
Grund.
Zweitens ist die Höchstbestellmenge je Position nirgends pflegbar. Sie steckt
als Vorgabewert `max = 10` im `QuantityStepper` und als `.max(20)` im
Checkout-Schema — zwei Zahlen im Code, die sich widersprechen und die der
Betreiber nicht ändern kann, wenn eine Sorte knapp wird.
## Ziele
1. Versandstaffeln je Produktkategorie (HONIG, KERZEN, PROPOLIS) pflegbar
machen, mit einer Standardstaffel für Kategorien ohne eigene Zeilen.
2. Die Höchstbestellmenge je Position im Adminportal festlegen: ein globaler
Standardwert plus eine optionale Übersteuerung je Produkt.
Nicht Ziel: gewichtsgenaue Versandberechnung, Versandklassen unabhängig von
der Verkaufskategorie, getrennte Pakete je Kategorie.
## Entscheidungen
### Kategorie statt Gewicht
Die Staffeln hängen an der Kategorie, nicht am Gewicht. Die Kategorie liegt
bereits an jedem Produkt (`Product.category`), ist im Adminportal vertraut und
lässt sich Kunden in Stück statt in Gramm erklären. Eine gewichtsbasierte
Staffel wäre genauer, verlangt vom Betreiber aber das Denken in Gramm und vom
Shop eine weniger anschauliche Preisauskunft.
### Gemischter Warenkorb: die teuerste Kategorie gewinnt
Enthält eine Bestellung mehrere Kategorien, wird je Kategorie gestaffelt und
anschließend der höchste dieser Preise berechnet — nicht ihre Summe. Es geht
ein Paket hinaus, also wird ein Versandpreis berechnet. Das Addieren würde
gemischte Bestellungen bestrafen, obwohl sie den Betrieb kaum mehr kosten.
Der Preis ist dadurch nie niedriger als der teuerste Einzelbestandteil und
nie höher als die Summe.
### Standardstaffel als Rückfallebene
`ShippingTier.category` ist optional. Zeilen ohne Kategorie bilden die
Standardstaffel und gelten für jede Kategorie, für die keine eigenen Zeilen
gepflegt sind. So genügt es, für Honig abweichende Staffeln anzulegen,
während Kerzen und Propolis sich die Standardstaffel teilen.
### Höchstmenge: global mit Produkt-Übersteuerung
Ein globaler Standardwert deckt den Normalfall ab, ein optionales Feld am
Produkt die Ausnahme (knappe Sorte). Das entspricht dem Muster, das
`Product.vatRatePercent` gegenüber dem Satz der Kategorie bereits verwendet:
leer bedeutet "es gilt der allgemeine Wert", und eine spätere Änderung des
allgemeinen Werts wirkt ohne Nachpflege auf alle Produkte.
## Datenmodell
In `prisma/schema.prisma`:
- `ShippingTier.category ProductCategory?` — null bedeutet Standardstaffel.
Dazu `@@index([category, minQuantity])`, weil künftig je Kategorie gesucht
wird.
- `Product.maxOrderQuantity Int?` — null bedeutet "globaler Standardwert".
Der globale Standardwert wird als Registry-Schlüssel `shipping.maxOrderQuantity`
in `src/lib/content-registry.ts` geführt, Gruppe `shipping`. Dafür bekommt
`ContentFieldType` den neuen Typ `number`, der im Admin-Formular ein
Zahlenfeld rendert und beim Speichern als ganze Zahl größer null geprüft
wird. Eine eigene Einstellungstabelle wäre für einen einzelnen Integer zu
schwer; die Gruppe "Versand" existiert im Adminbereich bereits.
Gelesen wird der Wert über einen Parser analog zu `parseShippingRegion`: ein
unlesbarer oder fehlender Wert fällt auf die im Code hinterlegte Vorgabe von
20 Stück zurück, statt den Checkout zu blockieren. Zwanzig ist der Wert, den
das Checkout-Schema heute schon kennt, die Umstellung ändert also zunächst
nichts am Verhalten.
## Berechnung
`src/lib/shipping-calc.ts` wechselt die Signatur von
`(tiers, totalQuantity)` auf `(tiers, items)`, wobei ein Item aus Kategorie
und Menge besteht. Der Ablauf:
1. Mengen je Kategorie summieren.
2. Je Kategorie die Staffeln dieser Kategorie heranziehen; gibt es keine, die
Standardstaffeln (Kategorie null).
3. Innerhalb der gewählten Staffeln greift die bisherige Zuordnung
unverändert, einschließlich des Verhaltens oberhalb der höchsten
gepflegten Staffel.
4. Das Ergebnis ist das Maximum der Kategoriepreise.
Sonderfälle: ein leerer Warenkorb kostet null; sind überhaupt keine Staffeln
gepflegt, gilt weiterhin `FALLBACK_SHIPPING_CENTS`.
`getNextShippingTier` bezieht sich künftig auf diejenige Kategorie, die den
Preis bestimmt. Andernfalls verspräche der Hinweis "noch X Stück bis zur
nächsten Stufe" im Warenkorb eine Schwelle, die den gezeigten Preis gar nicht
beeinflusst.
Der Warenkorb muss die Kategorie kennen: `CartItem` in `src/store/cart-store.ts`
bekommt das Feld `category`, gefüllt von `toCartItem` in
`src/components/product-card.tsx`. Eine Migration bestehender Warenkörbe aus
localStorage ist nicht nötig, weil der Shop noch nicht live ist und die
Datenbank vor dem Start neu aufgesetzt wird.
Bekannte Einschränkung: wird die Kategorie eines Produkts im Adminbereich
geändert, während ein Warenkorb offen ist, zeigt die Vorschau kurzzeitig den
alten Versandpreis. Der Server rechnet beim Anlegen der Bestellung mit den
Kategorien aus der Datenbank, der Kunde wird also korrekt belastet, und der
Zustand heilt sich, sobald der Artikel erneut hinzugefügt wird.
## Durchsetzung der Höchstmenge
Die Grenze wird an drei Stellen durchgesetzt, weil die ersten beiden allein
umgehbar wären:
1. Im `QuantityStepper` als obere Grenze, gebildet aus dem kleineren Wert von
Lagerbestand und wirksamer Höchstmenge. Das ist reine Bedienführung.
2. Im Checkout-Schema in `src/lib/validation.ts` als harte, vom Produkt
unabhängige Obergrenze von 999 Stück je Position — sie schützt gegen
absurde Mengen, bevor überhaupt Produkte geladen werden. Sie ersetzt das
heutige `.max(20)`, das dort zur festen Grenze nicht mehr taugt, sobald
der Betreiber den Standardwert selbst höher setzen kann.
3. In `src/app/api/checkout/route.ts` je Position gegen
`maxOrderQuantity ?? globaler Standard`. Das ist die verbindliche
Prüfung, denn erst dort sind die echten Produktdaten bekannt. Eine
Überschreitung führt zu einer Antwort mit Status 400 und einer
lokalisierten Meldung.
Eine reine UI-Begrenzung genügt nicht: die Checkout-Anfrage lässt sich frei
stellen, und ohne serverseitige Prüfung wäre die Höchstmenge eine Anzeige
ohne Wirkung.
## Adminportal
- `src/app/admin/versand/page.tsx` gruppiert die Liste nach Kategorie und
zeigt die Standardstaffeln als eigenen Block.
- `src/app/admin/versand/neu/page.tsx` und
`src/app/admin/versand/[id]/page.tsx` bekommen ein Auswahlfeld
"Kategorie (leer = alle)".
- `src/app/admin/actions/shipping.ts` und `shippingTierSchema` in
`src/lib/validation.ts` nehmen das Feld auf; ein unbekannter Wert wird
abgelehnt statt stillschweigend als Standardstaffel gespeichert.
- Das Produktformular unter `src/app/admin/produkte/` bekommt das Feld
"Max. Bestellmenge (leer = Standard)". Es wird in
`adminProductNumbersSchema` wie `vatRatePercent` behandelt: der leere
String wird zu null und darf nicht als Menge null verstanden werden.
- Admin, Inhalte, Gruppe Versand: das neue Zahlenfeld
"Standard-Höchstbestellmenge".
## Storefront
`src/app/versand-zahlung/page.tsx` stellt die Staffeln je Kategorie dar und
nennt ausdrücklich, dass bei gemischten Bestellungen die teuerste Staffel
gilt. Ohne diesen Satz wirkt der berechnete Preis willkürlich, sobald ein
Warenkorb mehrere Kategorien enthält.
Warenkorb, Warenkorb-Schublade und Checkout-Formular übergeben der
Berechnung künftig die Positionen statt der Gesamtmenge.
## Tests
- `src/lib/shipping-calc.test.ts`: Staffel je Kategorie, Rückfall auf die
Standardstaffel, gemischter Warenkorb ergibt das Maximum, leerer Warenkorb
ergibt null, keine Staffeln ergibt den Notfallpreis.
- Anzupassen wegen der geänderten Signatur:
`src/app/warenkorb/warenkorb-content.test.tsx` und
`src/app/checkout/checkout-form.test.tsx`.
- Neu: die Checkout-Route lehnt eine Menge oberhalb des globalen Standards ab
und ebenso oberhalb einer produktspezifischen Übersteuerung.
- `prisma/seed-demo.ts` legt eigene Honig-Staffeln und Standardstaffeln für
die übrigen Kategorien an.
- Neue Texte in `src/i18n/dictionaries/de.ts`, `src/i18n/dictionaries/en.ts`
und `src/i18n/dictionary-type.ts`.
@@ -0,0 +1,877 @@
# Bienenpatenschaft: Urkunde, Abo, Medaillen und 3D-Ansicht im Konto
Datum: 2026-10-16
Status: Entwurf zur Freigabe, Implementierung ausstehend
Gitea: #31
Dieses Dokument ersetzt den bisherigen Implementierungsauftrag zur
Bienenpatenschaft vollständig. Grundlage sind die verbindlichen Vorgaben des
Inhabers vom 2026-10-16 und seine Ergänzungen vom selben Tag (Abo,
Kundenkonto, 3D-Ansicht, Medaillen, Treuestufen, Übertragung). Wo ältere
Fassungen etwas anderes sagen, gilt dieses Dokument.
## Problem und Ziel
Wabenhain verkauft symbolische Bienenpatenschaften als Geschenk oder für sich
selbst, einmalig bezahlt oder als monatliches Abo. Das zentrale Produkt ist
eine hochwertige, persönlich gestaltete Urkunde. Im Kundenkonto erleben
Kunden ihre Urkunde und ihre Medaillen in einer interaktiven 3D-Ansicht und
laden alles herunter.
Nach dem Kauf läuft alles automatisch: Zahlung, Urkunde, Medaillen,
Abo-Abrechnung, Rechnungen, Lieferaufträge für den Abo-Honig. Die Handarbeit
des Inhabers beschränkt sich auf Drucken, Verpacken und Versenden.
Erfolg heißt: Eine Testbestellung erzeugt ohne manuelle Nachbearbeitung eine
korrekt personalisierte, hochwertige und druckfähige Urkunde, die sofort im
Konto in 3D erscheint. Ein Test-Abo rechnet monatlich ab und erzeugt die
Honiglieferung erst, wenn sie bezahlt ist.
## Teilprojekte und Reihenfolge
Der Umfang ist zu groß für einen Plan. Jedes Teilprojekt bekommt einen
eigenen Umsetzungsplan und wird einzeln geprüft und ausgerollt:
| Nr. | Teilprojekt | Hängt ab von | Schätzung |
|---|---|---|---|
| 1 | Urkunde, Einmal-Patenschaft, Kontopflicht, Kontobereich mit Downloads, Admin (Druck, Korrektur) | – | 4–5 Tage |
| 2 | 3D-Ansicht von Urkunde und Medaille, Patenschafts-Medaillen je Paket, „Alles herunterladen“ | 1 | 3–4 Tage |
| 3 | Übertragung an Beschenkte per befristetem Link | 1 | 1 Tag |
| 4 | Abo: Stripe-Abrechnung, Kündigungsbutton, Lieferzyklen, Honigkontingent, Rechnungen | 1 | 5–7 Tage |
| 5 | Treuestufen: Stammkunden-Stufen, Abo-Stufen, VIP | 2, 4 | 2 Tage |
Teilprojekt 1 ist allein verkaufsfähig. Jedes weitere macht das Angebot
reicher, ohne das vorherige umzubauen.
## Nicht im Umfang
Diese Leistungen entfallen. Sie werden weder auf der Verkaufsseite noch in
E-Mails, Urkunden, FAQs oder Rechtstexten versprochen:
- Besuche beim Imker oder am Bienenstand
- Patentage, Veranstaltungen, Terminbuchungen
- Videos, individuelle Bienenfotos, Saisonberichte
- jede Pflicht zur laufenden Erstellung von Inhalten
- Veranstaltungs- oder Teilnehmerverwaltung
- Zusagen zu Naturschutzprojekten oder unbelegte Umweltversprechen
- ein Vorlagen-Auswahlschritt für Kunden (es gibt genau eine Gestaltung)
- Gastbestellungen von Patenschaften
Später möglich, jetzt nicht gebaut: Bilder oder andere Medien im
Patenschaftsbereich des Kontos. Das Datenmodell lässt dafür Platz (siehe
„Erweiterbarkeit“), es gibt aber keine Oberfläche und kein Versprechen.
## Angebote
Alle Angebote sind im Admin pflegbar: Name, Beschreibung, Leistungsliste,
Preis, aktiv/inaktiv, Reihenfolge, verknüpftes Honigprodukt, Gläser,
Steueraufteilung, Medaillenstufe. Es kann beliebig viele Angebote beider Arten
geben.
### Einmal-Patenschaften
| Paket | Testpreis | Leistungen | Versand |
|---|---:|---|---|
| Bienenfreund | 19 € | symbolische Namensgebung, PDF-Urkunde, Medaille, Konto mit 3D-Ansicht und Downloads | keiner |
| Honigfreund | 39 € | digitale Leistungen, gedruckte Urkunde, 1 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
| Wabenfreund | 59 € | digitale Leistungen, gedruckte Urkunde, 3 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
- Laufzeit zwölf Monate ab Startdatum, endet automatisch.
- Nach Ablauf trägt die Urkunde weiter ihr Von-bis-Datum. Urkunde, Medaille
und Downloads bleiben im Konto verfügbar.
- Alle Pakete bekommen dieselbe Urkunde in derselben Qualität. Sie
unterscheiden sich in den physischen Leistungen und in der Medaillenstufe.
### Abo-Patenschaften
Zwei Ausgangsangebote, Preise vom Inhaber festzulegen:
| Angebot | Zahlung | Honig | Lieferung |
|---|---|---|---|
| Patenschaft im Abo, vierteljährlich | monatlich | z. B. 2 × 500 g je Lieferung | nach jeweils 3 bezahlten Monaten |
| Patenschaft im Abo, nach der Ernte | monatlich | z. B. 3 × 500 g je Lieferung | in den Erntefenstern, frühestens nach 6 bezahlten Monaten seit der letzten Lieferung |
- Monatlich kündbar zum Ende des bezahlten Monats.
- Versand ist im Monatspreis enthalten. Die Verkaufsseite sagt das deutlich.
- Ein Abo gilt einer Biene mit einem Namen und hat eine Urkunde.
- Abo und Einmalkauf werden getrennt bezahlt (eigene Stripe-Sitzung je Abo).
- Ein Abo lässt sich in dieser Fassung nicht übertragen, weil die Zahlung am
Konto des Käufers hängt.
### Grußkarte
Vorgedruckt, nicht personalisiert, im Wabenhain-Design. Persönliche Worte
stehen in der Widmung auf der Urkunde. So entsteht kein Handschreiben.
## Kundenkonto ist Pflicht
- Patenschaften kauft nur, wer angemeldet ist. Das betrifft Einmal- und
Abo-Angebote. Für andere Produkte bleibt die Gastbestellung wie heute.
- Die Personalisierung lässt sich ohne Anmeldung ausprobieren, samt Vorschau.
Beim Klick auf „In den Warenkorb“ oder „Abo abschließen“ führt die Seite zur
Anmeldung oder Registrierung. Die Eingaben bleiben dabei erhalten
(Entwurf im `sessionStorage`, nach der Anmeldung zurückgeschrieben).
- Der Checkout prüft serverseitig, dass ein Konto angemeldet ist, sobald eine
Patenschaft im Warenkorb liegt.
## Personalisierung
### Felder
| Feld | Pflicht | Hinweis |
|---|---|---|
| Name der Patenbiene | ja | symbolischer Name |
| Name der Person auf der Urkunde | ja | für wen die Urkunde ausgestellt ist |
| Schenkende | nein | eine oder mehrere Personen als Freitext („Anna und Ben“) |
| Widmung | nein | wird unverändert übernommen |
| Anlass | ja | für mich selbst, Geburtstag, Weihnachten, Dankeschön, allgemeines Geschenk |
| Startdatum | ja | Einmal: frühestens Bestelltag, spätestens sechs Monate danach. Abo: Tag der ersten Zahlung |
Bei „für mich selbst“ blendet das Formular „Schenkende“ aus.
### Eingabelimits
Die endgültigen Limits ergeben sich aus der Layout-Testmatrix. Startwerte:
- Bienenname: 2–24 Zeichen
- Name auf der Urkunde: 2–60 Zeichen
- Schenkende: bis 80 Zeichen
- Widmung: bis 280 Zeichen und höchstens 5 Zeilenumbrüche
Das Formular zeigt jedes Limit vor der Bestellung als Zeichenzähler an. Server
und Client prüfen mit demselben zod-Schema.
### Zeichen
- Eingaben werden nach Unicode NFC normalisiert, Steuerzeichen entfernt,
Mehrfach-Leerzeichen zusammengefasst.
- Zulässig ist jedes Zeichen, für das die eingebetteten Schriften eine Glyphe
haben. Die Prüfung läuft serverseitig über die Font-Metriken (fontkit).
Fehlt eine Glyphe (zum Beispiel Emoji), lehnt das Formular die Eingabe mit
klarer Meldung ab, statt ein Ersatzkästchen zu drucken.
- Umlaute, ß, Akzente, Bindestriche und Apostrophe funktionieren.
## Urkundentext
Der Text entsteht aus hinterlegten Bausteinen. Es gibt keine KI-Anfrage pro
Bestellung. Der Kunde sieht den endgültigen Wortlaut vor dem Kauf in der
Vorschau.
### Regeln
- Eigene und verschenkte Patenschaft haben unterschiedliche Formulierungen.
- Geburtstag, Weihnachten und Dankeschön haben je eine kurze Anlasszeile.
„Für mich selbst“ und „allgemeines Geschenk“ haben keine.
- Leere Felder erzeugen keine Platzhalter, keine leeren Zeilen und keine
verwaisten Bezeichnungen wie „Von:“ ohne Namen.
- Keine erfundenen Eigenschaften, Beziehungen oder Ereignisse.
- Keine Geschlechtszuordnung: keine Pronomen, keine aus dem Namen
abgeleiteten Anreden.
- Keine Formulierung, die eine individuelle Betreuung oder Verfolgung einer
einzelnen Biene behauptet.
- Die Widmung wird wörtlich übernommen, nur sicher dargestellt (kein HTML,
kein Markup) und sauber umbrochen.
### Bausteine (Entwurf, vor Abnahme sprachlich final prüfen)
Überschrift: „Bienenpatenschaft“, darunter klein „Urkunde“.
Anlasszeile: „Zum Geburtstag“, „Zu Weihnachten“, „Als Dankeschön“.
Haupttext, eigene Patenschaft:
> {Name auf der Urkunde} hat einer Biene der Imkerei Wabenhain symbolisch den
> Namen
> **{Bienenname}**
> gegeben und unterstützt damit die Imkerei und ihre Bienenvölker.
Haupttext, verschenkte Patenschaft:
> Für {Name auf der Urkunde}
> Einer Biene der Imkerei Wabenhain wurde symbolisch der Name
> **{Bienenname}**
> gegeben. Die Patenschaft unterstützt die Imkerei und ihre Bienenvölker.
Schenkende, nur wenn ausgefüllt: „Geschenkt von {Schenkende}“.
Widmung, nur wenn ausgefüllt: eigener, typografisch abgesetzter Block.
Laufzeit:
- Einmal: „Patenschaft vom {Start} bis {Ende}“, Ende = Start plus zwölf Monate
minus ein Tag.
- Abo, aktiv: „Patenschaft seit {Start}“.
- Abo, gekündigt: „Patenschaft vom {Start} bis {Ende}“, Ende = letzter
bezahlter Tag. Diese Fassung entsteht automatisch als neue Revision, sobald
die Kündigung wirksam wird (siehe „Abo“).
Kennung: „Nr. {Patenschaftsnummer}“, Format `WH-P-JJJJ-NNNNN`, fortlaufend über
einen eigenen Zähler (wie `InvoiceCounter`).
Hinweis, immer vorhanden und gut lesbar:
> Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene,
> sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker.
Sprache: Die Urkunde erscheint in der beim Kauf gewählten Sprache (de/en).
Beide Fassungen durchlaufen dieselbe Testmatrix.
## Gestaltung der Urkunde
### Anspruch
Die Urkunde soll wie ein rahmenwürdiges Geschenk wirken:
- elegante Typografie mit klarer Hierarchie
- großzügiger Weißraum, ausgewogene Proportionen
- Bienenname und Name auf der Urkunde als gestalterischer Mittelpunkt
- dezente Bienen-, Blüten- oder Wabenillustration als feine Strichgrafik
- zurückhaltende Markenfarben, feine Rahmen oder Ornamente
- harmonisch eingebundene Widmung
- diskrete Patenschaftsnummer und Datumsangaben
- keine Cliparts, keine Office-Vorlagenoptik, keine erfundenen Gütesiegel,
keine Unterschrift
### Markengrundlage
- Schriften: Cormorant Garamond für Überschrift, Namen und Widmung, Inter für
Kleintext. Beide stehen unter der SIL Open Font License und dürfen in PDFs
eingebettet werden. Die TTF-Dateien kommen samt Lizenz nach `assets/fonts/`.
- Farben aus `src/app/globals.css`: Forest `#24352b` für Text, Honey Dark
`#8a6531` für Akzente und Linien, Honey `#b88a44` nur klein, Sage `#788774`
für Ornamente. Gold ist auf Papier eine flache Druckfarbe, nie Verlauf oder
Folienimitat. Die Urkunde verspricht keine Goldfolie.
- Logo: das hochgeladene Markenlogo (`getBrandAssets`), für den Druck als SVG
oder als PNG mit mindestens 300 dpi in Druckgröße. Fehlt es, nutzt die
Urkunde das gezeichnete Waben-Zeichen aus `lib/brand-icon.ts`. Das Logo
wird nicht verzerrt, umgefärbt oder nachgezeichnet.
- Weißes Papier, keine randlosen Farbflächen.
### Drei Varianten, dann eine Entscheidung
Zuerst entstehen drei Varianten mit identischen Beispieldaten:
1. Klassisch-editorial: zentrierte Achse, feiner Doppelrahmen in Honey Dark,
kleine Biene als Strichzeichnung über der Überschrift.
2. Wabe: asymmetrischer Aufbau, feines Hexagon-Linienraster als Eckmotiv,
Namen linksbündig groß.
3. Botanisch: Lindenblüten- oder Kleezweig als Strichillustration, die den
Textblock an zwei Ecken einfasst.
Alle drei werden als echte PDFs gerendert und nebeneinander beurteilt. Die
passendste Variante wird vollständig umgesetzt. Die anderen bleiben nicht im
Produktcode. Illustrationen werden selbst als Vektor gezeichnet. Es gibt
keine fremden Bildrechte und keine Rasterillustrationen.
## Druck- und PDF-Anforderungen
- A4 hochkant, genau eine Seite
- Text als echter Text mit eingebetteten Schrift-Teilmengen, Illustrationen
und Rahmen als Vektor
- Rasterbilder (nur das Logo, falls kein SVG) mit mindestens 300 dpi
- sicherer Rand: Inhalt mindestens 15 mm vom Blattrand, Rahmenlinien
mindestens 12 mm
- keine abgeschnittenen Namen, keine Überlagerungen
- kleinste Schrift 8 pt (Kennung, Hinweis), Fließtext mindestens 11 pt
- PDF-Metadaten: Titel „Bienenpatenschaft {Bienenname}“, Autor „Imkerei
Wabenhain“
Lange Inhalte löst das Layout über Regeln, nicht durch Verkleinern der ganzen
Seite:
1. Layoutvarianten: ohne Widmung, mit kurzer Widmung, mit langer Widmung. Die
Variante folgt aus der gemessenen Höhe.
2. Kontrollierte Umbrüche: Namen brechen nur an Leerzeichen oder Bindestrich,
höchstens auf zwei Zeilen, nie mitten im Wort.
3. Begrenzte Schriftstufen: jedes Feld hat eine feste Liste von Größen (zum
Beispiel Bienenname 54, 46, 40 pt). Passt der Inhalt in der kleinsten
Stufe nicht, ist das ein Validierungsfehler. Die Eingabelimits verhindern
diesen Fall im Betrieb.
## Medaillen und Treuestufen
Es gibt drei Arten von Medaillen. Alle werden deterministisch als Vektor
erzeugt (SVG), im Konto in 3D gezeigt und als PNG (2048 px, transparent) und
SVG heruntergeladen. Auf dem Bildschirm dürfen Medaillen metallisch glänzen.
Das betrifft nur die digitale Darstellung, nicht den Druck.
### 1. Patenschafts-Medaille (Teilprojekt 2)
- Eine je Patenschaft, mit Bienenname, Jahr und Patenschaftsnummer.
- Die Stufe richtet sich nach dem Angebot: Bienenfreund, Honigfreund und
Wabenfreund haben eigene Prägung und eigenes Metall (zum Beispiel Bronze,
Silber, Gold). Abo-Angebote haben eine eigene Prägung. Die Zuordnung ist am
Angebot im Admin einstellbar.
- Sie gehört zur Patenschaft und bleibt dauerhaft, auch nach Ablauf oder
Kündigung. Bei einer Übertragung wandert sie mit.
### 2. Stammkunden-Stufen (Teilprojekt 5)
- Für alle Kunden mit Konto, über den ganzen Shop.
- Grundlage: Anzahl bezahlter, nicht erstatteter oder stornierter
Bestellungen. Abo-Monatszahlungen zählen hier nicht, sie haben eigene
Stufen.
- Stufen und Schwellen im Admin pflegbar. Vorschlag: „Blütenbesuch“ ab 2,
„Wabenbau“ ab 5, „Honigschatz“ ab 10, „Goldwabe“ ab 25 Bestellungen.
- Die Stufe bleibt, sie sinkt nur, wenn Bestellungen erstattet oder storniert
werden.
### 3. Abo-Stufen und VIP (Teilprojekt 5)
- Grundlage: ununterbrochen bezahlte Abo-Monate.
- Vorgabe: Stufe 1 bis 24, je bezahltem Monat eine Stufe, danach eine Stufe
ohne Zahl, „VIP“, als Zeichen besonders hoher Wertschätzung. Die Schwellen
sind im Admin pflegbar und lassen sich ohne Codeänderung anpassen.
- VIP bekommt eine eigene Medaille und eine VIP-Kennzeichnung im Konto. Weitere
Vorteile (Rabatt, Versand) gehören nicht zum Umfang und lassen sich später
über die bestehenden Rabattcodes ergänzen.
- Wird das Abo gekündigt, verschwindet die Abo-Stufe samt Medaille, sobald die
Kündigung wirksam ist. Ein neues Abo beginnt wieder bei null.
- Ausfälle durch fehlgeschlagene Zahlung unterbrechen die Dauer nur, wenn das
Abo deshalb endet.
Stufen werden aus den Daten berechnet, nicht als Zustand gespeichert. So gibt
es keine Abweichung zwischen Stufe und tatsächlichen Bestellungen oder
Zahlungen.
## Kontobereich „Patenschaft“
Neuer Bereich `/konto/patenschaft` neben „Bestellungen“ und „Wunschliste“.
### Übersicht
- alle Patenschaften des Kontos mit Bienenname, Art (einmalig/Abo), Laufzeit
und Status (aktiv, abgelaufen, gekündigt)
- Medaillenleiste: Patenschafts-Medaillen, aktuelle Stammkunden-Stufe,
aktuelle Abo-Stufe oder VIP
- bei Abos: nächste Abbuchung, nächste Lieferung, Fortschritt bis zur
nächsten Lieferung („2 von 3 Monaten bezahlt“), Kündigungsbutton
### Detail einer Patenschaft
- 3D-Ansicht der eigenen Urkunde und der Patenschafts-Medaille (Teilprojekt 2;
in Teilprojekt 1 eine statische Ansicht)
- Downloads: Urkunde (PDF), Medaille (PNG, SVG), „Alles herunterladen“ (ZIP)
- Revisionen sind für Kunden nicht sichtbar. Sie sehen immer die aktuelle
Fassung.
- Menüpunkt „Übertragen“ (nur Einmal-Patenschaften, Teilprojekt 3)
### 3D-Ansicht (Teilprojekt 2)
Ziel: Die Ansicht soll beeindrucken und sich hochwertig anfühlen, ohne die
Urkunde zu verfälschen.
- Technik: three.js mit `@react-three/fiber` und `@react-three/drei`, nur auf
dieser Seite und erst im Browser geladen (dynamischer Import), damit der
übrige Shop nicht schwerer wird.
- Urkunde als Papierblatt mit leichter Wölbung, weichem Schatten und einem
langsam wandernden Lichtreflex. Die Textur ist ein hochaufgelöstes Bild
(2048 px breit) derselben Revision, aus demselben Layout wie die PDF und
ohne Wasserzeichen. Sie wird nur angemeldeten Besitzern ausgeliefert.
- Rückseite der Urkunde: Wabenhain-Zeichen und Patenschaftsnummer.
- Medaille als Münze mit Rand und erhabener Prägung (SVG-Pfade extrudiert),
Metall mit Umgebungsspiegelung. Die Umgebungstextur liegt lokal im Projekt,
kein Abruf von fremden Servern.
- Bewegung: im Ruhezustand sanftes Schweben und Drehen. Der Mauszeiger neigt
das Blatt leicht. Ziehen dreht mit Nachschwung. Doppelklick oder
Doppeltippen wendet Urkunde oder Medaille. Zoom per Mausrad oder Zwei-Finger-
Geste, begrenzt.
- Smartphone: Wischen dreht, Neigen des Geräts bewegt die Szene erst nach
einem Tipp auf „Bewegung aktivieren“ (iOS verlangt die Erlaubnis).
- `prefers-reduced-motion`: kein Schweben, keine automatische Drehung,
Ziehen bleibt möglich.
- Ohne WebGL oder bei Fehlern: statisches Bild der Urkunde und der Medaille,
Downloads funktionieren unverändert.
- Leistung: Pixeldichte höchstens 2, Darstellung pausiert, wenn der Tab im
Hintergrund oder die Szene außer Sicht ist. Ziel 60 fps auf einem
Mittelklasse-Smartphone.
- Bedienbarkeit: Tastatur dreht (Pfeiltasten) und wendet (Leertaste), die
Szene hat eine Textbeschreibung für Screenreader, alle Downloads sind
normale Links.
### Erweiterbarkeit
`Sponsorship` bekommt später eine Medienliste. Die Detailseite ist so
aufgebaut, dass ein weiterer Abschnitt („Bilder“) ohne Umbau hinzukommen
kann. In diesem Auftrag wird dafür nichts gebaut.
## Übertragung an Beschenkte (Teilprojekt 3)
Nur Einmal-Patenschaften lassen sich übertragen.
1. Der Besitzer wählt im Menü der Patenschaft „Übertragen“.
2. Er wählt die Gültigkeit des Links: 24 Stunden, 1 Woche oder 30 Tage.
3. Ein Bestätigungsdialog fragt: „Möchtest du den Übertragungslink wirklich
erstellen? Jede Person, die diesen Link hat, kann die Patenschaft in ihr
Konto übernehmen. Gib ihn nur an die beschenkte Person weiter. Die
Weitergabe geschieht auf eigene Verantwortung.“ (Wortlaut rechtlich
prüfen.)
4. Erst nach Bestätigung entsteht der Link. Er ist einmal nutzbar, enthält ein
zufälliges Token mit mindestens 128 Bit, gespeichert wird nur dessen Hash.
5. Bis zur Einlösung sieht der Besitzer den offenen Link mit Ablaufzeit und
kann ihn widerrufen.
6. Die beschenkte Person öffnet den Link, meldet sich an oder registriert sich
und bestätigt die Übernahme. Danach gehören Urkunde, Medaille und
Downloads ihrem Konto. Beim bisherigen Besitzer verschwindet die
Patenschaft aus dem Bereich „Patenschaft“. Bestellung und Rechnung bleiben
bei ihm.
7. Abgelaufene, widerrufene oder eingelöste Links zeigen eine neutrale Meldung
ohne Daten der Patenschaft.
8. Jede Übertragung steht im Audit-Log (erstellt, widerrufen, eingelöst, von
welchem Konto an welches).
Eine Übertragung verändert die Urkunde nicht. Die Stammkunden-Stufe zählt den
Kauf weiter beim Käufer.
## Abo (Teilprojekt 4)
### Abrechnung
- Stripe Billing: Jedes Abo-Angebot hat einen Stripe-Preis, der beim
Speichern im Admin angelegt wird. Eine Preisänderung erzeugt einen neuen
Stripe-Preis für neue Abos. Laufende Abos behalten ihren Preis.
- `Customer` bekommt eine `stripeCustomerId`.
- Der Abschluss läuft über eine Stripe-Checkout-Sitzung im Modus
`subscription`.
- Neue Webhook-Ereignisse: `customer.subscription.created`,
`customer.subscription.updated`, `customer.subscription.deleted`,
`invoice.paid`, `invoice.payment_failed`. Jedes Ereignis wird über die
Stripe-ID idempotent verarbeitet.
- Jede bezahlte Monatsrechnung erzeugt eine Wabenhain-Rechnung im bestehenden
Rechnungssystem (Nummernkreis, PDF, E-Rechnung) und eine Mail.
- Zahlungsart ändern: über das Stripe-Kundenportal, nur für die Zahlungsart.
### Schutz vor Verlust: Lieferung erst nach Bezahlung
Der Kunde kann monatlich kündigen, bekommt aber nie mehr Honig, als er bezahlt
hat:
- Vierteljährlich: Der Anspruch auf eine Lieferung entsteht nach jeweils drei
bezahlten Monaten. Wer nach zwei Monaten kündigt, hatte zwei Monate
Patenschaft, aber keinen Honig.
- Nach der Ernte: Der Inhaber legt im Admin die Erntefenster fest. Geliefert
wird im nächsten Fenster, sobald seit der letzten Lieferung mindestens sechs
Monate bezahlt sind.
- Bezahlt heißt: Stripe meldet die Rechnung als bezahlt, bei SEPA zusätzlich
die Regeln unter „SEPA-Lastschrift und Rückbuchungen“.
- Fehlgeschlagene Zahlung: Stripe versucht es automatisch erneut. Solange das
Abo im Verzug ist, entsteht keine Lieferung. Scheitern alle Versuche, endet
das Abo wie bei einer Kündigung.
- Die Verkaufsseite und die Abo-Bedingungen sagen klar: „Der Honig wird nach
jeweils drei bezahlten Monaten verschickt“ beziehungsweise die
entsprechende Regel für die Ernte-Variante.
- Widerruf innerhalb von 14 Tagen: Bis dahin ist kein Honig verschickt, es
entsteht kein Warenverlust.
### SEPA-Lastschrift und Rückbuchungen
Abos lassen sich mit Karte, Apple Pay, Google Pay und SEPA-Lastschrift
bezahlen.
Grenze, die keine Einstellung aufhebt: Bei einer SEPA-Lastschrift kann der
Kunde acht Wochen lang ohne Angabe von Gründen bei seiner Bank die Erstattung
verlangen, bei einer nicht autorisierten Lastschrift 13 Monate lang. Das ist
Bankrecht. Stripe kann es nicht verhindern und solche Rückbuchungen nicht
anfechten. Stripe berechnet dafür außerdem eine Gebühr. Der Schutz liegt
deshalb in der Lieferlogik von Wabenhain:
1. Lieferung nur gegen bezahlte Monate (siehe oben). Eine Rückbuchung kann
höchstens Honig betreffen, der schon bezahlt erschien.
2. Neukunden-Frist: Bei SEPA zählt die erste Abo-Zahlung eines Kontos erst
nach einer Frist für den Lieferanspruch. Vorgabe 56 Tage, also die
Acht-Wochen-Frist. Die erste Lieferung kommt dadurch bei SEPA etwas später,
alle weiteren normal. Die Frist ist im Admin einstellbar (0 schaltet sie
ab). Die Verkaufsseite nennt sie bei der Zahlart.
3. Rückbuchung erkannt (`charge.dispute.created`, im Webhook schon
abonniert): Das Abo geht sofort auf `IM_VERZUG`, offene Lieferungen werden
gestoppt und ihre Reservierung freigegeben, die Abo-Stufe ruht. Der Kunde
bekommt eine Mail mit der Bitte, den Betrag zu begleichen. Der Fall
erscheint in der Aufmerksamkeitsliste.
4. Wird der Betrag nicht binnen 14 Tagen bezahlt, endet das Abo wie bei einer
Kündigung. Der Anspruch auf den Betrag bleibt bestehen. Ob er eingefordert
wird, entscheidet der Inhaber.
5. Ein Konto mit einer Rückbuchung kann neue Abos nur noch mit Karte oder
Wallet abschließen. Der Admin kann diese Sperre aufheben.
### Stripe-Einrichtung
Die Einrichtung erfolgt in Teilprojekt 4, zuerst im Testmodus, dann
identisch im Livemodus. Was per API geht, legt ein Einrichtungsskript im Repo
an (wiederholbar, ohne Geheimnisse im Code). Was nur im Dashboard geht,
steht als Checkliste in `docs/EINRICHTUNG.md`.
- Zahlarten für Abo-Checkout: Karte, Apple Pay, Google Pay, SEPA-Lastschrift
(Mandat wird im Checkout erteilt).
- Billing: Smart Retries für fehlgeschlagene Zahlungen, nach dem letzten
Versuch Abo kündigen. Stripe-eigene Kundenmails zu Rechnungen aus, weil
Wabenhain eigene Rechnungen und Mails schickt.
- Kundenportal: nur Zahlungsart ändern. Kündigung und Tarifwechsel im Portal
aus, weil die Kündigung über den eigenen Kündigungsbutton läuft.
- Webhook-Endpunkt: bestehende Ereignisse plus
`customer.subscription.created`, `customer.subscription.updated`,
`customer.subscription.deleted`, `invoice.paid`,
`invoice.payment_failed`, `charge.dispute.closed`.
- Produkte und Preise der Abo-Angebote legt der Admin über Wabenhain an,
nicht von Hand im Stripe-Dashboard.
- Tests mit Stripe Test Clocks (Abo über mehrere Monate) und
SEPA-Testkonten, darunter eines, das eine Rückbuchung auslöst.
- Entwicklung und CI in einer eigenen Stripe-Sandbox mit eingeschränktem
API-Schlüssel (`rk_`), nicht im Livekonto.
Ist-Stand Livekonto „Wabenhain“ (gelesen am 2026-10-16, nichts geändert):
- Zahlarten-Konfiguration „Default“: Karte, Apple Pay, SEPA-Lastschrift,
PayPal, Klarna, Link und weitere sind aktiv. Google Pay ist nicht verfügbar
und muss im Dashboard unter Zahlungsmethoden geprüft werden. Checkout zeigt
im Abo-Modus nur Zahlarten, die wiederkehrende Zahlungen unterstützen.
- Ein Webhook-Endpunkt `https://wabenhain.com/api/checkout/webhook` mit den
sechs bestehenden Ereignissen. Die Abo-Ereignisse fehlen und kommen mit
Teilprojekt 4 dazu.
- Kein Kundenportal konfiguriert.
- Die Stripe-Anbindung in Claude erreicht nur den Livemodus. Änderungen am
Livekonto erfolgen erst nach Abnahme von Teilprojekt 4 und mit Freigabe.
### Lieferaufträge
Ein täglicher Lauf im CronJob `wabenhain-certificates` prüft fällige
Lieferungen. Ist der Anspruch erfüllt und Bestand reserviert, legt er einen
Lieferauftrag als normale Bestellung an (Status `BEZAHLT`, Betrag 0 €, mit
Verweis auf das Abo). Er erscheint in der bestehenden Pack- und DHL-Liste. Der
Inhaber packt und versendet wie bei jeder Bestellung.
### Honigbestand und Kontingent
Kein Abo darf ohne Honig bleiben, weil der Shop zu viele Gläser einzeln
verkauft hat:
- Kontingent: Jedes Abo-Angebot hat eine Höchstzahl aktiver Abos, im Admin
pflegbar. Ist sie erreicht, zeigt die Verkaufsseite „Derzeit ausgebucht“
mit der bestehenden Benachrichtigung, sobald wieder Plätze frei sind.
- Reservierung, vierteljährlich: Für jedes aktive Abo sind die Gläser der
nächsten fälligen Lieferung im Bestand des verknüpften Honigprodukts
reserviert. Der Shop verkauft einzeln nur den freien Bestand: Bestand minus
Abo-Reservierung minus offene Bestellungen.
- Reservierung, Ernte-Variante: Der Inhaber trägt je Erntefenster die
geplanten Abo-Gläser ein. Sobald die Abfüllung im Bestand ist, wird dieser
Anteil reserviert, bevor er in den Freiverkauf geht.
- Deckungsanzeige im Admin-Dashboard: „Bestand deckt den Abo-Bedarf der
nächsten 2 Lieferungen.“ Unter einer Lieferung erscheint eine Warnung in der
Aufmerksamkeitsliste.
- Reicht die Deckung für neue Abos nicht, stoppt der Shop neue Abschlüsse
automatisch, bis der Inhaber nachlegt.
### Kündigung
- Kündigungsbutton nach § 312k BGB: „Verträge hier kündigen“. Im Konto mit
einem Klick auf die Bestätigungsseite, zusätzlich öffentlich erreichbar
unter `/vertraege-kuendigen` mit Name, E-Mail und Abo-Nummer, nach dem
Muster der bestehenden Route `vertrag-widerrufen`. Rechtlich prüfen lassen.
- Die Kündigung wirkt zum Ende des bezahlten Monats (`cancel_at_period_end`).
Eine Bestätigungsmail geht sofort hinaus.
- Wird die Kündigung wirksam:
1. Die Urkunde bekommt automatisch eine neue Revision mit „vom … bis …“.
Ersteller „System“, Grund „Abo beendet“. Sie bleibt im Konto
herunterladbar.
2. Die Abo-Stufe und ihre Medaille verschwinden.
3. Die Patenschafts-Medaille und alle Downloads bleiben.
4. Offene Reservierungen werden freigegeben.
## Architektur
### Ein Layout, mehrere Ausgaben
Vorschau, 3D-Textur und finale PDF müssen identisch umbrechen. Deshalb gibt es
genau eine Layout-Berechnung auf dem Server:
```
Personalisierung + Textbausteine + Vorlagenversion
│
▼
composeCertificateText() → aufgelöste Textblöcke (reine Funktion)
│
▼
layoutCertificate() → positionierte Elemente: Zeilen mit x/y,
Schriftgröße, Schrift, Pfade (reine Funktion,
misst mit fontkit an denselben TTF-Dateien)
│
├──► renderCertificatePdf() pdfkit, Text als Text
├──► renderCertificatePreview() SVG, Glyphen als Pfade, sharp → WebP,
│ Wasserzeichen, Bildschirmauflösung
└──► renderCertificateTexture() wie Vorschau, ohne Wasserzeichen,
2048 px, nur für Besitzer
```
Bild-Ausgaben setzen Glyphen als Pfade aus denselben Schriftdateien an
dieselben Positionen. Sie hängen nicht von Browser-Schriften ab. Ein Test
vergleicht die Zeilen aller Ausgaben.
Module unter `src/lib/certificate/`:
| Modul | Aufgabe |
|---|---|
| `schema.ts` | zod-Schema der Personalisierung, gemeinsam für Client und Server |
| `text.ts` | Bausteine je Sprache, Anlass, eigene/verschenkte Patenschaft, Laufzeitform |
| `layout.ts` | Messen, Umbrechen, Schriftstufen, Layoutvarianten |
| `templates/` | Vorlagen, je Version eine Datei, alte Versionen bleiben erhalten |
| `render-pdf.ts` | pdfkit-Ausgabe |
| `render-image.ts` | SVG-Ausgabe, Vorschau und Textur |
| `storage.ts` | Ablage der PDFs |
| `issue.ts` | Erzeugung nach Zahlung, idempotent, mit Wiederholung |
Weitere Module: `src/lib/medals/` (Medaillen-SVG, Stufenberechnung),
`src/lib/sponsorship-subscription/` (Abo, Lieferanspruch, Reservierung),
`src/lib/sponsorship-transfer.ts`.
### Vorlagenversion
Jede Vorlage hat eine feste Kennung, etwa `wabe-classic@1`. Eine
Designänderung erzeugt eine neue Version, die alte bleibt im Code. Neue
Patenschaften nutzen die aktuelle Version. Bestehende behalten ihre Version
und ihre gespeicherte PDF. Systemrevisionen (Abo-Ende) nutzen die Version der
vorherigen Revision. Medaillen sind ebenso versioniert.
### Datenmodell (Prisma, beide Provider)
- `SponsorshipOffer`: Art (`EINMALIG`, `ABO`), Name, Beschreibung,
Leistungen, Preis (einmalig) oder Monatspreis, aktiv, Reihenfolge, physisch,
verknüpftes Honigprodukt, Gläser je Lieferung, Lieferzyklus
(`VIERTELJAEHRLICH`, `ERNTE`), Kontingent, Stripe-Preis-ID,
Steueraufteilung, Medaillenstufe
- `Sponsorship`: Besitzerkonto, Angebot, Bezug auf `OrderItem` (einmalig)
oder `SponsorshipSubscription` (Abo), jeweils eindeutig als
Idempotenzschlüssel, Patenschaftsnummer, Start, Ende (leer bei aktivem Abo),
Status, aktuelle Revision, Zustellfelder `pdfAttempts`, `nextAttemptAt`,
`lastError`, `mailSentAt`, `printedAt`
- `SponsorshipRevision`: unveränderlich: Personalisierung (JSON), aufgelöster
Text, Vorlagenversion, PDF-Pfad, SHA-256, Ersteller (System, Admin),
Grund, Zeitpunkt
- `SponsorshipCounter`: fortlaufende Nummer je Jahr
- `SponsorshipSubscription`: Konto, Angebot, `stripeSubscriptionId`
(eindeutig), Status (`AKTIV`, `IM_VERZUG`, `GEKUENDIGT`, `BEENDET`),
Beginn, Kündigung wirksam zum, Ende
- `SubscriptionPayment`: Abo, `stripeInvoiceId` (eindeutig), bezahlt am,
Betrag, Zahlart, zählt für Lieferung ab (Datum, bei SEPA-Neukunden nach
der Frist), zurückgebucht am, Wabenhain-Rechnung
- `SubscriptionDelivery`: Abo, fällig am, Gläser, Status, erzeugte Bestellung
- `HarvestWindow`: Zeitraum, geplante Abo-Gläser je Angebot
- `SponsorshipTransfer`: Patenschaft, Token-Hash, gültig bis, erstellt von,
eingelöst von, eingelöst am, widerrufen am
- `LoyaltyLevel`: Art (`STAMMKUNDE`, `ABO`), Schwelle, Name, Medaillendesign;
VIP als Stufe ohne Zahl
- `Customer.stripeCustomerId`, `Customer.sepaBlockedAt` (gesetzt nach einer
Rückbuchung)
Statuswerte der Patenschaft: `AUSSTEHEND`, `FEHLER`, `BEREIT`, `GEDRUCKT`.
Aktiv, abgelaufen und gekündigt werden aus Daten berechnet.
### Warenkorb und Checkout (Einmal-Patenschaft)
- Patenschaft als Position im bestehenden Warenkorb, Menge immer 1 je
Personalisierung.
- Die Personalisierung reist mit der Position. Der Checkout prüft sie erneut
mit demselben Schema und denselben Layoutregeln und verlangt ein
angemeldetes Konto.
- Vor dem Hinzufügen bestätigt der Kunde: „Ich habe die Vorschau und die
Schreibweise geprüft.“
- Beim Anlegen der Bestellung wird die Personalisierung mit der
Vorlagenversion an der `OrderItem` gespeichert. Genau dieser Stand wird nach
der Zahlung zur ersten Revision.
- Reine Digitalbestellungen haben keine Versandkosten. Ob dafür ein neuer
`FulfillmentMethod`-Wert `DIGITAL` nötig ist, entscheidet der
Umsetzungsplan nach Lesen von Checkout, Rechnung und DHL-Logik.
- Physische Pakete laufen über die bestehenden Versandstaffeln je Kategorie
mit eigener Staffel für Patenschaftspakete.
- Der Honig eines Pakets bucht den Bestand des verknüpften Produkts über die
vorhandene Bestandslogik (einschließlich ApiCore-Abgleich), unter
Berücksichtigung der Abo-Reservierung.
### Steuer
Pakete und Abos enthalten Leistungen mit unterschiedlichen Steuersätzen
(Urkunde und Leistung, Honig). Das Angebot speichert deshalb eine Aufteilung:
Honiganteil in Cent zum Honig-Satz, Rest zum Regelsatz. Die Rechnung zeigt
beide Steuerzeilen über die vorhandene Gruppierung in `lib/vat.ts`. Die
Methode muss die Steuerberatung bestätigen. Bei Kleinunternehmerregelung
entfällt die Aufteilung wie heute.
### Ablauf nach der Zahlung (Einmal-Patenschaft)
1. Der Stripe-Webhook markiert die Bestellung wie heute über den atomaren
Claim als bezahlt. Nur dieser Lauf legt die `Sponsorship`-Zeilen an.
Ein Duplikat scheitert am eindeutigen `orderItemId` und bricht still ab.
2. Danach versucht der Webhook die Erzeugung direkt: Text, Layout, PDF,
Ablage, Hash, erste Revision.
3. Die Bestellbestätigung verlinkt auf `/konto/patenschaft/<id>`. Der
Mailversand wird über `updateMany … where mailSentAt = null` beansprucht,
damit doppelte Webhooks keine zweite Mail erzeugen. Erst danach ist der
Status `BEREIT`.
4. Scheitert die Erzeugung, antwortet der Webhook trotzdem 200 (die Zahlung
ist erfolgt). Die Patenschaft bleibt `AUSSTEHEND`. Die Mail sagt: „Deine
Urkunde wird gerade erstellt und erscheint in Kürze in deinem Konto.“
5. Der CronJob `wabenhain-certificates` (alle 10 Minuten,
`concurrencyPolicy: Forbid`, wie die Jobs in `k8s/07-backup-cronjob.yaml`)
holt offene Erzeugungen und Mails nach, mit wachsendem Abstand, höchstens
8 Versuche, danach `FEHLER` und Hinweis in der Aufmerksamkeitsliste.
Die finale Urkunde entsteht nie vor bestätigter Zahlung. SEPA-Zahlungen
erzeugen sie erst bei `async_payment_succeeded`. Beim Abo entsteht sie mit der
ersten bezahlten Rechnung.
### Ablage und Zugriff
- PDFs liegen in `CERTIFICATE_DIR`, nicht unter `UPLOAD_DIR`. Die
Startprüfung aus `lib/invoice-storage.ts` wird übernommen. Das Verzeichnis
kommt auf das gesicherte Volume und in den Sicherungs-CronJob.
- Alle Downloads und die 3D-Textur liefern Routen aus, die Anmeldung und
Besitz prüfen. Fremde IDs ergeben 404.
- Downloads liefern immer die gespeicherte PDF der aktuellen Revision.
- Kein QR-Code auf der Urkunde: Er brächte kaum Nutzen und würde die
Gestaltung stören.
### Vorschau
- `POST /api/patenschaft/vorschau`: prüft die Personalisierung, gibt ein WebP
mit Wasserzeichen „Unverbindliche Vorschau“ in Bildschirmauflösung zurück.
Rate-Limit je Besucher. Keine Anmeldung nötig.
- Der Client fordert sie etwa 400 ms nach der letzten Eingabe an und zeigt bis
dahin die letzte gültige Vorschau.
## Adminbereich
- Angebote: Einmal- und Abo-Angebote bearbeiten, Kontingent, Erntefenster,
Medaillenstufe.
- Patenschaften: Liste mit Filtern (ausstehend, Fehler, bereit, zu drucken,
gedruckt, Art, Angebot). Detail mit Vorschau, Revisionen, „Druck-PDF
öffnen“, „Als gedruckt markieren“, „Erneut erzeugen“, „Mail erneut senden“,
Übertragungshistorie.
- Abos: Liste mit Status, bezahlten Monaten, nächster Lieferung; Deckung des
Honigbedarfs.
- Treuestufen: Schwellen und Namen pflegen.
- Korrektur: Felder im Kundenformular ändern, Vorschau mit derselben
Layoutlogik, Pflichtfeld Änderungsgrund, neue Revision mit neuer PDF, alte
bleibt abrufbar, Eintrag über `logAdminAction`, optional Mail an den Kunden.
Standardmäßig mit der Vorlagenversion der bisherigen Revision.
- Druckablauf: Physische Bestellungen erscheinen nach Zahlung unter „zu
drucken“. PDF öffnen, auf festem Papier drucken, als gedruckt markieren,
wie gewohnt über DHL versenden.
## Verkaufsseite
Route `/patenschaft`, zweisprachig, im Design der Website.
Im Mittelpunkt:
- einer Biene symbolisch einen eigenen Namen geben
- ein persönlich gestaltetes Geschenk
- die regionale Imkerei Wabenhain unterstützen
- je nach Angebot eigenen Wabenhain-Honig, einmalig oder regelmäßig
Aufbau:
1. Kopfbereich mit großer, echter Urkundenvorschau
2. Umschalter „Einmalig“ / „Im Abo“ mit den Angeboten, Preis, Leistungen,
Versandkosten beziehungsweise „Versand inklusive“ und den Lieferregeln
3. Personalisierung in Schritten: Angebot, für wen und Anlass, Namen, Widmung
und Startdatum, Vorschau prüfen
4. Ausschnitt aus dem Kontobereich (3D-Urkunde und Medaille als Video oder
Bildfolge) mit dem Hinweis, dass der Kauf ein Kundenkonto braucht
5. Hinweis zur symbolischen Namensgebung, gut sichtbar
6. FAQ
Smartphone: Vorschau über dem jeweiligen Schritt, ein Tipp vergrößert sie.
Eingabefelder mindestens 16 px Schrift, Bedienelemente mindestens 44 px.
FAQ-Themen: symbolische Namensgebung, Laufzeit (einmalig zwölf Monate, Abo
monatlich kündbar), wann der Honig im Abo kommt, warum ein Konto nötig ist,
Übertragung an Beschenkte, Versandkosten, Tippfehler (Kontakt, Korrektur
durch Wabenhain), was nach Ablauf oder Kündigung bleibt. Keine FAQ verspricht
Besuche, Berichte, Fotos oder Umweltwirkungen.
## Prüfung
### Layout-Testmatrix
Automatisierte Tests rendern echte PDFs und prüfen die Layoutdaten (keine
Überlappung, alles in der Sicherheitszone, Schrift nie unter Mindestgröße,
genau eine Seite) für:
- sehr kurze Namen („Li“, „Bo“)
- maximal lange Namen in beiden Feldern
- Doppelnamen („Marie-Luise Schmidt-Habermann“)
- Umlaute und Sonderzeichen („Jürgen Weiß“, „Zoë“, „Ñúñez“, „O’Brien“)
- mehrere Schenkende („Anna, Ben, Carla und Familie Özdemir“)
- ohne Widmung und mit maximal langer Widmung samt allen Zeilenumbrüchen
- jeden Anlass, eigene und verschenkte Patenschaft, Deutsch und Englisch
- alle drei Laufzeitformen (einmalig, Abo aktiv, Abo beendet)
- Vorschau, Textur und PDF ergeben dieselben Zeilen
Die Matrix legt die endgültigen Eingabelimits fest.
### Visuelle Kontrolle
PDFs werden mit 300 dpi gerastert und angesehen: ganze Seite und Ausschnitte
in hoher Vergrößerung. Probedruck auf einem Bürodrucker. Medaillen als PNG in
Originalgröße. 3D-Ansicht auf Desktop, 390 px mobil, mit
`prefers-reduced-motion` und ohne WebGL.
### Ablauftests
- doppelter Webhook: eine Patenschaft, eine Mail
- PDF-Erzeugung scheitert: Webhook 200, CronJob holt nach
- Gastkauf einer Patenschaft wird serverseitig abgewiesen
- fremde Patenschaft, fremder Download, fremde Textur: 404
- Korrektur: neue Revision, alte unverändert, Audit-Eintrag
- neue Vorlagenversion: alte Urkunden unverändert
- Übertragung: Link läuft ab, wird widerrufen, wird genau einmal eingelöst
- Abo: Lieferung erst nach drei bezahlten Monaten; Kündigung nach zwei
Monaten erzeugt keine Lieferung; Zahlungsausfall stoppt Lieferungen;
Kündigung erzeugt Von-bis-Revision und entfernt die Abo-Stufe
- Bestand: Einzelverkauf kann reservierte Abo-Gläser nicht kaufen;
Kontingent voll stoppt neue Abos
- doppelte Stripe-Abo-Ereignisse: eine Rechnung, eine Zahlung
- SEPA: erste Zahlung zählt erst nach der Neukunden-Frist; Rückbuchung
stoppt Lieferungen, setzt das Abo in Verzug, sperrt SEPA für das Konto;
ohne Ausgleich endet das Abo nach 14 Tagen
- Treuestufen: Erstattung senkt die Stammkunden-Stufe
- E2E (Playwright): Einmalkauf mit Stripe-Testkarte bis zur 3D-Ansicht im
Konto; Abo-Abschluss mit Stripe-Testuhr (Test Clock) über drei Monate
Die Repo-Skripte `lint`, `typecheck`, `test` und `build` laufen grün.
## Abnahme
Je Teilprojekt gilt: fertig erst nach den passenden Tests, der visuellen
Kontrolle und einer Vorführung. Gesamtabnahme:
1. Verkaufsseite und Personalisierung auf Desktop und 390 px mobil
2. die drei Designvarianten und die gewählte Gestaltung
3. mehrere echte PDF-Ausgaben mit unterschiedlichen Beispieldaten
4. automatischer Ablauf vom Kauf bis zur 3D-Ansicht und zum Download
5. Medaillen aller Arten und Stufen
6. Übertragung von Konto zu Konto
7. Abo über drei Monate mit Test Clock bis zur Lieferung, dann Kündigung
8. Druck- und Korrekturablauf im Admin
9. offene geschäftliche Konfiguration (Liste unten)
## Geschäftliche Konfiguration vor Veröffentlichung
1. Preise kaufmännisch prüfen: Einmalpakete und Abo-Monatspreise mit Honig,
Papier, Verpackung, Grußkarte, Versand, Stripe-Gebühren, Arbeitszeit.
2. Gläser je Abo-Lieferung, Erntefenster und Kontingente festlegen.
3. Steueraufteilung mit der Steuerberatung abstimmen.
4. AGB, Abo-Bedingungen und Widerrufsbelehrung: personalisierte Ware
(§ 312g Abs. 2 Nr. 1 BGB), digitale Inhalte (§ 356 Abs. 5 BGB),
Abo-Laufzeit und Kündigung, Kündigungsbutton (§ 312k BGB), Lieferregel
„nach bezahlten Monaten“, Übertragung auf eigene Verantwortung. Rechtlich
prüfen lassen.
5. Datenschutzerklärung: Namen Dritter, Speicherdauer, Stripe Billing.
6. Stripe-Livemodus freigeben: Die Einrichtung (siehe „Stripe-Einrichtung“)
läuft erst im Testmodus. Den Livemodus stellt das Skript erst nach
ausdrücklicher Freigabe des Inhabers um. Neukunden-Frist für SEPA
bestätigen (Vorgabe 56 Tage).
7. Versandstaffel für Patenschaftspakete anlegen.
8. Logo als SVG oder PNG mit mindestens 300 dpi hochladen.
9. Material bevorraten: Urkundenpapier (200–250 g/m², matt, naturweiß),
Geschenkverpackung, Grußkarten. Probedruck freigeben.
10. Bestellschluss für Weihnachten festlegen.
11. Textbausteine und Medaillennamen sprachlich freigeben (de und en).
+154 -21
View File
@@ -1,30 +1,163 @@
# 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.
# Muss die Adresse des Postfachs aus SMTP_USER (Secret) sein — der Versand
# laeuft ueber Microsoft 365 mit OAuth 2.0, siehe README "E-Mail-Versand".
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). # Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
NEXT_PUBLIC_SITE_URL: "https://shop.example.com" #
SMTP_PORT: "587" # Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
SMTP_FROM: "Wabenhain <[email protected]>" # in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der
# Optional, siehe README-Abschnitte "Google-Rezensionen"/"CAPTCHA" — leer # voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der
# lassen, wenn nicht genutzt, die App faellt automatisch zurueck. # Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch
GOOGLE_PLACES_API_KEY: "" # welche. Entweder diesen Wert senken oder max_connections anheben.
GOOGLE_PLACE_ID: "" DATABASE_POOL_SIZE: "20"
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production". # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
DHL_ENVIRONMENT: "sandbox" # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
# Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos, # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
# siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des # Fehlt der Wert, schreibt die App nach "./uploads" relativ zum
# "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst # Arbeitsverzeichnis: das trifft im Image zufaellig denselben Ort, ist aber
# weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example. # nirgends zugesichert. Deshalb hier ausdruecklich setzen.
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"
# Urkunden-PDFs der Bienenpatenschaft (src/lib/sponsorship/storage.ts):
# /app/invoices/urkunden/2026/WH-P-2026-00042/r1.pdf
# Unterordner des Rechnungs-Volumes: ebenso privat (keine oeffentliche Route
# zeigt dorthin), ebenso taeglich gesichert (07-backup-cronjob.yaml sichert
# das ganze Volume), und kein zweites ReadWriteOnce-Volume am App-Pod. Die
# Pfade der Rechnungen beginnen mit dem Jahr, "urkunden" kollidiert nicht.
CERTIFICATE_DIR: "/app/invoices/urkunden"
# Hintergrundaufgaben im App-Prozess (src/lib/background-jobs.ts): Urkunden
# nachholen, Abo-Lieferungen anlegen, Verzug nach Rueckbuchung beenden.
# Laufen im App-Pod, weil nur er das Volume mit CERTIFICATE_DIR einhaengt.
# "off" nur fuer zusaetzliche Instanzen, die keine Aufgaben uebernehmen sollen.
BACKGROUND_JOBS: "on"
# Stripe-Kundenportal fuer "Zahlungsart aendern" im Abo (bpc_..., kein
# Geheimnis). Legt scripts/stripe-setup.ts --apply an und gibt die ID aus.
# Gesetzt am 2026-10-16 (Livekonto, nur "Zahlungsart aendern"). Leer: dann
# sucht die App die Konfiguration ueber ihre Metadaten
# (wabenhain=abo-zahlungsart). Gibt es keine, bietet das Konto
# "Zahlungsart aendern" nicht an — die Standardkonfiguration von Stripe wird
# bewusst nie benutzt, weil sie Kuendigung oder Tarifwechsel erlauben koennte.
STRIPE_PORTAL_CONFIGURATION_ID: "bpc_1URCIfEvHaDWQ1U0nD4jVWXc"
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
# 19 = voller Satz
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
# Steuerzeilen den Pflichthinweis nach § 19 UStG
#
# Die Shop-Preise sind BRUTTOPREISE (§ 1 PAngV). Die Steuer wird daraus
# herausgerechnet, nicht aufgeschlagen — der Endpreis bleibt also genau der,
# den die Kundschaft im Warenkorb gesehen hat.
#
# Der Satz wird bei jeder Rechnung FESTGESCHRIEBEN. Eine Aenderung hier gilt
# nur fuer kuenftige Rechnungen; bereits ausgestellte behalten ihren Satz.
VAT_RATE_PERCENT: "7"
# Satz je Kategorie. Der Steuersatz haengt an der Art der Ware: Honig ist
# ein Lebensmittel und ermaessigt besteuert, eine Bienenwachskerze nicht.
# Fehlt hier ein Wert, gilt VAT_RATE_PERCENT von oben.
#
# PROPOLIS bitte pruefen: als Lebensmittel waeren 7% richtig, als
# Nahrungsergaenzungs- oder Kosmetikprodukt 19%. Voreingestellt ist der
# volle Satz, weil er im Zweifel der sicherere ist.
VAT_RATE_PERCENT_HONIG: "7"
VAT_RATE_PERCENT_KERZEN: "19"
VAT_RATE_PERCENT_PROPOLIS: "19"
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher).
VAT_RATE_PERCENT_SONSTIGES: "19"
# Bienenpatenschaft (Urkunde, Namensgebung, Abo-Monatsbeitrag ohne
# Honiganteil): Leistung zum Regelsatz. Vor der Veroeffentlichung mit der
# Steuerberatung bestaetigen (Spec, "Geschaeftliche Konfiguration").
VAT_RATE_PERCENT_PATENSCHAFT: "19"
# 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"
# Oeffentlicher Stripe-Schluessel (pk_live_...) fuer das eingebettete
# Zahlungsformular. Nicht geheim — er steht ohnehin im HTML der Kasse.
# Muss zum STRIPE_SECRET_KEY im Secret passen (beide live). Leer = der
# Checkout nimmt keine Bestellungen an.
STRIPE_PUBLISHABLE_KEY: ""
# ---------------------------------------------------------------------
# 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: ""
+121 -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,130 @@ 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"
# Mailversand ueber Microsoft 365 mit OAuth 2.0 (src/lib/mail.ts) — mit der
# App-Registrierung der Entra-ID-SSO unten (ENTRA_TENANT_ID,
# ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), kein Passwort. Ohne diese Werte
# und SMTP_USER werden E-Mails nur ins Server-Log geschrieben: keine
# Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung.
# SMTP_USER ist das Postfach, mit dem gesendet wird (z. B.
# [email protected]) — dieselbe Adresse wie SMTP_FROM in
# 01-configmap.yaml. Einrichtung: README "E-Mail-Versand".
SMTP_USER: "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: ""
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
APICORE_API_KEY: ""
# Secret des ApiCore-Webhooks "stock.changed" (ApiCore -> Automatisierung ->
# Webhooks, Ziel https://<shop>/api/apicore/webhook). Damit kommen
# Entnahmen aus ApiCore sofort im Shop an; leer = nur der CronJob gleicht
# ab (alle 10 Minuten).
APICORE_WEBHOOK_SECRET: ""
# Rechnungsexport fuer ERPNext (GET /api/erp/rechnungen, nur lesend): ERPNext
# holt damit die Rechnungen samt PDF ab. Mindestens 32 Zeichen, derselbe
# Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus.
ERP_EXPORT_TOKEN: ""
# 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: ""
# Optional: eigener Key fuer die DHL-Sendungsverfolgung auf
# /bestellung-verfolgen. Leer = DHL_API_KEY wird verwendet.
DHL_TRACKING_API_KEY: ""
# 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). # Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben, Berechtigung
# SMTP.SendAsApp) — ohne diese Werte also auch kein Mailversand. Das
# Client-Secret laeuft ab (hoechstens 24 Monate): Ablaufdatum im Kalender
# eintragen, sonst fallen Anmeldung UND Mails gleichzeitig aus.
# 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: ""
+2 -2
View File
@@ -43,8 +43,8 @@ spec:
periodSeconds: 5 periodSeconds: 5
resources: resources:
requests: requests:
cpu: 100m cpu: "0"
memory: 256Mi memory: "0"
limits: limits:
memory: 512Mi memory: 512Mi
volumeClaimTemplates: volumeClaimTemplates:
+2 -2
View File
@@ -42,8 +42,8 @@ spec:
periodSeconds: 5 periodSeconds: 5
resources: resources:
requests: requests:
cpu: 100m cpu: "0"
memory: 256Mi memory: "0"
limits: limits:
memory: 512Mi memory: 512Mi
volumeClaimTemplates: volumeClaimTemplates:
+70
View File
@@ -0,0 +1,70 @@
# Persistenter Speicher fuer hochgeladene Bilder und Videos (Produkt-,
# Ernten- und Inhaltsfotos aus dem Admin-Bereich, siehe
# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts).
#
# WARUM EIGENES MANIFEST: 05-app-deployment.yaml hatte hier bisher nur einen
# "emptyDir"-Platzhalter. Der ist an die Lebensdauer des Pods gebunden —
# jeder Neustart, jeder Redeploy und jede Node-Verlagerung loescht saemtliche
# hochgeladenen Bilder. Die Datenbank behaelt dabei die Bildpfade, die
# Dateien dahinter sind aber weg: die Seite zeigt danach ueberall kaputte
# Bilder statt der Produktfotos, ohne dass irgendwo ein Fehler auftaucht.
#
# ANZUPASSEN VOR DEM ANWENDEN:
# - storageClassName: auf eine im Cluster vorhandene StorageClass setzen
# ("kubectl get storageclass" zeigt die verfuegbaren; die als
# "(default)" markierte wird verwendet, wenn das Feld ganz entfernt
# wird). Die Zeile ist bewusst auskommentiert, damit das Manifest ohne
# Aenderung gegen die Default-StorageClass anwendbar bleibt.
# - storage: Groesse nach erwartetem Bildbestand. 5Gi reichen fuer einige
# tausend optimierte Produktfotos; das Hero-Video (bis 100 MB roh, siehe
# MAX_UPLOAD_VIDEO_BYTES in lib/uploads.ts) faellt dabei kaum ins
# Gewicht.
#
# ZUGRIFFSMODUS: ReadWriteOnce genuegt, weil das Deployment mit genau einer
# Replica und der Strategie "Recreate" laeuft (siehe 05-app-deployment.yaml)
# — es greift also nie mehr als ein Pod gleichzeitig darauf zu. Soll spaeter
# hochskaliert werden, braucht es hier ReadWriteMany (NFS/SMB/CephFS) UND
# einen gemeinsamen Rate-Limit-Store (siehe Kommentar zu replicas in
# 05-app-deployment.yaml).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-uploads
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
# storageClassName: "dein-storage-class"
resources:
requests:
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
+124 -21
View File
@@ -1,5 +1,5 @@
# image: unten mit der tatsaechlichen Registry/Tag aus der Gitea-Actions- # image: unten auf den Build gepinnt, siehe Kommentar dort. Kein separater
# Pipeline ersetzen. Kein separater Migrations-Job mehr noetig: der # Migrations-Job mehr noetig: der
# Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus, # Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus,
# BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) — # BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) —
# beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos # beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos
@@ -19,6 +19,23 @@ spec:
# ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst # ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst
# lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen. # lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen.
replicas: 1 replicas: 1
# PFLICHT in Verbindung mit dem uploads-Volume (siehe 04-uploads-pvc.yaml):
# die Standardstrategie "RollingUpdate" startet den NEUEN Pod, bevor der
# alte beendet ist. Ein ReadWriteOnce-Volume kann aber immer nur an einen
# Pod gebunden sein — der neue Pod bliebe in ContainerCreating mit
# "Multi-Attach error for volume" haengen, und ein
# "kubectl rollout restart" (der im k8s/README.md dokumentierte Weg fuer
# ein neues Image) liefe schlicht in den Timeout.
#
# "Recreate" beendet den alten Pod zuerst. Das kostet waehrend des
# Redeploys ein paar Sekunden Ausfall — bei einer Replica ist der
# Unterschied ohnehin gering, und der Container migriert beim Start erst
# die Datenbank, bevor er Verkehr annimmt. Der Nebeneffekt ist erwuenscht:
# es laufen nie zwei Pods gleichzeitig, die sich sonst die im
# Prozessspeicher gehaltenen Rate-Limits teilen wuerden (siehe Kommentar
# zu replicas oben).
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: wabenhain-app app: wabenhain-app
@@ -34,13 +51,47 @@ spec:
# Image bereits per chown gesetzten Besitzer von /app/uploads # Image bereits per chown gesetzten Besitzer von /app/uploads
# vollstaendig, und der nicht-root laufende Container koennte trotzdem # vollstaendig, und der nicht-root laufende Container koennte trotzdem
# keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup # keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup
# sorgt dafuer, dass Kubernetes JEDES gemountete Volume zusaetzlich der # sorgt dafuer, dass Kubernetes das gemountete Volume zusaetzlich der
# Gruppe 1001 zuordnet und gruppen-schreibbar macht. # Gruppe 1001 zuordnet und gruppen-schreibbar macht.
#
# ACHTUNG, GILT NICHT FUER JEDES BACKEND: fsGroup wirkt nur dort, wo
# Kubernetes den Besitz tatsaechlich setzen kann — also bei
# blockbasiertem Speicher. Bei SMB/CIFS und NFS wird es IGNORIERT, weil
# diese Protokolle keinen per-Datei-Unix-Besitz kennen, den der Knoten
# nachtraeglich aendern koennte. Dort gehoert die Zuordnung in die
# mountOptions des PersistentVolume:
#
# mountOptions:
# - uid=1001
# - gid=1001
# - dir_mode=0770
# - file_mode=0660
#
# Ohne uid/gid erscheint bei SMB ALLES als root:root, und der Container
# scheitert trotz fsGroup mit EACCES. Siehe k8s/README.md, Abschnitt
# "Uploads lassen sich nicht speichern".
securityContext: securityContext:
fsGroup: 1001 fsGroup: 1001
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers: containers:
- name: app - name: app
image: git.encry.de/nick.rosenloecher/wabenhain:latest # Fest auf einen Build gepinnt (Tag sha-<kurzer Commit> aus der
# Gitea-Pipeline), nie :latest: ein "rollout restart" holt damit
# kein ungeprueftes neues Image. Neuer Build:
# kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
# und hier nachziehen. Tag plus Digest: der Tag ist lesbar, der
# Digest macht das Image unveraenderlich (Tags lassen sich in der
# Registry ueberschreiben).
image: git.encry.de/nick.rosenloecher/wabenhain:sha-1d88bac@sha256:49dd869c339f476fdb51a79e803f2c3cf8fba308b846cddb314b37d1d0a76731
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
ports: ports:
- containerPort: 3000 - containerPort: 3000
envFrom: envFrom:
@@ -60,15 +111,37 @@ 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).
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
# Load-Balancing, startet ihn aber nicht neu.
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /api/health/ready
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):
# eine Liveness-Probe an der Datenbank wuerde aus einer
# voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil
# der neu startende Container laut Dockerfile-CMD zuerst
# "prisma migrate deploy" gegen dieselbe gestoerte Datenbank faehrt.
# Beide Probes zeigten vorher auf "/" — also auf eine
# force-dynamic-Seite mit DB-Abfragen und vollem React-Rendering
# alle 10 bzw. 30 Sekunden.
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /api/health
port: 3000 port: 3000
# initialDelaySeconds/failureThreshold grosszuegiger als sonst # initialDelaySeconds/failureThreshold grosszuegiger als sonst
# ueblich: der Container migriert/seedet die DB selbst vor dem # ueblich: der Container migriert/seedet die DB selbst vor dem
@@ -78,26 +151,56 @@ 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
# Admin-Video-Upload: Node laedt den multipart-Body beim
# request.formData() vollstaendig in den Speicher, und die Datei
# darf bis zu 100 MB gross sein (MAX_UPLOAD_VIDEO_BYTES in
# lib/uploads.ts). Zusammen mit dem Grundverbrauch des
# Next.js-Servers sprengte das ein Limit von 512Mi — der Kernel
# beendet den Prozess dann mitten im Upload (OOMKilled), und im
# Browser kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an
# ("Unexpected token '<', "<!DOCTYPE "... is not valid JSON").
#
# Die Route selbst haelt inzwischen nur noch den Body im Speicher
# und streamt alles Weitere ueber die Platte (siehe
# app/api/admin/upload-video/route.ts) — 1Gi gibt darueber hinaus
# Reserve, damit ein zweiter gleichzeitiger Zugriff den Pod nicht
# kippt.
#
# ffmpeg ist CPU-gebunden: mit nur 100m dauert das Umrechnen ein
# Vielfaches und laeuft eher in ein Proxy-Zeitlimit. Bewusst KEIN
# CPU-Limit, nur ein hoeherer Request — ein Limit wuerde den
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
resources: resources:
requests: requests:
cpu: 100m cpu: "0"
memory: 256Mi memory: "0"
limits: limits:
memory: 512Mi memory: 1Gi
volumes: volumes:
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene # Bindet den PersistentVolumeClaim aus 04-uploads-pvc.yaml — dieses
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen # Manifest MUSS vor dem Deployment angewendet werden, sonst bleibt
# ein SMB-/NFS-Backend oder einen hostPath gebunden ist), etwa: # der Pod in "Pending" (unaufloesbare Volume-Referenz).
# - name: uploads #
# persistentVolumeClaim: # Vorher stand hier ein emptyDir-Platzhalter. Das ist kein
# claimName: wabenhain-uploads-pvc # persistenter Speicher: bei jedem Pod-Neustart, jedem Redeploy und
# jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt- und
# Erntenfotos weg, waehrend die Datenbank ihre Pfade behielt — die
# Seite zeigte danach ueberall kaputte Bilder, ohne Fehlermeldung.
- name: uploads - name: uploads
# CHANGE-ME: obige emptyDir-Zeile durch die echte Volume-Quelle persistentVolumeClaim:
# ersetzen. emptyDir ist NICHT persistent (Inhalt geht bei jedem claimName: wabenhain-uploads
# Pod-Neustart verloren) und dient hier nur als Platzhalter, damit - name: invoices
# dieses Manifest ohne weitere Aenderung "kubectl apply"-faehig persistentVolumeClaim:
# bleibt. claimName: wabenhain-invoices
emptyDir: {}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+92 -23
View File
@@ -1,40 +1,96 @@
# Setzt einen bereits im Cluster laufenden Ingress-Controller voraus (z.B. # Ingress fuer Traefik (der im Zielcluster eingesetzte Ingress-Controller),
# ingress-nginx) sowie optional cert-manager fuer automatisches TLS. Host # optional zusammen mit cert-manager fuer automatisches TLS. Host unten
# unten anpassen (muss zu NEXT_PUBLIC_SITE_URL in 01-configmap.yaml passen). # anpassen (muss zu NEXT_PUBLIC_SITE_URL in 01-configmap.yaml passen).
# #
# X-Forwarded-For: ingress-nginx setzt den Header standardmaessig korrekt # ============================================================================
# (verwirft einen vom Client mitgeschickten Wert), lib/rate-limit.ts vertraut # WICHTIG fuer die Admin-Uploads: eine Einstellung fehlt hier zwangslaeufig
# genau darauf — bei einem ANDEREN Ingress-Controller/zusaetzlichen Proxy # ============================================================================
# davor unbedingt pruefen, dass er sich genauso verhaelt, siehe Kommentar in # Der Video-Upload (bis 100 MB, MAX_UPLOAD_VIDEO_BYTES in lib/uploads.ts)
# lib/rate-limit.ts. # scheitert mit Traefiks Standardeinstellung, sobald die Uebertragung laenger
# als 60 Sekunden dauert. Das ist bei 20-100 MB ueber eine normale
# Upload-Leitung schnell erreicht: 80 MB bei 10 Mbit/s sind rund 64 Sekunden.
#
# Verantwortlich ist "readTimeout" des EntryPoints. Traefik beschreibt ihn als
# "maximum duration for reading the entire request, including the body" — er
# umfasst also die gesamte Uebertragung des Uploads, nicht nur die Header.
# Der Standardwert ist 60s. Laeuft er ab, kappt Traefik die Verbindung und
# antwortet mit einer eigenen HTML-Fehlerseite; im Browser erschien davon nur
# "Unexpected token '<', "<!DOCTYPE "... is not valid JSON", weil die Antwort
# kein JSON mehr war (lib/upload-client.ts meldet diesen Fall inzwischen mit
# dem HTTP-Status statt als Parse-Fehler).
#
# Dieser Wert laesst sich NICHT per Ingress-Annotation setzen — er gehoert zur
# statischen Konfiguration des EntryPoints und damit in die Traefik-
# Installation selbst. Mit dem offiziellen Helm-Chart:
#
# ports:
# websecure:
# transport:
# respondingTimeouts:
# readTimeout: 600s
#
# 600s deckt auch eine langsame Leitung ab. Ein hoher Wert ist hier
# unbedenklich, weil er nur greift, solange tatsaechlich Daten fliessen.
#
# Zwei Dinge, die bei Traefik ausdruecklich KEIN Problem sind:
#
# - Body-Groesse: Traefik erzwingt keine Obergrenze fuer den Request-Body
# (begrenzt ist nur maxHeaderBytes mit 1 MB, und das betrifft die Header).
# Eine Entsprechung zu nginx' "proxy-body-size" wird hier also nicht
# gebraucht. Nur falls eine "buffering"-Middleware im Einsatz ist, deren
# maxRequestBodyBytes pruefen.
# - Verarbeitungsdauer: "responseHeaderTimeout" (die Wartezeit auf die Antwort
# des Backends, also das ffmpeg-Transcoding) ist per Default 0 = kein Limit.
# Das Umrechnen darf demnach so lange dauern wie noetig; die Anwendung
# begrenzt es selbst auf 5 Minuten (TRANSCODE_TIMEOUT_MS in
# lib/video-transcode.ts).
#
# Sitzt zusaetzlich ein CDN davor (z.B. Cloudflare), gilt dessen eigenes
# Origin-Zeitlimit unabhaengig hiervon weiter — bei Cloudflare 100 Sekunden
# (Fehler 524), ebenfalls als HTML-Seite.
#
# ----------------------------------------------------------------------------
# X-Forwarded-For: lib/rate-limit.ts vertraut darauf, dass der Proxy diesen
# Header selbst setzt und einen vom Client mitgeschickten Wert verwirft —
# sonst liessen sich saemtliche Rate-Limits (Login, 2FA, Checkout) umgehen.
# Bei Traefik haengt das an "forwardedHeaders.trustedIPs" des EntryPoints:
# nur fuer dort aufgefuehrte Absender werden eingehende X-Forwarded-*-Header
# uebernommen, von allen anderen werden sie ersetzt. Vor dem Produktivbetrieb
# pruefen, dass diese Liste nicht unbeabsichtigt weit gefasst ist.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: wabenhain name: wabenhain
namespace: wabenhain namespace: wabenhain
annotations: annotations:
# Nur relevant, falls cert-manager installiert ist — sonst diese beiden # Nur relevant, falls cert-manager installiert ist — sonst diese Zeile
# Zeilen und den "tls:"-Block unten entfernen und Host per HTTP betreiben # und den "tls:"-Block unten entfernen und den Host per HTTP betreiben
# oder TLS auf andere Weise terminieren. # oder TLS auf andere Weise terminieren.
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true" # Erzwingt HTTPS ueber die Middleware unten. Legt die Traefik-Installation
# ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro # die Umleitung bereits EntryPoint-weit an (Helm-Chart:
# Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts # ports.web.redirectTo.port=websecure), sind diese Zeile UND die
# MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (100 MB, lib/uploads.ts # Middleware am Ende der Datei ueberfluessig. Der Namespace-Praefix im
# MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht # Wert ist Pflicht — Traefik loest den Namen sonst nicht auf.
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch #
# ohne verwertbare Fehlermeldung im Browser) — 110m deckt das groessere # "cloudflare-only" steht bewusst an erster Stelle: wabenhain.com laeuft
# der beiden Limits (100 MB) plus etwas Spielraum fuer den # ueber den Cloudflare-Proxy, direkte Zugriffe auf den Origin (an
# multipart/form-data-Overhead ab. # Cloudflare und damit an WAF und Rate-Limits vorbei) beantwortet Traefik
nginx.ingress.kubernetes.io/proxy-body-size: "110m" # mit 403. Erlaubt sind nur Cloudflare-IPs und private Netze. Die
# Middleware liegt nicht in diesem Repo, sondern in
# rke2-cluster/security/cloudflare-only-middlewares.yaml (Stand 2026-10-12).
traefik.ingress.kubernetes.io/router.middlewares: wabenhain-cloudflare-only@kubernetescrd,wabenhain-redirect-to-https@kubernetescrd
spec: spec:
ingressClassName: nginx # Muss zur IngressClass der Traefik-Installation passen. Das offizielle
# Helm-Chart legt sie als "traefik" an; mit "kubectl get ingressclass"
# pruefen, falls der Ingress ohne Wirkung bleibt.
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: /
@@ -44,3 +100,16 @@ spec:
name: wabenhain-app name: wabenhain-app
port: port:
number: 80 number: 80
---
# Leitet HTTP dauerhaft auf HTTPS um; wird von der Annotation oben
# referenziert. Entfaellt samt Annotation, wenn die Umleitung bereits
# EntryPoint-weit konfiguriert ist.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-to-https
namespace: wabenhain
spec:
redirectScheme:
scheme: https
permanent: true
+331
View File
@@ -0,0 +1,331 @@
# Taegliche Sicherung der Datenbank.
#
# WARUM: 03-postgres.yaml haelt Bestellungen, Rechnungen, Kundenkonten und
# Chargendaten in einem einzelnen PersistentVolume ohne jede Sicherung. Geht
# dieses Volume verloren (Speicherfehler, versehentliches "kubectl delete
# pvc", ein misslungenes Cluster-Update), sind saemtliche Bestellungen und
# Rechnungen unwiederbringlich weg. Rechnungen unterliegen in Deutschland
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
#
# Diese Sicherung liegt auf derselben Storage Box wie die Datenbank. Sie
# schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall
# der Storage Box oder des Standorts. Fuer eine vollstaendige Absicherung die
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
# und Wiederherstellung").
#
# MYSQL STATT POSTGRES: laeuft die Installation mit DATABASE_PROVIDER=mysql
# (03-mysql.yaml), stattdessen das Image "mysql:8.4.11" verwenden und den
# pg_dump-Aufruf unten ersetzen durch:
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
# Ziel ist die Storage Box wie bei den uebrigen Wabenhain-Volumes. Das PV
# zeigt auf den vorhandenen Ordner Customer/Wabenhain; der Unterordner
# db-backup entsteht ueber subPath beim ersten Lauf.
apiVersion: v1
kind: PersistentVolume
metadata:
name: wabenhain-db-backup
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions: ["vers=3.0", "uid=70", "gid=70", "dir_mode=0700", "file_mode=0600", "noperm"]
csi:
driver: smb.csi.k8s.io
volumeHandle: wabenhain-db-backup
volumeAttributes:
source: //u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain
nodeStageSecretRef:
name: smb-credentials
namespace: wabenhain
claimRef:
namespace: wabenhain
name: wabenhain-db-backup
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-db-backup
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
volumeName: wabenhain-db-backup
resources:
requests:
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
# niedrigen Megabyte-Bereich; 10Gi tragen die 30 Tage Aufbewahrung
# unten mit grossem Puffer.
storage: 10Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: wabenhain-db-backup
namespace: wabenhain
spec:
# 03:15 Uhr — ausserhalb der ueblichen Bestellzeiten. Die Uhrzeit richtet
# sich nach der Zeitzone des Clusters (meist UTC).
schedule: "15 3 * * *"
# Kein zweiter Lauf, solange der vorherige noch laeuft.
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
# Fehlgeschlagene Laeufe laenger aufheben als erfolgreiche: nur an ihnen
# laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung
# ausblieb.
failedJobsHistoryLimit: 5
# Faellt ein Lauf aus (Knoten weg, Cluster-Wartung), gilt er nach einer
# Stunde als verpasst statt ewig nachgeholt zu werden.
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
# 70 ist die UID/GID des "postgres"-Nutzers im offiziellen
# Alpine-Image; fsGroup gibt dem Job Schreibrecht auf dem
# gemounteten Backup-Volume (gleiche Begruendung wie beim
# uploads-Volume in 05-app-deployment.yaml).
securityContext:
runAsUser: 70
runAsGroup: 70
fsGroup: 70
containers:
- name: pg-dump
# Gleiche Hauptversion wie der Server in 03-postgres.yaml —
# ein aelteres pg_dump kann eine neuere Datenbank nicht lesen.
image: postgres:16.15-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: wabenhain-secrets
key: DB_PASSWORD
command: ["/bin/sh", "-c"]
args:
- |
set -eu
# pipefail ist hier das Wichtigste an diesem Skript:
# ohne es liefert "pg_dump | gzip" den Exit-Code von gzip.
# gzip gelingt auch dann, wenn pg_dump vorher abgebrochen
# ist — der Job galte als erfolgreich und im Verzeichnis
# laege eine abgeschnittene, beim Wiederherstellen
# wertlose Datei. Genau so entstehen Sicherungen, die erst
# im Ernstfall als unbrauchbar auffallen.
set -o pipefail
stamp="$(date -u +%Y-%m-%d_%H%M)"
target="/backup/wabenhain-${stamp}.sql.gz"
# Erst unter einem Zwischennamen schreiben, dann
# umbenennen: ein mitten im Lauf abgebrochener Job
# hinterlaesst so keine Datei, die wie eine vollstaendige
# Sicherung aussieht.
pg_dump --host=wabenhain-postgres --username=wabenhain \
--dbname=wabenhain --clean --if-exists \
| gzip -9 > "${target}.partial"
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.
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
# langfristige Archivierung der Rechnungen (§ 147 AO,
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
# ausgelagerte Sicherungen nutzen.
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete
echo "Sicherung abgeschlossen:"
ls -lh /backup
resources:
requests:
cpu: "0"
memory: "0"
limits:
memory: 256Mi
volumeMounts:
- name: backup
mountPath: /backup
subPath: db-backup
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
- name: invoices
mountPath: /invoices
readOnly: true
volumes:
- name: backup
persistentVolumeClaim:
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
---
# Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen.
#
# WARUM: eine Bestellung entsteht VOR der Zahlung und reserviert dabei sofort
# Lagerbestand — sonst koennte zwischen Kaufentscheidung und Zahlung jemand
# anders dasselbe Glas kaufen. Zurueck kommt der Bestand normalerweise ueber
# zwei Wege: den Abbruch-Knopf bei Stripe (/checkout/cancel) und das Ereignis
# checkout.session.expired, das Stripe an den Webhook schickt.
#
# Dieser CronJob ist das Netz unter beiden. Er greift genau dann, wenn der
# Webhook nicht ankommt: das Ereignis ist im Stripe-Dashboard nicht abonniert,
# der Endpunkt war beim Zustellversuch nicht erreichbar, oder die Versuche
# sind abgelaufen. Ohne ihn bliebe der reservierte Bestand dauerhaft
# blockiert — und zwar unsichtbar, denn eine solche Bestellung sieht im
# Admin-Bereich aus wie jede andere offene.
#
# Gefragt wird immer Stripe, nie die Uhr allein: zurueckgebucht wird nur, was
# dort nachweislich abgelaufen ist (siehe scripts/release-stale-orders.ts).
#
# Der Lauf endet mit Exit-Code 1, wenn er eine Bestellung findet, die bei
# Stripe bezahlt ist, hier aber noch auf "NEU" steht. Das ist Absicht: dieser
# Fall bedeutet entgangenen Umsatz und gehoert als fehlgeschlagener Job
# sichtbar gemacht, nicht in eine Logzeile.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wabenhain-release-stale-orders
namespace: wabenhain
spec:
# Alle 30 Minuten. Die Stripe-Sitzung laeuft nach 32 Minuten ab, der Lauf
# betrachtet Bestellungen ab 45 Minuten Alter — haeufiger zu pruefen bringt
# deshalb nichts, seltener laesst Ware unnoetig lange liegen.
schedule: "*/30 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
# Fehlgeschlagene Laeufe laenger aufheben: nur an ihnen laesst sich per
# "kubectl logs" nachlesen, welche Bestellung bezahlt, aber nicht
# verarbeitet ist.
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 600
jobTemplate:
spec:
# Kein endloses Wiederholen: schlaegt der Lauf zweimal fehl, laeuft er
# ohnehin in 30 Minuten erneut.
backoffLimit: 2
# Nach einer Stunde abbrechen — ein haengender Lauf wuerde sonst jeden
# weiteren blockieren (concurrencyPolicy: Forbid).
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
# 1001 nextjs) — beide Images legen denselben Nutzer an.
securityContext:
runAsUser: 1001
runAsGroup: 1001
containers:
- name: release-stale-orders
# Werkzeug-Image (Dockerfile "AS tools") mit DEMSELBEN Tag wie
# das App-Image in 05-app-deployment.yaml. Beide zusammen
# aktualisieren: der Lauf nutzt denselben Prisma-Client wie die
# App und muss deshalb zum selben Datenbankschema passen.
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
imagePullPolicy: IfNotPresent
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
# — die stehen bereits in denselben beiden Quellen wie fuer die
# App, deshalb hier keine eigene Liste, die auseinanderlaufen
# koennte.
envFrom:
- configMapRef:
name: wabenhain-config
- secretRef:
name: wabenhain-secrets
command: ["/bin/sh", "-c"]
args:
- |
set -eu
# prisma-provider.mjs schreibt die provider-Zeile in
# prisma/schema.prisma anhand von DATABASE_PROVIDER — ohne
# diesen Schritt laedt der Prisma-Client den falschen
# Treiber, wenn die Installation auf MySQL laeuft.
node scripts/prisma-provider.mjs
exec tsx scripts/release-stale-orders.ts
resources:
requests:
cpu: "0"
memory: "0"
limits:
# Derselbe Wert wie beim App-Container: der Lauf laedt
# denselben Prisma-Client samt Treiber.
memory: 512Mi
---
# Bestandsabgleich mit ApiCore (scripts/apicore-stock-sync.ts): sendet
# liegengebliebene Verkaeufe/Rueckbuchungen an ApiCore und gleicht alle
# verknuepften Produkte gegen den ApiCore-Restbestand ab. Sicherheitsnetz
# unter dem Sofort-Versand und dem Webhook "stock.changed" — scheitert der
# Lauf (ApiCore nicht erreichbar, Buchung abgelehnt), ist der Job rot.
apiVersion: batch/v1
kind: CronJob
metadata:
name: wabenhain-apicore-stock
namespace: wabenhain
spec:
schedule: "*/10 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 300
jobTemplate:
spec:
backoffLimit: 1
# Ein Lauf darf den naechsten nicht blockieren.
activeDeadlineSeconds: 540
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsUser: 1001
runAsGroup: 1001
containers:
- name: apicore-stock
# Werkzeug-Image mit demselben Tag wie in 05-app-deployment.yaml.
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
imagePullPolicy: IfNotPresent
# Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und
# APICORE_API_KEY — dieselben Quellen wie die App.
envFrom:
- configMapRef:
name: wabenhain-config
- secretRef:
name: wabenhain-secrets
command: ["/bin/sh", "-c"]
args:
- |
set -eu
node scripts/prisma-provider.mjs
exec tsx scripts/apicore-stock-sync.ts
resources:
requests:
cpu: "0"
memory: "0"
limits:
memory: 512Mi
+342 -17
View File
@@ -3,11 +3,18 @@
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
anwendbar. Nummerierung = empfohlene Reihenfolge. anwendbar. Nummerierung = empfohlene Reihenfolge.
Woher jeder Wert in `02-secret.yaml` und `01-configmap.yaml` kommt (Entra ID,
Microsoft 365, Stripe, optionale Dienste) und wie man danach prueft, dass
alles laeuft: [../docs/EINRICHTUNG.md](../docs/EINRICHTUNG.md).
## Einmalige Vorbereitung ## Einmalige Vorbereitung
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem 1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` prueft bei jedem
Push auf `main` das Image `<registry>/<owner>/wabenhain:latest` (+ Push auf `main` lint, audit, typecheck und test und baut erst danach das
`:sha-<kurzhash>`, `:vX.Y.Z` bei Tags) in der Gitea-Container-Registry. Image `<registry>/<owner>/wabenhain:latest` (+ `:sha-<kurzhash>`,
`:vX.Y.Z` bei Tags) in der Gitea-Container-Registry. Daneben entsteht
mit denselben Tags `wabenhain-tools` fuer die CronJobs in
`07-backup-cronjob.yaml`.
Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt
sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet
die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates
@@ -42,11 +49,20 @@ anwendbar. Nummerierung = empfohlene Reihenfolge.
4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und 4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und
`NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen. `NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen.
5. `image:` in `05-app-deployment.yaml` zeigt bereits auf 5. `04-uploads-pvc.yaml`: `storageClassName` auf eine im Cluster
`git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen vorhandene StorageClass setzen (`kubectl get storageclass`; die Zeile ist
(`:latest` passt fuer den jeweils neuesten `main`-Build). In auskommentiert, dann greift die als `(default)` markierte) und die
`06-ingress.yaml` `shop.example.com` durch die echte Domain ersetzen Groesse pruefen. Ohne diesen Claim gibt es keinen dauerhaften Speicher
(zweimal: `host:` und `tls.hosts`). fuer Produkt- und Erntenfotos.
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
`git.encry.de/nick.rosenloecher/wabenhain` und ist auf einen festen Build
gepinnt (`:sha-<commit>@sha256:<digest>`), nie auf `:latest` — siehe
„Bei einem neuen Image“ unten. Die Domain steht
in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
(`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
@@ -61,8 +77,15 @@ kubectl apply -f k8s/03-postgres.yaml
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
# Persistenter Speicher fuer hochgeladene Bilder — MUSS vor dem Deployment
# angewendet werden, sonst bleibt der App-Pod in "Pending" haengen.
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml kubectl apply -f k8s/06-ingress.yaml
# Taegliche Datenbanksicherung (siehe "Sicherung und Wiederherstellung").
kubectl apply -f k8s/07-backup-cronjob.yaml
``` ```
Migrationen + Seed (nur Admin-Konto, KEINE Beispieldaten — siehe Punkt 2 Migrationen + Seed (nur Admin-Konto, KEINE Beispieldaten — siehe Punkt 2
@@ -72,13 +95,37 @@ der erste Pod bereit ist.
## Bei einem neuen Image (Redeploy) ## Bei einem neuen Image (Redeploy)
Das Deployment ist auf Tag **und** Digest gepinnt. Ein `rollout restart`
holt deshalb kein neues Image, sondern startet nur denselben Build neu (das
genuegt nach einer Aenderung an Secret oder ConfigMap). Einen neuen Build
rollt man so aus:
```bash ```bash
kubectl rollout restart deployment/wabenhain-app -n wabenhain # Digest des gewuenschten Builds aus der Gitea-Registry (Paket "wabenhain",
# Tag sha-<kurzer Commit>) uebernehmen:
kubectl -n wabenhain set image deploy/wabenhain-app \
app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
kubectl -n wabenhain rollout status deploy/wabenhain-app
``` ```
Danach dieselbe Zeile in `05-app-deployment.yaml` nachziehen und
committen, damit Repo und Cluster nicht auseinanderlaufen. Laufen die
CronJobs aus `07-backup-cronjob.yaml`, gehoert ihr `wabenhain-tools`-Image
auf denselben Build (sie nutzen denselben Prisma-Client und muessen zum
Datenbankschema passen).
Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
(idempotent, siehe oben). (idempotent, siehe oben).
Das Deployment nutzt dabei die Strategie `Recreate`, nicht das
Kubernetes-Standardverhalten `RollingUpdate`: der alte Pod wird zuerst
beendet, dann der neue gestartet. Das ist mit dem uploads-Volume zwingend —
ein `ReadWriteOnce`-Claim kann immer nur an einen Pod gebunden sein, ein
parallel hochfahrender neuer Pod bliebe mit
`Multi-Attach error for volume` in `ContainerCreating` haengen und der
Rollout liefe in den Timeout. Der Preis sind wenige Sekunden Ausfall pro
Redeploy.
## Bekannte Grenzen dieser Manifeste ## Bekannte Grenzen dieser Manifeste
- **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das - **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das
@@ -86,11 +133,289 @@ Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen. horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`, keine - Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`. Die
`NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen. `NetworkPolicy`s (`default-deny-ingress`, `allow-same-namespace`,
- Die Postgres-/MySQL-StatefulSets sind Single-Instance ohne Replikation/ `allow-from-traefik`) und die Traefik-Middleware `cloudflare-only` liegen
Backup-Automation. Fuer produktiven Betrieb einen Managed-Datenbank-Dienst nicht hier, sondern in den Cluster-Manifesten (Haertung vom 2026-10-12,
oder einen dedizierten DB-Operator (z.B. CloudNativePG) in Erwaegung Ordner `rke2-cluster/`, nicht unter Git).
ziehen — `DATABASE_URL` in `02-secret.yaml` einfach auf den externen - Repo und Cluster weichen voneinander ab (Stand 2026-10-16): Im Cluster
Endpunkt zeigen lassen, `03-postgres.yaml`/`03-mysql.yaml` dann nicht sind `wabenhain-uploads` (5Gi) und `wabenhain-invoices` (50Gi)
anwenden. `ReadWriteMany` auf SMB mit eigenen PersistentVolumes, hier stehen
`ReadWriteOnce`-Claims gegen die Default-StorageClass (5Gi/2Gi).
`STRIPE_PUBLISHABLE_KEY` liegt im Cluster im Secret, hier in der
ConfigMap (beides funktioniert, weil die App beide Quellen per `envFrom`
liest). Vor einem `kubectl apply` auf ein bestehendes Cluster die
Unterschiede pruefen.
- Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
Replikation**. Eine taegliche Sicherung gibt es inzwischen
(`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
liegt aber auf derselben Storage Box wie die Datenbank und ersetzt damit
weder Replikation noch eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
`03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden.
## Bilder/Videos verschwinden nach jedem Redeploy
Symptom: nach einem Redeploy sind alle hochgeladenen Produkt-, Ernten- und
Inhaltsbilder weg und muessen neu hochgeladen werden.
Ursache: das Volume `uploads` im App-Deployment ist noch ein `emptyDir`.
Das ist an die Lebensdauer des Pods gebunden — jeder Neustart loescht es.
Die Datenbank behaelt dabei ihre Bildpfade, die Dateien dahinter sind aber
fort; im Shop erscheinen deshalb leere Flaechen statt einer Fehlermeldung.
Wichtig: Die Gitea-Pipeline baut nur das Image. Sie wendet **keine**
Kubernetes-Manifeste an. Ein `kubectl rollout restart` holt zwar das neue
Image, laesst die Deployment-Definition im Cluster aber unveraendert — die
Korrektur unten muss deshalb einmal von Hand angewendet werden:
```bash
# 1. Aktuellen Zustand pruefen — steht hier "emptyDir", ist genau das die Ursache:
kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'
# 2. Dauerhaften Speicher anlegen und Deployment aktualisieren:
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml
# 3. Gegenprobe — jetzt muss dort ein persistentVolumeClaim stehen:
kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
"}'
kubectl get pvc wabenhain-uploads -n wabenhain
```
Danach ueberstehen Uploads jeden Redeploy. Bereits verlorene Dateien lassen
sich nicht wiederherstellen — sie muessen einmalig neu hochgeladen werden.
Das Deployment nutzt dabei `strategy: Recreate` (siehe Abschnitt oben):
ohne das bliebe der neue Pod beim Redeploy an einem
`Multi-Attach error for volume` haengen, weil ein
`ReadWriteOnce`-Claim nur an einen Pod gebunden sein kann.
**Kontrolle im laufenden Betrieb:** Das Admin-Dashboard gleicht bei jedem
Aufruf ab, ob die in der Datenbank hinterlegten Bild-/Videopfade als Dateien
vorliegen, und zeigt sonst eine Warnung mit Anzahl und Beispielpfaden
(`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst
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.
## Uploads lassen sich nicht speichern
Symptom: Im Admin erscheint „Die Datei konnte nicht gespeichert werden. Bitte
informiere die Technik." Im Pod-Log steht dahinter ein `EACCES`.
Ursache ist fast immer der Besitz des gemounteten Volumes. Der Container läuft
als **`nextjs`, uid 1001, Gruppe `nodejs` gid 1001** (siehe `Dockerfile`). Das
Image setzt zwar `chown nextjs:nodejs /app/uploads`, aber ein darübergelegtes
Volume überschreibt das vollständig.
Zuerst feststellen, was der Container tatsächlich sieht:
```bash
kubectl -n wabenhain exec deploy/wabenhain-app -- sh -c \
'id; ls -ld /app/uploads /app/invoices; \
touch /app/uploads/.probe && echo SCHREIBEN_OK || echo SCHREIBEN_FEHLT'
```
Der Fix hängt am Speicher-Backend — die beiden Wege gelten für verschiedene
Fälle, nicht alternativ:
### Blockspeicher (die meisten CSI-Treiber)
`securityContext.fsGroup: 1001` im Deployment. Kubernetes ordnet das Volume
dann der Gruppe 1001 zu und macht es gruppenschreibbar. Steht bereits in
`05-app-deployment.yaml`.
### SMB/CIFS und NFS
**Hier wird `fsGroup` ignoriert.** Diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachträglich ändern könnte; ohne
Zuordnung erscheint alles als `root:root`, und der Container scheitert trotz
`fsGroup` mit `EACCES`. Die Zuordnung gehört in die `mountOptions` des
PersistentVolume:
```yaml
spec:
mountOptions:
- vers=3.1.1
- seal # SMB-Verschluesselung, die Storage Box liegt im Internet
- uid=1001 # muss zum Containerbenutzer passen
- gid=1001
- dir_mode=0770
- file_mode=0660
- mfsymlinks
- nobrl # keine Byte-Range-Locks über SMB
```
`dir_mode`/`file_mode` allein genügen nicht: sie bestimmen nur, welche Rechte
angezeigt werden — wem die Datei gehört, entscheiden `uid`/`gid`.
`noperm` (der Client prüft Rechte gar nicht erst) wirkt in vielen Aufbauten
ebenfalls, ist aber die schwächere Zusicherung: es umgeht die Prüfung, statt
den Besitz richtigzustellen. `uid`/`gid` also auch dann setzen, wenn `noperm`
bereits gesetzt ist.
**Änderungen an `mountOptions` greifen erst beim nächsten Einhängen.** Nach dem
Anpassen des PV den Pod neu starten:
```bash
kubectl -n wabenhain rollout restart deploy/wabenhain-app
```
Lässt sich das PV nicht mehr ändern (bei bereits gebundenen PVs je nach
Kubernetes-Version), PVC und PV löschen und neu anlegen. Bei
`persistentVolumeReclaimPolicy: Retain` bleiben die Daten auf der Freigabe
erhalten.
## Video-Upload bricht ab (`ECONNRESET`)
Symptom im Pod-Log:
```
[upload-video] Verbindung abgebrochen nach 41943040 von angekündigten 78643200 Bytes.
```
Das ist kein Fehler der Verarbeitung — die Datei ist gar nicht erst
vollständig angekommen. Die Zahlen im Log entscheiden, wo zu suchen ist:
- **Immer bei ungefähr derselben BYTEZAHL** → eine Größengrenze auf dem Weg.
Traefik begrenzt von sich aus nichts; prüfen, ob eine `buffering`-Middleware
mit `maxRequestBodyBytes` am Ingress hängt.
- **Immer nach ungefähr derselben ZEIT** → eine Zeitgrenze. Davon liegen
mehrere hintereinander:
| Grenze | Vorgabe | Wo einstellbar |
|--------|---------|----------------|
| Node `requestTimeout` | **300 s** | nicht konfigurierbar — harte Obergrenze für die Dauer eines Uploads |
| Traefik `respondingTimeouts.readTimeout` | je nach Version | `entryPoints.websecure.transport.respondingTimeouts` in der Traefik-Konfiguration |
| Browser bzw. Netz dazwischen | — | — |
Die 300 Sekunden von Node sind die eigentliche Obergrenze: Was in dieser Zeit
nicht durch die Leitung passt, lässt sich nicht hochladen — unabhängig davon,
dass `MAX_UPLOAD_VIDEO_BYTES` 100 MB erlaubt. Beispiel: 80 MB über eine
Upload-Leitung mit 5 Mbit/s brauchen rund 130 Sekunden und gehen durch; bei
2 Mbit/s sind es über 5 Minuten, und der Upload stirbt zuverlässig.
Praktisch heißt das: **das Hero-Video vor dem Hochladen verkleinern.** Der
Server rechnet es ohnehin auf höchstens 1920 px Breite und CRF 26 um (siehe
`src/lib/video-transcode.ts`) — was darüber hinausgeht, wird nur übertragen,
um anschließend weggerechnet zu werden. Ein vorab auf diese Größe gebrachtes
Video lädt um ein Vielfaches schneller hoch:
```bash
ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \
-c:v libx264 -preset veryfast -crf 26 -movflags +faststart hero.mp4
```
## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr (Zeitzone des
Clusters, meist UTC) eine gzip-komprimierte `pg_dump`-Sicherung und ein
`rechnungen-<Zeitstempel>.tar.gz` der Rechnungsablage auf einem eigenen
Volume ab und haelt beides 30 Tage vor. Das Volume ist ein SMB-PV auf der
Storage Box (`//u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain`,
Unterordner `db-backup`). Im Cluster ist der Job seit 2026-10-10
angelegt; die letzten drei Laeufe waren erfolgreich, zuletzt am 2026-10-16
um 03:15 UTC. Ohne diese Sicherung liegen Bestellungen,
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
jedes Netz — und Rechnungen unterliegen in Deutschland einer
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
**Sicherungen ansehen:**
```bash
kubectl get cronjob wabenhain-db-backup -n wabenhain
kubectl logs -n wabenhain -l job-name --tail=50 # Ausgabe des letzten Laufs
```
**Sofort eine Sicherung ausloesen** (etwa vor einem riskanten Eingriff):
```bash
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
```
**Aus dem Cluster herausziehen.** Die Sicherung schuetzt gegen
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall der Storage Box
oder des Standorts. Deshalb regelmaessig (oder per eigenem Cron auf einem
Rechner ausserhalb) an einen zweiten Ort kopieren. Die Dateien liegen auf
der Storage Box unter `Server/Disks/Customer/Wabenhain/db-backup/` und
lassen sich dort direkt abholen (SMB, SFTP oder rsync der Storage Box). Ein
`kubectl exec` in den Sicherungs-Pod geht nicht: der Pod ist nach dem Lauf
beendet (`Completed`).
**Wiederherstellen.** Vorher die App anhalten, damit waehrend des
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
vorhandenen Tabellen zuerst:
```bash
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=0
gunzip -c wabenhain-2026-01-31.sql.gz | kubectl exec -i -n wabenhain wabenhain-postgres-0 -- psql -U wabenhain -d wabenhain
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=1
```
Der App-Container fuehrt beim naechsten Start ohnehin
`prisma migrate deploy` aus (siehe Dockerfile-CMD) — ein Dump aus einem
aelteren Schemastand wird dadurch automatisch nachgezogen.
**Eine ungepruefte Sicherung ist keine Sicherung.** Einmal nach der
Einrichtung und danach gelegentlich einen Wiederherstellungsversuch gegen
eine Wegwerf-Datenbank fahren, statt sich erst im Ernstfall darauf zu
verlassen.
+60 -6
View File
@@ -9,12 +9,58 @@ const nextConfig: NextConfig = {
// wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt. // wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt.
output: "standalone", output: "standalone",
// pdfkit laedt seine Schriftart-Metadaten (.afm-Dateien) zur Laufzeit ueber // Pakete, die zur Laufzeit eigene Dateien ueber einen __dirname-relativen
// einen eigenen __dirname-relativen Pfad. Wird das Paket vom Bundler // Pfad nachladen. Werden sie vom Bundler (Turbopack/Webpack) mitgebuendelt,
// (Turbopack/Webpack) mitgebuendelt, geht dieser Pfad verloren (ENOENT). // zeigt ihr __dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt
// "serverExternalPackages" laesst Next.js es stattdessen ganz normal per // auf das echte Verzeichnis in node_modules — der Zugriff schlaegt dann mit
// require() aus node_modules laden. // ENOENT fehl. "serverExternalPackages" laesst Next.js sie stattdessen ganz
serverExternalPackages: ["pdfkit"], // normal per require() aus node_modules laden.
//
// - pdfkit: laedt seine Schriftart-Metadaten (.afm-Dateien).
// - ffmpeg-static: exportiert den Pfad zur mitgelieferten ffmpeg-Binary
// (siehe lib/video-transcode.ts). Gebuendelt lieferte es
// "\ROOT\node_modules\...\ffmpeg.exe" -> jeder Video-Upload ohne
// System-ffmpeg im PATH (also jede lokale Entwicklungsumgebung unter
// Windows/macOS) scheiterte mit "Das Video konnte nicht verarbeitet
// werden."
serverExternalPackages: ["pdfkit", "ffmpeg-static"],
images: {
// AVIF vor WebP. Next.js liefert das erste Format, das der Browser laut
// Accept-Header unterstuetzt; Browser ohne AVIF bekommen unveraendert
// WebP, ganz alte das Original.
//
// Am Hero-Bild der Startseite nachgemessen (1920x1280), mit den
// Einstellungen aus next/dist/server/image-optimizer.js — WebP q75 gegen
// AVIF q47 (Next.js skaliert die Qualitaet mit 50/80) bei effort 3:
// 640 px -43 %, 828 px -42 %, 1080 px -40 %, 1920 px -37 %.
//
// Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in
// derselben Messung gleichauf oder schneller als WebP (1920 px: 60 ms
// gegen 122 ms). Die frueher hier vermutete teure Erstkodierung gibt es
// in dieser Einstellung nicht.
formats: ["image/avif", "image/webp"],
// Ein Jahr statt der Next.js-Vorgabe von vier Wochen. Hochgeladene Bilder
// sind unveraenderlich: lib/uploads.ts legt jede Datei unter einem eigenen
// Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL.
// Ein kurzes TTL wuerde hier nur brauchbare Ergebnisse wegwerfen.
minimumCacheTTL: 60 * 60 * 24 * 365,
// Entspricht der Vorgabe von Next.js 16, die andere Werte bereits ablehnt
// (gegen die laufende Seite geprueft: ?q=42 antwortet mit 400) — aendert
// das Verhalten also nicht. Ausgeschrieben, damit eine spaetere
// Erweiterung der Liste eine bewusste Entscheidung ist: jeder zusaetzlich
// erlaubte Wert oeffnet einen eigenen Cache-Eintrag und eine eigene
// Kodierung je Bild, von aussen ueber die URL ausloesbar.
qualities: [75],
// Ebenfalls die Vorgabe, ausdruecklich festgehalten: ein SVG ist ein
// Dokument, das Skripte enthalten kann — same-origin ausgeliefert waere
// das ein XSS-Vektor. Diese Zeile soll verhindern, dass der Schalter
// spaeter beilaeufig umgelegt wird.
dangerouslyAllowSVG: false,
},
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack) // Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis // kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
@@ -26,6 +72,14 @@ const nextConfig: NextConfig = {
turbopackFileSystemCacheForBuild: false, turbopackFileSystemCacheForBuild: false,
}, },
// Das Favicon ist im Admin austauschbar und wird von app/icon.tsx erzeugt;
// eine statische app/favicon.ico gibt es deshalb nicht mehr. Browser und
// Crawler, die ohne <link rel="icon"> direkt /favicon.ico abfragen,
// bekommen dasselbe Bild.
async rewrites() {
return [{ source: "/favicon.ico", destination: "/icon" }];
},
async headers() { async headers() {
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht // Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer // eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
+23 -10
View File
@@ -7,6 +7,7 @@
"build": "node scripts/generate-prisma-clients.mjs && next build", "build": "node scripts/generate-prisma-clients.mjs && next build",
"start": "node scripts/generate-prisma-clients.mjs && next start", "start": "node scripts/generate-prisma-clients.mjs && next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "node scripts/generate-prisma-clients.mjs && next typegen && tsc --noEmit",
"postinstall": "node scripts/generate-prisma-clients.mjs", "postinstall": "node scripts/generate-prisma-clients.mjs",
"db:push": "node scripts/prisma-provider.mjs && prisma db push", "db:push": "node scripts/prisma-provider.mjs && prisma db push",
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev", "db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
@@ -16,27 +17,38 @@
"db:studio": "node scripts/prisma-provider.mjs && prisma studio", "db:studio": "node scripts/prisma-provider.mjs && prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"preflight": "tsx scripts/preflight.ts",
"places:find": "tsx scripts/find-place-id.ts",
"orders:release-stale": "tsx scripts/release-stale-orders.ts",
"stock:apicore-sync": "tsx scripts/apicore-stock-sync.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-mariadb": "^7.9.1", "@prisma/adapter-mariadb": "^7.10.0",
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.10.0",
"@react-three/drei": "10.0.6",
"@react-three/fiber": "9.1.2",
"@stripe/react-stripe-js": "^6.12.0",
"@stripe/stripe-js": "^9.17.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"fflate": "0.8.2",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"fontkit": "2.0.4",
"jose": "^6.2.8", "jose": "^6.2.8",
"mariadb": "^3.5.3", "mariadb": "^3.5.4",
"motion": "^13.0.0", "motion": "^13.0.0",
"next": "16.3.0", "next": "16.3.8",
"nodemailer": "^9.0.5", "nodemailer": "^10.0.10",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"pdfkit": "^0.19.1", "pdfkit": "^0.19.1",
"prisma": "^7.9.1", "prisma": "^7.10.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"sharp": "^0.35.3", "sharp": "^0.35.5",
"stripe": "^22.4.0", "stripe": "^22.4.0",
"three": "0.170.0",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
@@ -53,10 +65,11 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/three": "0.170.0",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.8",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4", "tsx": "^4",
+806 -287
View File
File diff suppressed because it is too large. Load diff
+21 -2
View File
@@ -15,5 +15,24 @@ overrides:
# lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine # lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine
# eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in # eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in
# den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm # den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm
# haelt dafuer sonst zwei getrennte Kopien vor. # haelt dafuer sonst zwei getrennte Kopien vor. Seit 2026-10-14 >= 3.5.4
mariadb: '>=3.4.6' # (neues High-Advisory fuer < 3.5.4, pnpm audit).
mariadb: '>=3.5.4'
# sharp < 0.35.5 und source-map-js < 1.2.2 (High-Advisories), beide
# transitiv ueber next; reine Patch-Spruenge.
sharp: '>=0.35.5'
source-map-js: '>=1.2.2'
# GHSA-rgwj-5xj2-c3m3 u.a.: mysql2 < 3.23.1. Kommt ausschliesslich ueber die
# prisma-CLI herein (Migrationen/Generate), nicht ueber den Laufzeitpfad der
# Anwendung — die nutzt bei DATABASE_PROVIDER=mysql den mariadb-Treiber
# (siehe lib/db-adapter.ts). Trotzdem angehoben, weil prisma bei jedem
# Containerstart "migrate deploy" ausfuehrt (siehe Dockerfile-CMD) und der
# Sprung innerhalb derselben Hauptversion bleibt.
mysql2: '>=3.23.1'
# GHSA: fast-uri < 3.1.6, transitiv ueber ajv unter der prisma-CLI.
# Reiner Patch-Sprung innerhalb von 3.1.x.
fast-uri: '>=3.1.6'
# deepmerge-ts < 8.0.0, transitiv ueber @prisma/config. Hauptversionssprung,
# deshalb nach dem Anheben mit "prisma validate" + "prisma generate"
# geprueft (beide laufen durch @prisma/config).
deepmerge-ts: '>=8.0.0'
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "addresses" ADD COLUMN "packstationNumber" TEXT,
ADD COLUMN "postNumber" TEXT;
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "packstationNumber" TEXT,
ADD COLUMN "postNumber" TEXT;
@@ -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";
@@ -0,0 +1,9 @@
-- Umsatzsteuersatz in ganzen Prozent, festgeschrieben zum Zeitpunkt der
-- Rechnungsstellung (0 = Kleinunternehmerregelung nach § 19 UStG).
--
-- Nullable, weil rueckwirkend nicht feststellbar ist, welcher Satz fuer
-- bereits bestehende Rechnungen richtig gewesen waere. Diese zeigen wie
-- bisher keine Steuerzeilen; erst ab hier ausgestellte Rechnungen weisen
-- die Steuer aus.
-- AlterTable
ALTER TABLE "invoices" ADD COLUMN "vatRatePercent" INTEGER;
@@ -0,0 +1,23 @@
-- Der Umsatzsteuersatz haengt in Deutschland an der Art der Ware: Honig ist
-- ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
-- Bienenwachskerze nicht. Eine Bestellung kann also Positionen mit
-- VERSCHIEDENEN Saetzen enthalten.
--
-- Der Satz wandert deshalb von der Rechnung an die einzelne Position und
-- gehoert zum Snapshot der Bestelldaten: wird ein Produkt spaeter
-- umkategorisiert, darf das bestehende Rechnungen nicht rueckwirkend
-- veraendern.
--
-- Die Spalte an "invoices" entstand in der vorigen Migration und wurde nie
-- in Betrieb beschrieben — es gehen keine Daten verloren.
-- AlterTable
ALTER TABLE "order_items" ADD COLUMN "vatRatePercent" INTEGER;
-- AlterTable
ALTER TABLE "invoices" DROP COLUMN "vatRatePercent";
-- Satz je PRODUKT, als Ausnahme zur Kategorie-Vorgabe. Leer heisst: es
-- gilt der Satz der Kategorie (siehe src/lib/vat.ts). Bestehende Produkte
-- bekommen NULL und folgen damit unveraendert ihrer Kategorie.
-- AlterTable
ALTER TABLE "products" ADD COLUMN "vatRatePercent" INTEGER;
@@ -0,0 +1,15 @@
-- Laufende Rechnungsnummer je Kalenderjahr.
--
-- § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer. Bisher wurde eine
-- Zufallsnummer vergeben: eindeutig, aber nicht fortlaufend.
--
-- Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
-- sperrt die Datenbank diese eine Zeile, wodurch zwei gleichzeitig bezahlte
-- Bestellungen zwangslaeufig verschiedene Nummern erhalten.
-- CreateTable
CREATE TABLE "invoice_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "invoice_counters_pkey" PRIMARY KEY ("year")
);
@@ -0,0 +1,16 @@
-- Abbruch-Token fuer die cancel_url der Stripe-Sitzung.
--
-- Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
-- Lagerbestand. Brach die Kundschaft den Checkout ab, blieb dieser Bestand
-- bisher blockiert, bis Stripe die Sitzung fuer abgelaufen erklaerte — nach
-- Voreinstellung 24 Stunden spaeter. Der Token steht ausschliesslich in der
-- cancel_url und gibt den Bestand beim Abbruch sofort frei.
--
-- Nullable, weil alle bereits bestehenden Bestellungen keinen haben; sie
-- laufen weiterhin ueber den Ablauf der Stripe-Sitzung.
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "cancelToken" TEXT;
-- CreateIndex
-- Eindeutig, damit ein Token immer genau eine Bestellung freigibt.
CREATE UNIQUE INDEX "orders_cancelToken_key" ON "orders"("cancelToken");
@@ -0,0 +1,22 @@
-- CreateIndex
-- PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders als
-- MySQL/InnoDB, das ihn automatisch anlegt). "order_items" ist die am
-- schnellsten wachsende Tabelle des Shops — je Bestellung eine Zeile pro
-- Position, und geloescht wird dort nichts. Ohne diesen Index laeuft jede
-- Abfrage nach productId als vollstaendiger Tabellenscan.
--
-- Zwei Stellen vertragen das nicht:
--
-- 1. hasCustomerPurchased (src/lib/reviews.ts) — der Nachweis
-- "verifizierter Kauf". Er laeuft bei JEDEM Aufruf einer Produktseite
-- durch eine angemeldete Kundschaft, also auf dem meistbesuchten
-- Seitentyp ueberhaupt.
-- 2. Das Loeschen eines Produkts im Admin-Bereich: die product-Relation ist
-- "onDelete: Restrict", die Datenbank muss also pruefen, ob noch
-- Positionen daran haengen.
CREATE INDEX "order_items_productId_idx" ON "order_items"("productId");
-- CreateIndex
-- Gleicher Grund: die batch-Relation ist ebenfalls "onDelete: Restrict". Das
-- Loeschen einer Charge im Admin-Bereich prueft gegen dieselbe Tabelle.
CREATE INDEX "order_items_batchId_idx" ON "order_items"("batchId");
@@ -0,0 +1,31 @@
-- Archiv fuer Bestellungen, Retouren und Produkte (siehe lib/archive.ts).
--
-- archivedAt gesetzt = Eintrag erscheint im Admin-Tab "Archiv" statt in der
-- aktiven Liste. Die Automatik archiviert abgeschlossene Eintraege nach einem
-- Monat ohne Bearbeitung (Produkte: Bestand 0) und holt sie zurueck, sobald
-- sie wieder offen sind. archivedManually markiert Eintraege, die jemand
-- von Hand archiviert hat — die fasst die Automatik nicht an.
--
-- Nullable bzw. mit Default: bestehende Zeilen gelten als aktiv; der erste
-- Aufruf der jeweiligen Admin-Liste archiviert faellige Altbestaende.
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "return_requests" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "products" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "orders_archivedAt_idx" ON "orders"("archivedAt");
-- CreateIndex
CREATE INDEX "return_requests_archivedAt_idx" ON "return_requests"("archivedAt");
-- CreateIndex
CREATE INDEX "products_archivedAt_idx" ON "products"("archivedAt");
@@ -0,0 +1,27 @@
-- Elektronische Widerrufsfunktion (§ 356a BGB, Pflicht seit 19.06.2026)
-- und Nachweis der Newsletter-Einwilligung (Art. 7 Abs. 1 DSGVO).
-- CreateTable
CREATE TABLE "withdrawal_declarations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"email" TEXT NOT NULL,
"message" TEXT,
"locale" TEXT NOT NULL DEFAULT 'de',
"orderId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmationSentAt" TIMESTAMP(3),
CONSTRAINT "withdrawal_declarations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_receivedAt_idx" ON "withdrawal_declarations"("receivedAt");
-- AddForeignKey
ALTER TABLE "withdrawal_declarations" ADD CONSTRAINT "withdrawal_declarations_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
-- Nullable: fuer bestehende Abonnements ist der Zeitpunkt nicht bekannt.
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmedAt" TIMESTAMP(3);
@@ -0,0 +1,9 @@
-- Bearbeitungsstand der Widerrufserklaerungen: null = offen. Die Zahl offener
-- Widerrufe steht im Admin-Menue, damit die 14-Tage-Erstattungsfrist
-- (§ 357 Abs. 1 BGB) nicht uebersehen wird.
-- AlterTable
ALTER TABLE "withdrawal_declarations" ADD COLUMN "processedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_processedAt_idx" ON "withdrawal_declarations"("processedAt");
@@ -0,0 +1,13 @@
-- Herkunftsschluessel fuer den ApiCore-Chargen-Sync: ID der Abfuellung
-- (BottlingBatch). Bisher wurde eine Charge nur ueber die Losnummer (code)
-- wiedererkannt — wird die Losnummer in ApiCore korrigiert, entstand beim
-- naechsten Lauf eine zweite Charge. Nullable: manuell angelegte Chargen
-- haben keine Herkunft in ApiCore, bestehende Zeilen bleiben gueltig. Schon
-- synchronisierte Chargen erhalten die ID beim naechsten Lauf ueber ihre
-- Losnummer.
-- AlterTable
ALTER TABLE "batches" ADD COLUMN "apicoreBottlingId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "batches_apicoreBottlingId_key" ON "batches"("apicoreBottlingId");
@@ -0,0 +1,5 @@
-- Abgelehnte Bewertungen werden nicht mehr geloescht, sondern markiert und
-- im Archiv unter "Abgelehnt" gefuehrt. null = nicht abgelehnt.
-- AlterTable
ALTER TABLE "reviews" ADD COLUMN "rejectedAt" TIMESTAMP(3);
@@ -0,0 +1,56 @@
-- Mehrere Ernten derselben Sorte im selben Jahr zulassen.
--
-- Bisher erzwang "harvests_year_type_key" genau eine Ernte je (Jahr, Sorte).
-- Eine Imkerei schleudert dieselbe Sorte aber oft mehrmals im Jahr; der
-- ApiCore-Sync scheiterte daran mit P2002. Eindeutig ist ab jetzt nur noch
-- der Slug (oeffentliche Adresse /ernten/<slug>).
--
-- Der Sync erkannte importierte Ernten bisher am Slug "apicore-<ID>". Diese
-- ID wandert in eine eigene Spalte, damit der Slug lesbar werden darf.
-- Keine Zeile wird geloescht; die Aenderung ist rein additiv bis auf den
-- entfernten Unique-Index.
-- AlterTable
ALTER TABLE "harvests" ADD COLUMN "apicoreHarvestId" TEXT;
-- Backfill: bisherige Sync-Ernten behalten ihre ApiCore-Zuordnung.
UPDATE "harvests"
SET "apicoreHarvestId" = substring("slug" from 9)
WHERE "slug" LIKE 'apicore-%';
-- Von Hand angelegte Ernten bekamen ihren Slug bisher ohne Umlaut-Behandlung
-- ("Frühtracht" 2026 -> "fr-htracht-2026"). Bis jetzt war das unsichtbar;
-- ab jetzt ist der Slug die oeffentliche Adresse. Genau diese Form wird auf
-- die Schreibweise von lib/slugify.ts gebracht ("fruehtracht-2026") — nur,
-- wenn sie sich unterscheidet und die neue Adresse noch frei ist. Sonst
-- bleibt der alte Slug: er funktioniert, ist nur weniger schoen.
WITH renamed AS (
SELECT
"id",
trim(both '-' from regexp_replace(
replace(replace(replace(replace(lower("type"), 'ä', 'ae'), 'ö', 'oe'), 'ü', 'ue'), 'ß', 'ss'),
'[^a-z0-9]+', '-', 'g'
)) || '-' || "year" AS "newSlug"
FROM "harvests"
WHERE "apicoreHarvestId" IS NULL
AND "slug" = regexp_replace(lower("type"), '[^a-z0-9]+', '-', 'g') || '-' || "year"
)
UPDATE "harvests" h
SET "slug" = r."newSlug"
FROM renamed r
WHERE h."id" = r."id"
AND r."newSlug" <> h."slug"
AND r."newSlug" <> ('-' || h."year")
AND NOT EXISTS (SELECT 1 FROM "harvests" o WHERE o."slug" = r."newSlug")
-- z.B. "Frühtracht" und "Fruehtracht" im selben Jahr: beide ergaeben
-- denselben Slug, der Unique-Index liesse die Migration scheitern.
AND (SELECT count(*) FROM renamed r2 WHERE r2."newSlug" = r."newSlug") = 1;
-- CreateIndex
CREATE UNIQUE INDEX "harvests_apicoreHarvestId_key" ON "harvests"("apicoreHarvestId");
-- DropIndex
DROP INDEX "harvests_year_type_key";
-- CreateIndex
CREATE INDEX "harvests_year_type_idx" ON "harvests"("year", "type");
@@ -0,0 +1,63 @@
-- Bestandsabgleich mit ApiCore (ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
-- ApiCore fuehrt den Restbestand je Charge; Wabenhain meldet bezahlte
-- Verkaeufe und Rueckbuchungen ueber eine Warteschlange und gleicht seinen
-- Shop-Bestand daran ab. Rein additiv.
-- AlterEnum
ALTER TYPE "ProductCategory" ADD VALUE 'SONSTIGES';
-- CreateEnum
CREATE TYPE "ApiCoreStockEventKind" AS ENUM ('SALE', 'RETURN');
-- AlterTable
ALTER TABLE "products" ADD COLUMN "apicoreProductId" TEXT;
-- AlterTable
ALTER TABLE "order_items" ADD COLUMN "apicoreAllocations" JSONB;
-- CreateTable
CREATE TABLE "apicore_stock_events" (
"id" TEXT NOT NULL,
"kind" "ApiCoreStockEventKind" NOT NULL,
"orderItemId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"apicoreBottlingId" TEXT,
"apicoreProductId" TEXT,
"quantity" INTEGER NOT NULL,
"reversesEventId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sentAt" TIMESTAMP(3),
"rejectedAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "apicore_stock_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "products_apicoreProductId_key" ON "products"("apicoreProductId");
-- CreateIndex
CREATE INDEX "apicore_stock_events_sentAt_rejectedAt_nextAttemptAt_idx" ON "apicore_stock_events"("sentAt", "rejectedAt", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "apicore_stock_events_orderItemId_idx" ON "apicore_stock_events"("orderItemId");
-- CreateIndex
CREATE INDEX "apicore_stock_events_productId_idx" ON "apicore_stock_events"("productId");
-- AddForeignKey
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_reversesEventId_fkey" FOREIGN KEY ("reversesEventId") REFERENCES "apicore_stock_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Genau ein ApiCore-Ziel je Buchung.
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_exactly_one_target"
CHECK (("apicoreBottlingId" IS NULL) <> ("apicoreProductId" IS NULL));
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_quantity_positive"
CHECK ("quantity" > 0);
@@ -0,0 +1,35 @@
-- Stornorechnungen: Gegenbeleg mit eigenem Nummernkreis (ST-JJJJ-NNNN) statt
-- Loeschen oder Aendern einer Rechnung. Rein additiv.
-- CreateTable
CREATE TABLE "cancellation_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "cancellation_counters_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "cancellation_invoices" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCents" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"pdfPath" TEXT,
CONSTRAINT "cancellation_invoices_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_invoiceId_key" ON "cancellation_invoices"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_number_key" ON "cancellation_invoices"("number");
-- CreateIndex
CREATE INDEX "cancellation_invoices_issuedAt_idx" ON "cancellation_invoices"("issuedAt");
-- AddForeignKey
ALTER TABLE "cancellation_invoices" ADD CONSTRAINT "cancellation_invoices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,342 @@
-- Bienenpatenschaft: Angebote, Patenschaften mit Urkunden-Revisionen,
-- Uebertragungen, Abos (Stripe Billing) mit Zahlungen und Lieferungen,
-- Erntefenster und Treuestufen. Rein additiv; die beiden neuen Enum-Werte
-- (PATENSCHAFT, DIGITAL) werden in dieser Migration nicht benutzt, ALTER TYPE
-- ADD VALUE ist deshalb auch innerhalb der Migrationstransaktion zulaessig.
-- CreateEnum
CREATE TYPE "SponsorshipOfferKind" AS ENUM ('EINMALIG', 'ABO');
-- CreateEnum
CREATE TYPE "SponsorshipCycle" AS ENUM ('VIERTELJAEHRLICH', 'ERNTE');
-- CreateEnum
CREATE TYPE "MedalTier" AS ENUM ('BRONZE', 'SILBER', 'GOLD', 'ABO');
-- CreateEnum
CREATE TYPE "SponsorshipStatus" AS ENUM ('AUSSTEHEND', 'FEHLER', 'BEREIT', 'GEDRUCKT');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('AUSSTEHEND', 'AKTIV', 'IM_VERZUG', 'GEKUENDIGT', 'BEENDET');
-- CreateEnum
CREATE TYPE "DeliveryStatus" AS ENUM ('ANGELEGT', 'ZURUECKGESTELLT');
-- CreateEnum
CREATE TYPE "LoyaltyKind" AS ENUM ('STAMMKUNDE', 'ABO');
-- AlterEnum
ALTER TYPE "ProductCategory" ADD VALUE 'PATENSCHAFT';
-- AlterEnum
ALTER TYPE "FulfillmentMethod" ADD VALUE 'DIGITAL';
-- AlterTable
ALTER TABLE "customers" ADD COLUMN "sepaBlockedAt" TIMESTAMP(3),
ADD COLUMN "stripeCustomerId" TEXT;
-- AlterTable
ALTER TABLE "order_items" ADD COLUMN "personalization" JSONB,
ADD COLUMN "sponsorshipOfferId" TEXT,
ADD COLUMN "sponsorshipRole" TEXT;
-- CreateTable
CREATE TABLE "sponsorship_offers" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "SponsorshipOfferKind" NOT NULL,
"name" TEXT NOT NULL,
"nameEn" TEXT,
"description" TEXT NOT NULL,
"descriptionEn" TEXT,
"features" JSONB NOT NULL DEFAULT '[]',
"featuresEn" JSONB NOT NULL DEFAULT '[]',
"priceCents" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isPhysical" BOOLEAN NOT NULL DEFAULT false,
"productId" TEXT,
"honeyProductId" TEXT,
"honeyJars" INTEGER NOT NULL DEFAULT 0,
"honeyShareCentsPerJar" INTEGER NOT NULL DEFAULT 0,
"honeyShareCentsPerMonth" INTEGER NOT NULL DEFAULT 0,
"cycle" "SponsorshipCycle",
"maxActiveSubscriptions" INTEGER,
"stripeProductId" TEXT,
"stripePriceId" TEXT,
"stripePriceAmountCents" INTEGER,
"medalTier" "MedalTier" NOT NULL DEFAULT 'BRONZE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorship_offers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorships" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"orderItemId" TEXT,
"subscriptionId" TEXT,
"locale" TEXT NOT NULL DEFAULT 'de',
"startDate" DATE NOT NULL,
"endDate" DATE,
"status" "SponsorshipStatus" NOT NULL DEFAULT 'AUSSTEHEND',
"currentRevision" INTEGER NOT NULL DEFAULT 0,
"pdfAttempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3),
"lastError" TEXT,
"mailSentAt" TIMESTAMP(3),
"printedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_revisions" (
"id" TEXT NOT NULL,
"sponsorshipId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"personalization" JSONB NOT NULL,
"term" JSONB NOT NULL,
"text" JSONB NOT NULL,
"templateVersion" TEXT NOT NULL,
"pdfPath" TEXT NOT NULL,
"pdfSha256" TEXT NOT NULL,
"createdById" TEXT,
"createdByLabel" TEXT NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sponsorship_revisions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "sponsorship_counters_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "sponsorship_transfers" (
"id" TEXT NOT NULL,
"sponsorshipId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdById" TEXT NOT NULL,
"claimedById" TEXT,
"claimedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sponsorship_transfers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sponsorship_subscriptions" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'AUSSTEHEND',
"personalization" JSONB NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'de',
"stripeCheckoutSessionId" TEXT,
"stripeSubscriptionId" TEXT,
"startedAt" TIMESTAMP(3),
"currentPeriodEnd" TIMESTAMP(3),
"cancelAt" TIMESTAMP(3),
"endedAt" TIMESTAMP(3),
"delinquentSince" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sponsorship_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_payments" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"stripeInvoiceId" TEXT NOT NULL,
"stripePaymentIntentId" TEXT,
"paymentMethodType" TEXT,
"amountCents" INTEGER NOT NULL,
"paidAt" TIMESTAMP(3) NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"countsFrom" TIMESTAMP(3) NOT NULL,
"reversedAt" TIMESTAMP(3),
"deliveryId" TEXT,
"orderId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscription_deliveries" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"dueAt" TIMESTAMP(3) NOT NULL,
"jars" INTEGER NOT NULL,
"status" "DeliveryStatus" NOT NULL,
"orderId" TEXT,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "subscription_deliveries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "harvest_windows" (
"id" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"startsOn" DATE NOT NULL,
"endsOn" DATE NOT NULL,
"plannedJars" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "harvest_windows_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "loyalty_levels" (
"id" TEXT NOT NULL,
"kind" "LoyaltyKind" NOT NULL,
"threshold" INTEGER,
"name" TEXT NOT NULL,
"nameEn" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loyalty_levels_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_offers_slug_key" ON "sponsorship_offers"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_offers_productId_key" ON "sponsorship_offers"("productId");
-- CreateIndex
CREATE INDEX "sponsorship_offers_kind_isActive_sortOrder_idx" ON "sponsorship_offers"("kind", "isActive", "sortOrder");
-- CreateIndex
CREATE INDEX "sponsorship_offers_honeyProductId_idx" ON "sponsorship_offers"("honeyProductId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_number_key" ON "sponsorships"("number");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_orderItemId_key" ON "sponsorships"("orderItemId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorships_subscriptionId_key" ON "sponsorships"("subscriptionId");
-- CreateIndex
CREATE INDEX "sponsorships_ownerId_idx" ON "sponsorships"("ownerId");
-- CreateIndex
CREATE INDEX "sponsorships_status_nextAttemptAt_idx" ON "sponsorships"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "sponsorships_offerId_idx" ON "sponsorships"("offerId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_revisions_sponsorshipId_revision_key" ON "sponsorship_revisions"("sponsorshipId", "revision");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_transfers_tokenHash_key" ON "sponsorship_transfers"("tokenHash");
-- CreateIndex
CREATE INDEX "sponsorship_transfers_sponsorshipId_idx" ON "sponsorship_transfers"("sponsorshipId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeCheckoutSessionId_key" ON "sponsorship_subscriptions"("stripeCheckoutSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeSubscriptionId_key" ON "sponsorship_subscriptions"("stripeSubscriptionId");
-- CreateIndex
CREATE INDEX "sponsorship_subscriptions_customerId_idx" ON "sponsorship_subscriptions"("customerId");
-- CreateIndex
CREATE INDEX "sponsorship_subscriptions_offerId_status_idx" ON "sponsorship_subscriptions"("offerId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_payments_stripeInvoiceId_key" ON "subscription_payments"("stripeInvoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_payments_orderId_key" ON "subscription_payments"("orderId");
-- CreateIndex
CREATE INDEX "subscription_payments_subscriptionId_paidAt_idx" ON "subscription_payments"("subscriptionId", "paidAt");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_deliveries_orderId_key" ON "subscription_deliveries"("orderId");
-- CreateIndex
CREATE INDEX "subscription_deliveries_subscriptionId_dueAt_idx" ON "subscription_deliveries"("subscriptionId", "dueAt");
-- CreateIndex
CREATE INDEX "harvest_windows_offerId_startsOn_idx" ON "harvest_windows"("offerId", "startsOn");
-- CreateIndex
CREATE UNIQUE INDEX "loyalty_levels_kind_threshold_key" ON "loyalty_levels"("kind", "threshold");
-- CreateIndex
CREATE UNIQUE INDEX "customers_stripeCustomerId_key" ON "customers"("stripeCustomerId");
-- AddForeignKey
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_honeyProductId_fkey" FOREIGN KEY ("honeyProductId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_revisions" ADD CONSTRAINT "sponsorship_revisions_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_transfers" ADD CONSTRAINT "sponsorship_transfers_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "subscription_deliveries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "harvest_windows" ADD CONSTRAINT "harvest_windows_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- Widerrufene Patenschaft: Einmalkauf storniert oder voll erstattet
-- (Stornorechnung ausgestellt). Rein additiv; der neue Wert wird in dieser
-- Migration nicht benutzt, ALTER TYPE ADD VALUE ist deshalb auch innerhalb
-- der Migrationstransaktion zulaessig.
-- AlterEnum
ALTER TYPE "SponsorshipStatus" ADD VALUE 'WIDERRUFEN';
+665 -3
View File
@@ -76,6 +76,17 @@ model Customer {
wishlistItems WishlistItem[] wishlistItems WishlistItem[]
auditLogs AuditLog[] auditLogs AuditLog[]
/// Stripe-Kunde fuer Abos (Stripe Billing). Erst beim ersten Abo-Abschluss
/// angelegt; Einmalkaeufe laufen weiter ohne Stripe-Kunde.
stripeCustomerId String? @unique
/// Gesetzt nach einer SEPA-Rueckbuchung: neue Abos dieses Kontos nur noch
/// mit Karte oder Wallet (siehe lib/sponsorship-subscription). Der Admin
/// kann die Sperre aufheben.
sepaBlockedAt DateTime?
sponsorships Sponsorship[]
subscriptions SponsorshipSubscription[]
@@map("customers") @@map("customers")
} }
@@ -118,6 +129,18 @@ model Address {
zip String zip String
city String city String
country String @default("DE") country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
isDefault Boolean @default(false) isDefault Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -136,9 +159,14 @@ model Address {
model Harvest { model Harvest {
id String @id @default(cuid()) id String @id @default(cuid())
year Int year Int
/// z.B. "Fruehtracht", "Sommertracht" /// z.B. "Fruehtracht", "Sommertracht". Bewusst NICHT eindeutig je Jahr:
/// dieselbe Sorte wird oft mehrmals im Jahr geerntet.
type String type String
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
slug String @unique slug String @unique
/// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs
/// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten.
apicoreHarvestId String? @unique
harvestDate DateTime harvestDate DateTime
location String @default("Altenmuenster") location String @default("Altenmuenster")
description String description String
@@ -158,7 +186,8 @@ model Harvest {
batches Batch[] batches Batch[]
products Product[] products Product[]
@@unique([year, type]) /// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
@@index([year, type])
@@map("harvests") @@map("harvests")
} }
@@ -166,6 +195,10 @@ model Batch {
id String @id @default(cuid()) id String @id @default(cuid())
/// z.B. FH-26-01 /// z.B. FH-26-01
code String @unique code String @unique
/// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
/// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
/// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
apicoreBottlingId String? @unique
harvestId String harvestId String
fillDate DateTime fillDate DateTime
bestBeforeDate DateTime bestBeforeDate DateTime
@@ -190,6 +223,11 @@ enum ProductCategory {
HONIG HONIG
KERZEN KERZEN
PROPOLIS PROPOLIS
SONSTIGES
/// Traegerprodukt einer Einmal-Patenschaft (siehe SponsorshipOffer). Nicht
/// im Shop gelistet; es haelt Preis, Steuersatz und Rechnungsposition, damit
/// Warenkorb, Checkout und Rechnung keinen Sonderweg brauchen.
PATENSCHAFT
} }
model Product { model Product {
@@ -197,6 +235,28 @@ 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
/// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis-
/// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und
/// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt
/// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt;
/// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock).
apicoreProductId 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?
@@ -213,10 +273,27 @@ model Product {
weightGrams Int weightGrams Int
priceCents Int priceCents Int
pricePerKgCents Int pricePerKgCents Int
/// Umsatzsteuersatz dieses Produkts in ganzen Prozent — uebersteuert den
/// Satz, der sich sonst aus der Kategorie ergibt (siehe lib/vat.ts).
///
/// Leer lassen ist der Normalfall: dann gilt der Satz der Kategorie, und
/// eine spaetere Aenderung der gesetzlichen Saetze wirkt ueber die
/// Konfiguration auf alle Produkte zugleich. Ein Wert hier ist fuer die
/// Ausnahmen gedacht, die sich nicht aus der Kategorie ergeben — etwa ein
/// Geschenkset, das anders einzuordnen ist als seine Kategorie vermuten
/// liesse.
vatRatePercent Int?
currency String @default("EUR") currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]") images Json @default("[]")
isActive Boolean @default(true) isActive Boolean @default(true)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -225,9 +302,16 @@ model Product {
inventory Inventory? inventory Inventory?
orderItems OrderItem[] orderItems OrderItem[]
reviews Review[] reviews Review[]
apicoreStockEvents ApiCoreStockEvent[]
wishlistItems WishlistItem[] wishlistItems WishlistItem[]
stockNotifications StockNotification[] stockNotifications StockNotification[]
/// Einmal-Patenschaft, deren Traegerprodukt dieses Produkt ist.
sponsorshipOffer SponsorshipOffer? @relation("SponsorshipOfferProduct")
/// Patenschafts-Angebote, deren Honig aus diesem Produkt kommt.
sponsorshipHoneyFor SponsorshipOffer[] @relation("SponsorshipOfferHoney")
@@index([archivedAt])
@@map("products") @@map("products")
} }
@@ -276,6 +360,10 @@ model Review {
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt — /// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung. /// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
isApproved Boolean @default(false) isApproved Boolean @default(false)
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
rejectedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -316,6 +404,9 @@ enum OrderStatus {
enum FulfillmentMethod { enum FulfillmentMethod {
VERSAND VERSAND
ABHOLUNG ABHOLUNG
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
DIGITAL
} }
model Order { model Order {
@@ -340,6 +431,18 @@ model Order {
city String city String
country String @default("DE") country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
subtotalCents Int subtotalCents Int
shippingCents Int @default(0) shippingCents Int @default(0)
discountCents Int @default(0) discountCents Int @default(0)
@@ -349,11 +452,27 @@ model Order {
discountCodeId String? discountCodeId String?
stripeCheckoutSessionId String? stripeCheckoutSessionId String?
/// Nicht erratbares Kennzeichen, das ausschliesslich in der cancel_url der
/// Stripe-Sitzung steht. Bricht die Kundschaft dort ab, landet sie auf
/// /checkout/cancel?ref=<token>; erst dieser Token weist nach, dass der
/// Abbruch zu DIESER Bestellung gehoert, und gibt den reservierten Bestand
/// sofort wieder frei. Die Bestellnummer taugt dafuer nicht: sie steht in
/// jeder Bestaetigungsmail und auf jeder Rechnung, mit ihr koennte also
/// jemand die noch offene Zahlung einer fremden Person abbrechen.
cancelToken String? @unique
notes String? notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim /// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String? giftMessage String?
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -365,6 +484,11 @@ model Order {
pickup Pickup? pickup Pickup?
invoice Invoice? invoice Invoice?
returnRequest ReturnRequest? returnRequest ReturnRequest?
withdrawalDeclarations WithdrawalDeclaration[]
/// Gesetzt, wenn diese Bestellung eine bezahlte Abo-Monatsrechnung abbildet.
subscriptionPayment SubscriptionPayment?
/// Gesetzt, wenn diese Bestellung eine Abo-Honiglieferung ist (0 €).
subscriptionDelivery SubscriptionDelivery?
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach /// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
/// createdAt) als auch den reinen customerId-Filter beim Datenexport. /// createdAt) als auch den reinen customerId-Filter beim Datenexport.
@@ -376,6 +500,8 @@ model Order {
@@index([createdAt]) @@index([createdAt])
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId). /// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
@@index([discountCodeId]) @@index([discountCodeId])
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
@@index([archivedAt])
@@map("orders") @@map("orders")
} }
@@ -393,11 +519,19 @@ model ReturnRequest {
orderId String @unique orderId String @unique
reason String reason String
status ReturnStatus @default(ANGEFRAGT) status ReturnStatus @default(ANGEFRAGT)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([archivedAt])
@@map("return_requests") @@map("return_requests")
} }
@@ -415,17 +549,112 @@ model OrderItem {
quantity Int quantity Int
weightGrams Int weightGrams Int
totalCents Int totalCents Int
/// Umsatzsteuersatz dieser Position in ganzen Prozent, abgeleitet aus der
/// Produktkategorie zum Bestellzeitpunkt (Honig ermaessigt, Kerzen voll —
/// siehe lib/vat.ts). Teil des Snapshots aus demselben Grund wie der Preis:
/// wird ein Produkt spaeter umkategorisiert oder aendert sich der Satz,
/// darf das bestehende Rechnungen nicht rueckwirkend veraendern.
///
/// Null bei Bestellungen aus der Zeit vor der Steuerausweisung — deren
/// Rechnungen zeigen weiterhin keine Steuerzeilen, weil rueckwirkend nicht
/// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent Int?
/// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie
/// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer
/// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
apicoreAllocations Json?
/// Personalisierung einer Patenschaft, wie sie beim Kauf bestaetigt wurde
/// (lib/certificate/schema.ts), samt Vorlagenversion. Nur an der
/// Hauptposition (sponsorshipRole "MAIN"); daraus entsteht nach der Zahlung
/// die erste Revision der Urkunde.
personalization Json?
/// Angebot, zu dem diese Position gehoert — an Haupt- UND Honigposition,
/// damit Warenkorb, Rechnung und Admin sie als ein Paket zeigen koennen.
sponsorshipOfferId String?
/// "MAIN" (Patenschaft, Regelsteuersatz) oder "HONEY" (Honiganteil des
/// Pakets zum Honig-Satz). Null bei gewoehnlichen Positionen.
sponsorshipRole String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
sponsorship Sponsorship?
product Product @relation(fields: [productId], references: [id], onDelete: Restrict) product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict) batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
apicoreStockEvents ApiCoreStockEvent[]
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin, /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt. /// Rechnung) ueber die order.items-Relation abgefragt.
@@index([orderId]) @@index([orderId])
/// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders
/// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId
/// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des
/// Shops. Betroffen sind zwei Stellen, die das nicht vertragen:
///
/// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter
/// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine
/// angemeldete Kundschaft ausgefuehrt wird.
/// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss
/// beim Loeschen eines Produkts pruefen, ob noch Positionen daran
/// haengen.
@@index([productId])
/// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls
/// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen
/// diese Tabelle.
@@index([batchId])
@@map("order_items") @@map("order_items")
} }
// ---------------------------------------------------------------------------
// Bestandsabgleich mit ApiCore
// ---------------------------------------------------------------------------
enum ApiCoreStockEventKind {
SALE
RETURN
}
/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock).
///
/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung
/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per
/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade
/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht
/// jede id nur einmal, Wiederholungen sind also gefahrlos.
model ApiCoreStockEvent {
id String @id @default(cuid())
kind ApiCoreStockEventKind
orderItemId String
/// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt.
productId String
/// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber
/// dessen Herstellungschargen).
apicoreBottlingId String?
apicoreProductId String?
quantity Int
/// Bei RETURN: der Verkauf, der zurueckgebucht wird.
reversesEventId String?
createdAt DateTime @default(now())
/// Von ApiCore bestaetigt (neu gebucht oder schon bekannt).
sentAt DateTime?
/// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es.
rejectedAt DateTime?
attempts Int @default(0)
lastError String?
nextAttemptAt DateTime @default(now())
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict)
reversedBy ApiCoreStockEvent[] @relation("StockEventReversal")
@@index([sentAt, rejectedAt, nextAttemptAt])
@@index([orderItemId])
@@index([productId])
@@map("apicore_stock_events")
}
enum PaymentStatus { enum PaymentStatus {
PENDING PENDING
SUCCEEDED SUCCEEDED
@@ -478,19 +707,90 @@ model Pickup {
@@map("pickups") @@map("pickups")
} }
/// Laufende Nummer je Kalenderjahr fuer die Rechnungsstellung.
///
/// § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer. Eine
/// Zufallsnummer waere zwar eindeutig, aber nicht fortlaufend — bei einer
/// Betriebspruefung liesse sich damit nicht zeigen, dass keine Rechnung
/// fehlt.
///
/// Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
/// sperrt die Datenbank genau diese eine Zeile, zwei gleichzeitig bezahlte
/// Bestellungen bekommen dadurch zwangslaeufig verschiedene Nummern. Ein
/// Maximum-Abfragen waere dagegen ein klassisches Wettrennen — beide lesen
/// dieselbe hoechste Nummer, beide zaehlen auf denselben Wert hoch.
model InvoiceCounter {
/// Kalenderjahr; je Jahr beginnt die Zaehlung neu bei 1.
year Int @id
lastNumber Int @default(0)
@@map("invoice_counters")
}
model Invoice { model Invoice {
id String @id @default(cuid()) id String @id @default(cuid())
orderId String @unique orderId String @unique
invoiceNumber String @unique invoiceNumber String @unique
issuedAt DateTime @default(now()) issuedAt DateTime @default(now())
totalCents Int totalCents Int
pdfUrl String? // Kein Steuersatz an der Rechnung: eine Bestellung kann Waren mit
// VERSCHIEDENEN Saetzen enthalten (Honig 7%, Bienenwachskerze 19%). Der
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
/// "2026/mustermann-erika/RE-2026-0001.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)
cancellation CancellationInvoice?
@@map("invoices") @@map("invoices")
} }
/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN),
/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie
/// InvoiceCounter.
model CancellationCounter {
year Int @id
lastNumber Int @default(0)
@@map("cancellation_counters")
}
/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird
/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung
/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit
/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts).
/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als
/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf
/// "Credit Note Issued".
model CancellationInvoice {
id String @id @default(cuid())
invoiceId String @unique
number String @unique
issuedAt DateTime @default(now())
/// Betrag der aufgehobenen Rechnung (brutto, positiv).
totalCents Int
/// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe).
reason String
/// Wie Invoice.pdfPath, relativ zu INVOICE_DIR.
pdfPath String?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([issuedAt])
@@map("cancellation_invoices")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Marketing / Kontakt // Marketing / Kontakt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -504,6 +804,9 @@ model NewsletterSubscriber {
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto. /// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique confirmationToken String? @unique
subscribedAt DateTime @default(now()) subscribedAt DateTime @default(now())
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
confirmedAt DateTime?
unsubscribedAt DateTime? unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@ -660,3 +963,362 @@ model BusinessInquiry {
@@map("business_inquiries") @@map("business_inquiries")
} }
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
/// automatische Zuordnung, wenn beides passt.
model WithdrawalDeclaration {
id String @id @default(cuid())
name String
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
orderNumber String
email String
message String?
locale String @default("de")
orderId String?
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
receivedAt DateTime @default(now())
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
confirmationSentAt DateTime?
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
processedAt DateTime?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([receivedAt])
@@index([processedAt])
@@map("withdrawal_declarations")
}
// ---------------------------------------------------------------------------
// Bienenpatenschaft (docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md)
// ---------------------------------------------------------------------------
enum SponsorshipOfferKind {
EINMALIG
ABO
}
enum SponsorshipCycle {
/// Lieferanspruch nach jeweils drei bezahlten Monaten.
VIERTELJAEHRLICH
/// Lieferung in den Erntefenstern, frueheste nach sechs bezahlten Monaten
/// seit der letzten Lieferung.
ERNTE
}
/// Praegung der Patenschafts-Medaille (lib/medals).
enum MedalTier {
BRONZE
SILBER
GOLD
ABO
}
enum SponsorshipStatus {
/// Bezahlt, PDF noch nicht erzeugt oder Mail noch nicht versendet.
AUSSTEHEND
/// Alle automatischen Versuche aufgebraucht — braucht den Admin.
FEHLER
/// PDF liegt vor, Mail ist raus.
BEREIT
/// Nur physische Pakete: Urkunde gedruckt.
GEDRUCKT
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
WIDERRUFEN
}
enum SubscriptionStatus {
/// Checkout gestartet, bei Stripe noch nicht abgeschlossen.
AUSSTEHEND
AKTIV
/// Zahlung offen oder zurueckgebucht — keine Lieferungen, Stufe ruht.
IM_VERZUG
/// Gekuendigt, laeuft bis zum Ende des bezahlten Monats.
GEKUENDIGT
BEENDET
}
enum DeliveryStatus {
/// Bestellung fuer den Versand angelegt.
ANGELEGT
/// Faellig, aber ohne Bestand oder Anspruch zurueckgestellt.
ZURUECKGESTELLT
}
enum LoyaltyKind {
STAMMKUNDE
ABO
}
/// Ein im Admin pflegbares Patenschafts-Angebot (Einmalpaket oder Abo).
model SponsorshipOffer {
id String @id @default(cuid())
slug String @unique
kind SponsorshipOfferKind
name String
nameEn String?
description String @db.Text
descriptionEn String? @db.Text
/// Leistungsliste als JSON-Array von Strings (lib/json-array.ts).
features Json @default("[]")
featuresEn Json @default("[]")
/// EINMALIG: Bruttopreis des ganzen Pakets. ABO: Monatspreis brutto.
priceCents Int
isActive Boolean @default(true)
sortOrder Int @default(0)
/// Mit gedruckter Urkunde, Honig, Grusskarte und Versand.
isPhysical Boolean @default(false)
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
productId String? @unique
/// UNGENUTZT: Pakete und Abos bekommen ihren Honig automatisch (nie cremig,
/// neueste Ernte, genug Bestand — lib/sponsorship/honey-selection.ts). Die
/// Spalte wird weder gelesen noch geschrieben und bleibt nur stehen, damit
/// die laufende Produktion keine Migration braucht; Altwerte sind bedeutungslos.
honeyProductId String?
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
honeyJars Int @default(0)
/// EINMALIG: Honiganteil des Paketpreises je Glas (zum Honig-Steuersatz).
honeyShareCentsPerJar Int @default(0)
/// ABO: Honiganteil des Monatspreises (zum Honig-Steuersatz).
honeyShareCentsPerMonth Int @default(0)
/// Nur ABO.
cycle SponsorshipCycle?
/// Nur ABO: Hoechstzahl aktiver Abos (Kontingent); null = unbegrenzt.
maxActiveSubscriptions Int?
/// Nur ABO: Stripe-Produkt und der Preis, der priceCents abbildet. Bei einer
/// Preisaenderung entsteht ein neuer Stripe-Preis; laufende Abos behalten
/// ihren alten.
stripeProductId String?
stripePriceId String?
stripePriceAmountCents Int?
medalTier MedalTier @default(BRONZE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product? @relation("SponsorshipOfferProduct", fields: [productId], references: [id], onDelete: Restrict)
honeyProduct Product? @relation("SponsorshipOfferHoney", fields: [honeyProductId], references: [id], onDelete: SetNull)
sponsorships Sponsorship[]
subscriptions SponsorshipSubscription[]
harvestWindows HarvestWindow[]
@@index([kind, isActive, sortOrder])
@@index([honeyProductId])
@@map("sponsorship_offers")
}
/// Eine gekaufte Patenschaft (eine Biene, ein Name, eine Urkunde).
model Sponsorship {
id String @id @default(cuid())
/// WH-P-JJJJ-NNNNN, fortlaufend ueber SponsorshipCounter.
number String @unique
ownerId String
offerId String
/// Einmalkauf: die Hauptposition der Bestellung. Eindeutig — das ist der
/// Idempotenzschluessel gegen doppelt zugestellte Webhooks.
orderItemId String? @unique
/// Abo: das Abo, aus dem die Patenschaft entstand.
subscriptionId String? @unique
locale String @default("de")
startDate DateTime @db.Date
/// Leer bei laufendem Abo.
endDate DateTime? @db.Date
status SponsorshipStatus @default(AUSSTEHEND)
/// Nummer der aktuellen Revision; 0 = noch keine Urkunde erzeugt.
currentRevision Int @default(0)
pdfAttempts Int @default(0)
nextAttemptAt DateTime?
lastError String? @db.Text
mailSentAt DateTime?
printedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner Customer @relation(fields: [ownerId], references: [id], onDelete: Cascade)
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
orderItem OrderItem? @relation(fields: [orderItemId], references: [id], onDelete: SetNull)
subscription SponsorshipSubscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
revisions SponsorshipRevision[]
transfers SponsorshipTransfer[]
@@index([ownerId])
@@index([status, nextAttemptAt])
@@index([offerId])
@@map("sponsorships")
}
/// Unveraenderlicher Stand einer Urkunde. Korrekturen und das Abo-Ende
/// erzeugen neue Revisionen; alte bleiben samt PDF erhalten.
model SponsorshipRevision {
id String @id @default(cuid())
sponsorshipId String
revision Int
/// Personalisierung wie bestaetigt (lib/certificate/schema.ts).
personalization Json
/// Laufzeitform (FIXED/OPEN/ENDED) mit Daten.
term Json
/// Aufgeloeste Textbausteine — Beleg dessen, was auf der Urkunde steht.
text Json
templateVersion String
/// Relativ zu CERTIFICATE_DIR.
pdfPath String
pdfSha256 String
/// Admin-Konto bei Korrekturen, null bei System-Revisionen.
createdById String?
/// Lesbarer Ersteller ("System" oder die Admin-Adresse) — bleibt lesbar,
/// auch wenn das Admin-Konto spaeter geloescht wird.
createdByLabel String
reason String?
createdAt DateTime @default(now())
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
@@unique([sponsorshipId, revision])
@@map("sponsorship_revisions")
}
/// Jahreszaehler der Patenschaftsnummern, Muster wie InvoiceCounter.
model SponsorshipCounter {
year Int @id
lastNumber Int @default(0)
@@map("sponsorship_counters")
}
/// Befristeter, einmal nutzbarer Link zur Uebertragung einer
/// Einmal-Patenschaft an ein anderes Konto. Gespeichert wird nur der Hash.
model SponsorshipTransfer {
id String @id @default(cuid())
sponsorshipId String
tokenHash String @unique
expiresAt DateTime
createdById String
claimedById String?
claimedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
@@index([sponsorshipId])
@@map("sponsorship_transfers")
}
/// Abo-Patenschaft ueber Stripe Billing.
model SponsorshipSubscription {
id String @id @default(cuid())
customerId String
offerId String
status SubscriptionStatus @default(AUSSTEHEND)
/// Personalisierung beim Abschluss bestaetigt; daraus entsteht mit der
/// ersten bezahlten Rechnung die Patenschaft.
personalization Json
locale String @default("de")
stripeCheckoutSessionId String? @unique
stripeSubscriptionId String? @unique
startedAt DateTime?
currentPeriodEnd DateTime?
/// Gesetzt nach Kuendigung: Ende des bezahlten Zeitraums.
cancelAt DateTime?
endedAt DateTime?
/// Beginn des Verzugs (fehlgeschlagene Zahlung oder Rueckbuchung).
delinquentSince DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
payments SubscriptionPayment[]
deliveries SubscriptionDelivery[]
sponsorship Sponsorship?
@@index([customerId])
@@index([offerId, status])
@@map("sponsorship_subscriptions")
}
/// Eine bezahlte Abo-Monatsrechnung von Stripe.
model SubscriptionPayment {
id String @id @default(cuid())
subscriptionId String
/// Idempotenzschluessel gegen doppelt zugestellte invoice.paid-Ereignisse.
stripeInvoiceId String @unique
stripePaymentIntentId String?
paymentMethodType String?
amountCents Int
paidAt DateTime
periodStart DateTime
periodEnd DateTime
/// Ab wann diese Zahlung fuer den Lieferanspruch zaehlt (SEPA-Neukunden-
/// Frist, siehe lib/sponsorship-subscription/entitlement.ts).
countsFrom DateTime
/// Zurueckgebucht — zaehlt nie mehr fuer eine Lieferung.
reversedAt DateTime?
/// Lieferung, die diese Zahlung verbraucht hat.
deliveryId String?
/// Wabenhain-Bestellung samt Rechnung zu dieser Zahlung.
orderId String? @unique
createdAt DateTime @default(now())
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
delivery SubscriptionDelivery? @relation(fields: [deliveryId], references: [id], onDelete: SetNull)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([subscriptionId, paidAt])
@@map("subscription_payments")
}
/// Eine Abo-Honiglieferung (0-€-Bestellung mit Honigpositionen).
model SubscriptionDelivery {
id String @id @default(cuid())
subscriptionId String
dueAt DateTime
jars Int
status DeliveryStatus
orderId String? @unique
note String?
createdAt DateTime @default(now())
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
payments SubscriptionPayment[]
@@index([subscriptionId, dueAt])
@@map("subscription_deliveries")
}
/// Erntefenster eines Abo-Angebots mit Zyklus ERNTE.
model HarvestWindow {
id String @id @default(cuid())
offerId String
startsOn DateTime @db.Date
endsOn DateTime @db.Date
/// Fuer Abos geplante Glaeser — werden reserviert, bevor sie in den
/// Freiverkauf gehen.
plannedJars Int @default(0)
createdAt DateTime @default(now())
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Cascade)
@@index([offerId, startsOn])
@@map("harvest_windows")
}
/// Treuestufe (Stammkunde nach bezahlten Bestellungen, Abo nach bezahlten
/// Monaten). threshold null = VIP (nur ABO, nach der hoechsten Zahl).
model LoyaltyLevel {
id String @id @default(cuid())
kind LoyaltyKind
threshold Int?
name String
nameEn String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kind, threshold])
@@map("loyalty_levels")
}
+115
View File
@@ -83,6 +83,121 @@ async function main() {
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird." "Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
); );
} }
await seedSponsorshipDefaults();
}
/**
* Startangebote der Bienenpatenschaft und Treuestufen — nur wenn es noch
* keine gibt, und die Angebote INAKTIV: dieser Seed laeuft bei jedem
* Containerstart auch in Produktion, und Preise, Honig und Rechtstexte muss
* der Inhaber vor der Veroeffentlichung pruefen (Spec, "Geschaeftliche
* Konfiguration"). Erst das Aktivieren im Admin macht /patenschaft sichtbar.
*/
async function seedSponsorshipDefaults(): Promise<void> {
if ((await prisma.sponsorshipOffer.count()) === 0) {
const packages = [
{
slug: "bienenfreund",
name: "Bienenfreund",
nameEn: "Bee Friend",
priceCents: 1900,
isPhysical: false,
honeyJars: 0,
medalTier: "BRONZE" as const,
description: "Symbolische Namensgebung mit individuell gestalteter Urkunde als PDF.",
descriptionEn: "Symbolic naming with an individually designed certificate as a PDF.",
features: ["Symbolische Namensgebung", "Individuell gestaltete PDF-Urkunde", "Medaille und 3D-Ansicht im Kundenkonto", "Sicherer Download"],
featuresEn: ["Symbolic naming", "Individually designed PDF certificate", "Medal and 3D view in your account", "Secure download"],
},
{
slug: "honigfreund",
name: "Honigfreund",
nameEn: "Honey Friend",
priceCents: 3900,
isPhysical: true,
honeyJars: 1,
medalTier: "SILBER" as const,
description: "Digitale Leistungen plus gedruckte Urkunde, ein Glas Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
descriptionEn: "Digital items plus a printed certificate, a jar of Wabenhain honey, a greeting card and gift wrapping.",
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "1 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
featuresEn: ["All digital items", "High-quality printed certificate", "1 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
},
{
slug: "wabenfreund",
name: "Wabenfreund",
nameEn: "Comb Friend",
priceCents: 5900,
isPhysical: true,
honeyJars: 3,
medalTier: "GOLD" as const,
description: "Digitale Leistungen plus gedruckte Urkunde, drei Gläser Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
descriptionEn: "Digital items plus a printed certificate, three jars of Wabenhain honey, a greeting card and gift wrapping.",
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "3 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
featuresEn: ["All digital items", "High-quality printed certificate", "3 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
},
];
for (const [index, pkg] of packages.entries()) {
// Honiganteil als Vorschlag (7 € je Glas) — der Inhaber prueft ihn mit
// der Steuerberatung, bevor er das Angebot aktiviert.
const honeyShareCentsPerJar = pkg.honeyJars > 0 ? 700 : 0;
const product = await prisma.product.create({
data: {
slug: `patenschaft-${pkg.slug}`,
name: `Bienenpatenschaft ${pkg.name}`,
category: "PATENSCHAFT",
description: pkg.description,
weightGrams: 0,
priceCents: pkg.priceCents - pkg.honeyJars * honeyShareCentsPerJar,
pricePerKgCents: 0,
isActive: true,
},
});
await prisma.sponsorshipOffer.create({
data: {
slug: pkg.slug,
kind: "EINMALIG",
name: pkg.name,
nameEn: pkg.nameEn,
description: pkg.description,
descriptionEn: pkg.descriptionEn,
features: pkg.features,
featuresEn: pkg.featuresEn,
priceCents: pkg.priceCents,
isActive: false,
sortOrder: index,
isPhysical: pkg.isPhysical,
productId: product.id,
honeyJars: pkg.honeyJars,
honeyShareCentsPerJar,
medalTier: pkg.medalTier,
},
});
}
console.log("Bienenpatenschaft: drei Startangebote angelegt (inaktiv, bitte im Admin pruefen und aktivieren).");
}
if ((await prisma.loyaltyLevel.count()) === 0) {
const regular = [
{ threshold: 2, name: "Blütenbesuch", nameEn: "Blossom Visitor" },
{ threshold: 5, name: "Wabenbau", nameEn: "Comb Builder" },
{ threshold: 10, name: "Honigschatz", nameEn: "Honey Treasure" },
{ threshold: 25, name: "Goldwabe", nameEn: "Golden Comb" },
];
const subscription = Array.from({ length: 24 }, (_, i) => ({
threshold: i + 1,
name: `Stufe ${i + 1}`,
nameEn: `Level ${i + 1}`,
}));
await prisma.loyaltyLevel.createMany({
data: [
...regular.map((level) => ({ kind: "STAMMKUNDE" as const, ...level })),
...subscription.map((level) => ({ kind: "ABO" as const, ...level })),
{ kind: "ABO" as const, threshold: null, name: "VIP", nameEn: "VIP" },
],
});
console.log("Treuestufen angelegt (Stammkunde 2/5/10/25, Abo 1–24 und VIP).");
}
} }
main() main()
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+45
View File
@@ -0,0 +1,45 @@
/**
* Bestandsabgleich mit ApiCore als Sicherheitsnetz unter den Sofort-Wegen.
*
* Normalerweise geht eine Buchung direkt nach der Zahlung/Stornierung an
* ApiCore, und eine Entnahme in ApiCore kommt per Webhook sofort an. Dieser
* Lauf faengt alles ab, was dabei liegen blieb: ApiCore war nicht erreichbar,
* ein Webhook ging verloren, der Server wurde mitten im Senden neu gestartet.
*
* 1. offene Buchungen senden (mit Wartezeiten 1, 5, 15, 60 Minuten),
* 2. ALLE verknuepften Produkte gegen ApiCore abgleichen.
*
* Aufruf: pnpm stock:apicore-sync
* Im Cluster laeuft er alle 10 Minuten als CronJob (k8s/07-backup-cronjob.yaml).
*
* Exit-Code 1, wenn ApiCore nicht erreichbar war oder Buchungen abgelehnt
* hat: dann soll der Job als fehlgeschlagen sichtbar sein, nicht nur in
* einer Logzeile verschwinden.
*/
import { prisma } from "../src/lib/prisma";
import { deliverPendingStockEvents } from "../src/lib/apicore-stock/delivery";
import { reconcileApiCoreStock } from "../src/lib/apicore-stock/reconcile";
async function main(): Promise<number> {
const delivered = await deliverPendingStockEvents();
console.log(
`[apicore-stock] Buchungen: ${delivered.sent} bestaetigt, ${delivered.rejected} abgelehnt, ${delivered.failed} nicht zugestellt${delivered.error ? ` (${delivered.error})` : ""}`
);
const reconciled = await reconcileApiCoreStock();
console.log(
`[apicore-stock] Abgleich: ${reconciled.updated} Produkte aktualisiert, ${reconciled.missing} in ApiCore nicht gefunden${reconciled.error ? ` (${reconciled.error})` : ""}`
);
const failed = delivered.failed > 0 || delivered.rejected > 0 || Boolean(reconciled.error);
return failed ? 1 : 0;
}
main()
.then(async (code) => {
await prisma.$disconnect();
process.exit(code);
})
.catch(async (error: unknown) => {
console.error("[apicore-stock] Lauf abgebrochen:", error);
await prisma.$disconnect();
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
set -euo pipefail
fail() {
echo "Runner-Isolation verletzt: $*" >&2
exit 1
}
reachable() {
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
}
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
# gelegentlich erst beim zweiten Versuch.
resolve() {
local address=""
for _ in 1 2 3 4 5; do
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
if [ -n "$address" ]; then echo "$address"; return 0; fi
sleep 2
done
return 1
}
# 1. Kein Zugang zum Docker-Daemon über den Socket.
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
for socket in /var/run/docker.sock /run/docker.sock; do
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
done
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
fi
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
done
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
host=${target%:*}
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
done
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
+102
View File
@@ -0,0 +1,102 @@
/**
* Sucht die Place-ID eines Google-Maps-Eintrags.
*
* Die Place-ID steht NICHT in der Google Cloud Console — dort gibt es nur den
* API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss ueber die
* Places API nachgeschlagen werden. Genau das tut dieses Skript.
*
* Es ist zugleich der schnellste Weg, die Einrichtung zu pruefen: kommt hier
* eine ID heraus, sind API, Abrechnung und Schluessel richtig gesetzt und die
* Startseite kann die Rezensionen abrufen. Kommt stattdessen eine
* Fehlermeldung, benennt sie die Ursache — anders als die Meldung der alten
* API, die bei nicht aktivierter Schnittstelle behauptet, der Schluessel sei
* ungueltig.
*
* Aufruf:
* pnpm places:find "Wabenhain Altenmünster"
*/
import "dotenv/config";
const SEARCH_URL = "https://places.googleapis.com/v1/places:searchText";
const TIMEOUT_MS = 10_000;
interface SearchResponse {
places?: {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
}[];
error?: { message?: string; status?: string };
}
function fail(message: string): never {
console.error(`\n✖ ${message}\n`);
process.exit(1);
}
async function main(): Promise<void> {
const query = process.argv.slice(2).join(" ").trim();
if (!query) {
fail('Bitte einen Suchbegriff angeben, z.B.: pnpm places:find "Wabenhain Altenmünster"');
}
const apiKey = process.env.GOOGLE_PLACES_API_KEY?.trim();
if (!apiKey) {
fail(
"GOOGLE_PLACES_API_KEY ist nicht gesetzt. Den API-Schluessel aus der Google Cloud Console\n" +
" in die .env eintragen (Anmeldedaten → API-Schluessel, NICHT OAuth-Client)."
);
}
const response = await fetch(SEARCH_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": apiKey,
// Nur die drei Felder, die unten ausgegeben werden — die API rechnet
// nach angeforderten Feldern ab.
"X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress",
},
body: JSON.stringify({ textQuery: query, languageCode: "de" }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = (await response.json().catch(() => null)) as SearchResponse | null;
if (!response.ok || data?.error) {
const detail = data?.error?.message ?? `HTTP ${response.status}`;
fail(
`Google hat die Anfrage abgelehnt: ${detail}\n\n` +
" Die drei haeufigsten Ursachen, in dieser Reihenfolge:\n" +
' 1. "Places API (New)" ist im Projekt nicht aktiviert (die gleichnamige ALTE\n' +
" API reicht nicht — sie laesst sich in neuen Projekten gar nicht mehr\n" +
" aktivieren).\n" +
" 2. Am Projekt ist keine Abrechnung hinterlegt.\n" +
" 3. Der Wert ist ein OAuth-Client statt eines API-Schluessels, oder der\n" +
" Schluessel traegt eine Referrer-Beschraenkung — dieser Abruf laeuft\n" +
" serverseitig und hat keinen Referrer."
);
}
const places = data?.places ?? [];
if (places.length === 0) {
fail(`Kein Eintrag zu "${query}" gefunden. Genauer suchen, z.B. mit Ort oder Strasse.`);
}
console.log(`\nTreffer für "${query}":\n`);
for (const place of places) {
console.log(` ${place.displayName?.text ?? "(ohne Namen)"}`);
console.log(` ${place.formattedAddress ?? "(ohne Adresse)"}`);
console.log(` GOOGLE_PLACE_ID=${place.id ?? "(ohne ID)"}\n`);
}
console.log(
"Den passenden Wert als GOOGLE_PLACE_ID eintragen — in der .env fuer lokal,\n" +
"in k8s/01-configmap.yaml fuer den Cluster.\n"
);
}
main().catch((error: unknown) => {
// Netzwerkfehler und Zeitueberschreitungen landen hier; alles Fachliche ist
// oben bereits mit einer deutlicheren Meldung abgefangen.
fail(`Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`);
});
+386
View File
@@ -0,0 +1,386 @@
/**
* Go-Live-Pruefung: faellt laut durch, statt den Shop still mit
* Platzhaltern oder Test-Zugangsdaten oeffentlich zu stellen.
*
* Aufruf: `pnpm preflight` (liest .env) — gedacht als letzter Schritt vor
* dem ersten oeffentlichen Deploy und als Schritt in der Deploy-Pipeline.
* Endet mit Exit-Code 1, sobald ein Blocker offen ist.
*
* Die Inhaltspruefungen fragen bewusst die DATENBANK und nicht den
* Quelltext: Impressum, Datenschutz, AGB, Widerrufsbelehrung und die
* Telefonnummer sind im Admin-Bereich unter "Inhalte" pflegbar (siehe
* lib/content-registry.ts). Die Texte in i18n/dictionaries/ sind nur der
* Rueckfallwert, solange dort nichts gepflegt ist. Eine Pruefung, die nur
* in den Quelltext schaut, meldet deshalb einen Blocker, den die
* Betreiberin im Admin laengst geschlossen hat — und schweigt umgekehrt,
* wenn jemand einen Platzhaltertext erst im Admin eintraegt.
*
* Ist die Datenbank nicht erreichbar (Aufruf von einer Maschine ohne
* Zugang zur Produktionsdatenbank), werden diese Pruefungen ausdruecklich
* als ungeprueft gemeldet statt geraten.
*/
import "dotenv/config";
import path from "node:path";
import { prisma } from "../src/lib/prisma";
import { getContentValue } from "../src/lib/content";
import { locales } from "../src/i18n/config";
import { getStripeClient } from "../src/lib/stripe";
const blockers: string[] = [];
const warnings: string[] = [];
const passed: string[] = [];
function must(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else blockers.push(`${label}\n ${hint}`);
}
function should(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else warnings.push(`${label}\n ${hint}`);
}
const value = (name: string): string => (process.env[name] ?? "").trim();
const isSet = (name: string): boolean => value(name).length > 0;
/**
* Eine Regel, die einen GESETZTEN Wert voraussetzt.
*
* Ohne diese Weiche meldete der Bericht gruene Haken fuer Variablen, die gar
* nicht existieren: "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein
* Platzhalter" stand als bestanden da, weil eine leere Zeichenkette nun einmal
* kein "example.com" enthaelt — direkt neben dem Blocker
* "NEXT_PUBLIC_SITE_URL gesetzt". Dasselbe galt fuer den Test auf einen
* sk_test-Schluessel: ohne jeden Stripe-Schluessel galt der Live-Modus als
* bestaetigt.
*
* Das ist die gefaehrlichste Art von Fehlmeldung, die eine Go-Live-Pruefung
* machen kann — sie beruhigt ueber genau den Punkt, der offen ist. Fehlt der
* Wert, entfaellt die Folgeregel hier stillschweigend; gemeldet wird er
* ohnehin schon von der Regel, die seine blosse Existenz prueft.
*/
function mustWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
must(label, ok(value(name)), hint);
}
/** Wie mustWhenSet, nur als Warnung statt als Blocker. */
function shouldWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
should(label, ok(value(name)), hint);
}
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET");
must(
"SESSION_SECRET gesetzt und mindestens 32 Zeichen lang",
sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
);
mustWhenSet(
"SESSION_SECRET",
"SESSION_SECRET ist nicht mehr der Beispielwert",
(raw) => !raw.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
);
const adminEmail = value("ADMIN_EMAIL");
must(
"ADMIN_EMAIL gesetzt und kein Beispielwert",
adminEmail.length > 0 && !adminEmail.includes("change-me") && !adminEmail.endsWith("@example.com"),
'Legt das erste Admin-Konto an (prisma db seed). Eine unvorhersehbare Adresse waehlen, nicht "admin@...".'
);
must(
"ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen",
value("ADMIN_PASSWORD").length >= 12 && !value("ADMIN_PASSWORD").includes("change-me"),
"Dieses Konto darf Rueckerstattungen ausloesen und alle Kundendaten einsehen."
);
// --- Oeffentliche Adresse der Seite ---------------------------------------
const siteUrl = value("NEXT_PUBLIC_SITE_URL");
must(
"NEXT_PUBLIC_SITE_URL gesetzt",
siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
);
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
(raw) => raw.startsWith("https://") && !raw.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
(raw) => !raw.includes("example.com") && !raw.includes("example.org"),
'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 ---------------------------------------------------------------
must(
"STRIPE_SECRET_KEY gesetzt",
isSet("STRIPE_SECRET_KEY"),
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
);
must(
"STRIPE_WEBHOOK_SECRET gesetzt",
isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
);
mustWhenSet(
"STRIPE_SECRET_KEY",
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
(raw) => !raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
must(
"STRIPE_PUBLISHABLE_KEY gesetzt",
isSet("STRIPE_PUBLISHABLE_KEY"),
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
);
mustWhenSet(
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
(raw) => raw.startsWith("pk_live_"),
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
);
// --- E-Mail ----------------------------------------------------------------
// Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App-
// Registrierung (siehe src/lib/mail.ts).
must(
"Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)",
isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
// pruefen, wenn tatsaechlich ein eigener Wert hinterlegt ist.
shouldWhenSet(
"SMTP_FROM",
"SMTP_FROM zeigt auf eine eigene Domain",
(raw) => !raw.includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
);
// --- Umsatzsteuer ----------------------------------------------------------
// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht
// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier
// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen.
const vatRate = value("VAT_RATE_PERCENT");
const vatRateNumber = Number(vatRate);
must(
"VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert",
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
);
shouldWhenSet(
"VAT_RATE_PERCENT",
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
(raw) => ["0", "7", "19"].includes(raw),
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
);
// --- Rechnungsablage -------------------------------------------------------
// Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert
// (app/uploads/[...path]). Liegt die Rechnungsablage darin, sind fremde
// Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar, der den
// Pfad erraet — und im Betrieb faellt das nicht auf, weil weiterhin alles
// funktioniert.
const invoiceDir = path.resolve(value("INVOICE_DIR") || "./invoices");
const uploadDir = path.resolve(value("UPLOAD_DIR") || "./uploads");
must(
"INVOICE_DIR liegt ausserhalb von UPLOAD_DIR",
invoiceDir !== uploadDir && !invoiceDir.startsWith(uploadDir + path.sep),
`Rechnungen (${invoiceDir}) duerfen nicht unterhalb des oeffentlich ausgelieferten Upload-Verzeichnisses (${uploadDir}) liegen. Eigenes Verzeichnis waehlen, siehe .env.example.`
);
// --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
// --- Missbrauchsschutz -----------------------------------------------------
must(
"Cloudflare Turnstile konfiguriert",
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
"Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
// die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse
// die jeweils andere Sprachfassung mit Luecken online gehen.
/**
* Erste aussagekraeftige Zeile einer Fehlermeldung. Prisma-Verbindungsfehler
* beginnen mit Leerzeilen — ein blosses split("\n")[0] ergaebe eine leere
* Klammer statt der Ursache.
*/
function firstMeaningfulLine(error: unknown): string {
if (!(error instanceof Error)) return "unbekannter Fehler";
const line = error.message.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
return line ?? error.name;
}
const PLACEHOLDER_MARKERS = ["[PLATZHALTER", "[PLACEHOLDER"];
const hasPlaceholder = (text: string): boolean =>
PLACEHOLDER_MARKERS.some((marker) => text.includes(marker));
const LEGAL_KEYS = [
{ key: "legal.impressum", label: "Impressum" },
{ key: "legal.datenschutz", label: "Datenschutz" },
{ key: "legal.agb", label: "AGB" },
{ key: "legal.widerruf", label: "Widerrufsbelehrung" },
] as const;
// Kein Top-Level-await: das Projekt hat kein "type": "module" in der
// package.json, tsx uebersetzt dieses Skript daher nach CommonJS.
async function checkDatabaseContent(): Promise<void> {
try {
// Verbindung zuerst pruefen, damit ein DB-Ausfall unten nicht faelschlich
// als "Inhalt in Ordnung" durchgeht.
await prisma.$queryRaw`SELECT 1`;
const withPlaceholder: string[] = [];
for (const { key, label } of LEGAL_KEYS) {
for (const locale of locales) {
if (hasPlaceholder(await getContentValue(key, locale))) {
withPlaceholder.push(`${label} (${locale})`);
}
}
}
must(
"Rechtstexte enthalten keine Platzhalter-Markierungen mehr",
withPlaceholder.length === 0,
`Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
);
// Ein LEERES Feld ist ausdruecklich in Ordnung und loest keine Warnung aus:
// ohne gepflegte Nummer entfaellt die Zeile auf der Kontaktseite und im
// Impressum einfach (siehe lib/site-config.ts). Gemeldet wird nur eine
// tatsaechlich eingetragene Platzhalternummer — die sieht nach einem
// Kontaktweg aus, an dessen Ende niemand abnimmt.
should(
"Telefonnummer ist keine Platzhalternummer",
!(await getContentValue("company.phone", "de")).includes("000 000000"),
'Eingetragen ist eine Platzhalternummer. Entweder die echte Nummer eintragen oder das Feld LEEREN — dann entfaellt die Angabe ueberall. Im Admin-Bereich unter "Inhalte" pflegbar.'
);
} catch (error: unknown) {
// Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst
// sich der tatsaechlich ausgelieferte Text nicht feststellen.
warnings.push(
`Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine(
error
)}). Diese Inhalte liegen in der Datenbank, nicht im Quelltext — die Pruefung mit Zugriff auf die Produktionsdatenbank wiederholen oder /impressum im Browser aufrufen.`
);
} finally {
await prisma.$disconnect();
}
}
/**
* Prueft, ob der Stripe-Webhook-Endpunkt die vier Ereignisse abonniert hat,
* auf die der Shop angewiesen ist.
*
* Das ist keine Formalie: fehlt checkout.session.expired, bleibt der beim
* Checkout reservierte Lagerbestand jedes abgebrochenen Kaufs dauerhaft
* blockiert. Fehlt checkout.session.completed, wird ueberhaupt keine Zahlung
* bestaetigt. Beides faellt im Betrieb erst auf, wenn Ware unverkaeuflich im
* System steht oder Geld eingezogen wurde, ohne dass eine Rechnung entstand —
* beides zu spaet.
*/
const REQUIRED_WEBHOOK_EVENTS = [
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
// Geld, das den Shop wieder verlaesst, ohne dass hier jemand etwas
// angeklickt hat: eine Erstattung von Hand im Stripe-Dashboard und eine
// Rueckbuchung durch die Bank. Ohne diese beiden Ereignisse steht die
// Zahlung im Shop weiter als erfolgreich da, und eine fristgebundene
// Rueckbuchung faellt nur auf, wenn jemand ins Dashboard sieht.
"charge.refunded",
"charge.dispute.created",
] as const;
async function checkStripeWebhookEvents(): Promise<void> {
if (!isSet("STRIPE_SECRET_KEY")) return; // fehlt bereits als Blocker oben
try {
const stripe = getStripeClient();
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
const active = endpoints.data.filter((endpoint) => endpoint.status === "enabled");
// "*" abonniert alle Ereignisse — dann ist alles Noetige dabei.
const covered = new Set<string>();
for (const endpoint of active) {
for (const event of endpoint.enabled_events) covered.add(event);
}
const missing = covered.has("*")
? []
: REQUIRED_WEBHOOK_EVENTS.filter((event) => !covered.has(event));
if (active.length === 0) {
warnings.push(
"Kein aktiver Stripe-Webhook-Endpunkt gefunden\n Ohne ihn wird keine Zahlung bestaetigt und kein abgebrochener Checkout aufgeraeumt. Unter https://dashboard.stripe.com/webhooks einen Endpunkt auf /api/checkout/webhook anlegen. Hinweis: mit einem eingeschraenkten Schluessel (rk_...) kann diese Pruefung die Liste moeglicherweise nicht sehen, obwohl ein Endpunkt existiert."
);
return;
}
must(
`Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`,
missing.length === 0,
`Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.`
);
} catch (error: unknown) {
// Weder Blocker noch stilles Bestehen: der Schluessel darf die Liste
// vielleicht gar nicht lesen (eingeschraenkter Schluessel), und daraus
// laesst sich nichts ueber die tatsaechliche Konfiguration ableiten.
warnings.push(
`Stripe-Webhook-Ereignisse konnten NICHT geprueft werden\n ${firstMeaningfulLine(
error
)}. Im Dashboard unter https://dashboard.stripe.com/webhooks von Hand nachsehen, ob ${REQUIRED_WEBHOOK_EVENTS.join(
", "
)} abonniert sind.`
);
}
}
function report(): void {
// --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
// Ohne diesen Hinweis wird der Bericht regelmaessig falsch gelesen: auf einem
// Entwicklungsrechner meldet er Blocker fuer Werte, die in der ConfigMap des
// Clusters laengst richtig stehen (NEXT_PUBLIC_SITE_URL, SMTP_FROM,
// VAT_RATE_PERCENT). Wer das ein paarmal erlebt, sieht ueber den Bericht
// hinweg — und damit auch ueber den einen Blocker, der echt ist.
console.log(
`
Geprueft wird die Umgebung, in der dieses Skript LAEUFT. Fuer eine Aussage
ueber den Live-Betrieb im Pod ausfuehren, nicht auf dem eigenen Rechner —
siehe README, Abschnitt "Vor dem Live-Gang".`
);
console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`);
if (warnings.length > 0) {
console.log(`\n⚠ Warnungen: ${warnings.length}`);
for (const item of warnings) console.log(` ⚠ ${item}`);
}
if (blockers.length > 0) {
console.log(`\n✖ Blocker: ${blockers.length}`);
for (const item of blockers) console.log(` ✖ ${item}`);
console.log(`\n${line}`);
console.log("Nicht bereit fuer den Live-Betrieb. Die Blocker oben zuerst beheben.\n");
process.exit(1);
}
console.log(`\n${line}`);
console.log(
warnings.length > 0
? "Keine Blocker. Warnungen oben pruefen, dann ist der Live-Betrieb moeglich.\n"
: "Alle Pruefungen bestanden — bereit fuer den Live-Betrieb.\n"
);
}
void Promise.all([checkDatabaseContent(), checkStripeWebhookEvents()]).then(report);
+153
View File
@@ -0,0 +1,153 @@
/**
* Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen.
*
* Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
* Lagerbestand. Drei Wege geben ihn normalerweise wieder frei:
*
* 1. der Abbruch durch die Kundschaft (checkout/cancel),
* 2. das Ereignis checkout.session.expired von Stripe (Webhook),
* 3. dieser Lauf hier.
*
* Die ersten beiden sind die Regel, dieser hier ist das Netz darunter. Er
* faengt genau die Faelle ab, in denen Weg 2 nicht funktioniert: das Ereignis
* checkout.session.expired ist im Stripe-Dashboard nicht abonniert, der
* Endpunkt war beim Zustellversuch nicht erreichbar, oder die Zustellversuche
* sind abgelaufen. Ohne dieses Netz bliebe der reservierte Bestand dann fuer
* immer blockiert — unsichtbar, denn im Admin-Bereich sieht eine solche
* Bestellung aus wie jede andere offene.
*
* Gefragt wird immer Stripe, nie die Uhr allein: nur wenn die Sitzung dort
* nachweislich abgelaufen ist, wird zurueckgebucht. Ein Lauf, der nur nach
* dem Alter ginge, koennte eine gerade laufende Zahlung unter den Fuessen
* wegstornieren.
*
* Aufruf: pnpm orders:release-stale
* Im Cluster laeuft er als CronJob (siehe k8s/07-backup-cronjob.yaml).
*/
import { prisma } from "../src/lib/prisma";
import { getStripeClient } from "../src/lib/stripe";
import { releaseUnpaidOrder } from "../src/lib/order-release";
/**
* Mindestalter, bevor eine Bestellung ueberhaupt betrachtet wird.
*
* Die Stripe-Sitzung laeuft nach 32 Minuten ab (siehe
* CHECKOUT_SESSION_TTL_MINUTES in app/api/checkout/route.ts). 45 Minuten
* lassen dem regulaeren Weg — Ablauf bei Stripe, Ereignis, Webhook — genug
* Luft, seine Arbeit zuerst zu tun.
*/
const MIN_AGE_MINUTES = 45;
/** Obergrenze je Lauf, damit ein Rueckstand die Datenbank nicht am Stueck belastet. */
const MAX_ORDERS_PER_RUN = 200;
async function main(): Promise<void> {
const cutoff = new Date(Date.now() - MIN_AGE_MINUTES * 60 * 1000);
// Bewusst OHNE Filter auf stripeCheckoutSessionId: genau die Bestellungen
// ohne hinterlegte Sitzung waren frueher das Loch in diesem Netz. Sie
// wurden hier uebergangen, der Abbruch-Klick kam ohne Sitzungs-ID ebenfalls
// nicht weiter, und ein Ereignis von Stripe konnte es ohne Sitzung gar
// nicht geben — ihr reservierter Lagerbestand blieb dauerhaft blockiert.
const candidates = await prisma.order.findMany({
where: {
status: "NEU",
createdAt: { lt: cutoff },
},
select: { id: true, orderNumber: true, stripeCheckoutSessionId: true },
orderBy: { createdAt: "asc" },
take: MAX_ORDERS_PER_RUN,
});
if (candidates.length === 0) {
console.log("[release-stale-orders] Keine liegengebliebenen Bestellungen.");
return;
}
console.log(
`[release-stale-orders] ${candidates.length} offene Bestellung(en) werden bei Stripe geprueft.`
);
const stripe = getStripeClient();
let released = 0;
let stillOpen = 0;
let paid = 0;
let failed = 0;
for (const order of candidates) {
try {
if (!order.stripeCheckoutSessionId) {
// Seit api/checkout/route.ts die Sitzung wieder schliesst, wenn sich
// ihre ID nicht speichern laesst, sollte es das nicht mehr geben. Es
// bleibt aber der Fall, dass auch dieses Schliessen fehlschlug, und
// Altbestaende aus der Zeit davor. Beide sind aelter als MIN_AGE_MINUTES
// und damit aelter als CHECKOUT_SESSION_TTL_MINUTES: eine Sitzung, die
// es noch gaebe, waere zu diesem Zeitpunkt in jedem Fall abgelaufen.
// Der Bestand kann also gefahrlos zurueck.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.warn(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt — zu dieser Bestellung war KEINE Stripe-Sitzung hinterlegt, sie war also nie bezahlbar.`
);
}
continue;
}
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (session.status === "complete") {
// Bezahlt, aber der Status steht noch auf "NEU": der
// checkout.session.completed-Webhook ist nicht angekommen. Diesen Fall
// loest der Lauf hier bewusst NICHT auf — er wuerde Rechnung,
// Bestaetigungsmail und Gutscheinzaehlung ueberspringen. Stattdessen
// laut melden, damit die Zustellung im Stripe-Dashboard wiederholt
// werden kann.
paid += 1;
console.error(
`[release-stale-orders] Bestellung ${order.orderNumber} ist bei Stripe BEZAHLT, steht hier aber auf "NEU". Der Webhook ist nicht angekommen — bitte im Stripe-Dashboard erneut zustellen.`
);
continue;
}
if (session.status === "open") {
// Sitzung laeuft noch (moeglich bei Zahlungsarten mit langer Frist).
// Nicht anfassen.
stillOpen += 1;
continue;
}
// "delete": nie bezahlt, nie bestaetigt — die Vormerkung verschwindet
// mitsamt der Bestellung, statt als "STORNIERT" im Admin-Bereich wie
// ein gescheiterter Verkauf auszusehen.
const result = await releaseUnpaidOrder(order.id, "delete");
if (result) {
released += 1;
console.log(
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt (Sitzung abgelaufen).`
);
}
} catch (error: unknown) {
// Eine einzelne fehlgeschlagene Abfrage darf den Lauf nicht beenden —
// die uebrigen Bestellungen sollen trotzdem geprueft werden.
failed += 1;
console.error(`[release-stale-orders] ${order.orderNumber} konnte nicht geprueft werden:`, error);
}
}
console.log(
`[release-stale-orders] Fertig: ${released} freigegeben, ${stillOpen} noch offen, ${paid} bezahlt aber ohne Webhook, ${failed} fehlgeschlagen.`
);
// Ein bezahlter, aber nicht verarbeiteter Fall ist ein echter Missstand und
// soll den CronJob als fehlgeschlagen markieren, damit er im Cluster
// auffaellt statt nur in einem Logeintrag zu verschwinden.
if (paid > 0) process.exitCode = 1;
}
main()
.catch((error: unknown) => {
console.error("[release-stale-orders] Abgebrochen:", error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+141
View File
@@ -0,0 +1,141 @@
/**
* Rendert Beispiel-Urkunden fuer die Sichtpruefung (Spec "Visuelle
* Kontrolle"): je Vorlage und Datensatz ein PNG mit 300 dpi (2480 px breit)
* und die Druck-PDF, dazu eine Vorschau mit Wasserzeichen.
*
* Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=a@1,b@1] [--samples=standard,lang]
*
* Schreibt nur in den angegebenen Ordner; keine Datenbank, keine Uploads.
*/
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { layoutCertificate } from "../src/lib/certificate/layout";
import { renderCertificateImage } from "../src/lib/certificate/render-image";
import { certificatePdfTitle, renderCertificatePdf } from "../src/lib/certificate/render-pdf";
import { fixedTermFor, type CertificateTerm, type Personalization } from "../src/lib/certificate/schema";
import { listTemplateVersions } from "../src/lib/certificate/templates";
import { composeCertificateText } from "../src/lib/certificate/text";
import type { Locale } from "../src/i18n/config";
interface Sample {
personalization: Personalization;
locale: Locale;
term: CertificateTerm;
number: string;
}
const START = "2026-12-24";
const NUMBER = "WH-P-2026-00042";
function sample(personalization: Personalization, extra: Partial<Omit<Sample, "personalization">> = {}): Sample {
return { personalization, locale: "de", term: fixedTermFor(personalization.startDate), number: NUMBER, ...extra };
}
const MAX_DEDICATION =
"Für die Frau, die jeden Garten zum Summen bringt.\nMöge diese kleine Biene dich an all die sonnigen Tage erinnern, die wir gemeinsam zwischen Lavendel und Linden verbracht haben.\n\nIn Liebe und Dankbarkeit,\nAnna, Ben und Carla";
const SAMPLES: Record<string, Sample> = {
// Identische Beispieldaten fuer den Vergleich der drei Varianten.
standard: sample({
beeName: "Summsi",
recipientName: "Marie-Luise Schmidt",
giverNames: "Anna und Ben",
dedication: "Für die beste Gärtnerin der Welt –\nmögen deine Blüten immer summen.",
occasion: "WEIHNACHTEN",
startDate: START,
}),
kurz: sample({ beeName: "Bo", recipientName: "Li", occasion: "SELBST", startDate: START }),
lang: sample({
beeName: "Hildegard-Wilhelmine",
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden",
giverNames: "Anna, Ben, Carla und Familie Özdemir aus dem Nachbarhaus",
dedication: MAX_DEDICATION,
occasion: "GEBURTSTAG",
startDate: START,
}),
// An den Eingabelimits (CERTIFICATE_LIMITS): 23/24, 57/60, 80/80, 277/280 Zeichen, 5 Umbrueche.
maximal: sample(
{
beeName: "Wilhelmine-Mümmelwunder",
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden-Württemberg",
giverNames: "Anna, Ben, Carla, Dominik, Emilia, Friedrich und die ganze Familie Özdemir-Lange",
dedication: [
"Möge diese kleine Biene dich an sonnige Tage im Garten erinnern,",
"an Lindenblüten, Lavendel und an den Duft von frischem Honigbrot,",
"an lange Abende auf der Bank vor dem Haus und an alle Gespräche.",
"Danke für alles, was du für uns getan hast, jeden einzelnen Tag!",
"Alles Liebe",
"Anna",
].join("\n"),
occasion: "DANKESCHOEN",
startDate: START,
},
{ locale: "de" }
),
sonderzeichen: sample(
{
beeName: "Zoë",
recipientName: "Jürgen Weiß & Ñúñez O’Brien",
giverNames: "Anna, Ben, Carla und Familie Özdemir",
occasion: "DANKESCHOEN",
startDate: START,
},
{ term: { kind: "OPEN", start: START } }
),
englisch: sample(
{
beeName: "Honeybell",
recipientName: "Charlotte Ashworth-Blake",
giverNames: "Oliver and Grace",
dedication: "For the kindest gardener we know —\nmay your days always hum with sunshine.",
occasion: "GEBURTSTAG",
startDate: START,
},
{ locale: "en" }
),
beendet: sample(
{ beeName: "Lindenblüte", recipientName: "Familie Wagner", occasion: "GESCHENK", startDate: START },
{ term: { kind: "ENDED", start: START, end: "2027-05-31" } }
),
};
function parseList(flag: string): string[] | null {
const arg = process.argv.find((value) => value.startsWith(`--${flag}=`));
return arg ? arg.slice(flag.length + 3).split(",").filter(Boolean) : null;
}
async function main(): Promise<void> {
const outDir = process.argv[2];
if (!outDir || outDir.startsWith("--")) {
throw new Error("Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=…] [--samples=…]");
}
await mkdir(outDir, { recursive: true });
const templates = parseList("templates") ?? listTemplateVersions();
const samples = parseList("samples") ?? Object.keys(SAMPLES);
for (const version of templates) {
for (const name of samples) {
const data = SAMPLES[name];
if (!data) throw new Error(`Unbekannter Datensatz "${name}".`);
const text = composeCertificateText(data);
const layout = layoutCertificate(text, version);
const base = path.join(outDir, `${version.replace("@", "-v")}_${name}`);
const [png, pdf] = await Promise.all([
renderCertificateImage(layout, { widthPx: 2480, format: "png" }),
renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: new Date(`${START}T00:00:00Z`) }),
]);
await writeFile(`${base}.png`, png);
await writeFile(`${base}.pdf`, pdf);
if (name === "standard") {
const preview = await renderCertificateImage(layout, { widthPx: 1240, format: "png", watermark: data.locale });
await writeFile(`${base}_vorschau.png`, preview);
}
process.stdout.write(`${base}.png / .pdf\n`);
}
}
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Rendert Beispiel-Medaillen aller Arten und Stufen als PNG (und SVG) zur
* Sichtpruefung. Aufruf: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]
*/
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { medalReliefSvg, renderMedalPng, renderMedalSvg, type MedalInput } from "../src/lib/medals";
const SAMPLES: Array<[string, MedalInput]> = [
["patenschaft-bronze", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Summsi", year: 2026, number: "WH-P-2026-00042" }],
["patenschaft-silber", { kind: "PATENSCHAFT", tier: "SILBER", beeName: "Zoë", year: 2026, number: "WH-P-2026-00043" }],
["patenschaft-gold", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Ñúñez", year: 2026, number: "WH-P-2026-00044" }],
["patenschaft-abo", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Hildegard", year: 2026, number: "WH-P-2026-00045" }],
["patenschaft-lang-24", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Wilhelmine Augusta Maxi", year: 2026, number: "WH-P-2026-00046" }],
["patenschaft-lang-ohne-umbruch", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Bienenköniginwunderbarer", year: 2026, number: "WH-P-2026-00047" }],
["patenschaft-abo-lang", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Wilhelmine-Augusta Maxi", year: 2026, number: "WH-P-2026-00048" }],
["stammkunde-1", { kind: "STAMMKUNDE", level: 1, name: "Blütenbesuch" }],
["stammkunde-2", { kind: "STAMMKUNDE", level: 2, name: "Wabenbau" }],
["stammkunde-3", { kind: "STAMMKUNDE", level: 3, name: "Honigschatz" }],
["stammkunde-4", { kind: "STAMMKUNDE", level: 4, name: "Goldwabe" }],
["abo-1", { kind: "ABO", months: 1 }],
["abo-12", { kind: "ABO", months: 12 }],
["abo-24", { kind: "ABO", months: 24 }],
["vip", { kind: "VIP" }],
];
async function main(): Promise<void> {
const target = process.argv[2];
if (!target) throw new Error("Zielordner fehlt: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]");
const size = Number(process.argv[3] ?? 1024);
await mkdir(target, { recursive: true });
for (const [name, input] of SAMPLES) {
await writeFile(path.join(target, `${name}.png`), await renderMedalPng(input, size));
await writeFile(path.join(target, `${name}.svg`), renderMedalSvg(input));
if (name === "patenschaft-gold") await writeFile(path.join(target, `${name}-relief.svg`), medalReliefSvg(input));
}
process.stdout.write(`${SAMPLES.length} Medaillen in ${target}\n`);
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Ersteinrichtung der Bienenpatenschaft in einer bestehenden Installation.
*
* Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der
* Inhaber schon bearbeitet hat (Felder werden nur gesetzt, wenn sie leer sind).
*
* - legt die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen),
* - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und
* Traegerprodukt an (syncOfferStripePrice).
*
* Einen Honig verknuepft das Skript nicht mehr (frueher --honey=<slug>):
* Pakete und Abos bekommen ihren Honig automatisch — nie cremig, neueste
* Ernte, genug Bestand (lib/sponsorship/honey-selection.ts). Die Spalte
* honeyProductId bleibt ungenutzt im Schema; der Probelauf zeigt zur
* Kontrolle, welchen Honig die Regel gerade waehlen wuerde.
*
* Aufruf (Werkzeug-Image, Umgebung wie die App):
* tsx scripts/sponsorship-bootstrap.ts (Probelauf)
* tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren)
*/
import { prisma } from "@/lib/prisma";
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
interface AboTemplate {
slug: string;
name: string;
nameEn: string;
description: string;
descriptionEn: string;
features: string[];
featuresEn: string[];
priceCents: number;
honeyJars: number;
honeyShareCentsPerMonth: number;
cycle: "VIERTELJAEHRLICH" | "ERNTE";
maxActiveSubscriptions: number;
sortOrder: number;
}
// Kurze Namen: die Verkaufsseite zeigt sie unter "Im Abo", Stripe und die
// Rechnung als "Bienenpatenschaft-Abo <Name>".
// Testpreise — vor der Aktivierung kaufmaennisch pruefen (Spec, "Geschaeftliche
// Konfiguration"). Honiganteil je Monat = Glaeser je Lieferung × 7 € ÷ Monate
// je Lieferung, zum Honig-Steuersatz.
const ABO_TEMPLATES: AboTemplate[] = [
{
slug: "abo-vierteljaehrlich",
name: "Vierteljährlich",
nameEn: "Quarterly",
description: "Monatlich zahlen, alle drei bezahlten Monate zwei Gläser Wabenhain-Honig. Versand inklusive, monatlich kündbar.",
descriptionEn: "Pay monthly, two jars of Wabenhain honey after every three paid months. Shipping included, cancel monthly.",
features: ["Symbolische Namensgebung mit Urkunde", "2 × 500 g Honig nach jeweils 3 bezahlten Monaten", "Versand inklusive", "Monatlich kündbar"],
featuresEn: ["Symbolic naming with certificate", "2 × 500 g honey after every 3 paid months", "Shipping included", "Cancel monthly"],
priceCents: 1290,
honeyJars: 2,
honeyShareCentsPerMonth: 467,
cycle: "VIERTELJAEHRLICH",
maxActiveSubscriptions: 20,
sortOrder: 10,
},
{
slug: "abo-ernte",
name: "Nach der Ernte",
nameEn: "After the harvest",
description: "Monatlich zahlen, Honig frisch nach der Ernte: drei Gläser, sobald seit der letzten Lieferung sechs Monate bezahlt sind. Versand inklusive, monatlich kündbar.",
descriptionEn: "Pay monthly, honey fresh after the harvest: three jars once six months have been paid since the last delivery. Shipping included, cancel monthly.",
features: ["Symbolische Namensgebung mit Urkunde", "3 × 500 g Honig im Erntefenster", "Versand inklusive", "Monatlich kündbar"],
featuresEn: ["Symbolic naming with certificate", "3 × 500 g honey in the harvest window", "Shipping included", "Cancel monthly"],
priceCents: 990,
honeyJars: 3,
honeyShareCentsPerMonth: 350,
cycle: "ERNTE",
maxActiveSubscriptions: 10,
sortOrder: 11,
},
];
async function main(): Promise<void> {
const apply = process.argv.includes("--apply");
const honey = pickHoneyProduct(await loadHoneyCandidates(), 1);
console.log(
`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}`
);
for (const template of ABO_TEMPLATES) {
const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } });
if (existing) {
console.log(` Abo ${template.slug}: vorhanden${existing.stripePriceId ? "" : ", Stripe-Preis fehlt"}`);
if (apply && !existing.stripePriceId) await syncOfferStripePrice(existing.id);
continue;
}
console.log(` Abo ${template.slug}: anlegen (inaktiv, ${(template.priceCents / 100).toFixed(2)} €/Monat) + Stripe-Preis`);
if (!apply) continue;
const created = await prisma.sponsorshipOffer.create({
data: {
...template,
kind: "ABO",
isActive: false,
isPhysical: true,
medalTier: "ABO",
},
});
await syncOfferStripePrice(created.id);
}
const offers = await prisma.sponsorshipOffer.findMany({
orderBy: { sortOrder: "asc" },
select: { slug: true, kind: true, isActive: true, priceCents: true, honeyJars: true, stripePriceId: true, productId: true },
});
console.table(offers);
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+189
View File
@@ -0,0 +1,189 @@
/**
* Stripe-Einrichtung fuer das Abo der Bienenpatenschaft (Spec
* "Stripe-Einrichtung", docs/EINRICHTUNG.md).
*
* Wiederholbar und ohne Geheimnisse im Code: liest STRIPE_SECRET_KEY aus der
* Umgebung (.env), vergleicht den Ist-Stand mit dem Soll und zeigt, was es
* aendern wuerde. Ohne --apply wird NICHTS geschrieben.
*
* pnpm tsx scripts/stripe-setup.ts # Probelauf (Standard)
* pnpm tsx scripts/stripe-setup.ts --apply # Aenderungen schreiben
* pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
*
* Gegen ein Livekonto (sk_live_/rk_live_) schreibt es nur mit dem
* zusaetzlichen Schalter --live-ok — erst nach ausdruecklicher Freigabe des
* Inhabers.
*
* Was es einrichtet:
* 1. Webhook-Endpunkt <BASE_URL>/api/checkout/webhook mit allen Ereignissen
* (die bestehenden sechs plus die Abo-Ereignisse). Bestehende Ereignisse
* bleiben, es wird nur ergaenzt.
* 2. Kundenportal-Konfiguration, die NUR das Aendern der Zahlungsart erlaubt
* (keine Kuendigung, kein Tarifwechsel, keine Kundendaten, keine
* Stripe-Rechnungen). Die App findet sie ueber ihre Metadaten.
* Was nur im Dashboard geht, gibt es am Ende als Checkliste aus.
*/
import "dotenv/config";
import Stripe from "stripe";
const REQUIRED_EVENTS = [
// bestehend (Einmalbestellungen)
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
"charge.refunded",
"charge.dispute.created",
// Abo
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.payment_failed",
"charge.dispute.closed",
] as const satisfies readonly Stripe.WebhookEndpointCreateParams.EnabledEvent[];
/** Muss zu PORTAL_METADATA_VALUE in src/lib/sponsorship-subscription/cancellation.ts passen. */
const PORTAL_METADATA = { wabenhain: "abo-zahlungsart" } as const;
const PORTAL_FEATURES = {
payment_method_update: { enabled: true },
subscription_cancel: { enabled: false },
subscription_update: { enabled: false },
customer_update: { enabled: false },
invoice_history: { enabled: false },
} as const;
interface Options {
apply: boolean;
liveOk: boolean;
baseUrl: string;
}
function parseOptions(argv: string[]): Options {
const baseArg = argv.find((arg) => arg.startsWith("--base-url="))?.slice("--base-url=".length);
const baseUrl = (baseArg || process.env.NEXT_PUBLIC_SITE_URL || "https://wabenhain.com").replace(/\/+$/, "");
return { apply: argv.includes("--apply"), liveOk: argv.includes("--live-ok"), baseUrl };
}
function log(line: string): void {
process.stdout.write(`${line}\n`);
}
async function ensureWebhook(stripe: Stripe, options: Options): Promise<void> {
const url = `${options.baseUrl}/api/checkout/webhook`;
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
const existing = endpoints.data.find((endpoint) => endpoint.url === url);
if (!existing) {
log(`Webhook: kein Endpunkt fuer ${url} — ${options.apply ? "wird angelegt" : "wuerde angelegt"} mit ${REQUIRED_EVENTS.length} Ereignissen.`);
if (!options.apply) return;
const created = await stripe.webhookEndpoints.create({
url,
enabled_events: [...REQUIRED_EVENTS],
description: "Wabenhain Shop (Bestellungen und Abos)",
});
log(` angelegt: ${created.id}. Das Signatur-Geheimnis steht NUR in dieser Antwort und im Dashboard:`);
log(" im Dashboard unter Entwickler → Webhooks → Endpunkt → Signaturgeheimnis anzeigen, als STRIPE_WEBHOOK_SECRET setzen.");
return;
}
const enabled = new Set<string>(existing.enabled_events);
if (enabled.has("*")) {
log(`Webhook ${existing.id}: empfaengt alle Ereignisse ("*") — nichts zu tun.`);
return;
}
const missing = REQUIRED_EVENTS.filter((event) => !enabled.has(event));
if (missing.length === 0) {
log(`Webhook ${existing.id}: alle ${REQUIRED_EVENTS.length} Ereignisse abonniert — nichts zu tun.`);
return;
}
log(`Webhook ${existing.id}: ${missing.length} Ereignisse fehlen — ${options.apply ? "werden ergaenzt" : "wuerden ergaenzt"}:`);
for (const event of missing) log(` + ${event}`);
if (existing.status !== "enabled") log(` Hinweis: Endpunkt ist "${existing.status}".`);
if (!options.apply) return;
await stripe.webhookEndpoints.update(existing.id, {
enabled_events: [...existing.enabled_events, ...missing] as Stripe.WebhookEndpointUpdateParams.EnabledEvent[],
});
log(" ergaenzt.");
}
function portalDeviations(config: Stripe.BillingPortal.Configuration): string[] {
const features = config.features;
const actual: Record<keyof typeof PORTAL_FEATURES, boolean> = {
payment_method_update: features.payment_method_update.enabled,
subscription_cancel: features.subscription_cancel.enabled,
subscription_update: features.subscription_update.enabled,
customer_update: features.customer_update.enabled,
invoice_history: features.invoice_history.enabled,
};
return (Object.keys(PORTAL_FEATURES) as (keyof typeof PORTAL_FEATURES)[])
.filter((key) => actual[key] !== PORTAL_FEATURES[key].enabled)
.map((key) => `${key}: ${actual[key]} → ${PORTAL_FEATURES[key].enabled}`);
}
async function ensurePortal(stripe: Stripe, options: Options): Promise<void> {
const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 100 });
const existing = configs.data.find((config) => config.metadata?.wabenhain === PORTAL_METADATA.wabenhain);
const defaultReturnUrl = `${options.baseUrl}/konto/patenschaft`;
if (!existing) {
log(`Kundenportal: keine Wabenhain-Konfiguration — ${options.apply ? "wird angelegt" : "wuerde angelegt"} (nur Zahlungsart aendern).`);
if (!options.apply) return;
const created = await stripe.billingPortal.configurations.create({
name: "Wabenhain Abo – nur Zahlungsart",
features: PORTAL_FEATURES,
default_return_url: defaultReturnUrl,
metadata: PORTAL_METADATA,
});
log(` angelegt: ${created.id} (die App findet sie ueber die Metadaten; optional STRIPE_PORTAL_CONFIGURATION_ID=${created.id}).`);
return;
}
const deviations = portalDeviations(existing);
if (deviations.length === 0) {
log(`Kundenportal ${existing.id}: erlaubt nur das Aendern der Zahlungsart — nichts zu tun.`);
return;
}
log(`Kundenportal ${existing.id}: weicht ab — ${options.apply ? "wird korrigiert" : "wuerde korrigiert"}:`);
for (const deviation of deviations) log(` ~ ${deviation}`);
if (!options.apply) return;
await stripe.billingPortal.configurations.update(existing.id, { features: PORTAL_FEATURES, default_return_url: defaultReturnUrl });
log(" korrigiert.");
}
function printDashboardChecklist(): void {
log("");
log("Nur im Stripe-Dashboard einstellbar (siehe docs/EINRICHTUNG.md):");
log(" [ ] Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen: Smart Retries an.");
log(" [ ] … nach dem letzten erfolglosen Versuch: Abo kuendigen (nicht 'als unbezahlt markieren').");
log(" [ ] … Kunden-E-Mails: Rechnungen/Belege fuer erfolgreiche Zahlungen, bevorstehende Verlaengerungen und");
log(" fehlgeschlagene Zahlungen AUS — Wabenhain verschickt eigene Rechnungen und Mails.");
log(" [ ] Einstellungen → Zahlungsmethoden: Karte, Apple Pay, Google Pay, SEPA-Lastschrift aktiv (Google Pay pruefen).");
log(" [ ] Apple Pay / Google Pay im eingebetteten Checkout: Domain wabenhain.com unter Zahlungsmethoden-Domains registrieren.");
log(" [ ] Stripe Tax nur mit aktiver Registrierung einschalten — sonst bleibt es aus (Preise sind Bruttopreise).");
}
async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2));
const key = process.env.STRIPE_SECRET_KEY?.trim();
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
const live = /^(sk|rk)_live_/.test(key);
if (live && options.apply && !options.liveOk) {
throw new Error("Livekonto: --apply schreibt hier nur zusammen mit --live-ok (nach Freigabe des Inhabers).");
}
log(`Stripe-Einrichtung (${live ? "LIVEMODUS" : "Testmodus"}, ${options.apply ? "SCHREIBT" : "Probelauf, schreibt nichts"})`);
log(`Basis-URL: ${options.baseUrl}`);
log("");
const stripe = new Stripe(key);
await ensureWebhook(stripe, options);
await ensurePortal(stripe, options);
printDashboardChecklist();
if (!options.apply) log("\nProbelauf beendet. Mit --apply schreiben.");
}
main().catch((error: unknown) => {
process.stderr.write(`Fehler: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
+6
View File
@@ -14,3 +14,9 @@ export * from "./actions/discount-codes";
export * from "./actions/content"; export * from "./actions/content";
export * from "./actions/apicore-sync"; export * from "./actions/apicore-sync";
export * from "./actions/dhl"; export * from "./actions/dhl";
export * from "./actions/archive";
export * from "./actions/sponsorship";
export * from "./actions/sponsorship-offers";
export * from "./actions/sponsorship-harvest";
export * from "./actions/sponsorship-subscriptions";
export * from "./actions/sponsorship-loyalty";
+7 -10
View File
@@ -1,4 +1,3 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import type { CustomerRole } from "@/generated/prisma/client"; import type { CustomerRole } from "@/generated/prisma/client";
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
return admin; return admin;
} }
export function fail(path: string, message: string): never { export { fail, succeed } from "@/lib/flash";
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */ /** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
export async function isLastAdmin(customerId: string): Promise<boolean> { export async function isLastAdmin(customerId: string): Promise<boolean> {
@@ -59,9 +56,9 @@ export function strOrNull(formData: FormData, key: string): string | null {
* Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben * Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben
* Namen) als getrimmtes String-Array ohne leere Eintraege. * Namen) als getrimmtes String-Array ohne leere Eintraege.
*/ */
export function strArray(formData: FormData, key: string): string[] { // Reine FormData-Leser ohne Datenbankbezug liegen in lib/form-fields.ts,
return formData // damit sie sich ohne DATABASE_URL testen lassen (_shared.ts zieht ueber
.getAll(key) // lib/prisma.ts einen Datenbank-Adapter nach, sobald es importiert wird).
.map((value) => String(value).trim()) // Re-Export, damit alle bestehenden Importe aus dieser Datei weiterhin
.filter(Boolean); // funktionieren.
} export { strArray, strArraySlots } from "@/lib/form-fields";
+557 -129
View File
@@ -5,106 +5,180 @@ 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 {
fetchApiCoreHarvests, getApiCoreConnection,
fetchApiCoreBottlingBatches, fetchApiCoreShopData,
isSellableStatus,
isManufactured,
ApiCoreConfigError, ApiCoreConfigError,
ApiCoreRequestError, ApiCoreRequestError,
type ApiCoreHarvestWithApiaryName, type ApiCoreShopData,
type ApiCoreHarvest,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId, type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client"; } from "@/lib/apicore-client";
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
import {
hasLegacyApiCoreSlug,
buildHarvestDescription,
buildProductDescription,
deriveJarWeightGrams,
productSlugCandidates,
slugifyProductName,
harvestFactChanges,
batchFactChanges,
} from "@/lib/apicore-mapping";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery";
import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile";
import type { ProductCategory } from "@/generated/prisma/client";
/** /**
* 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 * Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu * Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein * nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren * Anlegen nie mehr angefasst.
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
* verfuegbar sind.
* *
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B. * - Ernten: Datum und Jahr kommen immer aus ApiCore, auch bei
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") * veroeffentlichten Ernten. Sorte, Standort und Text sind nur eine
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische * Vorbelegung: ein unveroeffentlichter, noch von niemandem bearbeiteter
* Entwurf wird damit aufgefrischt, sonst bleiben sie, wie der Admin sie
* gesetzt hat.
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
* gepflegt werden und die es in ApiCore gar nicht gibt.
* - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
* Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
* eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
* liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
* Wiedererkannt wird eine Charge an der ApiCore-ID der Abfuellung, nicht an
* der Losnummer — die kann in ApiCore korrigiert werden.
*
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
* die ApiCore-ID der Ernte als Harvest.apicoreHarvestId, und die ApiCore-ID
* der Abfuellung als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
* Mehrere Ernten derselben Sorte im selben Jahr sind ausdruecklich erlaubt;
* sie unterscheiden sich im Slug am Erntedatum (lib/harvest-slug.ts).
*
* 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; interface HarvestSyncCounts extends SyncCounts {
let updated = 0; /**
let skipped = 0; * Entwuerfe, die ein Mensch bearbeitet hat: ihre Shop-Felder blieben
let failed = 0; * unveraendert (Fakten wie das Datum werden trotzdem nachgezogen).
*/
protectedDrafts: number;
}
for (const source of sourceHarvests) { interface BatchSyncCounts extends SyncCounts {
const slug = slugFor(source.id); /** Chargen, deren Shop-Bestand ueber der (korrigierten) Abfuellmenge liegt. */
stockConflicts: number;
}
/**
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
* damit nicht mit.
*/
async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
const entries = await prisma.auditLog.count({
where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } },
});
return entries > 0;
}
/**
* Ernten aus der Zeit, als der Slug "apicore-<ID>" der Sync-Schluessel war,
* bekommen eine lesbare Adresse. Gefahrlos auch fuer veroeffentlichte
* Ernten: oeffentlich verlinkt war bis dahin /ernten/<jahr>/<sorte>, nie der
* Slug. Gibt zurueck, ob umbenannt wurde.
*/
async function replaceLegacySlug(harvest: { id: string; slug: string; type: string; harvestDate: Date }): Promise<boolean> {
if (!hasLegacyApiCoreSlug(harvest.slug)) return false;
await writeWithFreeSlug(harvestSlugCandidates(harvest.type, harvest.harvestDate), (slug) =>
prisma.harvest.update({ where: { id: harvest.id }, data: { slug } }),
findTakenHarvestSlugs
);
return true;
}
/**
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
* siehe syncProducts().
*/
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;
}
function describeError(error: unknown): string {
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: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
for (const source of sources) {
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;
} }
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`; const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
try { try {
const existing = await prisma.harvest.findUnique({ where: { slug } }); const existing = await prisma.harvest.findUnique({ where: { apicoreHarvestId: source.id } });
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
if (existing?.isPublished) { if (existing && shopOwned) {
skipped++; // Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
const facts = harvestFactChanges(existing, date);
if (!existing.isPublished) counts.protectedDrafts++;
const renamed = await replaceLegacySlug(existing);
if (Object.keys(facts).length === 0) {
if (renamed) counts.updated++;
else counts.skipped++;
continue;
}
await prisma.harvest.update({ where: { id: existing.id }, data: facts });
counts.updated++;
continue; continue;
} }
@@ -113,113 +187,467 @@ 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: { id: existing.id }, data });
updated++; await replaceLegacySlug({ ...existing, ...data });
counts.updated++;
} else { } else {
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } }); // Der Slug ist die oeffentliche Adresse und wird aus Sorte und Datum
created++; // gebildet; mehrere Ernten derselben Sorte im selben Jahr bekommen
// ueber das Erntedatum je eine eigene (lib/harvest-slug.ts).
await writeWithFreeSlug(harvestSlugCandidates(type, date), (slug) =>
prisma.harvest.create({ data: { ...data, slug, apicoreHarvestId: source.id, isPublished: false } }),
findTakenHarvestSlugs
);
counts.created++;
} }
} catch (error: unknown) { } catch (error: unknown) {
failed++; counts.failed++;
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; // Bewusst nicht fatal fuer den gesamten Sync-Lauf: die uebrigen Ernten
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten // werden weiter verarbeitet, der Fehler steht im Server-Log.
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`);
} }
} }
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; * Ein neu importiertes Produkt ist absichtlich NICHT verkaufsfaehig:
let batchSkipped = 0; *
let batchFailed = 0; * - `isActive: false` haelt es aus Shop, Suche und Checkout heraus.
let batchLoadError: string | null = null; * - `priceCents: 0` ist kein geschaetzter, sondern ein FEHLENDER Preis:
* ApiCore fuehrt ueberhaupt keine Verkaufspreise, das ist reine
* Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Damit aus
* diesem Platzhalter nie eine Gratis-Bestellung wird, weigern sich
* zusaetzlich die Aktivierung im Admin und der Checkout, ein Produkt mit
* 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;
}
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
try { try {
sourceBatches = await fetchApiCoreBottlingBatches(); const existing = await prisma.product.findUnique({
} catch (error: unknown) { where: { apicoreBottlingId: bottling.id },
batchLoadError = select: { id: true },
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError });
? error.message if (existing) {
: error instanceof Error counts.skipped++;
? error.message continue;
: "Unbekannter Fehler";
} }
for (const source of sourceBatches) { // 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 found = await findBatchForBottling(bottling.id, bottling.lotNumber);
const batch = found
? await prisma.batch.findUnique({
where: { id: found.id },
include: { harvest: { select: { id: true, year: true } } },
})
: null;
if (!batch) {
counts.pending++;
continue;
}
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)}`
);
}
}
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.
*/
const SHOP_CATEGORIES: readonly ProductCategory[] = ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"];
function shopCategoryOf(article: ApiCoreProduct): ProductCategory {
const category = article.shopCategory as ProductCategory | undefined;
return category && SHOP_CATEGORIES.includes(category) && category !== "HONIG" ? category : "SONSTIGES";
}
/**
* Hergestellte Waren (Kerzen, Propolis-Tinkturen, Salben, ...): EIN
* Shop-Produkt je ApiCore-Artikel, nicht je Charge wie beim Honig. Kundinnen
* kaufen "Bienenwachskerze klein", nicht eine bestimmte Charge — welche Charge
* verkauft wird, entscheidet ApiCore (aelteste zuerst) und meldet es zurueck.
*
* Gleiche Regeln wie bei Honig-Entwuerfen: deaktiviert, ohne Preis, nie
* aktualisiert. Ein schon in Wabenhain gepflegtes Produkt verknuepft man
* im Admin ("ApiCore-Artikel verknuepfen") statt es neu anlegen zu lassen.
*/
async function syncManufacturedProducts(articles: ApiCoreProduct[]): Promise<ProductSyncCounts> {
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
for (const article of articles.filter(isManufactured)) {
try {
const linked = await prisma.product.findUnique({
where: { apicoreProductId: article.id },
select: { id: true },
});
if (linked) {
counts.skipped++;
continue;
}
if (!isSellableStatus(article.status)) {
counts.discontinued++;
continue;
}
const base = slugifyProductName(article.name) || "produkt";
const sku = slugifyProductName(article.sku ?? "");
const slug = await firstFreeSlug(sku ? [base, `${base}-${sku}`] : [base]);
if (!slug) {
counts.failed++;
console.error(
`[apicore-sync] Für "${article.name}" ist keine freie Adresse frei — Produkt bitte von Hand anlegen und verknüpfen.`
);
continue;
}
await prisma.product.create({
data: {
slug,
apicoreProductId: article.id,
name: article.name,
description: buildProductDescription(article),
weightGrams: deriveJarWeightGrams(null, article),
category: shopCategoryOf(article),
priceCents: 0,
pricePerKgCents: 0,
isActive: false,
// Der Bestand kommt im Abgleich direkt danach aus ApiCore.
inventory: { create: { quantity: 0 } },
},
});
counts.created++;
} catch (error: unknown) {
counts.failed++;
console.error(`[apicore-sync] Artikel "${article.name}" konnte nicht als Produkt importiert werden: ${describeError(error)}`);
}
}
return counts;
}
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
// ---------------------------------------------------------------------------
/**
* Die Wabenhain-Charge zu einer ApiCore-Abfuellung: zuerst ueber die
* gespeicherte ApiCore-ID, sonst — fuer Chargen aus der Zeit vor dieser
* Spalte — ueber die Losnummer, sofern die Charge noch keiner anderen
* Abfuellung gehoert.
*/
async function findBatchForBottling(bottlingId: string, lotNumber: string) {
const byId = await prisma.batch.findUnique({ where: { apicoreBottlingId: bottlingId } });
if (byId) return byId;
const byCode = await prisma.batch.findUnique({ where: { code: lotNumber } });
return byCode && byCode.apicoreBottlingId === null ? byCode : null;
}
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<BatchSyncCounts> {
const counts: BatchSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 };
for (const source of sources) {
if (!source.harvestSourceId) { 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;
} }
try { try {
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } }); const existing = await findBatchForBottling(source.id, source.lotNumber);
if (existing) { if (existing) {
batchSkipped++; const { changes, stockExceedsFilled } = batchFactChanges(existing, {
code: source.lotNumber,
fillDate,
bestBeforeDate,
glassCount: Math.max(Math.round(source.jarCount), 0),
});
const adopt = existing.apicoreBottlingId === null;
if (stockExceedsFilled) {
counts.stockConflicts++;
console.warn(
`[apicore-sync] Charge ${source.lotNumber}: Shop-Bestand (${existing.remainingGlasses}) liegt über der Abfüllmenge — bitte prüfen.`
);
}
if (Object.keys(changes).length === 0 && !adopt) {
counts.skipped++;
continue; continue;
} }
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } }); await prisma.batch.update({
where: { id: existing.id },
data: { ...changes, apicoreBottlingId: source.id },
});
counts.updated++;
continue;
}
const harvest = await prisma.harvest.findUnique({
where: { apicoreHarvestId: 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,
apicoreBottlingId: source.id,
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;
}
// ---------------------------------------------------------------------------
// Server Actions
// ---------------------------------------------------------------------------
/**
* Ziele, zu denen nach dem Sync zurueckgesprungen werden darf.
*
* Feste Liste statt des uebergebenen Werts: "returnTo" kommt aus einem
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
* Wert koennte Admins auf eine fremde Seite schicken.
*/
// "/admin" ist der Normalfall (der eine Sync-Knopf sitzt auf dem Dashboard);
// die uebrigen bleiben erlaubt, damit alte Lesezeichen/Formulare nicht ins Leere laufen.
const RETURN_TARGETS = ["/admin", "/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
function returnTargetFrom(formData: FormData): string {
const requested = formData.get("returnTo");
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin";
}
/**
* EIN Knopf fuer alles: zieht Ernten, daraus die Chargen und daraus die
* Produkte — in genau dieser Reihenfolge, weil jede Stufe auf der
* vorherigen aufbaut.
*
* Eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, und ein
* Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer
* das getrennt ausloest, muss die Reihenfolge kennen und zweimal klicken;
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
*
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
*/
export async function syncApiCoreAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const returnTo = returnTargetFrom(formData);
let source: ApiCoreShopData;
try {
source = await fetchApiCoreShopData(getApiCoreConnection());
} catch (error: unknown) {
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
}
// 1. Ernten
const harvests = await syncHarvests(source.harvests);
await logAdminAction(admin, {
action: "harvest.sync_apicore",
entityType: "Harvest",
detail:
`${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` +
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
});
// 2. Chargen
const batches = await syncBatches(source.bottlings);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "batch.sync_apicore", action: "batch.sync_apicore",
entityType: "Batch", entityType: "Batch",
detail: batchLoadError detail:
? `Chargen konnten nicht geladen werden: ${batchLoadError}` `${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`, `${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
}); });
// 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel.
const honeyProducts = await syncProducts(source.products, source.bottlings);
const manufacturedProducts = await syncManufacturedProducts(source.products);
const products: ProductSyncCounts = {
created: honeyProducts.created + manufacturedProducts.created,
skipped: honeyProducts.skipped + manufacturedProducts.skipped,
failed: honeyProducts.failed + manufacturedProducts.failed,
pending: honeyProducts.pending,
discontinued: honeyProducts.discontinued + manufacturedProducts.discontinued,
};
await logAdminAction(admin, {
action: "product.sync_apicore",
entityType: "Product",
detail:
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
});
// 4. Bestand — offene Buchungen senden, dann alle verknuepften Produkte
// auf den ApiCore-Rest setzen. Scheitert das, bleibt der Sync trotzdem
// gueltig; der CronJob holt den Abgleich nach.
try {
const delivered = await deliverPendingStockEvents();
const reconciled = await reconcileApiCoreStock();
await logAdminAction(admin, {
action: "stock.sync_apicore",
entityType: "Inventory",
detail:
`${delivered.sent} Buchungen bestätigt, ${delivered.rejected} abgelehnt, ${delivered.failed} offen; ` +
`${reconciled.updated} Bestände abgeglichen, ${reconciled.missing} in ApiCore nicht gefunden`,
});
} catch (error: unknown) {
console.error(`[apicore-sync] Bestandsabgleich fehlgeschlagen: ${describeError(error)}`);
}
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/admin/chargen");
revalidatePath("/admin/produkte");
redirect( redirect(
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` + `${returnTo}?syncDone=1` +
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` + `&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "") `&hProtected=${harvests.protectedDrafts}` +
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
`&bConflicts=${batches.stockConflicts}` +
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
); );
} }
+65
View File
@@ -0,0 +1,65 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import type { ArchiveKind } from "@/lib/archive";
import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash";
const LIST_PATH: Record<ArchiveKind, string> = {
orders: "/admin/bestellungen",
returns: "/admin/retouren",
products: "/admin/produkte",
};
const ENTITY_TYPE: Record<ArchiveKind, string> = {
orders: "Order",
returns: "ReturnRequest",
products: "Product",
};
function isArchiveKind(value: string): value is ArchiveKind {
return value in LIST_PATH;
}
/**
* Archiviert einen Eintrag von Hand oder holt ihn zurueck.
*
* Archivieren setzt archivedManually, damit die Automatik den Eintrag nicht
* beim naechsten Seitenaufruf wieder in die aktive Liste holt.
* Wiederherstellen setzt die Markierung zurueck; da @updatedAt dabei die
* Bearbeitungszeit erneuert, bleibt der Eintrag einen weiteren Monat aktiv.
*/
export async function setArchivedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const kind = str(formData, "kind");
const id = str(formData, "id");
const archive = str(formData, "mode") === "archive";
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
const data = archive
? { archivedAt: new Date(), archivedManually: true }
: { archivedAt: null, archivedManually: false };
// updateMany statt update: eine nicht (mehr) vorhandene ID ist dann kein
// Fehler, sondern schlicht count 0 — z.B. nach dem Loeschen in einem
// zweiten Tab.
const where = { id };
const result =
kind === "orders"
? await prisma.order.updateMany({ where, data })
: kind === "returns"
? await prisma.returnRequest.updateMany({ where, data })
: await prisma.product.updateMany({ where, data });
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
await logAdminAction(admin, {
action: archive ? "archive.manual_archive" : "archive.manual_restore",
entityType: ENTITY_TYPE[kind],
entityId: id,
});
revalidatePath(LIST_PATH[kind]);
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
}
+31 -4
View File
@@ -1,19 +1,24 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; 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 { CONTENT_REGISTRY } from "@/lib/content-registry"; import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
const COMPANY_EMAIL_KEY = "company.email";
/** /**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action * — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht * verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" / * lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
* "en::{key}" (siehe admin/inhalte/page.tsx). * "en::{key}" (siehe admin/einstellungen/content-form.tsx).
*/ */
export async function updateSiteContentAction(formData: FormData): Promise<void> { export async function updateSiteContentAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -48,7 +53,29 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) { if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
continue; continue;
} }
updates.push({ key: def.key, valueDe, valueEn: null }); // Die Firmen-E-Mail darf so eingetragen werden, wie sie gemeint ist —
// auch verschleiert ("info [at] …", "info [á] …"). Gespeichert wird der
// Eintrag unveraendert; die Website rechnet ihn in die echte Adresse um
// und zeigt sie geschuetzt an (lib/contact-email.ts). Abgelehnt wird nur,
// was sich in keine gueltige Adresse umrechnen laesst — sonst stuende im
// Impressum ein Kontaktweg, der ins Leere fuehrt.
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
return fail(
settingsPathForGroup(group),
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
);
}
// Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder
// eine echte Adresse, sonst verschwaenden die Kopien stillschweigend.
// Genau eine Adresse: "[email protected], [email protected]" waere fuer nodemailer eine Liste.
if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && !/^[^,;]+$/.test(normalizeContactEmail(valueDe) ?? "")) {
return fail(
settingsPathForGroup(group),
"„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. [email protected] eintragen oder das Feld leer lassen."
);
}
const trimmed = def.key === COMPANY_EMAIL_KEY || def.key === INVOICE_COPY_KEY;
updates.push({ key: def.key, valueDe: trimmed ? valueDe.trim() : valueDe, valueEn: null });
} }
} }
@@ -70,5 +97,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
redirect("/admin/inhalte?saved=1"); return succeed(settingsPathForGroup(group), "Einstellungen gespeichert.");
} }
+41 -19
View File
@@ -1,6 +1,5 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -10,6 +9,9 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation"; import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
import { requireAdmin, fail, isLastAdmin, str } from "./_shared"; import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
export async function createCustomerAction(formData: FormData): Promise<void> { export async function createCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -21,12 +23,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE", role: str(formData, "role") || "KUNDE",
}); });
if (!parsed.success) { if (!parsed.success) {
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (existing) { if (existing) {
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto."); return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
} }
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen, // Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
@@ -43,7 +45,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
}, },
}); });
await createAndSendPasswordResetEmail(customer); const mailSent = await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "customer.create", action: "customer.create",
entityType: "Customer", entityType: "Customer",
@@ -52,7 +54,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
redirect("/admin/benutzer"); if (!mailSent) {
return fail(
"/admin/benutzer",
`Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.`
);
}
return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`);
} }
export async function updateCustomerAction(formData: FormData): Promise<void> { export async function updateCustomerAction(formData: FormData): Promise<void> {
@@ -67,23 +75,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE", role: str(formData, "role") || "KUNDE",
}); });
if (!parsed.success) { if (!parsed.success) {
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
if (id === admin.id && parsed.data.role !== admin.role) { if (id === admin.id && parsed.data.role !== admin.role) {
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern."); return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
} }
const target = await prisma.customer.findUnique({ where: { id } }); const target = await prisma.customer.findUnique({ where: { id } });
if (!target) fail("/admin/benutzer", "Konto nicht gefunden."); if (!target) return fail("/admin/benutzer", "Konto nicht gefunden.");
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) { if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden."); return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
} }
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } }); const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (emailOwner && emailOwner.id !== id) { if (emailOwner && emailOwner.id !== id) {
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto."); return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
} }
const emailChanged = parsed.data.email !== target.email; const emailChanged = parsed.data.email !== target.email;
@@ -116,7 +124,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
redirect("/admin/benutzer"); return succeed("/admin/benutzer", "Änderungen gespeichert.");
} }
export async function blockCustomerAction(formData: FormData): Promise<void> { export async function blockCustomerAction(formData: FormData): Promise<void> {
@@ -124,10 +132,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id"); const id = str(formData, "id");
if (id === admin.id) { if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren."); return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
} }
if (await isLastAdmin(id)) { if (await isLastAdmin(id)) {
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden."); return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
} }
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der // sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
@@ -139,6 +147,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
}); });
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email }); await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
} }
export async function unblockCustomerAction(formData: FormData): Promise<void> { export async function unblockCustomerAction(formData: FormData): Promise<void> {
@@ -156,6 +165,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
detail: unblocked.email, detail: unblocked.email,
}); });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
} }
export async function deleteCustomerAction(formData: FormData): Promise<void> { export async function deleteCustomerAction(formData: FormData): Promise<void> {
@@ -163,16 +173,27 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id"); const id = str(formData, "id");
if (id === admin.id) { if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen."); return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
} }
if (await isLastAdmin(id)) { if (await isLastAdmin(id)) {
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden."); return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
}
// Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege
// Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts).
if (await hasBlockingSubscription(id)) {
return fail(
"/admin/benutzer",
"Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen."
);
} }
const certificateDirs = await certificateDirsOfCustomer(id);
await discardPendingCheckouts(id);
const deleted = await prisma.customer.delete({ where: { id } }); const deleted = await prisma.customer.delete({ where: { id } });
await removeCertificateDirs(certificateDirs);
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email }); await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
revalidatePath("/admin/benutzer"); revalidatePath("/admin/benutzer");
redirect("/admin/benutzer"); return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
} }
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */ /** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
@@ -181,14 +202,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
const id = str(formData, "id"); const id = str(formData, "id");
const customer = await prisma.customer.findUnique({ where: { id } }); const customer = await prisma.customer.findUnique({ where: { id } });
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden."); if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
await createAndSendPasswordResetEmail(customer); const mailSent = await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "customer.password_reset_sent", action: "customer.password_reset_sent",
entityType: "Customer", entityType: "Customer",
entityId: id, entityId: id,
detail: customer.email, detail: customer.email,
}); });
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt.")); if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
} }
+15 -9
View File
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { setFlash } from "@/lib/flash";
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe // Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format, // Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
}, },
}); });
if (!order) { if (!order) {
fail("/admin/bestellungen", "Bestellung nicht gefunden."); return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
} }
if (order.fulfillmentMethod !== "VERSAND") { if (order.fulfillmentMethod !== "VERSAND") {
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig."); return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
} }
if (order.payment?.status !== "SUCCEEDED") { if (order.payment?.status !== "SUCCEEDED") {
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden."); return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
} }
if (order.shipment?.trackingNumber) { if (order.shipment?.trackingNumber) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
} }
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige // Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
claimed = false; claimed = false;
} }
if (!claimed) { if (!claimed) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade."); return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
} }
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0); const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
@@ -107,6 +108,10 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
street: order.street, street: order.street,
zip: order.zip, zip: order.zip,
city: order.city, city: order.city,
// Bei gesetzter Packstation-Nummer baut lib/dhl.ts daraus einen
// Locker-Empfaenger statt einer Hausanschrift.
packstationNumber: order.packstationNumber,
postNumber: order.postNumber,
country: order.country, country: order.country,
phone: order.phone, phone: order.phone,
email: order.email, email: order.email,
@@ -122,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
.catch(() => {}); .catch(() => {});
if (error instanceof DhlNotConfiguredError) { if (error instanceof DhlNotConfiguredError) {
fail(detailPath, error.message); return fail(detailPath, error.message);
} }
if (error instanceof DhlApiError) { if (error instanceof DhlApiError) {
fail(detailPath, error.message); return fail(detailPath, error.message);
} }
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const message = error instanceof Error ? error.message : "Unbekannter Fehler";
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
} }
try { try {
@@ -149,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`, `createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
error error
); );
fail( return fail(
detailPath, detailPath,
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen." "Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
); );
@@ -164,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bestellungen"); revalidatePath("/admin/bestellungen");
revalidatePath(detailPath); revalidatePath(detailPath);
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
} }
+9 -9
View File
@@ -1,11 +1,11 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; 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 { discountCodeSchema } from "@/lib/validation"; import { discountCodeSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { succeed } from "@/lib/flash";
function parseDiscountCodeForm(formData: FormData) { function parseDiscountCodeForm(formData: FormData) {
const rawMax = str(formData, "maxUses"); const rawMax = str(formData, "maxUses");
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData); const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing) { if (existing) {
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits."); return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
} }
const code = await prisma.discountCode.create({ data: parsed.data }); const code = await prisma.discountCode.create({ data: parsed.data });
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein angelegt.");
} }
export async function updateDiscountCodeAction(formData: FormData): Promise<void> { export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData); const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } }); const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing && existing.id !== id) { if (existing && existing.id !== id) {
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet."); return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
} }
await prisma.discountCode.update({ where: { id }, data: parsed.data }); await prisma.discountCode.update({ where: { id }, data: parsed.data });
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein gespeichert.");
} }
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> { export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
try { try {
deleted = await prisma.discountCode.delete({ where: { id } }); deleted = await prisma.discountCode.delete({ where: { id } });
} catch { } catch {
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht."); return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
} }
await logAdminAction(admin, { await logAdminAction(admin, {
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
}); });
revalidatePath("/admin/gutscheine"); revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine"); return succeed("/admin/gutscheine", "Gutschein gelöscht.");
} }
+47 -22
View File
@@ -1,35 +1,60 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; 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 { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, num, strArray } from "./_shared"; import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
import { succeed } from "@/lib/flash";
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
/**
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
* auf /charge/[code] das Bild fuellt (Slot 1 links, Slot 2 rechts/Standort) —
* deshalb ein leerer Slot als "" statt eines Eintrags weniger. Ein ungueltiger
* Pfad wird ebenfalls zu "" und damit zum Platzhalter, statt die
* nachfolgenden Slots zu verschieben.
*/
function imageSlots(formData: FormData): string[] {
const slots = strArraySlots(formData, "images").map((path) =>
isValidUploadPath(path) ? path : ""
);
while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop();
return slots;
}
export async function createHarvestAction(formData: FormData): Promise<void> { export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const year = num(formData, "year"); const year = num(formData, "year");
const type = str(formData, "type"); const type = str(formData, "type");
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`; const harvestDate = new Date(str(formData, "harvestDate"));
if (!type || !Number.isInteger(year) || year < 1 || Number.isNaN(harvestDate.getTime())) {
return fail("/admin/ernten", "Bitte Sorte, Jahr und ein gültiges Erntedatum angeben.");
}
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz // Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer // (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor // manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
// Einfuehrung von isPublished). // Einfuehrung von isPublished).
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on"; const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
const harvest = await prisma.harvest.create({ // Dieselbe Sorte darf mehrmals im Jahr geerntet werden; die zweite bekommt
// eine Adresse mit Erntedatum statt eines Fehlers (lib/harvest-slug.ts).
const harvest = await writeWithFreeSlug(harvestSlugCandidates(type, harvestDate), (slug) =>
prisma.harvest.create({
data: { data: {
year, year,
type, type,
slug, slug,
harvestDate: new Date(str(formData, "harvestDate")), harvestDate,
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
isPublished, isPublished,
}, },
}); }),
findTakenHarvestSlugs
);
await logAdminAction(admin, { await logAdminAction(admin, {
action: "harvest.create", action: "harvest.create",
@@ -40,7 +65,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Ernte angelegt.");
} }
export async function updateHarvestAction(formData: FormData): Promise<void> { export async function updateHarvestAction(formData: FormData): Promise<void> {
@@ -56,7 +81,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")), harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
isPublished, isPublished,
}, },
}); });
@@ -65,7 +90,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Ernte gespeichert.");
} }
/** /**
@@ -84,10 +109,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
where: { id }, where: { id },
include: { _count: { select: { batches: true, products: true } } }, include: { _count: { select: { batches: true, products: true } } },
}); });
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden."); if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
if (harvest._count.batches > 0 || harvest._count.products > 0) { if (harvest._count.batches > 0 || harvest._count.products > 0) {
fail( return fail(
"/admin/ernten", "/admin/ernten",
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.` `Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
); );
@@ -104,7 +129,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Ernte gelöscht.");
} }
/** /**
@@ -133,7 +158,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten"); revalidatePath("/ernten");
redirect("/admin/ernten"); return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
} }
export async function createBatchAction(formData: FormData): Promise<void> { export async function createBatchAction(formData: FormData): Promise<void> {
@@ -147,14 +172,14 @@ export async function createBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")), bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"), glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"), remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
}, },
}); });
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code }); await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge angelegt.");
} }
export async function updateBatchAction(formData: FormData): Promise<void> { export async function updateBatchAction(formData: FormData): Promise<void> {
@@ -168,14 +193,14 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")), bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"), glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"), remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
}, },
}); });
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id }); await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge gespeichert.");
} }
/** /**
@@ -195,16 +220,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
where: { id }, where: { id },
include: { _count: { select: { products: true, orderItems: true } } }, include: { _count: { select: { products: true, orderItems: true } } },
}); });
if (!batch) fail("/admin/chargen", "Charge nicht gefunden."); if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
if (batch._count.orderItems > 0) { if (batch._count.orderItems > 0) {
fail( return fail(
"/admin/chargen", "/admin/chargen",
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.` `Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
); );
} }
if (batch._count.products > 0) { if (batch._count.products > 0) {
fail( return fail(
"/admin/chargen", "/admin/chargen",
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.` `Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
); );
@@ -215,5 +240,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code }); await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
revalidatePath("/admin/chargen"); revalidatePath("/admin/chargen");
redirect("/admin/chargen"); return succeed("/admin/chargen", "Charge gelöscht.");
} }
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { strArray, strArraySlots } from "@/lib/form-fields";
/**
* Chargen- und Erntebilder werden ueber ZWEI Formularfelder mit demselben
* Namen "images" uebermittelt — die Position entscheidet, welche Kachel auf
* /charge/[code] gefuellt wird (Slot 1 links, Slot 2 rechts/Standort).
* strArraySlots muss diese Position erhalten, strArray darf es weiterhin
* nicht (Produktbilder sind eine echte, lueckenlose Liste).
*/
function formWith(...images: string[]): FormData {
const formData = new FormData();
for (const image of images) formData.append("images", image);
return formData;
}
describe("strArraySlots", () => {
it("haelt einen leeren ersten Slot offen, damit das zweite Bild in der rechten Kachel landet", () => {
const slots = strArraySlots(formWith("", "/uploads/standort.webp"), "images");
// Genau der Fall, der vorher unmoeglich war: nur das Standortbild pflegen.
expect(slots).toEqual(["", "/uploads/standort.webp"]);
});
it("laesst strArray denselben Fall weiterhin zusammenrutschen", () => {
// Gegenprobe zur Begruendung der zweiten Funktion: mit strArray waere das
// Standortbild auf Position 0 gerutscht und in der linken Kachel gelandet.
expect(strArray(formWith("", "/uploads/standort.webp"), "images")).toEqual([
"/uploads/standort.webp",
]);
});
it("behaelt beide Slots, wenn beide gefuellt sind", () => {
expect(strArraySlots(formWith("/uploads/a.webp", "/uploads/b.webp"), "images")).toEqual([
"/uploads/a.webp",
"/uploads/b.webp",
]);
});
it("entfernt am Ende stehende leere Slots", () => {
// Nur das erste Bild gepflegt: der leere zweite Slot muss weg, sonst
// stuende in der Datenbank ["/uploads/a.webp", ""] statt eines Eintrags.
expect(strArraySlots(formWith("/uploads/a.webp", ""), "images")).toEqual([
"/uploads/a.webp",
]);
});
it("ergibt fuer ein vollstaendig leeres Feld ein leeres Array, nicht zwei leere Slots", () => {
expect(strArraySlots(formWith("", ""), "images")).toEqual([]);
});
it("schneidet Leerzeichen ab", () => {
expect(strArraySlots(formWith(" /uploads/a.webp ", " "), "images")).toEqual([
"/uploads/a.webp",
]);
});
it("liefert ein leeres Array, wenn das Feld gar nicht vorkommt", () => {
expect(strArraySlots(new FormData(), "images")).toEqual([]);
});
});
+6 -4
View File
@@ -3,17 +3,19 @@
import { revalidatePath } from "next/cache"; 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 { requireAdmin, str } from "./_shared"; import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash";
import { isInquiryStatus } from "@/lib/inquiry-status";
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> { export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
const status = str(formData, "status"); const status = str(formData, "status");
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return; if (!isInquiryStatus(status)) return fail("/admin/anfragen", "Ungültiger Status.");
await prisma.businessInquiry.update({ await prisma.businessInquiry.update({
where: { id }, where: { id },
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" }, data: { status },
}); });
await logAdminAction(admin, { await logAdminAction(admin, {
@@ -22,6 +24,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
entityId: id, entityId: id,
detail: status, detail: status,
}); });
revalidatePath("/admin/anfragen"); revalidatePath("/admin/anfragen");
await setFlash("success", "Status der Anfrage gespeichert.");
} }
+407 -11
View File
@@ -6,9 +6,23 @@ import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory"; import {
InsufficientStockError,
needsConfirmToChangeStatus,
needsReserveOnReopen,
needsRestockOnCancel,
reserveOrderItems,
restockOrderItems,
} from "@/lib/inventory";
import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [ const ORDER_STATUSES = [
"NEU", "NEU",
@@ -21,13 +35,109 @@ const ORDER_STATUSES = [
"STORNIERT", "STORNIERT",
] as const; ] as const;
/**
* Erledigt die Geldseite einer gerade ausgesprochenen Stornierung.
*
* Zwei Dinge, die eine Statusaenderung allein offen laesst:
*
* 1. WAR SCHON BEZAHLT — dann muss das Geld zurueck. Ohne diesen Schritt
* stand die Bestellung als "storniert" da, die Ware lag wieder im Regal,
* und der Betrag blieb trotzdem beim Shop. Erstattet wird der volle
* Betrag; eine Teilerstattung ist ueber den Retouren-Bereich abzuwickeln,
* wo sie hingehoert.
*
* 2. WAR NOCH OFFEN ("NEU") — dann ist die Stripe-Sitzung unter Umstaenden
* noch bezahlbar. Sie muss geschlossen werden, sonst kann die Kundschaft
* Minuten spaeter noch zahlen: der Webhook findet die Bestellung dann
* nicht mehr im Status "NEU", bricht ab, und das Geld liegt bei Stripe,
* ohne dass im Shop irgendetwas davon zu sehen waere.
*
* Fehler werden nicht geworfen, sondern als Klartext zurueckgegeben. Die
* Stornierung ist an dieser Stelle schon geschrieben und die Ware
* zurueckgebucht — sie deswegen abzubrechen wuerde nichts heilen. Was offen
* bleibt, gehoert stattdessen sichtbar vor die Imkerei, damit sie es von Hand
* erledigen kann.
*
* @returns leere Liste, wenn alles erledigt ist; sonst je offener Punkt einen
* Satz fuer die Fehlermeldung im Admin-Bereich.
*/
async function settleCancelledOrder(order: {
orderId: string;
orderNumber: string;
previousStatus: OrderStatus;
stripeCheckoutSessionId: string | null;
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
}): Promise<string[]> {
const warnings: string[] = [];
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
// 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten.
if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) {
const paymentIntentId = order.payment.stripePaymentIntentId;
if (!paymentIntentId) {
warnings.push(
"Zu dieser bezahlten Bestellung ist keine Stripe-Zahlung hinterlegt — die Erstattung muss von Hand angestossen werden."
);
} else {
try {
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.payment.update({
where: { orderId: order.orderId },
data: { status: "REFUNDED" },
});
} catch (error: unknown) {
console.error(
`[order-cancel] Erstattung fuer Bestellung ${order.orderNumber} (PaymentIntent ${paymentIntentId}) fehlgeschlagen:`,
error
);
warnings.push(
"Die Erstattung ueber Stripe ist fehlgeschlagen — bitte im Stripe-Dashboard von Hand erstatten."
);
}
}
}
if (order.previousStatus === "NEU" && order.stripeCheckoutSessionId) {
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
// Nur eine offene Sitzung laesst sich schliessen. "complete" bedeutet,
// dass in der Zwischenzeit doch bezahlt wurde — dann ist das hier ein
// Wettlauf, den die Zahlung gewonnen hat, und er gehoert gemeldet statt
// stillschweigend uebergangen.
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
warnings.push(
"Achtung: Diese Bestellung wurde bei Stripe zwischenzeitlich doch bezahlt. Bitte den Zahlungseingang pruefen und die Erstattung von Hand anstossen."
);
}
} catch (error: unknown) {
console.error(
`[order-cancel] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geschlossen werden:`,
error
);
warnings.push(
"Die offene Zahlungssitzung konnte nicht geschlossen werden — sie laeuft von selbst ab, bis dahin ist eine Zahlung noch moeglich."
);
}
}
return warnings;
}
export async function updateOrderStatusAction(formData: FormData): Promise<void> { export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const orderId = str(formData, "orderId"); const orderId = str(formData, "orderId");
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number]; const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
const detailPath = `/admin/bestellungen/${orderId}`; const detailPath = `/admin/bestellungen/${orderId}`;
// Gesetzt vom Bestaetigungsdialog der Statusauswahl, nur bei stornierten Bestellungen.
const confirmedReopen = formData.get("confirmReopen") === "1";
if (!ORDER_STATUSES.includes(status)) { if (!ORDER_STATUSES.includes(status)) {
fail(detailPath, "Ungültiger Status."); return fail(detailPath, "Ungültiger Status.");
} }
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
@@ -39,11 +149,35 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
email: true, email: true,
firstName: true, firstName: true,
locale: true, locale: true,
items: { select: { productId: true, quantity: true } }, stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
// Eine stornierte Bestellung wieder zu oeffnen verlangt die ausdrueckliche
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
// sie wird nichts veraendert.
const reopening = needsConfirmToChangeStatus(previous.status, status);
// Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet;
// ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue
// Bestellung anlegen.
if (reopening && previous.invoice?.cancellation) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const };
}
if (reopening && !confirmedReopen) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
}
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
// beides darf bei einem zweiten Klick auf eine bereits stornierte
// Bestellung nicht noch einmal passieren.
let cancelledNow = false;
if (status === "STORNIERT") { if (status === "STORNIERT") {
// Atomarer Claim statt einfachem update(): verhindert, dass zwei // Atomarer Claim statt einfachem update(): verhindert, dass zwei
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene // schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
@@ -58,10 +192,46 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
data: { status }, data: { status },
}); });
if (claim.count === 1) { if (claim.count === 1) {
cancelledNow = true;
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items); await restockOrderItems(tx, previous.items);
} }
// War die Bestellung bezahlt, steht ein Verkauf in ApiCore — der
// wird zurueckgebucht. Unbezahlte Bestellungen haben keinen, dann
// passiert hier nichts.
await recordOrderReturn(tx, orderId);
}
} else if (reopening) {
// Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird
// wieder abgezogen — sonst buchte ein spaeteres erneutes Stornieren sie
// ein zweites Mal zurueck (+2 statt +1). Atomar: nur, wenn die
// Bestellung in diesem Moment noch storniert ist.
const reopened = await tx.order.updateMany({
where: { id: orderId, status: "STORNIERT" },
data: { status },
});
if (reopened.count === 0) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const };
}
if (needsReserveOnReopen(previous.returnRequest?.status ?? null)) {
// Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist —
// die Transaktion nimmt dann auch den Statuswechsel zurueck.
await reserveOrderItems(tx, previous.items);
// Eine bezahlte Bestellung ist damit wieder verkauft.
if (previous.payment?.status === "SUCCEEDED") {
await recordOrderSale(tx, orderId);
}
}
} else { } else {
await tx.order.update({ where: { id: orderId }, data: { status } }); // Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
// Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das.
const moved = await tx.order.updateMany({
where: { id: orderId, status: { not: "STORNIERT" } },
data: { status },
});
if (moved.count === 0) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const };
}
} }
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null; let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
@@ -88,13 +258,45 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
} }
return { ...previous, shipment }; return { ...previous, shipment, cancelledNow, blocked: false as const };
}).catch((error: unknown) => {
if (error instanceof InsufficientStockError) return { insufficientStock: error.productName };
throw error;
}); });
if (!result) { if (result && !("insufficientStock" in result)) {
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht."); scheduleApiCoreStockSync(result.items.map((item) => item.productId));
} }
if (result && "insufficientStock" in result) {
return fail(
detailPath,
`Die Bestellung kann nicht wieder geöffnet werden: für „${result.insufficientStock}“ ist nicht mehr genug Bestand da. Sie bleibt storniert.`
);
}
if (!result) {
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
}
if (result.blocked === "needs-confirm") {
return fail(
detailPath,
"Diese Bestellung ist storniert. Zum Wiederöffnen muss die Warnung bestätigt werden — der Status wurde nicht geändert."
);
}
if (result.blocked === "changed") {
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
}
if (result.blocked === "cancellation-invoice") {
return fail(
detailPath,
`Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.`
);
}
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined;
if (result) { if (result) {
await logAdminAction(admin, { await logAdminAction(admin, {
action: "order.status_change", action: "order.status_change",
@@ -103,6 +305,48 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
detail: `${result.orderNumber}: ${result.status} → ${status}`, detail: `${result.orderNumber}: ${result.status} → ${status}`,
}); });
// Die Geldseite der Stornierung — bewusst NACH der Transaktion: ein
// Stripe-Aufruf dauert ein Vielfaches einer Datenbankoperation und wuerde
// die Zeilensperren auf Bestellung und Lagerbestand so lange halten.
//
// Bis hierher hat die Stornierung nur den Status gesetzt und die Ware
// zurueckgebucht. Ohne die beiden folgenden Schritte blieb eine
// Stornierung auf halbem Weg stehen: bei einer bezahlten Bestellung
// behielt der Shop das Geld, bei einer noch offenen blieb die
// Zahlungssitzung bezahlbar.
if (result.cancelledNow) {
const stornoWarnings = await settleCancelledOrder({
orderId,
orderNumber: result.orderNumber,
previousStatus: result.status,
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment,
});
// Hatte die Bestellung eine Rechnung, wird sie durch eine
// Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung
// oben scheiterte: verkauft ist dann trotzdem nichts mehr.
try {
await issueCancellationInvoice(orderId, "STORNIERUNG");
} catch (error: unknown) {
console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error);
stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden. Sie entsteht spätestens mit der Stripe-Erstattung; sonst bitte den Support informieren.");
}
if (stornoWarnings.length > 0) {
await logAdminAction(admin, {
action: "order.cancel_incomplete",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${stornoWarnings.join(" | ")}`,
});
// Die Stornierung selbst steht und wird NICHT zurueckgenommen — die
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
}
}
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach // E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht // der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts). // rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
@@ -118,7 +362,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
trackingNumber, trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
}); });
await sendMail({ to: result.email, subject, html, text }); mailSent = await sendMail({ to: result.email, subject, html, text });
} }
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") { if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({ const { subject, html, text } = await pickupReadyEmail({
@@ -126,11 +370,155 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
orderNumber: result.orderNumber, orderNumber: result.orderNumber,
firstName: result.firstName, firstName: result.firstName,
}); });
await sendMail({ to: result.email, subject, html, text }); mailSent = await sendMail({ to: result.email, subject, html, text });
} }
} }
revalidatePath("/admin/bestellungen"); revalidatePath("/admin/bestellungen");
const done = `Status von ${result.orderNumber} gespeichert.`;
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
}
/**
* Loescht eine Bestellung endgueltig — aber nur dort, wo nie ein
* Geschaeftsvorfall daraus geworden ist.
*
* WARUM NICHT JEDE BESTELLUNG:
* Eine bezahlte Bestellung und die Rechnung dazu sind Buchungsbelege. § 147
* AO und § 257 HGB verlangen, dass sie jahrelang vorgelegt werden koennen.
* Ein Loeschknopf darauf wuerde also nicht aufraeumen, sondern Unterlagen
* vernichten, die im Zweifel das Finanzamt sehen will — und zwar ohne
* Rueckgaengig. Wer eine bezahlte Bestellung loswerden will, storniert sie:
* das erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.
*
* Erlaubt ist das Loeschen deshalb nur, wenn ALLE drei Bedingungen gelten:
* - keine Zahlung erfasst (auch keine fehlgeschlagene — sie belegt einen
* Zahlungsversuch),
* - keine Rechnung erzeugt,
* - Status NEU oder STORNIERT.
* Das trifft genau auf abgebrochene Checkouts und Testbestellungen zu.
*
* LAGERBESTAND: bei NEU ist die Ware noch reserviert und wird beim Loeschen
* zurueckgebucht. Bei STORNIERT ist das bereits passiert (siehe
* updateOrderStatusAction) — dort noch einmal gutzuschreiben wuerde den
* Bestand erhoehen, ohne dass je ein Glas zurueckkam.
*
* ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
* Stripe-Sitzung haben. Wird die Bestellung geloescht, ohne sie zu
* schliessen, kann die Kundschaft Minuten spaeter noch bezahlen — der Webhook
* findet dann keine Bestellung mehr, und das Geld liegt bei Stripe, ohne dass
* im Shop irgendetwas davon zu sehen waere. Deshalb wird die Sitzung VOR dem
* Loeschen geschlossen, und wenn das nicht sicher gelingt, wird gar nicht
* geloescht.
*/
export async function deleteOrderAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const detailPath = `/admin/bestellungen/${orderId}`;
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
totalCents: true,
currency: true,
email: true,
stripeCheckoutSessionId: true,
payment: { select: { status: true } },
invoice: { select: { invoiceNumber: true } },
items: { select: { productId: true, quantity: true } },
},
});
if (!order) {
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
}
const verdict = checkOrderDeletable({
status: order.status,
hasPayment: order.payment !== null,
hasInvoice: order.invoice !== null,
});
if (!verdict.deletable) {
return fail(detailPath, {
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
payment:
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
status: `Löschen ist nur bei Bestellungen im Status NEU oder STORNIERT möglich, diese steht auf ${order.status}. Storniere sie zuerst.`,
}[verdict.reason]);
}
// Vor dem Loeschen, nicht danach: nach dem Loeschen gaebe es keine
// Bestellung mehr, auf die sich eine noch eingehende Zahlung beziehen
// koennte.
if (order.status === "NEU" && order.stripeCheckoutSessionId) {
try {
const stripe = getStripeClient();
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
return fail(
detailPath,
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
);
}
} catch (error: unknown) {
// redirect() aus fail() wirft intern eine Ausnahme, die Next.js
// auswertet — sie darf hier nicht als Stripe-Fehler behandelt und
// verschluckt werden.
if (error instanceof Error && error.message === "NEXT_REDIRECT") throw error;
console.error(
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
error
);
return fail(
detailPath,
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
);
}
}
const deleted = await prisma.$transaction(async (tx) => {
// Die Bedingungen stehen bewusst noch einmal im DELETE selbst und nicht
// nur in den Pruefungen oben: zwischen Lesen und Loeschen koennte der
// Stripe-Webhook eine Zahlung eingetragen haben. Die WHERE-Klausel wird
// atomar gegen den aktuellen Zeilenstand ausgewertet, die Pruefung oben
// gegen einen Schnappschuss.
const claim = await tx.order.deleteMany({
where: {
id: orderId,
status: { in: ["NEU", "STORNIERT"] },
payment: { is: null },
invoice: { is: null },
},
});
if (claim.count !== 1) return false;
if (needsRestockOnDelete(order.status)) {
await restockOrderItems(tx, order.items);
}
return true;
});
if (!deleted) {
return fail(
detailPath,
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
);
}
// Ausfuehrlicher als bei anderen Aktionen, und das mit Absicht: der
// Datensatz ist weg, dieser Eintrag ist die einzige verbleibende Spur.
await logAdminAction(admin, {
action: "order.delete",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber} (Status ${order.status}, ${(order.totalCents / 100).toFixed(2)} ${order.currency}, ${order.email}, ${order.items.length} Position(en)) endgültig gelöscht`,
});
revalidatePath("/admin/bestellungen");
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
} }
/** /**
@@ -151,7 +539,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
where: { id: orderId }, where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true }, select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
}); });
if (!order) return; if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
await prisma.shipment.upsert({ await prisma.shipment.upsert({
where: { orderId }, where: { orderId },
@@ -166,6 +554,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(), detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
}); });
let mailSent: boolean | undefined;
if (order.status === "VERSENDET" && carrier && trackingNumber) { if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({ const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale, locale: isLocale(order.locale) ? order.locale : defaultLocale,
@@ -175,9 +564,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
trackingNumber, trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber), trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
}); });
await sendMail({ to: order.email, subject, html, text }); mailSent = await sendMail({ to: order.email, subject, html, text });
} }
revalidatePath("/admin/bestellungen"); revalidatePath("/admin/bestellungen");
revalidatePath(`/admin/bestellungen/${orderId}`); revalidatePath(`/admin/bestellungen/${orderId}`);
if (mailSent === false) {
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
}
await setFlash(
"success",
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
);
} }
+204 -19
View File
@@ -1,17 +1,46 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; 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 { notifyStockAvailable } from "@/lib/stock-notifications"; import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation"; import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { planInventoryEdit } from "@/lib/inventory";
import type { Prisma } from "@/generated/prisma/client";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
/**
* 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.
*/
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
if (isActive && priceCents <= 0) {
return 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 parsedIdentity = adminProductIdentitySchema.safeParse({
name: str(formData, "name"),
slug: str(formData, "slug"),
category: str(formData, "category") || "HONIG",
});
if (!parsedIdentity.success) {
return fail("/admin/produkte/neu", parsedIdentity.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const { slug, category } = parsedIdentity.data;
const parsedNumbers = adminProductNumbersSchema.safeParse({ const parsedNumbers = adminProductNumbersSchema.safeParse({
intensityMild: str(formData, "intensityMild"), intensityMild: str(formData, "intensityMild"),
@@ -21,18 +50,36 @@ export async function createProductAction(formData: FormData): Promise<void> {
priceCents: str(formData, "priceCents"), priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"), pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"), quantity: str(formData, "quantity"),
vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { if (!parsedNumbers.success) {
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const numbers = parsedNumbers.data; const numbers = parsedNumbers.data;
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS"; const isActive = formData.get("isActive") === "on";
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
// 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) {
return 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,
@@ -49,8 +96,9 @@ export async function createProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams, weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
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 } },
}, },
}); });
@@ -63,7 +111,85 @@ export async function createProductAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
redirect("/admin/produkte"); return succeed("/admin/produkte", "Produkt angelegt.");
}
/**
* Loescht ein Produkt endgueltig.
*
* Nur moeglich, solange es nie bestellt wurde. OrderItem verweist mit
* onDelete: Restrict auf das Produkt (prisma/schema.prisma), und das ist
* Absicht: Bestellpositionen und die daraus erzeugten Rechnungen sind Belege
* ueber einen vergangenen Vorgang. Verschwaende das Produkt darunter weg,
* verloere eine bereits gestellte Rechnung ihren Bezug — bei zehn Jahren
* Aufbewahrungspflicht (§ 147 AO) keine Option.
*
* Wer ein bereits verkauftes Produkt aus dem Shop nehmen will, schaltet es
* deshalb inaktiv, statt es zu loeschen. Genau das sagt die Meldung.
*
* Was mitgeht: Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
* Verfuegbarkeits-Benachrichtigungen haengen mit onDelete: Cascade am
* Produkt. Hochgeladene Bilder bleiben dagegen im Upload-Verzeichnis liegen
* — sie koennen auch anderswo eingebunden sein, und eine verwaiste Datei ist
* harmloser als ein totes Bild an anderer Stelle.
*/
export async function deleteProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const product = await prisma.product.findUnique({
where: { id },
include: { _count: { select: { orderItems: true } } },
});
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
if (product._count.orderItems > 0) {
return fail(
"/admin/produkte",
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
);
}
await prisma.product.delete({ where: { id } });
await logAdminAction(admin, {
action: "product.delete",
entityType: "Product",
entityId: id,
detail: product.name,
});
revalidatePath("/admin/produkte");
return succeed("/admin/produkte", "Produkt gelöscht.");
}
/**
* Schreibt den Bestand nur, wenn er noch dem Stand beim Oeffnen des
* Formulars entspricht. Die Bedingung steht im UPDATE selbst und wird damit
* atomar gegen den aktuellen Zeilenstand geprueft — ein vorheriges Lesen
* koennte eine gleichzeitige Bestellung nicht ausschliessen.
*
* @returns false, wenn sich der Bestand inzwischen geaendert hat.
*/
async function setInventoryIfUnchanged(
tx: Prisma.TransactionClient,
productId: string,
plan: { expected: number; quantity: number }
): Promise<boolean> {
const updated = await tx.inventory.updateMany({
where: { productId, quantity: plan.expected },
data: { quantity: plan.quantity },
});
if (updated.count === 1) return true;
// Kein Bestands-Datensatz gilt im Formular als 0 (siehe produkte/[id]).
// Nur dann anlegen; existiert der Datensatz mit anderem Wert, ueberspringt
// skipDuplicates das Anlegen und count bleibt 0 — also ein Konflikt.
if (plan.expected !== 0) return false;
const created = await tx.inventory.createMany({
data: [{ productId, quantity: plan.quantity }],
skipDuplicates: true,
});
return created.count === 1;
} }
export async function updateProductAction(formData: FormData): Promise<void> { export async function updateProductAction(formData: FormData): Promise<void> {
@@ -78,18 +204,50 @@ export async function updateProductAction(formData: FormData): Promise<void> {
priceCents: str(formData, "priceCents"), priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"), pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"), quantity: str(formData, "quantity"),
vatRatePercent: str(formData, "vatRatePercent"),
}); });
if (!parsedNumbers.success) { if (!parsedNumbers.success) {
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const numbers = parsedNumbers.data; const numbers = parsedNumbers.data;
const previousInventory = await prisma.inventory.findUnique({ const isActive = formData.get("isActive") === "on";
where: { productId: id }, await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
select: { quantity: true },
});
const product = await prisma.product.update({ const current = await prisma.product.findUnique({
where: { id },
select: { apicoreBottlingId: true, apicoreProductId: true },
});
if (!current) return fail("/admin/produkte", "Produkt nicht gefunden.");
// Verknuepfung mit einem ApiCore-Artikel (nur hergestellte Waren; Honig
// haengt fest an seiner Abfuellung). Feld fehlt = unveraendert lassen.
const requestedLink = formData.has("apicoreProductId") ? strOrNull(formData, "apicoreProductId") : undefined;
const apicoreProductId = current.apicoreBottlingId ? current.apicoreProductId : requestedLink;
const linkChanged = apicoreProductId !== undefined && apicoreProductId !== current.apicoreProductId;
const stockFromApiCore = Boolean(current.apicoreBottlingId || (apicoreProductId ?? current.apicoreProductId));
// Bei verknuepften Produkten gehoert der Bestand ApiCore: ein Wert aus dem
// Formular wird nie geschrieben, auch wenn jemand das gesperrte Feld per
// Entwicklerwerkzeug aendert.
const inventoryPlan = stockFromApiCore
? ({ kind: "keep" } as const)
: planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
if (inventoryPlan.kind === "reload") {
return fail(
`/admin/produkte/${id}`,
"Das Formular ist veraltet. Bitte die Seite neu laden und die Änderung erneut eintragen."
);
}
// Bestand und Produktdaten in einer Transaktion: passt der Bestand nicht
// mehr zum geladenen Stand, wird auch der Rest nicht gespeichert — sonst
// haette die Imkerei halb gespeicherte Aenderungen, ohne es zu merken.
const product = await prisma.$transaction(async (tx) => {
if (inventoryPlan.kind === "set" && !(await setInventoryIfUnchanged(tx, id, inventoryPlan))) {
return null;
}
return tx.product.update({
where: { id }, where: { id },
data: { data: {
name: str(formData, "name"), name: str(formData, "name"),
@@ -102,13 +260,39 @@ export async function updateProductAction(formData: FormData): Promise<void> {
weightGrams: numbers.weightGrams, weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents, priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents, pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath), images: strArray(formData, "images").filter(isValidUploadPath),
isActive: formData.get("isActive") === "on", isActive,
inventory: { ...(linkChanged ? { apicoreProductId } : {}),
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
},
}, },
}); });
}).catch((error: unknown) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return "link-taken" as const;
}
throw error;
});
if (product === "link-taken") {
return fail(
`/admin/produkte/${id}`,
"Dieser ApiCore-Artikel ist bereits mit einem anderen Produkt verknüpft. Nichts wurde gespeichert."
);
}
if (linkChanged && apicoreProductId) {
// Bestand sofort aus ApiCore holen, nicht erst beim naechsten CronJob.
scheduleApiCoreStockSync([id]);
}
if (!product) {
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
return fail(
`/admin/produkte/${id}`,
`Der Bestand hat sich seit dem Öffnen geändert (jetzt ${current?.quantity ?? 0} statt ${
inventoryPlan.kind === "set" ? inventoryPlan.expected : 0
}), vermutlich durch eine Bestellung. Nichts wurde gespeichert — bitte die Seite neu laden und erneut eintragen.`
);
}
await logAdminAction(admin, { await logAdminAction(admin, {
action: "product.update", action: "product.update",
@@ -119,10 +303,11 @@ export async function updateProductAction(formData: FormData): Promise<void> {
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und // Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden. // ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) { // Bei "set" ist expected garantiert der Stand direkt vor dem Schreiben.
if (inventoryPlan.kind === "set" && inventoryPlan.expected === 0 && inventoryPlan.quantity > 0) {
await notifyStockAvailable(id); await notifyStockAvailable(id);
} }
revalidatePath("/admin/produkte"); revalidatePath("/admin/produkte");
redirect("/admin/produkte"); return succeed("/admin/produkte", "Produkt gespeichert.");
} }
+31 -1
View File
@@ -4,6 +4,7 @@ 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 { requireAdmin, str } from "./_shared"; import { requireAdmin, str } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function approveReviewAction(formData: FormData): Promise<void> { export async function approveReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -11,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
const review = await prisma.review.update({ const review = await prisma.review.update({
where: { id }, where: { id },
data: { isApproved: true }, data: { isApproved: true, rejectedAt: null },
select: { product: { select: { slug: true, name: true } } }, select: { product: { select: { slug: true, name: true } } },
}); });
@@ -24,6 +25,34 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen"); revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`); revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
}
/**
* Lehnt eine Bewertung ab, statt sie zu loeschen: sie verschwindet aus dem
* Shop (falls sie dort war) und steht im Archiv unter "Abgelehnt", damit die
* Entscheidung nachvollziehbar bleibt und sich rueckgaengig machen laesst.
*/
export async function rejectReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const review = await prisma.review.update({
where: { id },
data: { isApproved: false, rejectedAt: new Date() },
select: { product: { select: { slug: true, name: true } } },
});
await logAdminAction(admin, {
action: "review.reject",
entityType: "Review",
entityId: id,
detail: review.product.name,
});
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung abgelehnt — sie steht jetzt im Archiv.");
} }
export async function deleteReviewAction(formData: FormData): Promise<void> { export async function deleteReviewAction(formData: FormData): Promise<void> {
@@ -44,4 +73,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen"); revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`); revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung gelöscht.");
} }
+6 -6
View File
@@ -1,11 +1,11 @@
"use server"; "use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache"; 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 { shippingTierSchema } from "@/lib/validation"; import { shippingTierSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared"; import { requireAdmin, fail, str } from "./_shared";
import { succeed } from "@/lib/flash";
function parseShippingTierForm(formData: FormData) { function parseShippingTierForm(formData: FormData) {
const rawMax = str(formData, "maxQuantity"); const rawMax = str(formData, "maxQuantity");
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData); const parsed = parseShippingTierForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
const tier = await prisma.shippingTier.create({ const tier = await prisma.shippingTier.create({
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe angelegt.");
} }
export async function updateShippingTierAction(formData: FormData): Promise<void> { export async function updateShippingTierAction(formData: FormData): Promise<void> {
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData); const parsed = parseShippingTierForm(formData);
if (!parsed.success) { if (!parsed.success) {
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe."); return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
} }
await prisma.shippingTier.update({ await prisma.shippingTier.update({
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe gespeichert.");
} }
export async function deleteShippingTierAction(formData: FormData): Promise<void> { export async function deleteShippingTierAction(formData: FormData): Promise<void> {
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand"); revalidatePath("/admin/versand");
revalidatePath("/warenkorb"); revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung"); revalidatePath("/versand-zahlung");
redirect("/admin/versand"); return succeed("/admin/versand", "Versandstufe gelöscht.");
} }
@@ -0,0 +1,124 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { isIsoDate } from "@/lib/certificate/schema";
import { requireAdmin, fail, succeed, str } from "./_shared";
const windowSchema = z
.object({
startsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Beginndatum angeben."),
endsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Enddatum angeben."),
plannedJars: z.coerce
.number({ error: "Geplante Gläser: bitte eine ganze Zahl." })
.int("Geplante Gläser: bitte eine ganze Zahl.")
.min(0, "Geplante Gläser: mindestens 0.")
.max(100_000, "Geplante Gläser: höchstens 100000."),
})
.refine((value) => value.endsOn >= value.startsOn, "Das Ende darf nicht vor dem Beginn liegen.");
function windowPath(offerId: string): string {
return `/admin/patenschaften/angebote/${offerId}/erntefenster`;
}
function toDate(isoDate: string): Date {
return new Date(`${isoDate}T00:00:00.000Z`);
}
/** Erntefenster gibt es nur fuer Abo-Angebote mit Zyklus ERNTE. */
async function loadHarvestOffer(offerId: string) {
const offer = await prisma.sponsorshipOffer.findUnique({ where: { id: offerId }, select: { id: true, name: true, kind: true, cycle: true } });
return offer && offer.kind === "ABO" && offer.cycle === "ERNTE" ? offer : null;
}
/**
* Fenster desselben Angebots duerfen sich nicht ueberschneiden: sonst waere
* unklar, in welchem Fenster eine Lieferung faellig wird, und die geplanten
* Glaeser wuerden doppelt reserviert.
*/
async function overlaps(offerId: string, startsOn: Date, endsOn: Date, exceptId?: string): Promise<boolean> {
const count = await prisma.harvestWindow.count({
where: {
offerId,
...(exceptId ? { id: { not: exceptId } } : {}),
startsOn: { lte: endsOn },
endsOn: { gte: startsOn },
},
});
return count > 0;
}
function parseWindow(formData: FormData) {
return windowSchema.safeParse({
startsOn: str(formData, "startsOn"),
endsOn: str(formData, "endsOn"),
plannedJars: str(formData, "plannedJars") || "0",
});
}
export async function createHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const offer = await loadHarvestOffer(str(formData, "offerId"));
if (!offer) return fail("/admin/patenschaften/angebote", "Erntefenster gibt es nur für Abo-Angebote mit dem Zyklus „Nach der Ernte“.");
const back = windowPath(offer.id);
const parsed = parseWindow(formData);
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
const startsOn = toDate(parsed.data.startsOn);
const endsOn = toDate(parsed.data.endsOn);
if (await overlaps(offer.id, startsOn, endsOn)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
const window = await prisma.harvestWindow.create({
data: { offerId: offer.id, startsOn, endsOn, plannedJars: parsed.data.plannedJars },
});
await logAdminAction(admin, {
action: "harvest_window.create",
entityType: "HarvestWindow",
entityId: window.id,
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
});
revalidatePath(back);
return succeed(back, "Erntefenster angelegt.");
}
export async function updateHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.harvestWindow.findUnique({ where: { id }, select: { offerId: true } });
const offer = existing ? await loadHarvestOffer(existing.offerId) : null;
if (!existing || !offer) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden.");
const back = windowPath(offer.id);
const parsed = parseWindow(formData);
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
const startsOn = toDate(parsed.data.startsOn);
const endsOn = toDate(parsed.data.endsOn);
if (await overlaps(offer.id, startsOn, endsOn, id)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
await prisma.harvestWindow.update({ where: { id }, data: { startsOn, endsOn, plannedJars: parsed.data.plannedJars } });
await logAdminAction(admin, {
action: "harvest_window.update",
entityType: "HarvestWindow",
entityId: id,
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
});
revalidatePath(back);
return succeed(back, "Erntefenster gespeichert.");
}
export async function deleteHarvestWindowAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.harvestWindow.findUnique({ where: { id }, include: { offer: { select: { name: true } } } });
if (!existing) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden — möglicherweise bereits gelöscht.");
const back = windowPath(existing.offerId);
await prisma.harvestWindow.deleteMany({ where: { id } });
await logAdminAction(admin, {
action: "harvest_window.delete",
entityType: "HarvestWindow",
entityId: id,
detail: `${existing.offer.name}: ${existing.startsOn.toISOString().slice(0, 10)} – ${existing.endsOn.toISOString().slice(0, 10)}`,
});
revalidatePath(back);
return succeed(back, "Erntefenster gelöscht.");
}
@@ -0,0 +1,110 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { isUniqueViolation } from "./sponsorship-shared";
const LOYALTY_PATH = "/admin/patenschaften/treuestufen";
const nameFields = {
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(60, "Name: höchstens 60 Zeichen."),
nameEn: z
.string()
.trim()
.max(60, "Name (Englisch): höchstens 60 Zeichen.")
.transform((value) => (value === "" ? null : value)),
};
const thresholdField = z.coerce
.number({ error: "Schwelle: bitte eine ganze Zahl." })
.int("Schwelle: bitte eine ganze Zahl.")
.min(1, "Schwelle: mindestens 1.")
.max(1000, "Schwelle: höchstens 1000.");
const levelSchema = z.object({ ...nameFields, threshold: thresholdField });
const newLevelSchema = levelSchema.extend({ kind: z.enum(["STAMMKUNDE", "ABO"], { error: "Unbekannte Stufenart." }) });
function readLevel(formData: FormData) {
return {
name: str(formData, "name"),
nameEn: str(formData, "nameEn"),
threshold: str(formData, "threshold"),
};
}
const DUPLICATE_THRESHOLD = "Für diese Schwelle gibt es bereits eine Stufe.";
export async function createLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const parsed = newLevelSchema.safeParse({ ...readLevel(formData), kind: str(formData, "kind") });
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
try {
const level = await prisma.loyaltyLevel.create({ data: parsed.data });
await logAdminAction(admin, {
action: "loyalty_level.create",
entityType: "LoyaltyLevel",
entityId: level.id,
detail: `${level.kind} ab ${level.threshold}: ${level.name}`,
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
throw error;
}
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe angelegt.");
}
/**
* Speichert Namen und Schwelle einer Stufe. Die VIP-Stufe (Abo, ohne
* Schwelle) behaelt ihre fehlende Schwelle — sie folgt immer nach der
* hoechsten Zahl, nur ihr Name ist pflegbar.
*/
export async function updateLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden.");
const isVip = existing.threshold === null;
const parsed = isVip
? z.object(nameFields).safeParse(readLevel(formData))
: levelSchema.safeParse(readLevel(formData));
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
try {
await prisma.loyaltyLevel.update({ where: { id }, data: parsed.data });
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
throw error;
}
const threshold = "threshold" in parsed.data ? parsed.data.threshold : null;
await logAdminAction(admin, {
action: "loyalty_level.update",
entityType: "LoyaltyLevel",
entityId: id,
detail: `${existing.kind} ${existing.threshold ?? "VIP"} → ${threshold ?? "VIP"}: ${existing.name} → ${parsed.data.name}`,
});
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe gespeichert.");
}
export async function deleteLoyaltyLevelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden — möglicherweise bereits gelöscht.");
if (existing.threshold === null) return fail(LOYALTY_PATH, "Die VIP-Stufe kann nicht gelöscht werden.");
await prisma.loyaltyLevel.deleteMany({ where: { id, threshold: { not: null } } });
await logAdminAction(admin, {
action: "loyalty_level.delete",
entityType: "LoyaltyLevel",
entityId: id,
detail: `${existing.kind} ab ${existing.threshold}: ${existing.name}`,
});
revalidatePath(LOYALTY_PATH);
return succeed(LOYALTY_PATH, "Stufe gelöscht.");
}
+205
View File
@@ -0,0 +1,205 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { formatPrice } from "@/lib/format";
import type { Prisma } from "@/generated/prisma/client";
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
import { setFlash } from "@/lib/flash";
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
import { redirect } from "next/navigation";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
const OFFERS_PATH = "/admin/patenschaften/angebote";
function carrierSlug(slug: string): string {
return `patenschaft-${slug}`;
}
/**
* Traegerprodukt einer Einmal-Patenschaft (Kategorie PATENSCHAFT): haelt
* Preis, Steuersatz und Rechnungsposition, damit Warenkorb, Checkout und
* Rechnung keinen Sonderweg brauchen. Sein Preis ist Paketpreis minus
* Honiganteil — der Honig wird im Checkout als eigene Position zum
* Honig-Steuersatz angelegt.
*/
function carrierProductData(values: OfferFormValues) {
return {
name: `Bienenpatenschaft ${values.name}`,
slug: carrierSlug(values.slug),
category: "PATENSCHAFT" as const,
description: values.description,
weightGrams: 0,
priceCents: carrierPriceCents(values),
pricePerKgCents: 0,
isActive: true,
};
}
/**
* honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch
* gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im
* Schema stehen, damit die laufende Produktion keine Migration braucht; ein
* dort noch stehender Altwert wird nirgends mehr gelesen.
*/
function offerData(values: OfferFormValues) {
return {
slug: values.slug,
name: values.name,
nameEn: values.nameEn,
description: values.description,
descriptionEn: values.descriptionEn,
features: values.features,
featuresEn: values.featuresEn,
priceCents: values.priceCents,
isActive: values.isActive,
sortOrder: values.sortOrder,
isPhysical: values.isPhysical,
honeyJars: values.honeyJars,
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
cycle: values.cycle,
maxActiveSubscriptions: values.maxActiveSubscriptions,
medalTier: values.medalTier,
} satisfies Prisma.SponsorshipOfferUncheckedUpdateInput;
}
/**
* Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft
* honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
* jeder Bestellung und jeder neuen Ernte aendert.
*/
async function findConflict(
values: OfferFormValues,
existing: { id: string; productId: string | null } | null
): Promise<string | null> {
const slugOwner = await prisma.sponsorshipOffer.findUnique({ where: { slug: values.slug }, select: { id: true } });
if (slugOwner && slugOwner.id !== existing?.id) return `Die Adresse "${values.slug}" hat bereits ein anderes Angebot.`;
if (values.kind === "EINMALIG") {
const product = await prisma.product.findUnique({ where: { slug: carrierSlug(values.slug) }, select: { id: true } });
if (product && product.id !== existing?.productId) {
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
}
}
return null;
}
function auditDetail(values: OfferFormValues): string {
const state = values.isActive ? "aktiv" : "inaktiv";
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
}
/**
* Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den
* die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer
* die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann
* auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis
* fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis,
* statt sich ueber ein "ausverkauftes" neues Angebot zu wundern.
*/
async function honeyWarning(values: OfferFormValues): Promise<string | null> {
if (!values.isActive || values.honeyJars <= 0) return null;
if (pickHoneyProduct(await loadHoneyCandidates(), values.honeyJars)) return null;
return "Hinweis: Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand – bestellbar erst, wenn flüssiger Honig nachgelegt ist.";
}
/** Nach dem Speichern: Stripe-Preis eines Abo-Angebots nachziehen, Honig pruefen und passend melden. */
async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
revalidatePath(OFFERS_PATH);
revalidatePath("/patenschaft");
const target = `${OFFERS_PATH}/${offerId}`;
if (values.kind === "ABO") {
const stripeError = await syncStripePriceSafely(offerId);
if (stripeError) {
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
}
}
const warning = await honeyWarning(values);
if (warning) {
await setFlash("info", `${message} ${warning}`);
redirect(target);
}
return succeed(target, message);
}
export async function createSponsorshipOfferAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const back = `${OFFERS_PATH}/neu`;
const parsed = parseOfferForm(formData);
if (!parsed.ok) return fail(back, parsed.message);
const values = parsed.value;
const conflict = await findConflict(values, null);
if (conflict) return fail(back, conflict);
let offerId: string;
try {
offerId = await prisma.$transaction(async (tx) => {
const productId = values.kind === "EINMALIG" ? (await tx.product.create({ data: carrierProductData(values) })).id : null;
const offer = await tx.sponsorshipOffer.create({ data: { ...offerData(values), kind: values.kind, productId } });
return offer.id;
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
throw error;
}
await logAdminAction(admin, {
action: "sponsorship_offer.create",
entityType: "SponsorshipOffer",
entityId: offerId,
detail: auditDetail(values),
});
return finishSave(offerId, values, "Angebot angelegt.");
}
export async function updateSponsorshipOfferAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const existing = await prisma.sponsorshipOffer.findUnique({
where: { id },
select: { id: true, kind: true, productId: true, isActive: true, priceCents: true },
});
if (!existing) return fail(OFFERS_PATH, "Das Angebot wurde nicht gefunden.");
const back = `${OFFERS_PATH}/${id}`;
const parsed = parseOfferForm(formData, existing.kind);
if (!parsed.ok) return fail(back, parsed.message);
const values = parsed.value;
const conflict = await findConflict(values, existing);
if (conflict) return fail(back, conflict);
try {
await prisma.$transaction(async (tx) => {
let productId = existing.productId;
if (values.kind === "EINMALIG") {
// Fehlt das Traegerprodukt (Altbestand), wird es hier nachgeholt —
// ohne Produkt ist eine Einmal-Patenschaft nicht bestellbar.
productId = productId
? (await tx.product.update({ where: { id: productId }, data: carrierProductData(values) })).id
: (await tx.product.create({ data: carrierProductData(values) })).id;
}
await tx.sponsorshipOffer.update({ where: { id }, data: { ...offerData(values), productId } });
});
} catch (error: unknown) {
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
throw error;
}
const changes = [
existing.isActive !== values.isActive ? (values.isActive ? "aktiviert" : "deaktiviert") : null,
existing.priceCents !== values.priceCents ? `Preis ${formatPrice(existing.priceCents)} → ${formatPrice(values.priceCents)}` : null,
].filter(Boolean);
await logAdminAction(admin, {
action: "sponsorship_offer.update",
entityType: "SponsorshipOffer",
entityId: id,
detail: `${auditDetail(values)}${changes.length > 0 ? `; ${changes.join(", ")}` : ""}`,
});
return finishSave(id, values, "Angebot gespeichert.");
}
@@ -0,0 +1,38 @@
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
/**
* Gemeinsame Helfer der Patenschafts-Actions (bewusst ohne "use server":
* hier liegen keine aufrufbaren Endpunkte, nur Bausteine).
*/
/** Prisma-Fehler P2002: eindeutiger Wert (Slug, Schwelle) bereits vergeben, etwa durch einen parallelen Speichervorgang. */
export function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Legt Stripe-Produkt und -Preis eines Abo-Angebots an bzw. zieht sie nach.
* Scheitert das (Stripe nicht erreichbar, kein Schluessel), bleibt das
* Angebot gespeichert — der Admin bekommt eine klare Meldung und kann erneut
* speichern; syncOfferStripePrice ist idempotent. Gibt die Fehlermeldung
* zurueck oder null.
*/
export async function syncStripePriceSafely(offerId: string): Promise<string | null> {
try {
await syncOfferStripePrice(offerId);
return null;
} catch (error: unknown) {
const message = errorMessage(error);
console.error(`[patenschaft] Stripe-Preis fuer Angebot ${offerId} nicht synchronisiert: ${message}`);
return message;
}
}
/** Ganze Zahl aus einem Formularfeld, sonst null (leer, Komma, Buchstaben). */
export function intOrNull(value: string): number | null {
return /^\d+$/.test(value.trim()) ? Number(value.trim()) : null;
}
@@ -0,0 +1,54 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { MAX_SEPA_HOLD_DAYS, SEPA_HOLD_DAYS_KEY, getSepaHoldDays } from "@/lib/sponsorship/settings";
import { requireAdmin, fail, succeed, str } from "./_shared";
import { intOrNull } from "./sponsorship-shared";
const SUBSCRIPTIONS_PATH = "/admin/patenschaften/abos";
/**
* Hebt die SEPA-Sperre eines Kontos auf (gesetzt nach einer Rueckbuchung).
* Danach kann das Konto neue Abos wieder per Lastschrift abschliessen.
*/
export async function liftSepaBlockAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const customerId = str(formData, "customerId");
const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { email: true, sepaBlockedAt: true } });
if (!customer) return fail(SUBSCRIPTIONS_PATH, "Das Konto wurde nicht gefunden.");
if (!customer.sepaBlockedAt) return succeed(SUBSCRIPTIONS_PATH, "Für dieses Konto besteht keine SEPA-Sperre.");
await prisma.customer.update({ where: { id: customerId }, data: { sepaBlockedAt: null } });
await logAdminAction(admin, {
action: "customer.sepa_unblock",
entityType: "Customer",
entityId: customerId,
detail: `${customer.email} (gesperrt seit ${customer.sepaBlockedAt.toISOString().slice(0, 10)})`,
});
revalidatePath(SUBSCRIPTIONS_PATH);
return succeed(SUBSCRIPTIONS_PATH, `SEPA-Sperre für ${customer.email} aufgehoben.`);
}
export async function updateSepaHoldDaysAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const days = intOrNull(str(formData, "sepaHoldDays"));
if (days === null || days > MAX_SEPA_HOLD_DAYS) {
return fail(SUBSCRIPTIONS_PATH, `Bitte eine ganze Zahl von 0 bis ${MAX_SEPA_HOLD_DAYS} Tagen angeben.`);
}
const previous = await getSepaHoldDays();
await prisma.siteContent.upsert({
where: { key: SEPA_HOLD_DAYS_KEY },
create: { key: SEPA_HOLD_DAYS_KEY, valueDe: String(days) },
update: { valueDe: String(days) },
});
await logAdminAction(admin, {
action: "sponsorship.sepa_hold_days",
entityType: "SiteContent",
entityId: SEPA_HOLD_DAYS_KEY,
detail: `${previous} → ${days} Tage`,
});
revalidatePath(SUBSCRIPTIONS_PATH);
return succeed(SUBSCRIPTIONS_PATH, days === 0 ? "SEPA-Neukunden-Frist abgeschaltet." : `SEPA-Neukunden-Frist auf ${days} Tage gesetzt.`);
}
+204
View File
@@ -0,0 +1,204 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { MAIL_FAILED_HINT } from "@/lib/flash";
import { getBaseUrl } from "@/lib/url";
import { defaultLocale, isLocale } from "@/i18n/config";
import { processSponsorship, resetSponsorshipForRetry, type ProcessResult } from "@/lib/sponsorship/issue";
import { sponsorshipReadyEmail } from "@/lib/sponsorship/emails";
import { reviseSponsorship, type ReviseSponsorshipError } from "@/lib/sponsorship/revise";
import { beeNameFromJson } from "@/app/admin/patenschaften/_lib/revision-data";
import { requireAdmin, fail, succeed, str } from "./_shared";
const LIST_PATH = "/admin/patenschaften";
function detailPath(id: string): string {
return `${LIST_PATH}/${id}`;
}
export async function markSponsorshipPrintedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
// Atomar an den Zustand gebunden: ein Doppelklick oder ein zweiter Tab
// setzt printedAt nicht ein zweites Mal neu.
const updated = await prisma.sponsorship.updateMany({
where: { id, status: "BEREIT", printedAt: null, offer: { isPhysical: true } },
data: { status: "GEDRUCKT", printedAt: new Date() },
});
if (updated.count === 0) {
return fail(detailPath(id), "Nur bereite, noch nicht gedruckte Urkunden physischer Pakete lassen sich als gedruckt markieren.");
}
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true } });
await logAdminAction(admin, {
action: "sponsorship.mark_printed",
entityType: "Sponsorship",
entityId: id,
detail: sponsorship?.number,
});
revalidatePath(LIST_PATH);
return succeed(detailPath(id), "Als gedruckt markiert.");
}
const PROCESS_MESSAGES: Record<ProcessResult, string> = {
ready: "Urkunde erzeugt und Mail versendet.",
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
missing: "Die Patenschaft wurde nicht gefunden.",
revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.",
};
/**
* "Erneut erzeugen": bei FEHLER werden die Versuche zurueckgesetzt, bei
* AUSSTEHEND wird nur sofort ein Versuch gestartet, statt auf den CronJob zu
* warten. Beides laeuft ueber processSponsorship — Revision 1 entsteht dort
* nur, solange es noch keine gibt, eine Wiederholung erzeugt also nie eine
* zweite Urkunde.
*/
export async function retrySponsorshipAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true, status: true } });
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
if (sponsorship.status !== "FEHLER" && sponsorship.status !== "AUSSTEHEND") {
return fail(detailPath(id), "Die Urkunde ist bereits erzeugt.");
}
if (sponsorship.status === "FEHLER") await resetSponsorshipForRetry(id);
const result = await processSponsorship(id);
await logAdminAction(admin, {
action: "sponsorship.retry",
entityType: "Sponsorship",
entityId: id,
detail: `${sponsorship.number}: ${sponsorship.status} → ${result}`,
});
revalidatePath(LIST_PATH);
if (result !== "ready") return fail(detailPath(id), PROCESS_MESSAGES[result]);
return succeed(detailPath(id), PROCESS_MESSAGES.ready);
}
/**
* "Mail erneut senden" — nur der Versand, sonst nichts. Bewusst NICHT ueber
* processSponsorship: das bricht bei BEREIT/GEDRUCKT sofort ab, und den
* Status dafuer zurueckzusetzen wuerde einen gedruckten Stand verlieren.
* Revisionen werden hier weder gelesen noch angelegt, ausser zum Auslesen
* des aktuellen Bienennamens.
*/
export async function resendSponsorshipMailAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const sponsorship = await prisma.sponsorship.findUnique({
where: { id },
select: {
id: true,
number: true,
status: true,
locale: true,
currentRevision: true,
owner: { select: { email: true, firstName: true } },
},
});
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
if ((sponsorship.status !== "BEREIT" && sponsorship.status !== "GEDRUCKT") || sponsorship.currentRevision === 0) {
return fail(detailPath(id), "Die Mail lässt sich erst erneut senden, wenn die Urkunde zugestellt wurde.");
}
const revision = await prisma.sponsorshipRevision.findUnique({
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
select: { personalization: true },
});
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
const { subject, html, text } = await sponsorshipReadyEmail({
locale,
firstName: sponsorship.owner.firstName,
beeName: beeNameFromJson(revision?.personalization) ?? "",
number: sponsorship.number,
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
});
const sent = await sendMail({ to: sponsorship.owner.email, subject, html, text });
if (!sent) return fail(detailPath(id), MAIL_FAILED_HINT);
await prisma.sponsorship.update({ where: { id }, data: { mailSentAt: new Date() } });
await logAdminAction(admin, {
action: "sponsorship.resend_mail",
entityType: "Sponsorship",
entityId: id,
detail: `${sponsorship.number} an ${sponsorship.owner.email}`,
});
return succeed(detailPath(id), `Mail erneut an ${sponsorship.owner.email} gesendet.`);
}
const FIELD_LABELS: Record<string, string> = {
beeName: "Name der Patenbiene",
recipientName: "Name auf der Urkunde",
giverNames: "Schenkende",
dedication: "Widmung",
occasion: "Anlass",
startDate: "Startdatum",
layout: "Gesamtlayout",
};
const SCHEMA_MESSAGES: Record<string, string> = {
required: "ist ein Pflichtfeld",
too_short: "ist zu kurz",
too_long: "ist zu lang",
too_many_line_breaks: "hat zu viele Zeilenumbrüche",
no_letter: "braucht mindestens einen Buchstaben",
invalid_date: "ist kein gültiges Datum",
};
function revisionErrorMessage(error: ReviseSponsorshipError): string {
switch (error.code) {
case "not_found":
return "Die Patenschaft wurde nicht gefunden.";
case "no_revision":
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
case "revoked":
return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren.";
case "reason":
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
case "unknown_template":
return "Unbekannte Vorlagenversion.";
case "unchanged":
return "Keine Änderung gegenüber der aktuellen Revision.";
case "start_date_out_of_range":
return `Das Startdatum muss zwischen ${error.min} und ${error.max} liegen.`;
case "invalid": {
const label = error.field ? FIELD_LABELS[error.field] : "Eingabe";
return `${label} ${SCHEMA_MESSAGES[error.message] ?? "ist ungültig"}.`;
}
case "glyphs":
return `${FIELD_LABELS[error.field] ?? "Text"}: diese Zeichen kann die Urkunde nicht drucken: ${error.characters.join(" ")}`;
case "fit":
return `${FIELD_LABELS[error.field] ?? "Text"} passt so nicht auf die Urkunde — bitte kürzen.`;
case "conflict":
return "Die Urkunde wurde gerade von jemand anderem korrigiert. Bitte die Seite neu laden und erneut prüfen.";
}
}
export async function reviseSponsorshipAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const back = `${detailPath(id)}/korrigieren`;
const result = await reviseSponsorship({
sponsorshipId: id,
personalization: {
beeName: String(formData.get("beeName") ?? ""),
recipientName: String(formData.get("recipientName") ?? ""),
giverNames: String(formData.get("giverNames") ?? ""),
dedication: String(formData.get("dedication") ?? ""),
occasion: str(formData, "occasion"),
startDate: str(formData, "startDate"),
},
reason: String(formData.get("reason") ?? ""),
templateVersion: str(formData, "templateVersion") || null,
notifyCustomer: formData.get("notifyCustomer") === "on",
admin,
});
if (!result.ok) return fail(back, revisionErrorMessage(result.error));
revalidatePath(LIST_PATH);
const message = `Revision ${result.revision} gespeichert.`;
if (result.mailSent === false) return fail(detailPath(id), `${message} ${MAIL_FAILED_HINT}`);
return succeed(detailPath(id), result.mailSent ? `${message} Kunde benachrichtigt.` : message);
}
+77 -33
View File
@@ -1,12 +1,28 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
import { AdminArchiveTabs } from "@/components/admin-archive";
import { isArchiveView } from "@/lib/archive";
import { inquiryViewWhere } from "@/lib/inquiry-status";
import { updateBusinessInquiryStatusAction } from "../actions"; import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
const STATUS_TONES: Record<string, AdminBadgeTone> = {
NEU: "pending",
IN_BEARBEITUNG: "pending",
ABGESCHLOSSEN: "positive",
ABGELEHNT: "neutral",
};
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) { export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
@@ -15,11 +31,11 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted, ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
ABGELEHNT: t.adminInquiriesPage.statusRejected, ABGELEHNT: t.adminInquiriesPage.statusRejected,
}; };
const { q } = await props.searchParams; const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const inquiries = await prisma.businessInquiry.findMany({ const filterWhere = query
where: query
? { ? {
OR: [ OR: [
{ companyName: insensitiveContains(query) }, { companyName: insensitiveContains(query) },
@@ -27,54 +43,70 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
{ email: insensitiveContains(query) }, { email: insensitiveContains(query) },
], ],
} }
: undefined, : {};
orderBy: { createdAt: "desc" },
const [inquiries, activeCount, archivedCount] = await Promise.all([
prisma.businessInquiry.findMany({
where: { ...filterWhere, ...inquiryViewWhere(isArchive) },
// Aktiv: zuletzt eingegangen oben. Archiv: zuletzt erledigt oben.
orderBy: isArchive ? { updatedAt: "desc" } : { createdAt: "desc" },
take: 100, take: 100,
}); }),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(false) } }),
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(true) } }),
]);
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1> <AdminPageHeader title={t.adminInquiriesPage.title} />
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminArchiveTabs
<label className="text-sm"> basePath="/admin/anfragen"
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span> isArchive={isArchive}
<input counts={{ active: activeCount, archived: archivedCount }}
type="text" keepParams={{ q: query }}
name="q" labels={{
defaultValue={query} ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleInquiries}</p> : null}
<AdminSearchForm
query={query}
label={t.adminInquiriesPage.searchLabel}
placeholder={t.adminInquiriesPage.searchPlaceholder} placeholder={t.adminInquiriesPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminInquiriesPage.filterButton}
resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
resetLabel={t.adminInquiriesPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminInquiriesPage.filterButton}
</button>
{query ? (
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
{t.adminInquiriesPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4"> <div className="space-y-4">
{inquiries.map((inquiry) => ( {inquiries.map((inquiry) => (
<div key={inquiry.id} className="bg-background border border-ink/10 p-6"> <div key={inquiry.id} className="bg-background border border-ink/10 p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<p className="text-forest">{inquiry.companyName}</p> <div className="flex flex-wrap items-center gap-3">
<p className="text-heading">{inquiry.companyName}</p>
<AdminBadge tone={STATUS_TONES[inquiry.status] ?? "neutral"} pulse={inquiry.status === "NEU"}>
{statusLabels[inquiry.status] ?? inquiry.status}
</AdminBadge>
</div>
<p className="text-sm text-ink/70"> <p className="text-sm text-ink/70">
{inquiry.contactPerson} · {inquiry.email} {inquiry.contactPerson} · {inquiry.email}
{inquiry.phone ? ` · ${inquiry.phone}` : ""} {inquiry.phone ? ` · ${inquiry.phone}` : ""}
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p> <p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(inquiry.createdAt)}</p>
</div> </div>
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2"> <form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
<input type="hidden" name="id" value={inquiry.id} /> <input type="hidden" name="id" value={inquiry.id} />
<select <select
name="status" name="status"
defaultValue={inquiry.status} defaultValue={inquiry.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs" aria-label={t.adminInquiriesPage.statusSelectLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
> >
{Object.entries(statusLabels).map(([value, label]) => ( {Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -82,7 +114,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="text-xs text-forest underline"> <button
type="submit"
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminInquiriesPage.save} {t.adminInquiriesPage.save}
</button> </button>
</form> </form>
@@ -107,9 +142,18 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</div> </div>
))} ))}
{inquiries.length === 0 ? ( {inquiries.length === 0 ? (
<p className="text-ink/65"> <AdminEmptyState
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet} bordered
</p> message={
query
? t.adminInquiriesPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: archivedCount > 0
? t.adminInquiriesPage.noOpenInquiries
: t.adminInquiriesPage.noInquiriesYet
}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+5 -5
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -16,8 +17,7 @@ export default async function AdminAuditLogPage() {
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} /> <BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1> <AdminPageHeader title={t.adminAuditLogPage.title} intro={t.adminAuditLogPage.intro(RECENT_LIMIT)} />
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
<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">
@@ -33,9 +33,9 @@ export default async function AdminAuditLogPage() {
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td> <td className="p-4 whitespace-nowrap tabular-nums text-ink/70">{formatDateTime(entry.createdAt)}</td>
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td> <td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td> <td className="p-4 whitespace-nowrap font-mono text-xs text-heading">{entry.action}</td>
<td className="p-4 whitespace-nowrap text-ink/70"> <td className="p-4 whitespace-nowrap text-ink/70">
{entry.entityType} {entry.entityType}
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null} {entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
@@ -45,7 +45,7 @@ export default async function AdminAuditLogPage() {
))} ))}
</tbody> </tbody>
</table> </table>
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null} {entries.length === 0 ? <AdminEmptyState message={t.adminAuditLogPage.noEntriesYet} /> : null}
</div> </div>
</div> </div>
); );
+57 -92
View File
@@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { AdminField, AdminFieldset, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
import { import {
updateCustomerAction, updateCustomerAction,
blockCustomerAction, blockCustomerAction,
@@ -16,8 +19,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) { export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tu = t.adminUserEditPage;
const { id } = await props.params; const { id } = await props.params;
const { error, message } = await props.searchParams;
const [admin, customer] = await Promise.all([ const [admin, customer] = await Promise.all([
getCurrentAdmin(), getCurrentAdmin(),
@@ -29,66 +32,56 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date()); const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} /> <BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2"> <h1 className="text-2xl text-heading mb-2">
{customer.firstName} {customer.lastName} {customer.firstName} {customer.lastName}
</h1> </h1>
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p> <p className="text-sm text-ink/65 mb-8">{tu.createdOn(formatDate(customer.createdAt))}</p>
{error ? ( <AdminFormPanel action={updateCustomerAction} actions={<Button type="submit">{tu.save}</Button>}>
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tu.sectionProfile} last>
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required /> <div className="grid grid-cols-2 items-end gap-4">
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required /> <AdminField name="firstName" label={tu.firstNameLabel} defaultValue={customer.firstName} required />
<AdminField name="lastName" label={tu.lastNameLabel} defaultValue={customer.lastName} required />
</div> </div>
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required /> <AdminField name="email" label={tu.emailLabel} type="email" defaultValue={customer.email} required />
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} /> <AdminField name="phone" label={tu.phoneLabel} type="tel" defaultValue={customer.phone ?? ""} />
<label className="block text-sm"> <AdminSelect
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
<select
name="role" name="role"
label={tu.roleLabel}
defaultValue={customer.role} defaultValue={customer.role}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" hint={isSelf ? tu.cannotChangeOwnRole : undefined}
> >
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option> <option value="KUNDE">{tu.roleCustomer}</option>
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option> <option value="ADMIN">{tu.roleAdmin}</option>
</select> </AdminSelect>
{isSelf ? ( </AdminFieldset>
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span> </AdminFormPanel>
) : null}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <section className="mt-8 space-y-6 border border-ink/10 bg-background p-6 sm:p-8">
{t.adminUserEditPage.save} <h2 className="font-serif text-xl text-heading">{tu.securityHeading}</h2>
</button> <dl className="divide-y divide-ink/10 text-sm">
</form> <div className="flex items-center justify-between gap-4 py-3">
<dt className="text-ink/70">{tu.twoFactorLabel}</dt>
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
<dl className="text-sm space-y-1">
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
<dd> <dd>
{customer.blockedAt ? ( {customer.twoFactorEnabled ? (
<span className="text-red-700"> <AdminBadge tone="positive">{tu.twoFactorActive}</AdminBadge>
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
</span>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
) : ( ) : (
<span className="text-forest">{t.adminUserEditPage.statusActive}</span> <AdminBadge tone="neutral">{tu.twoFactorInactive}</AdminBadge>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="text-ink/70">{tu.statusLabel}</dt>
<dd className="text-right">
{customer.blockedAt ? (
<AdminBadge tone="danger">{tu.blockedSince(formatDate(customer.blockedAt))}</AdminBadge>
) : isTemporarilyLocked ? (
<AdminBadge tone="pending">{tu.temporarilyLockedFailedAttempts}</AdminBadge>
) : (
<AdminBadge tone="positive">{tu.statusActive}</AdminBadge>
)} )}
</dd> </dd>
</div> </div>
@@ -96,67 +89,39 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
<form action={sendCustomerPasswordResetAction}> <form action={sendCustomerPasswordResetAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm"> <Button type="submit" variant="secondary" className="px-5 py-2.5">
{t.adminUserEditPage.sendPasswordReset} {tu.sendPasswordReset}
</button> </Button>
</form> </form>
{/* Sperren/Loeschen des eigenen Kontos ist serverseitig ohnehin
verboten — hier gar nicht erst angeboten. */}
{!isSelf ? ( {!isSelf ? (
<div className="flex items-center gap-4 pt-2 border-t border-ink/10"> <div className="border-t border-ink/10 pt-6">
<h3 className="mb-3 text-xs tracking-wide-caps text-ink/65">{tu.sectionDanger}</h3>
<div className="flex flex-wrap items-center gap-6 text-sm">
{customer.blockedAt ? ( {customer.blockedAt ? (
<form action={unblockCustomerAction}> <form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline text-sm"> <AdminActionButton>{tu.unblock}</AdminActionButton>
{t.adminUserEditPage.unblock}
</button>
</form> </form>
) : ( ) : (
<form action={blockCustomerAction}> <form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline text-sm"> <AdminActionButton tone="muted">{tu.block}</AdminActionButton>
{t.adminUserEditPage.block}
</button>
</form> </form>
)} )}
<ConfirmForm <ConfirmForm
action={deleteCustomerAction} action={deleteCustomerAction}
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)} confirmMessage={tu.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
> >
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline text-sm"> <AdminActionButton tone="danger">{tu.deleteAccount}</AdminActionButton>
{t.adminUserEditPage.deleteAccount}
</button>
</ConfirmForm> </ConfirmForm>
</div> </div>
</div>
) : null} ) : null}
</div> </section>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+19 -54
View File
@@ -1,68 +1,33 @@
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { createCustomerAction } from "../../actions"; import { createCustomerAction } 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 AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) { export default async function AdminBenutzerNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { error } = await props.searchParams; const tu = t.adminUserNewPage;
return ( return (
<div className="max-w-xl"> <div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} /> <BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1> <h1 className="text-2xl text-heading mb-2">{tu.title}</h1>
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p> <p className="text-sm text-ink/70 mb-8">{tu.intro}</p>
{error ? ( <AdminFormPanel action={createCustomerAction} actions={<Button type="submit">{tu.createAndSend}</Button>}>
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> <div className="space-y-4">
) : null} <div className="grid grid-cols-2 items-end gap-4">
<AdminField name="firstName" label={tu.firstNameLabel} required />
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminField name="lastName" label={tu.lastNameLabel} required />
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
</div> </div>
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required /> <AdminField name="email" label={tu.emailLabel} type="email" required />
<label className="block text-sm"> <AdminSelect name="role" label={tu.roleLabel} defaultValue="KUNDE">
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span> <option value="KUNDE">{tu.roleCustomer}</option>
<select <option value="ADMIN">{tu.roleAdmin}</option>
name="role" </AdminSelect>
defaultValue="KUNDE" </div>
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" </AdminFormPanel>
>
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
</select>
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminUserNewPage.createAndSend}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
required = false,
}: {
name: string;
label: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+50 -59
View File
@@ -1,10 +1,18 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
AdminSearchForm,
} from "@/components/admin-list";
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions"; import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -12,7 +20,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) { export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin }; const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
const { error, message, q } = await props.searchParams; const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const [admin, customers] = await Promise.all([ const [admin, customers] = await Promise.all([
@@ -32,43 +40,21 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
}), }),
]); ]);
const newUser = { href: "/admin/benutzer/neu", label: t.adminUsersPage.newUser };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminUsersPage.title} action={newUser} />
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminUsersPage.newUser}
</Link>
</div>
{error ? ( <AdminSearchForm
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> query={query}
) : null} label={t.adminUsersPage.searchLabel}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminUsersPage.searchPlaceholder} placeholder={t.adminUsersPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminUsersPage.filterButton}
resetHref="/admin/benutzer"
resetLabel={t.adminUsersPage.resetFilter}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminUsersPage.filterButton}
</button>
{query ? (
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
{t.adminUsersPage.resetFilter}
</Link>
) : null}
</form>
<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">
@@ -90,42 +76,48 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
return ( return (
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest"> <td className="p-4 text-heading">
{customer.firstName} {customer.lastName} {customer.firstName} {customer.lastName}
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null} {isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
</td> </td>
<td className="p-4">{customer.email}</td> <td className="p-4">{customer.email}</td>
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
<td className="p-4"> <td className="p-4">
{customer.blockedAt ? ( {customer.role === "ADMIN" ? (
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span> <AdminBadge tone="neutral">{roleLabels.ADMIN}</AdminBadge>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
) : ( ) : (
<span className="text-forest">{t.adminUsersPage.statusActive}</span> (roleLabels[customer.role] ?? customer.role)
)} )}
</td> </td>
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
<td className="p-4">{formatDate(customer.createdAt)}</td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> {customer.blockedAt ? (
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline"> <AdminBadge tone="danger">{t.adminUsersPage.statusBlocked}</AdminBadge>
{t.adminUsersPage.edit} ) : isTemporarilyLocked ? (
</Link> <AdminBadge tone="pending">{t.adminUsersPage.statusTemporarilyLocked}</AdminBadge>
) : (
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
)}
</td>
<td className="p-4">
{customer.twoFactorEnabled ? (
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
) : (
<span className="text-ink/55">—</span>
)}
</td>
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(customer.createdAt)}</td>
<td className="p-4">
<AdminRowActions>
<AdminActionLink href={`/admin/benutzer/${customer.id}`}>{t.adminUsersPage.edit}</AdminActionLink>
{!isSelf ? ( {!isSelf ? (
customer.blockedAt ? ( customer.blockedAt ? (
<form action={unblockCustomerAction}> <form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline"> <AdminActionButton>{t.adminUsersPage.unblock}</AdminActionButton>
{t.adminUsersPage.unblock}
</button>
</form> </form>
) : ( ) : (
<form action={blockCustomerAction}> <form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline"> <AdminActionButton tone="muted">{t.adminUsersPage.block}</AdminActionButton>
{t.adminUsersPage.block}
</button>
</form> </form>
) )
) : null} ) : null}
@@ -135,12 +127,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)} confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
> >
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminUsersPage.delete}</AdminActionButton>
{t.adminUsersPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : null} ) : null}
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
); );
@@ -148,9 +138,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
</tbody> </tbody>
</table> </table>
{customers.length === 0 ? ( {customers.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet} message={query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
</p> action={query ? undefined : newUser}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+129 -37
View File
@@ -3,66 +3,108 @@ 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, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions"; import {
updateOrderStatusAction,
updateShipmentTrackingAction,
createDhlLabelAction,
deleteOrderAction,
} from "../../actions";
import { ConfirmForm } from "@/components/confirm-form";
import { OrderStatusForm } from "@/components/order-status-form";
import { checkOrderDeletable } from "@/lib/order-delete";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl"; import { isDhlConfigured } from "@/lib/dhl";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { Button } from "@/components/ui";
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale()); const locale = await getLocale();
const t = getDictionary(locale);
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { id } = await props.params; const { id } = await props.params;
const { error, message } = await props.searchParams;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } }, items: {
include: {
product: { select: { slug: true } },
batch: { select: { code: true } },
sponsorship: { select: { number: true, status: true } },
},
},
payment: true, payment: true,
shipment: true, shipment: true,
pickup: true, pickup: true,
invoice: true, invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } }, customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } }, discountCode: { select: { code: true } },
}, },
}); });
if (!order) notFound(); if (!order) notFound();
const dhlConfigured = isDhlConfigured(); const dhlConfigured = isDhlConfigured();
// Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts);
// ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her.
const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : []));
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
// auseinanderlaufen koennen.
const isDeletable = checkOrderDeletable({
status: order.status,
hasPayment: order.payment !== null,
hasInvoice: order.invoice !== null,
}).deletable;
return ( return (
<div className="max-w-3xl"> <div className="max-w-4xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} /> <BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8"> <div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div> <div>
<h1 className="text-2xl text-forest">{order.orderNumber}</h1> <div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl text-heading">{order.orderNumber}</h1>
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
</div>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p> <p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div> </div>
<form action={updateOrderStatusAction} className="flex items-center gap-2"> <OrderStatusForm
<input type="hidden" name="orderId" value={order.id} /> action={updateOrderStatusAction}
<select orderId={order.id}
name="status" currentStatus={order.status}
defaultValue={order.status} labels={statusLabels}
className="border border-ink/20 bg-background px-3 py-2 text-sm" saveLabel={t.adminOrderDetailPage.saveStatus}
> statusAriaLabel={t.adminOrdersPage.statusLabel}
{Object.entries(statusLabels).map(([value, label]) => ( reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
<option key={value} value={value}> />
{label}
</option>
))}
</select>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.saveStatus}
</button>
</form>
</div> </div>
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
<div className="mb-8 border border-ink/10 bg-background px-6 py-5">
<OrderStatusTimeline
status={order.status}
fulfillmentMethod={order.fulfillmentMethod}
labels={{
ordered: t.orderStatusTimeline.ordered,
paid: t.orderStatusTimeline.paid,
preparing: t.orderStatusTimeline.preparing,
readyToShip: t.orderStatusTimeline.readyToShip,
shipped: t.orderStatusTimeline.shipped,
readyForPickup: t.orderStatusTimeline.readyForPickup,
pickedUp: t.orderStatusTimeline.pickedUp,
cancelled: t.orderStatusTimeline.cancelled,
ariaLabel: t.orderStatusTimeline.ariaLabel,
}}
/>
</div>
{revokedNumbers.length > 0 ? (
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
</p>
) : null}
{order.giftMessage ? ( {order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8"> <div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2> <h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
@@ -93,6 +135,14 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<> <>
<p>{order.street}</p> <p>{order.street}</p>
<p>{order.zip} {order.city}</p> <p>{order.zip} {order.city}</p>
{/* Nur bei Packstation-Lieferung: ohne die Postnummer laesst
sich keine Versandmarke erstellen, und ohne Anzeige hier
waere nicht nachvollziehbar, warum DHL sie ablehnt. */}
{order.packstationNumber ? (
<p className="mt-1 text-ink/70">
{t.adminOrderDetailPage.postNumberLabel}: {order.postNumber ?? "—"}
</p>
) : null}
<p className="mt-2 text-xs text-ink/65"> <p className="mt-2 text-xs text-ink/65">
{order.shipment?.shippedAt {order.shipment?.shippedAt
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt)) ? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
@@ -121,7 +171,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
) : dhlConfigured ? ( ) : dhlConfigured ? (
<form action={createDhlLabelAction}> <form action={createDhlLabelAction}>
<input type="hidden" name="orderId" value={order.id} /> <input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-honey-dark text-cream px-4 py-2 text-sm"> <button type="submit" className="bg-honey-deep text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.dhlCreateButton} {t.adminOrderDetailPage.dhlCreateButton}
</button> </button>
</form> </form>
@@ -153,9 +203,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm" className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
/> />
</label> </label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveTracking} {t.adminOrderDetailPage.saveTracking}
</button> </Button>
</form> </form>
</div> </div>
) : null} ) : null}
@@ -175,10 +225,10 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
{order.items.map((item) => ( {order.items.map((item) => (
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="py-2"> <td className="py-2">
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline"> <Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
{item.productName} {item.productName}
</Link>{" "} </Link>{" "}
<span className="text-ink/65">({item.weightGrams} g)</span> {item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
</td> </td>
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td> <td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
<td className="py-2 text-right">{item.quantity}</td> <td className="py-2 text-right">{item.quantity}</td>
@@ -209,7 +259,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<span>−{formatPrice(order.discountCents, order.currency)}</span> <span>−{formatPrice(order.discountCents, order.currency)}</span>
</div> </div>
) : null} ) : null}
<div className="flex justify-between text-forest pt-1 border-t border-ink/10"> <div className="flex justify-between text-heading pt-1 border-t border-ink/10">
<span>{t.adminOrderDetailPage.totalLabel}</span> <span>{t.adminOrderDetailPage.totalLabel}</span>
<span>{formatPrice(order.totalCents, order.currency)}</span> <span>{formatPrice(order.totalCents, order.currency)}</span>
</div> </div>
@@ -242,16 +292,58 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<span>{order.invoice.invoiceNumber}</span> <span>{order.invoice.invoiceNumber}</span>
<a <a
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`} href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
className="text-forest underline text-sm" className="link-draw text-sm text-heading"
> >
{t.adminOrderDetailPage.downloadPdf} {t.adminOrderDetailPage.downloadPdf}
</a> </a>
{order.invoice.cancellation && (
<>
<span className="mt-3">
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
</span>
<a
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
className="link-draw text-sm text-heading"
>
{t.adminOrderDetailPage.downloadPdf}
</a>
</>
)}
</div> </div>
) : ( ) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p> <p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
)} )}
</div> </div>
</div> </div>
{/* Dieselben drei Bedingungen prueft deleteOrderAction noch einmal
serverseitig. Das hier ist reine Anzeige: ein Knopf, den man nicht
sieht, ist keine Sicherheitsgrenze (siehe requireAdmin in
actions/_shared.ts). Steht am Seitenende, damit man nicht aus
Versehen daraufklickt, waehrend man oben den Status setzt. */}
<div className="mt-10 border border-red-200 bg-red-50/40 p-6">
<h2 className="text-xs tracking-wide-caps text-red-700 mb-3">
{t.adminOrderDetailPage.deleteHeading}
</h2>
{isDeletable ? (
<>
<p className="text-sm text-ink/70 leading-relaxed mb-4">
{t.adminOrderDetailPage.deleteHint}
</p>
<ConfirmForm
action={deleteOrderAction}
confirmMessage={t.adminOrderDetailPage.deleteConfirm(order.orderNumber)}
>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-red-700 text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.deleteButton}
</button>
</ConfirmForm>
</>
) : (
<p className="text-sm text-ink/70 leading-relaxed">{t.adminOrderDetailPage.deleteBlocked}</p>
)}
</div>
</div> </div>
); );
} }
Loaded 100 of 656 files, more files were not shown because too many files have changed in this diff. Show more