Compare commits
232
Commits
5c76fcc266
...
main
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
src/generated
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
|
coverage
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
# Datenbank: "postgresql" (Standard) oder "mysql". Nach einer Aenderung
|
||||||
|
# einmal `pnpm db:push` ausfuehren (schreibt prisma/schema.prisma neu und
|
||||||
|
# generiert den passenden Treiber-Adapter-Client) — siehe README, Abschnitt
|
||||||
|
# "Datenbank-Provider wechseln".
|
||||||
|
DATABASE_PROVIDER=postgresql
|
||||||
|
|
||||||
|
# PostgreSQL (Standard). Fuer docker compose siehe POSTGRES_* unten.
|
||||||
|
DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public"
|
||||||
|
# MySQL/MariaDB stattdessen:
|
||||||
|
# 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_USER=wabenhain
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=wabenhain
|
||||||
|
|
||||||
|
# Nur noetig, wenn DATABASE_PROVIDER=mysql: Zugangsdaten fuer den
|
||||||
|
# db-mysql-Container ("docker compose --profile mysql up -d db-mysql")
|
||||||
|
MYSQL_USER=wabenhain
|
||||||
|
MYSQL_PASSWORD=change-me
|
||||||
|
MYSQL_DATABASE=wabenhain
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||||
|
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
|
||||||
|
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
||||||
|
# Rolle "ADMIN" am Konto (siehe README, Abschnitt "Admin-Zugang"). Diese
|
||||||
|
# beiden Werte legt `prisma db seed` als initiales Admin-Konto an, falls es
|
||||||
|
# noch nicht existiert. Eine UNVORHERSEHBARE E-Mail waehlen (nicht
|
||||||
|
# "admin@..." o.ae.) — sonst koennte sich vor dem ersten Seed-Lauf jemand
|
||||||
|
# genau diese Adresse selbst registrieren; prisma/seed.ts befoerdert ein
|
||||||
|
# bereits bestehendes Nicht-Admin-Konto zwar nicht mehr automatisch, ein
|
||||||
|
# unvorhersehbarer Wert ist trotzdem die robustere erste Verteidigungslinie.
|
||||||
|
ADMIN_EMAIL=[email protected]
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Geheimer Schluessel zum Signieren der Kundenkonto-JWTs (HS256).
|
||||||
|
# In Produktion MINDESTENS 32 Zeichen (wird beim Start erzwungen).
|
||||||
|
# Erzeugen z.B. mit: openssl rand -base64 32
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Basis-URL der Seite (fuer absolute Links, z.B. in E-Mails/Sitemaps).
|
||||||
|
# Lokale Entwicklung (pnpm dev): http://localhost:3000 (Standard hier).
|
||||||
|
# Vollstaendiger Docker-Betrieb: http://localhost (Caddy laeuft auf Port 80,
|
||||||
|
# siehe SITE_ADDRESS/Caddyfile unten) bzw. die echte Domain in Produktion.
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Nur fuer den vollstaendigen Docker-Betrieb (docker-compose.yml, Caddyfile):
|
||||||
|
# die oeffentliche Domain, unter der die Seite erreichbar sein soll. Leer
|
||||||
|
# gelassen laeuft Caddy lokal per HTTP auf Port 80 ohne TLS — fuer eine
|
||||||
|
# echte Domain mit automatischem HTTPS z.B. "wabenhain.com" eintragen.
|
||||||
|
SITE_ADDRESS=
|
||||||
|
|
||||||
|
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
||||||
|
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
||||||
|
# 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
|
||||||
|
GOOGLE_PLACES_API_KEY=
|
||||||
|
GOOGLE_PLACE_ID=
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (optional). Ohne diese beiden Werte wird auf
|
||||||
|
# Registrierung, Newsletter und Firmenanfrage kein CAPTCHA angezeigt und die
|
||||||
|
# serverseitige Pruefung uebersprungen. Site-Key ist oeffentlich (NEXT_PUBLIC_),
|
||||||
|
# Secret-Key bleibt privat. Einrichtung: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||||
|
|
||||||
|
# Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
|
||||||
|
# Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben
|
||||||
|
# App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort
|
||||||
|
# zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README
|
||||||
|
# "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die
|
||||||
|
# ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer
|
||||||
|
# lokale Entwicklung ohne Mail-Setup.
|
||||||
|
# SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe
|
||||||
|
# Adresse tragen.
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
|
|
||||||
|
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
|
||||||
|
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
|
||||||
|
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
|
||||||
|
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
|
||||||
|
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
|
||||||
|
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
|
||||||
|
# Details siehe src/lib/apicore-client.ts.
|
||||||
|
APICORE_API_URL=
|
||||||
|
APICORE_API_KEY=
|
||||||
|
# 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) —
|
||||||
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||||
|
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
||||||
|
# der Button dort nur den Hinweis "DHL ist noch nicht konfiguriert".
|
||||||
|
#
|
||||||
|
# So kommt ihr an die Werte:
|
||||||
|
# 1. Account anlegen auf https://developer.dhl.com und eine App registrieren,
|
||||||
|
# die die API "Parcel DE Shipping" (Post & Parcel Germany) einbindet.
|
||||||
|
# Die App liefert DHL_API_KEY (Consumer Key) und DHL_API_SECRET
|
||||||
|
# (Consumer Secret). Fuer den Sandbox-Test reichen diese beiden Werte
|
||||||
|
# plus die DHL-Testzugangsdaten unten.
|
||||||
|
# 2. Fuer den PRODUKTIV-Betrieb zusaetzlich einen DHL-Geschaeftskundenvertrag
|
||||||
|
# ("DHL Paket" Versandvertrag) abschliessen — Ansprechpartner ist der
|
||||||
|
# DHL-Vertrieb bzw. https://www.dhl.de/de/geschaeftskunden/paket.html.
|
||||||
|
# Darueber bekommt ihr:
|
||||||
|
# - Zugangsdaten zum Geschaeftskundenportal (GKP) -> DHL_GKP_USERNAME/
|
||||||
|
# DHL_GKP_PASSWORD (dieselben Zugangsdaten, mit denen ihr euch auf
|
||||||
|
# dhl.de/geschaeftskundenportal einloggt).
|
||||||
|
# - Die EKP (10-stellige Kundennummer) plus Verfahren/Teilnahme fuer
|
||||||
|
# das gebuchte Produkt (Standard-Inlandspaket V01PAK) -> zusammen
|
||||||
|
# ergibt das die 14-stellige DHL_ACCOUNT_NUMBER (billingNumber).
|
||||||
|
# 3. DHL_ENVIRONMENT auf "sandbox" lassen, bis alles funktioniert, dann auf
|
||||||
|
# "production" umstellen (unterschiedliche Basis-URLs, siehe lib/dhl.ts).
|
||||||
|
#
|
||||||
|
# DHL-Sandbox-Testzugangsdaten (Stand der Recherche, koennen sich aendern —
|
||||||
|
# im Zweifel im DHL-Developer-Portal unter der App nachschauen):
|
||||||
|
# DHL_GKP_USERNAME=user-valid
|
||||||
|
# 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_SECRET=
|
||||||
|
DHL_GKP_USERNAME=
|
||||||
|
DHL_GKP_PASSWORD=
|
||||||
|
# 14-stellig: EKP (10-stellig) + Verfahren (2-stellig) + Teilnahme (2-stellig)
|
||||||
|
DHL_ACCOUNT_NUMBER=
|
||||||
|
DHL_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
|
# Microsoft Entra ID SSO ("Mit Microsoft anmelden", siehe
|
||||||
|
# src/app/api/auth/entra/). Ohne diese vier Werte zeigt der Button auf
|
||||||
|
# /konto einen Hinweis, dass SSO noch nicht konfiguriert ist, statt
|
||||||
|
# abzustuerzen.
|
||||||
|
#
|
||||||
|
# So richtet ihr es ein (Owner-Aufgabe im Azure-Portal, https://entra.microsoft.com):
|
||||||
|
# 1. Entra ID -> App registrations -> "New registration".
|
||||||
|
# - Name frei waehlbar, z.B. "Wabenhain Kundenlogin".
|
||||||
|
# - "Supported account types": i.d.R. "Accounts in this organizational
|
||||||
|
# directory only" (Single Tenant) fuer eine reine Firmen-SSO-Anbindung.
|
||||||
|
# - Redirect URI: Plattform "Web", URL = <eure Domain>/api/auth/entra/callback
|
||||||
|
# (lokal: http://localhost:3000/api/auth/entra/callback).
|
||||||
|
# 2. Nach dem Erstellen: "Application (client) ID" -> ENTRA_CLIENT_ID,
|
||||||
|
# "Directory (tenant) ID" -> ENTRA_TENANT_ID (beide auf der Overview-Seite
|
||||||
|
# der App-Registrierung sichtbar).
|
||||||
|
# 3. "Certificates & secrets" -> "New client secret" erzeugen -> der
|
||||||
|
# angezeigte WERT (nicht die Secret-ID!) -> ENTRA_CLIENT_SECRET. Nur
|
||||||
|
# einmal sichtbar, bei Verlust ein neues Secret erzeugen.
|
||||||
|
# 4. ENTRA_REDIRECT_URI = exakt dieselbe URL wie die in Schritt 1 hinterlegte
|
||||||
|
# Redirect-URI (muss zeichengenau uebereinstimmen).
|
||||||
|
# 5. "Enterprise applications" in Entra ID zeigt die App danach automatisch
|
||||||
|
# als Unternehmensapplikation (wird beim App-Registration-Schritt
|
||||||
|
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
|
||||||
|
# (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_CLIENT_ID=
|
||||||
|
ENTRA_CLIENT_SECRET=
|
||||||
|
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
||||||
|
|
||||||
|
# Verzeichnis fuer hochgeladene Bilder (Inhalte-Bild in admin/inhalte,
|
||||||
|
# Produkt-/Erntenfotos in admin/produkte bzw. admin/ernten — siehe
|
||||||
|
# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts). Die App
|
||||||
|
# liest/schreibt hier ganz normal per node:fs; sie weiss nichts davon, WAS
|
||||||
|
# dieses Verzeichnis in Produktion ist (siehe unten).
|
||||||
|
#
|
||||||
|
# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per
|
||||||
|
# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt).
|
||||||
|
#
|
||||||
|
# In docker-compose.yml ist UPLOAD_DIR bereits fest auf /app/uploads gesetzt
|
||||||
|
# und ueber ein benanntes Docker-Volume persistiert (siehe dort) — hier
|
||||||
|
# nichts weiter noetig, dieser Wert wirkt nur fuer lokales "npm run dev".
|
||||||
|
#
|
||||||
|
# In Produktion per Kubernetes (siehe k8s/05-app-deployment.yaml) zeigt
|
||||||
|
# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder
|
||||||
|
# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret
|
||||||
|
# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des
|
||||||
|
# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath
|
||||||
|
# zeigen.
|
||||||
|
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=
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
name: Docker Image bauen und veroeffentlichen
|
||||||
|
|
||||||
|
# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>")
|
||||||
|
# und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell
|
||||||
|
# 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:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Fest hinterlegt statt ${{ gitea.server_url }}: das hat in manchen
|
||||||
|
# Gitea-Versionen einen Bug (leerer Wert im Runner, siehe
|
||||||
|
# go-gitea/gitea#23276) und liefert selbst wenn es funktioniert eine
|
||||||
|
# volle URL MIT "https://"-Schema — docker login/Image-Tags brauchen
|
||||||
|
# aber den reinen Hostnamen ohne Schema. Bei einem Umzug auf eine
|
||||||
|
# andere Gitea-Instanz hier anpassen. Nur noch fuer Doku/OCI-Labels
|
||||||
|
# relevant — der eigentliche Push laeuft ueber REGISTRY_PUSH (s.u.),
|
||||||
|
# Kubernetes zieht die Images weiterhin ueber diesen Hostnamen.
|
||||||
|
REGISTRY: git.encry.de
|
||||||
|
# Gitea-Registry-Push laeuft ueber den Cluster-internen Service-Namen
|
||||||
|
# statt git.encry.de: Docker-Registry-Blob-Uploads (PUT) vertragen sich
|
||||||
|
# nicht mit dem Cloudflare-Proxy vor git.encry.de — "413 Payload Too
|
||||||
|
# Large" auch bei kleinen Layern, unabhaengig von der eigentlichen
|
||||||
|
# Groesse. Der Runner-Pod (gitea-runner, Namespace "gitea") erreicht
|
||||||
|
# den Gitea-Service direkt ueber Kubernetes-DNS ("gitea:80" — siehe
|
||||||
|
# dessen eigene GITEA_INSTANCE_URL-Env, die denselben Hostnamen nutzt),
|
||||||
|
# ohne ueber Cloudflare zu hairpinnen. Push und Pull landen im selben
|
||||||
|
# Registry-Pfad (Owner/Repo:Tag) — der Hostname ist nur die Verbindung,
|
||||||
|
# nicht Teil des gespeicherten Images, daher bleibt der Pull in den
|
||||||
|
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||||
|
REGISTRY_PUSH: gitea:80
|
||||||
|
steps:
|
||||||
|
- name: Code auschecken
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
|
# ${{ gitea.repository }} ("Owner/Repo") behaelt die Gross-/
|
||||||
|
# Kleinschreibung des Gitea-Repos bei.
|
||||||
|
id: image
|
||||||
|
run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker Buildx einrichten
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea:80"]
|
||||||
|
http = true
|
||||||
|
|
||||||
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# GITEA_TOKEN hat aktuell KEIN Package-Write-Recht (Gitea-
|
||||||
|
# Einschraenkung, Stand heute) — deshalb ein separates Personal
|
||||||
|
# Access Token als Repo-/Org-Secret REGISTRY_TOKEN anlegen
|
||||||
|
# (Scope: "package", Gitea-Einstellungen -> Anwendungen ->
|
||||||
|
# Access Tokens) und unter Repo-Einstellungen -> Actions ->
|
||||||
|
# Secrets als REGISTRY_TOKEN hinterlegen.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
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 +
|
||||||
|
# MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe
|
||||||
|
# src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile-
|
||||||
|
# Kommentar bei "AS migrator") — die App migriert/seedet sich beim
|
||||||
|
# Start selbst (idempotent), kein separates Migrations-Image/Job mehr.
|
||||||
|
- name: Image bauen und pushen
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
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
|
||||||
+21
-1
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -30,8 +33,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,3 +43,19 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
/src/generated/prisma-mysql
|
||||||
|
|
||||||
|
# kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
||||||
|
/k8s/02-secret.yaml
|
||||||
|
|
||||||
|
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
|
||||||
|
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
|
||||||
|
/uploads
|
||||||
|
|
||||||
|
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
||||||
|
/invoices
|
||||||
|
|
||||||
|
# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR).
|
||||||
|
/certificates
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
# Fuer eine echte Domain mit automatischem HTTPS: {$SITE_ADDRESS} unten auf
|
||||||
|
# z.B. "wabenhain.com" setzen und diesen Block entfernen. Ohne echte
|
||||||
|
# Domain kann Caddy kein Zertifikat ausstellen, daher hier bewusst
|
||||||
|
# lokal-tauglich per HTTP eingerichtet.
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
{$SITE_ADDRESS::80} {
|
||||||
|
# Terminiert die tatsaechliche Client-Verbindung und ueberschreibt
|
||||||
|
# X-Forwarded-For dabei komplett mit der selbst beobachteten IP (Caddy
|
||||||
|
# verwirft eingehende X-Forwarded-For/-Proto/-Host-Header von Clients
|
||||||
|
# per Default, siehe https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#defaults).
|
||||||
|
# Ohne diesen Proxy koennte jeder Client den Header selbst faelschen und
|
||||||
|
# damit saemtliche Rate-Limits (Login, 2FA, Checkout, ...) umgehen, siehe
|
||||||
|
# lib/rate-limit.ts.
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22.23.2-alpine3.24 AS base
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- Dependencies -----------------------------------------------------------
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
# --ignore-scripts: postinstall generiert die Prisma-Clients aus
|
||||||
|
# prisma/schema.prisma + scripts/ — in dieser Stage noch nicht vorhanden
|
||||||
|
# (erst "COPY . ." im builder-Stage unten). Die build-Stage generiert ueber
|
||||||
|
# "pnpm build" ohnehin beide Client-Varianten, s.u.
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# --- Build --------------------------------------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# DATABASE_URL wird zur Build-Zeit nicht benoetigt (Prisma generate braucht
|
||||||
|
# keine Live-Verbindung), muss aber gesetzt sein, damit prisma.config.ts
|
||||||
|
# (laedt selbst kein .env im Image) nicht wegen einer fehlenden URL abbricht.
|
||||||
|
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
|
||||||
|
# Generiert intern beide Prisma-Client-Varianten (Postgres + MySQL, siehe
|
||||||
|
# scripts/generate-prisma-clients.mjs) und baut danach die Next.js-App — ein
|
||||||
|
# einzelnes Image, das per DATABASE_PROVIDER zur Laufzeit zwischen beiden
|
||||||
|
# Datenbanken wechseln kann, siehe lib/prisma.ts.
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# --- Migrator-Tools -----------------------------------------------------
|
||||||
|
# Eigenstaendiger, schlanker node_modules-Baum nur mit Prisma-CLI, tsx und
|
||||||
|
# den paar Laufzeit-Paketen, die prisma/seed.ts importiert — NICHT
|
||||||
|
# next/react/sharp/stripe/alle devDependencies, die der volle builder-Stage
|
||||||
|
# oben mitbringt. Wird unten in die runner-Stage kopiert, damit die App sich
|
||||||
|
# beim Start selbst migrieren/seeden kann (kein separates Migrations-Image/
|
||||||
|
# kein Kubernetes-Job mehr noetig).
|
||||||
|
FROM base AS migrator
|
||||||
|
# pnpm-workspace.yaml zuerst kopieren: pnpm v11 blockt Postinstall-Skripte
|
||||||
|
# (u.a. @prisma/engines, laedt die Schema-Engine-Binary fuer "migrate
|
||||||
|
# deploy") standardmaessig, ausser sie sind per "allowBuilds" in dieser
|
||||||
|
# Datei freigegeben. Ohne die Datei an dieser Stelle liefe "pnpm add" gegen
|
||||||
|
# ein Verzeichnis ohne jede Allowlist und wuerde die Skripte ignorieren
|
||||||
|
# ([ERR_PNPM_IGNORED_BUILDS]) -> Schema Engine fehlt -> "migrate deploy"
|
||||||
|
# schlaegt mit E_CANNOT_RESOLVE_VERSION fehl.
|
||||||
|
COPY pnpm-workspace.yaml ./
|
||||||
|
# dotenv: prisma.config.ts importiert "dotenv/config" (Vorlagen-Kommentar
|
||||||
|
# dort: "assumes you have installed ... dotenv") — im vollen builder-Stage
|
||||||
|
# oben nur ueber eine transitive Abhaengigkeit erreichbar, hier in der
|
||||||
|
# isolierten node_modules-Installation nicht automatisch mit dabei.
|
||||||
|
RUN pnpm add --save-prod \
|
||||||
|
prisma@^7.9.1 tsx@^4 bcryptjs@^3.0.3 dotenv@^17.4.2 \
|
||||||
|
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
|
||||||
|
@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 --------------------------------------------------------------
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# System-ffmpeg statt des npm-Pakets ffmpeg-static (lib/video-transcode.ts
|
||||||
|
# nutzt es lokal per "npm run dev" trotzdem als Fallback, siehe dort):
|
||||||
|
# ffmpeg-static laedt seine Binary in den pnpm-Store, dessen Pfad ueber
|
||||||
|
# mehrere Docker-Build-Stages hinweg (migrator -> runner) per COPY nicht
|
||||||
|
# zuverlaessig ankam (spawn ... ffmpeg ENOENT im Betrieb beobachtet). Ein
|
||||||
|
# per apk installiertes System-Paket liegt dagegen fest unter /usr/bin auf
|
||||||
|
# PATH, unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
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/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
COPY scripts ./scripts
|
||||||
|
# Das ganze lib-Verzeichnis statt einzelner Dateien: neben prisma/seed.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
|
||||||
|
# Migrations-/Seed-Pakete (prisma-CLI/tsx/dotenv) DIREKT ins selbe
|
||||||
|
# node_modules wie der Next.js-Standalone-Output kopieren (ueberlagert dort
|
||||||
|
# nur exakt dieselben Paketversionen, die builder bereits fuer next/react
|
||||||
|
# aufgeloest hat) statt in einen separaten Baum + PATH/NODE_PATH-Trick: der
|
||||||
|
# generierte Prisma-Client + tsx nutzen Node's ESM-Modulaufloesung, die
|
||||||
|
# NODE_PATH schlicht ignoriert (nur ein CommonJS-"require()"-Feature) —
|
||||||
|
# fuehrte zu "Cannot find package '@prisma/client'" beim Seed-Lauf. Die
|
||||||
|
# normale node_modules-Huelle nach oben (von jeder Datei unter /app/ aus)
|
||||||
|
# funktioniert dagegen fuer beide Modulsysteme zuverlaessig.
|
||||||
|
COPY --chown=nextjs:nodejs --from=migrator /app/node_modules ./node_modules
|
||||||
|
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
|
# UPLOAD_DIR (Standard "./uploads", siehe .env.example) faellt ohne diesen
|
||||||
|
# Schritt unter das root-eigene /app-Arbeitsverzeichnis: der Container laeuft
|
||||||
|
# als non-root "nextjs" (s.u.), dessen mkdir()-Aufruf in
|
||||||
|
# app/api/admin/upload*/route.ts dort ohne Schreibrecht auf /app selbst mit
|
||||||
|
# EACCES fehlschlaegt — Admin-Uploads (Bilder UND Hero-Video) scheitern dann
|
||||||
|
# ausnahmslos. Verzeichnis hier bereits VOR "USER nextjs" anlegen und
|
||||||
|
# uebereignen; docker-compose.yml mountet ausserdem ein Volume genau hierher,
|
||||||
|
# damit Uploads auch einen Container-Neustart ueberleben (siehe dort).
|
||||||
|
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Migrationen + Seed laufen VOR dem Serverstart, damit Readiness-/Liveness-
|
||||||
|
# Probes nie gegen eine ungemigrateet Datenbank treffen — dafuer kein
|
||||||
|
# separater Kubernetes-Job mehr noetig. Beide Schritte sind idempotent
|
||||||
|
# ("migrate deploy" wendet nur neue Migrationen an, prisma/seed.ts
|
||||||
|
# legt/erneuert nur das Admin-Konto) und damit bei jedem Neustart
|
||||||
|
# gefahrlos. KEINE Beispieldaten im Image/Auto-Seed — dafuer separat
|
||||||
|
# "pnpm db:seed:demo" (prisma/seed-demo.ts), nie automatisch ausgefuehrt.
|
||||||
|
CMD ["sh", "-c", "node scripts/prisma-provider.mjs && prisma migrate deploy && prisma db seed && exec node server.js"]
|
||||||
@@ -1,36 +1,581 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Wabenhain
|
||||||
|
|
||||||
## Getting Started
|
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
||||||
|
|
||||||
First, run the development server:
|
> **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)**.
|
||||||
|
|
||||||
```bash
|
## Projektstand
|
||||||
npm run dev
|
|
||||||
# or
|
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)**.
|
||||||
yarn dev
|
|
||||||
# or
|
Kurzfassung (Stand 2026-10-16):
|
||||||
pnpm dev
|
|
||||||
# or
|
- 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.
|
||||||
bun dev
|
- 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
|
||||||
|
|
||||||
|
| Bereich | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Sprache | TypeScript |
|
||||||
|
| Framework | Next.js 16 (App Router) + React 19 |
|
||||||
|
| Styling | Tailwind CSS v4 |
|
||||||
|
| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
|
||||||
|
| 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`) |
|
||||||
|
| Payment | Stripe Checkout, eingebettet (`ui_mode: "embedded_page"`) |
|
||||||
|
| Hosting | Docker / Node.js |
|
||||||
|
|
||||||
|
## Was ist enthalten
|
||||||
|
|
||||||
|
- Startseite mit allen Sections aus dem Briefing (Hero, aktuelle Ernte, Herkunft, Philosophie, Produktbereich, Prozess-Timeline, Regionale Imkerei, Nachhaltigkeit, Firmenkunden-Teaser, Bewertungen, Newsletter) — die Bewertungen-Section zeigt echte Google-Rezensionen, sobald konfiguriert (siehe „Google-Rezensionen" unten), sonst statische Beispiel-Stimmen als Fallback
|
||||||
|
- Shop-Übersicht mit Suche (`?q=`, mehrwortig und case-insensitiv über Name, Beschreibung und Geschmacks-Tags), Sortierung (Neu eingetroffen/Preis auf-/absteigend/Verfügbarkeit), „Nur verfügbare"-Filter und `BreadcrumbList`-JSON-LD + Produktdetailseite (Geschmacksskala, Charge, strukturierte Daten/JSON-LD, Lagerbestands-Hinweis, „Das könnte dir auch gefallen" mit verwandten Produkten derselben Kategorie)
|
||||||
|
- Warenkorb (Drawer + Seite) mit Produktbild-Platzhaltern je Position und „Das könnte dir auch gefallen"-Cross-Sell auf der Warenkorb-Seite (verfügbare Produkte, die noch nicht im Warenkorb liegen)
|
||||||
|
- **Zuletzt angesehen**: rein lokal im Browser (localStorage, kein Tracking-Cookie) gemerkte zuletzt besuchte Produkte, als Sektion auf Produktdetail- und Shop-Seite
|
||||||
|
- **Echte Kaufsignale statt künstlicher Dringlichkeit**: Sternebewertungen (aus echten, freigeschalteten Bewertungen) jetzt auch direkt auf den Produktkarten der Shop-Übersicht, nicht nur auf der Detailseite — bewusst keine erfundenen Countdown-Timer oder Live-Besucherzahlen
|
||||||
|
- **Echte Produktbewertungen**: eingeloggte Kund:innen können ein Produkt nur bewerten, wenn sie es tatsächlich gekauft haben (verifizierter Kauf, eine Bewertung pro Konto und Produkt); neue Bewertungen erscheinen erst nach admin-Freischaltung (`/admin/bewertungen`) öffentlich, inkl. Sternebewertung, optionalem Kommentar und `AggregateRating`-JSON-LD auf der Produktseite
|
||||||
|
- **Rabattcodes/Gutscheine**: prozentual oder als fester Betrag, mit optionalem Mindestbestellwert, Gültigkeitszeitraum und Nutzungslimit, verwaltet unter `/admin/gutscheine`. Der Code wird beim Checkout eingelöst und als einmaliger Stripe-Coupon auf die Checkout-Session angewendet (Rabatt gilt nur auf den Warenwert, nicht auf den Versand); die Nutzung wird erst bei bestätigter Zahlung gezählt, nicht schon bei einem abgebrochenen Checkout
|
||||||
|
- **Lagerbestands-Hinweise**: „Ausverkauft"-/„Nur noch N"-Badges auf Produktkarte und Produktdetailseite (ab 5 Stück oder weniger), Bestellbutton wird bei Bestand 0 deaktiviert
|
||||||
|
- **Benachrichtigung bei Wiederverfügbarkeit**: bei ausverkauften Produkten kann eine E-Mail-Adresse hinterlegt werden (`StockNotification`-Modell); sobald ein Admin den Lagerbestand im Admin-Bereich wieder über 0 setzt, werden alle wartenden Adressen automatisch per E-Mail benachrichtigt (`lib/stock-notifications.ts`)
|
||||||
|
- **Retouren**: Kund:innen können im Konto (`/konto/bestellungen`) für bezahlte, nicht stornierte Bestellungen eine Rücksendung mit Begründung anfragen (`ReturnRequest`-Modell). Admin sieht alle Anfragen unter `/admin/retouren`, kann sie annehmen oder ablehnen und eine angenommene Retoure per Klick über die Stripe Refunds API erstatten — dabei wird automatisch der Lagerbestand zurückgebucht und `Payment.status` auf `REFUNDED` gesetzt. Jeder Schritt (Anfrage, Annahme/Ablehnung, Erstattung) löst eine E-Mail an die Kundschaft aus.
|
||||||
|
- **Schnell-Hinzufügen**: Produktkarten in der Shop-Übersicht haben einen direkten „In den Warenkorb"-Button für verfügbare Produkte, ohne die Produktseite öffnen zu müssen
|
||||||
|
- **Mengenauswahl**: auf der Produktseite kann die Menge vor dem Hinzufügen zum Warenkorb direkt per Mengen-Steller gewählt werden (begrenzt auf den verfügbaren Lagerbestand)
|
||||||
|
- **Wunschliste** (`/konto/wunschliste`): eingeloggte Kund:innen können Produkte auf der Produktdetailseite merken und später in ihrem Konto wiederfinden oder entfernen
|
||||||
|
- Warenkorb (Zustand-Store, persistiert im Browser) + Cart-Drawer
|
||||||
|
- Checkout mit Gastbestellung oder eingeloggtem Konto (bei Login werden Name/E-Mail/Standardadresse automatisch vorausgefüllt), Versand/Abholung, Stripe Checkout Session mit Karte, **SEPA-Lastschrift** und **Klarna** (siehe „Stripe" unten), admin-konfigurierbare **Versandkosten-Staffel** nach Gesamtmenge (siehe „Versandkosten" unten). Die Checkout-Seite zeigt Versandkosten und Gesamtsumme live an (reagiert auf Versand/Abholung-Auswahl) statt sie erst nach der Weiterleitung zu Stripe sichtbar zu machen.
|
||||||
|
- Chargen-Seite (`/charge/[code]`) für den QR-Code auf jedem Glas
|
||||||
|
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
||||||
|
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
||||||
|
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
||||||
|
- 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
|
||||||
|
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
||||||
|
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
|
||||||
|
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über 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
|
||||||
|
- **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 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
|
||||||
|
- **Chargen-Seite (`/charge/[code]`, QR-Code auf jedem Glas) aufgewertet**: statt einer reinen Datenliste jetzt mit "Verifizierte Charge"-Kennzeichnung, einer Ernte-→-Abfüllung-→-MHD-Zeitleiste und einem Verweis auf das zugehörige Produkt — der Moment, in dem jemand mit dem Glas in der Hand die Herkunft nachprüft, bekommt entsprechend mehr Gewicht. Alle gezeigten Angaben sind echte, bereits vorhandene Chargendaten.
|
||||||
|
- **Lieferzeit im Checkout sichtbar**: bei Versand zeigt die Lieferart-Auswahl direkt die bereits auf „Versand und Zahlung" dokumentierte übliche Lieferzeit (2–4 Werktage nach Zahlungseingang) an, statt dass Kund:innen dafür extra die Versandseite aufsuchen müssen.
|
||||||
|
- **Live-Suchvorschläge**: die Kopfzeilen-Suche zeigt beim Tippen (ab 2 Zeichen, debounced) bis zu 5 passende Produkte mit Preis direkt zum Anklicken — `/api/shop/search-suggestions`, rate-limitiert wie jeder andere öffentliche Endpunkt, nutzt dieselbe Suchlogik wie die Shop-Übersicht.
|
||||||
|
- **Ernten-Detailseiten aufgewertet**: Zurück-Link, Chargen als klickbare Karten statt Fließtext-Links, Produkte über die neue geteilte `ProductTeaserLink`-Komponente (auch auf der Chargen-Seite verwendet — kein doppelter Code für dasselbe Muster).
|
||||||
|
- **Admin-2FA-Hinweis**: Admin-Konten ohne aktivierte Zwei-Faktor-Authentifizierung sehen einen dauerhaften, nicht blockierenden Hinweis mit Link zur Einrichtung — relevant, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt.
|
||||||
|
- **`.well-known/security.txt`** (RFC 9116): dokumentierter Kontaktweg für verantwortungsvolle Schwachstellenmeldungen.
|
||||||
|
- **Escape schließt Overlays + vollständiges Fokus-Management**: Cart-Drawer, mobiles Menü und die Kopfzeilen-Suche lassen sich jetzt auch per Escape-Taste schließen, nicht nur per Maus-Klick. Beim Öffnen wandert der Tastatur-Fokus aktiv in das Overlay hinein (statt auf dem jetzt verdeckten Ausloeser-Button stehen zu bleiben), Tab/Shift+Tab bleiben innerhalb des Overlays gefangen (kein Durchtabben zu unsichtbaren Links dahinter), und beim Schließen springt der Fokus zurück auf den auslösenden Button.
|
||||||
|
- **Mobile-Layout-Fixes**: die Warenkorb-Zeile (Menge/Preis/Entfernen wurde auf schmalen Viewports komplett aus dem sichtbaren Bereich gedrängt) und Platzhalterbild-Beschriftungen (hartes Abschneiden statt Kürzung mit „…") behoben — gefunden durch echte Viewport-Emulation, nicht nur Desktop-Tests.
|
||||||
|
- **Kundenkonto-Design**: der Konto-Bereich (Übersicht, Bestellungen, Adressen, Wunschliste, Zwei-Faktor) hat jetzt dieselbe optische Sorgfalt wie Shop/Checkout — Icon-Navigationskarten mit Hover-Anhebung statt reiner Text-Boxen, farbcodierte Bestellstatus-Badges statt Fließtext, und gestaltete Leer-Zustände mit Icon und Weiterleitung zum Shop statt einzelner Hinweissätze. Zusätzlich behoben: der Warenkorb-Button im Header hatte einen `aria-label`, der die sichtbare Artikelanzahl nicht einschloss (gleiches Muster wie bei der Bildergalerie).
|
||||||
|
- **Admin-Design**: alle 10 Tabellen im Admin-Bereich (Bestellungen, Produkte, Chargen, Ernten, Benutzer, Gutscheine, Versand, Bewertungen, Audit-Log) haben jetzt einen Hover-Zustand auf Zeilenebene statt komplett statischer Reihen. Das Dashboard zeigt jede Kennzahl mit einem passenden Icon, und der wichtigste Wert („Umsatz heute") ist optisch hervorgehoben statt gleichwertig neben sieben anderen Zahlen zu stehen. Die Bestellliste zeigt den Status jetzt zusätzlich als farbcodiertes Badge (wiederverwendet aus dem Kundenkonto) neben dem bestehenden Bearbeiten-Dropdown.
|
||||||
|
|
||||||
|
### Bewusst vereinfacht
|
||||||
|
|
||||||
|
- **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 ausformuliert, **müssen** aber anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
|
## Admin-Zugang
|
||||||
|
|
||||||
|
Es gibt **kein separates Admin-Login** und keine Basic-Auth mehr. Der `/admin`-Bereich wird über dasselbe Login-Formular wie das Kundenkonto (`/konto`) erreicht — ob ein Konto zusätzlich Admin-Rechte hat, entscheidet einzig das Feld `role` (`KUNDE` | `ADMIN`) am `Customer`-Datensatz:
|
||||||
|
|
||||||
|
- Beim Login/bei der Registrierung wird die Rolle des Kontos in die Session (JWT) geschrieben.
|
||||||
|
- `src/proxy.ts` prüft bei jedem Zugriff auf `/admin/*`, ob die Session die Rolle `ADMIN` trägt, und leitet sonst mit einer erklärenden Meldung zu `/konto` um.
|
||||||
|
- Jede Admin-Server-Action (`src/app/admin/actions.ts`) und das Admin-Layout prüfen die Rolle zusätzlich **direkt gegen die Datenbank** (`getCurrentAdmin()`) — der Proxy ist nur der schnelle Vorfilter, nicht die einzige Kontrollinstanz.
|
||||||
|
- Neue Konten über das öffentliche Registrierungsformular erhalten immer die Rolle `KUNDE`; es gibt keinen Weg, sich selbst zum Admin zu machen.
|
||||||
|
|
||||||
|
**Erstes Admin-Konto anlegen:** `ADMIN_EMAIL` (unvorhersehbar wählen, nicht „admin@...") und `ADMIN_PASSWORD` in `.env` setzen und `pnpm db:seed` (bzw. `docker compose exec app pnpm prisma db seed`) **vor** dem ersten öffentlichen Start ausführen — legt das Konto neu an, oder erneuert bei einem bereits bestehenden Admin-Konto mit dieser E-Mail-Adresse nur dessen Passwort. Existiert unter dieser E-Mail-Adresse bereits ein **Nicht**-Admin-Konto (z. B. weil sich jemand die Adresse vorregistriert hat), befördert der Seed-Lauf es bewusst **nicht** automatisch — stattdessen erscheint eine Warnung im Log, und die Beförderung muss manuell über die Datenbank erfolgen (siehe unten). So lässt sich ein Admin-Konto nicht durch Vorregistrieren der erwarteten E-Mail-Adresse kapern. Login danach ganz normal über `/konto`.
|
||||||
|
|
||||||
|
**Benutzerverwaltung:** Unter `/admin/benutzer` können Admins alle Konten einsehen, anlegen, bearbeiten (inkl. Rolle `KUNDE`/`ADMIN`), sperren/entsperren und löschen. Beim Anlegen wird **kein Passwort vom Admin vergeben** — das neue Konto erhält sofort eine E-Mail mit einem Link, über den die Person ihr eigenes Passwort festlegt (derselbe Mechanismus wie „Passwort vergessen"). Ein Passwort-Reset für bestehende Konten läuft genauso: der Admin-Button löst nur den Versand des Reset-Links aus, ein Admin bekommt nie ein Kunden-Passwort im Klartext zu Gesicht.
|
||||||
|
|
||||||
|
Eingebaute Selbstschutz-Regeln verhindern, dass sich ein Admin versehentlich aussperrt:
|
||||||
|
- Ein Admin kann seine eigene Rolle nicht ändern und sich selbst nicht sperren oder löschen.
|
||||||
|
- Der letzte verbleibende `ADMIN`-Account kann nicht herabgestuft, gesperrt oder gelöscht werden.
|
||||||
|
|
||||||
|
Alternativ weiterhin direkt über die Datenbank möglich:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE customers SET role = 'ADMIN' WHERE email = '[email protected]';
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
oder per Prisma Studio (`pnpm db:studio`) am Datensatz das Feld `role` ändern.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Voraussetzungen
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
- Node.js 20.9+ (getestet mit 24.x)
|
||||||
|
- pnpm (`corepack enable` oder `npm i -g pnpm`)
|
||||||
|
- Docker + Docker Compose (für PostgreSQL lokal und für den Produktions-Build)
|
||||||
|
|
||||||
## Learn More
|
## Lokale Entwicklung
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env anpassen (mindestens SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD; Stripe-Keys optional für UI-Arbeit)
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
pnpm install
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
# Nur die Datenbank per Docker starten:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
## Deploy on Vercel
|
pnpm db:migrate # erstellt Tabellen (Prisma Migrate)
|
||||||
|
pnpm db:seed # legt/erneuert nur das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) an
|
||||||
|
pnpm db:seed:demo # optional: 2 Beispiel-Ernten/Chargen/Produkte zum Ausprobieren (nicht im Docker-Image, siehe unten)
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
pnpm dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Admin-Bereich: `http://localhost:3000/admin` — Login mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` über das normale Formular unter `/konto` (siehe „Admin-Zugang" unten).
|
||||||
|
|
||||||
|
## Datenbank-Provider wechseln
|
||||||
|
|
||||||
|
Die App läuft wahlweise mit PostgreSQL (Standard) oder MySQL/MariaDB — steuerbar über zwei Variablen in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_PROVIDER=postgresql # oder: mysql
|
||||||
|
DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public"
|
||||||
|
# bei mysql stattdessen z.B.:
|
||||||
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach einer Änderung von `DATABASE_PROVIDER` (z.B. lokal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nur die MySQL-Datenbank per Docker starten (statt "docker compose up -d db"):
|
||||||
|
docker compose --profile mysql up -d db-mysql
|
||||||
|
|
||||||
|
pnpm db:push # legt Tabellen beim gewaehlten Provider an (schreibt kurzzeitig die passende provider-Zeile in schema.prisma, siehe unten)
|
||||||
|
pnpm db:seed # Admin-Konto
|
||||||
|
pnpm db:seed:demo # optional: Beispieldaten
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ein Build/Image reicht für beide Provider gleichzeitig**: `scripts/generate-prisma-clients.mjs` generiert bei jedem `dev`/`build`/`start`/`postinstall` automatisch BEIDE Prisma-Client-Varianten (Postgres und MySQL) — `src/lib/prisma.ts`/`prisma/seed.ts` wählen zur Laufzeit rein anhand von `DATABASE_PROVIDER`, welche davon tatsächlich verwendet wird (technisch nötig, weil ein einzelner generierter Prisma-Client fest an einen Provider gebunden ist). Ein bereits gebautes Docker-Image lässt sich also allein durch Ändern von `DATABASE_PROVIDER`/`DATABASE_URL` und einen Neustart umschalten, **ohne Neubau** — praktisch für Kubernetes, siehe „Kubernetes / Gitea CI" unten.
|
||||||
|
|
||||||
|
Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich gegen EINE konkrete Datenbank Schema-Änderungen anwenden) schreiben vorher kurzzeitig die passende `provider`-Zeile in `prisma/schema.prisma` (Prisma erlaubt dort kein `env()`, siehe `scripts/prisma-provider.mjs`) — das betrifft nur diese Befehle, nicht die beiden generierten Clients selbst.
|
||||||
|
|
||||||
|
**Technischer Hintergrund**: `Harvest.images`, `Product.tasteTags` und `Product.images` sind als `Json` statt `String[]` modelliert, weil MySQL keine nativen Skalar-Array-Spalten kennt (Prisma-Einschränkung). Zur Laufzeit werden diese Felder in `lib/data.ts`/`lib/json-array.ts` transparent zu normalen String-Arrays normalisiert — der Rest der App merkt vom Provider-Wechsel nichts.
|
||||||
|
|
||||||
|
## Stripe
|
||||||
|
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Eingebetteter Checkout
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `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_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 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), sondern nach einer im Admin-Bereich unter `/admin/versand` gepflegten Mengenstaffel — die Gesamtmenge aller Positionen im Warenkorb wird dabei zusammengezählt, mehrere verschiedene Produkte also gemeinsam bewertet statt einzeln berechnet. Ab Werk sind drei Staffeln angelegt (0–3 Stück: 7,00 €, 4–9 Stück: 8,50 €, 10–19 Stück: 11,00 €), die jederzeit im Admin-Bereich angepasst, gelöscht oder um weitere Stufen ergänzt werden können — praktisch, wenn sich die Kosten der Versanddienstleister ändern. Eine Staffel ohne "Bis"-Wert gilt als offene, oberste Stufe ("ab X Stück"). Sind gar keine Staffeln gepflegt, greift ein fester Sicherheits-Fallback von 4,90 €, statt den Checkout zu blockieren. Abholung in Altenmünster ist immer versandkostenfrei. Änderungen wirken sich sofort auf Warenkorb, Checkout und die öffentliche Seite `/versand-zahlung` aus.
|
||||||
|
|
||||||
|
## Google-Rezensionen
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
**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).
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env ausfuellen
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app pnpm db:deploy
|
||||||
|
docker compose exec app pnpm db:seed # legt/befördert das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD)
|
||||||
|
docker compose exec app pnpm db:seed:demo # optional: Beispieldaten zum Ausprobieren
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
`.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.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Sessions als JWT (HS256, `jose`)**: httpOnly-Cookie, `SameSite=Lax`, `Secure` in Produktion, trägt Kunden-ID, Rolle und `sessionVersion`. Gültigkeit 30 Tage für Kundenkonten, **7 Tage für Admin-Konten** (kürzeres Zeitfenster für ein gestohlenes/vergessenes Cookie, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt). `SESSION_SECRET` muss in Produktion mindestens 32 Zeichen lang sein (wird beim Start erzwungen).
|
||||||
|
- **Session-Widerruf**: `Customer.sessionVersion` wird bei Passwort-Reset, 2FA-Ein-/Ausschalten und "Alle Geräte abmelden" hochgezählt; `getCurrentCustomer()` vergleicht diesen Wert gegen das JWT-Claim und behandelt eine Abweichung als abgelaufen — ein technisch noch gültiges Token verliert so sofort seine Wirkung, ohne eine serverseitige Session-Tabelle für jeden Request führen zu müssen.
|
||||||
|
- **Zwei-Faktor-Authentifizierung (TOTP)**: optional pro Konto unter `/konto/zwei-faktor`, kompatibel mit gängigen Authenticator-Apps (RFC 6238, `otpauth`). Bei Aktivierung werden 8 Einmal-Backup-Codes generiert (nur als SHA-256-Hash gespeichert, Klartext einmalig direkt nach der Einrichtung angezeigt). Login mit aktivem 2FA läuft zweistufig: Passwort korrekt → kurzlebiger Zwischenschritt-Cookie (5 Minuten, eigener Token-Typ, niemals mit einer vollständigen Session verwechselbar) → Code- oder Backup-Code-Eingabe auf `/konto/zwei-faktor-code`.
|
||||||
|
- **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).
|
||||||
|
- **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. 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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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); 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.
|
||||||
|
- **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.
|
||||||
|
- **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`.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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`, `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.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **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. 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.
|
||||||
|
|
||||||
|
Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bug ans Licht: der Cart-Store persistierte versehentlich auch den rein transienten „ist das Drawer offen"-Zustand in `localStorage` (`persist()` ohne `partialize`). Dadurch konnte sich das Cart-Drawer nach einem Seiten-Reload ungewollt wieder öffnen und die Seite dahinter blockieren, wenn es beim letzten Verlassen offen war. Behoben in `src/store/cart-store.ts`, indem nur noch `items` persistiert wird.
|
||||||
|
|
||||||
|
## Nützliche Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Entwicklungsserver
|
||||||
|
pnpm build # Produktions-Build
|
||||||
|
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:migrate # neue Migration erstellen/anwenden (Dev)
|
||||||
|
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.
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
@@ -0,0 +1,138 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16.15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
# Nur relevant, wenn DATABASE_PROVIDER=postgresql (Standard) — bei mysql
|
||||||
|
# stattdessen "docker compose --profile mysql up -d db-mysql" starten,
|
||||||
|
# siehe README, Abschnitt "Datenbank-Provider wechseln".
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wabenhain}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss in .env gesetzt sein}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wabenhain}
|
||||||
|
ports:
|
||||||
|
# Nur an localhost binden statt an alle Interfaces — die App erreicht
|
||||||
|
# die DB ueber das interne Compose-Netzwerk, dieser Port dient nur
|
||||||
|
# lokalem Debugging (z.B. per psql/DB-GUI vom selben Host aus).
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- wabenhain_db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wabenhain}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Alternative zu "db": nur starten, wenn DATABASE_PROVIDER=mysql in .env
|
||||||
|
# gesetzt ist ("docker compose --profile mysql up -d db-mysql"). Laeuft
|
||||||
|
# nicht automatisch mit "docker compose up" mit, um den Postgres-Standardfall
|
||||||
|
# nicht zu verlangsamen.
|
||||||
|
db-mysql:
|
||||||
|
image: mysql:8.4.11
|
||||||
|
profiles: ["mysql"]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-wabenhain}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD muss in .env gesetzt sein, falls DATABASE_PROVIDER=mysql}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-wabenhain}
|
||||||
|
MYSQL_ALLOW_EMPTY_PASSWORD: "no"
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3306:3306"
|
||||||
|
volumes:
|
||||||
|
- wabenhain_db_mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER:-wabenhain} -p${MYSQL_PASSWORD}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Einziger nach aussen exponierter Dienst: terminiert die Client-Verbindung
|
||||||
|
# und setzt X-Forwarded-For dabei neu (verwirft einen vom Client selbst
|
||||||
|
# mitgeschickten Wert, siehe Caddyfile-Kommentar). Ohne diesen Proxy koennte
|
||||||
|
# "app" direkt erreicht werden und jedes Rate-Limit (Login, 2FA, Checkout)
|
||||||
|
# liesse sich durch einen gefaelschten X-Forwarded-For-Header umgehen.
|
||||||
|
proxy:
|
||||||
|
image: caddy:2.11.4-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
# Fuer echtes HTTPS mit eigener Domain zusaetzlich freigeben und
|
||||||
|
# SITE_ADDRESS in .env auf die Domain setzen (siehe Caddyfile):
|
||||||
|
# - "443:443"
|
||||||
|
# - "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- wabenhain_caddy_data:/data
|
||||||
|
environment:
|
||||||
|
SITE_ADDRESS: ${SITE_ADDRESS:-}
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
required: false
|
||||||
|
expose:
|
||||||
|
- "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:
|
||||||
|
# Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt
|
||||||
|
# /app/uploads bereits mit passendem Besitzer fuer den non-root
|
||||||
|
# "nextjs"-User an) ueber Container-Neustarts/-Redeploys hinweg — ohne
|
||||||
|
# dieses Volume waere jeder Upload nach dem naechsten "docker compose
|
||||||
|
# up" wieder weg.
|
||||||
|
- wabenhain_uploads_data:/app/uploads
|
||||||
|
environment:
|
||||||
|
# Muss zum Volume-Mount oben passen — siehe .env.example.
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
# DATABASE_URL kommt komplett aus .env — je nach DATABASE_PROVIDER
|
||||||
|
# entweder die Postgres- ("db"-Service, Port 5432) oder die
|
||||||
|
# MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306).
|
||||||
|
DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql}
|
||||||
|
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_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||||
|
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
|
||||||
|
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
|
||||||
|
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
|
||||||
|
# liesse sich vor dem ersten Deploy-Seed-Lauf selbst registrieren, siehe
|
||||||
|
# prisma/seed.ts fuer die zusaetzliche Absicherung dagegen.
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL muss in .env gesetzt sein}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 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_PLACE_ID: ${GOOGLE_PLACE_ID}
|
||||||
|
# Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe
|
||||||
|
# App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example).
|
||||||
|
SMTP_USER: ${SMTP_USER}
|
||||||
|
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}
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
wabenhain_db_data:
|
||||||
|
wabenhain_db_mysql_data:
|
||||||
|
wabenhain_caddy_data:
|
||||||
|
wabenhain_uploads_data:
|
||||||
@@ -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 <Organisation> 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).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("login with wrong credentials shows an error message", async ({ page }) => {
|
||||||
|
await page.goto("/konto");
|
||||||
|
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).first().fill("[email protected]");
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).first().fill("falschespasswort");
|
||||||
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/E-Mail-Adresse oder Passwort ist falsch/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registration rejects a password shorter than 8 characters", async ({ page }) => {
|
||||||
|
await page.goto("/konto");
|
||||||
|
|
||||||
|
await page.getByLabel("Vorname").fill("Test");
|
||||||
|
await page.getByLabel("Nachname").fill("Person");
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(`e2e-${Date.now()}@example.de`);
|
||||||
|
await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill("kurz");
|
||||||
|
await page.getByRole("button", { name: "Konto erstellen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/mindestens 8 Zeichen/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registering, logging out, and logging back in reaches the logged-in account page", async ({ page }) => {
|
||||||
|
const email = `e2e-login-${Date.now()}@example.de`;
|
||||||
|
// Zufaellig statt eines festen Werts wie "Passwort123!" — der HIBP-
|
||||||
|
// Breach-Check (isPasswordBreached) lehnt bekannte Leak-Passwoerter ab,
|
||||||
|
// ein per Zeitstempel eindeutiges Passwort landet nicht in solchen Listen.
|
||||||
|
const password = `Xk9-qLp2-Zt${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto("/konto");
|
||||||
|
await page.getByLabel("Vorname").fill("Test");
|
||||||
|
await page.getByLabel("Nachname").fill("Person");
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(email);
|
||||||
|
await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill(password);
|
||||||
|
await page.getByLabel("Passwort bestätigen").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Konto erstellen" }).click();
|
||||||
|
|
||||||
|
// Registrierung meldet automatisch an — erst abmelden, um den separaten
|
||||||
|
// Login-Pfad (loginAction statt der impliziten Session bei der
|
||||||
|
// Registrierung) tatsaechlich zu pruefen. Zwei Buttons heissen "Abmelden"
|
||||||
|
// (auch "alle Geraete abmelden") — .last() ist der einfache Logout-Button,
|
||||||
|
// der tatsaechlich zur ausgeloggten Ansicht zurueckfuehrt.
|
||||||
|
await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Abmelden" }).last().click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).first().fill(email);
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).first().fill(password);
|
||||||
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).not.toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("cart page: increasing quantity updates the line total", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/warenkorb");
|
||||||
|
|
||||||
|
const lineTotalBefore = await page.getByText(/^\d+,\d{2}\s€$/).first().textContent();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Menge erhöhen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/^\d+,\d{2}\s€$/).first()).not.toHaveText(lineTotalBefore ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cart page: removing the only item shows the empty-cart state", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/warenkorb");
|
||||||
|
await expect(page.getByText("Zwischensumme")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Entfernen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Dein Warenkorb ist noch leer.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("checkout shows live shipping cost for Versand and switches to free for Abholung", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/checkout");
|
||||||
|
|
||||||
|
await expect(page.getByText("Zwischensumme")).toBeVisible();
|
||||||
|
await expect(page.getByText("Gesamt")).toBeVisible();
|
||||||
|
|
||||||
|
// Versand ist per Default aktiv und zeigt eine Versandstaffel-Kosten
|
||||||
|
// (nicht "kostenlos"), solange der Warenkorb unter der Freigrenze liegt.
|
||||||
|
const summary = page.locator("aside");
|
||||||
|
await expect(summary.getByText(/Versand/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Abholung in Altenmünster" }).click();
|
||||||
|
|
||||||
|
await expect(summary.getByText("kostenlos")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkout requires street address only when Versand is selected", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/checkout");
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Straße und Hausnummer")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Abholung in Altenmünster" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Straße und Hausnummer")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("homepage shows the hero heading and primary navigation", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Honig aus den Westlichen Wäldern/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("navigation").getByRole("link", { name: "Shop", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("homepage newsletter form is present and accepts an email", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const emailField = page.getByPlaceholder("[email protected]");
|
||||||
|
await expect(emailField).toBeVisible();
|
||||||
|
await emailField.fill("[email protected]");
|
||||||
|
await expect(emailField).toHaveValue("[email protected]");
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("shop overview lists products", async ({ page }) => {
|
||||||
|
await page.goto("/shop");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Unser Sortiment" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /Zum Honig|Zum Produkt/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adding a product to the cart updates the cart count", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
|
||||||
|
// "In den Warenkorb" erscheint mehrfach auf der Seite (Haupt-CTA oben,
|
||||||
|
// weitere in der "Das koennte dir auch gefallen"-Produktkarten-Sektion
|
||||||
|
// weiter unten) — .first() greift gezielt den Haupt-CTA des Produkts.
|
||||||
|
const cartButton = page.getByRole("button", { name: /Warenkorb öffnen/ });
|
||||||
|
const initialLabel = await cartButton.getAttribute("aria-label");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await expect(cartButton).not.toHaveAttribute("aria-label", initialLabel ?? "");
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: wabenhain
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Schluessel)
|
||||||
|
# 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
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-config
|
||||||
|
namespace: wabenhain
|
||||||
|
data:
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# ANPASSEN
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
DATABASE_PROVIDER: "postgresql"
|
||||||
|
|
||||||
|
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
||||||
|
#
|
||||||
|
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
||||||
|
# in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der
|
||||||
|
# voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der
|
||||||
|
# Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch
|
||||||
|
# welche. Entweder diesen Wert senken oder max_connections anheben.
|
||||||
|
DATABASE_POOL_SIZE: "20"
|
||||||
|
|
||||||
|
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
||||||
|
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
|
||||||
|
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
|
||||||
|
# Fehlt der Wert, schreibt die App nach "./uploads" relativ zum
|
||||||
|
# Arbeitsverzeichnis: das trifft im Image zufaellig denselben Ort, ist aber
|
||||||
|
# nirgends zugesichert. Deshalb hier ausdruecklich setzen.
|
||||||
|
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: ""
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Geheime Konfiguration. Vorlage OHNE echte Werte — kopieren nach
|
||||||
|
# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), ausfuellen,
|
||||||
|
# dann "kubectl apply -f k8s/02-secret.yaml".
|
||||||
|
#
|
||||||
|
# ANPASSEN: jeder Wert, der "CHANGE-ME" enthaelt. Die leeren Werte ("")
|
||||||
|
# gehoeren zu optionalen Funktionen und koennen leer bleiben — die App faellt
|
||||||
|
# dann nachvollziehbar zurueck (siehe Kommentar an der jeweiligen Stelle) und
|
||||||
|
# stuerzt nicht ab. Nichts hier darf FEHLEN: ein fehlender und ein leerer
|
||||||
|
# Schluessel sind fuer die App zwar dasselbe, aber ein vollstaendiges Secret
|
||||||
|
# macht sofort sichtbar, welche Funktionen es ueberhaupt gibt.
|
||||||
|
#
|
||||||
|
# Gegenprobe nach dem Ausrollen: "pnpm preflight" meldet jeden Wert, der fuer
|
||||||
|
# den Live-Betrieb fehlt, als Blocker — mit Begruendung, was ohne ihn kaputt
|
||||||
|
# geht.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-secrets
|
||||||
|
namespace: wabenhain
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# PFLICHT — ohne diese Werte ist kein Live-Betrieb moeglich
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus
|
||||||
|
# 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306
|
||||||
|
# (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen.
|
||||||
|
#
|
||||||
|
# WICHTIG: das Passwort MUSS hier URL-encodiert sein — Sonderzeichen wie
|
||||||
|
# @ : / % # ? & 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'))"
|
||||||
|
DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public"
|
||||||
|
|
||||||
|
# Dasselbe Passwort wie in DATABASE_URL, aber UNENCODIERT. Das
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Signiert die Login-Cookies. Mindestens 32 Zeichen, unvorhersehbar.
|
||||||
|
# Erzeugen mit: openssl rand -base64 32
|
||||||
|
# Wird dieser Wert bekannt, ist jede Sitzung faelschbar, auch die eines
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Stripe im LIVE-Modus (sk_live_...). Mit einem sk_test-Schluessel sehen
|
||||||
|
# Bestellungen erfolgreich aus, ohne dass Geld fliesst.
|
||||||
|
STRIPE_SECRET_KEY: "CHANGE-ME"
|
||||||
|
|
||||||
|
# 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: ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
||||||
|
# "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
|
||||||
|
# oder Echtbetrieb, steuert DHL_ENVIRONMENT in 01-configmap.yaml.
|
||||||
|
DHL_API_KEY: ""
|
||||||
|
DHL_API_SECRET: ""
|
||||||
|
DHL_GKP_USERNAME: ""
|
||||||
|
DHL_GKP_PASSWORD: ""
|
||||||
|
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
|
||||||
|
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
|
||||||
|
# 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_CLIENT_ID: ""
|
||||||
|
ENTRA_CLIENT_SECRET: ""
|
||||||
|
ENTRA_REDIRECT_URI: ""
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Alternative zu 03-postgres.yaml (DATABASE_PROVIDER=mysql in
|
||||||
|
# 01-configmap.yaml). Nur eines von beiden anwenden.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-mysql
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
serviceName: wabenhain-mysql
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wabenhain-mysql
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wabenhain-mysql
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: mysql
|
||||||
|
image: mysql:8.4.11
|
||||||
|
ports:
|
||||||
|
- containerPort: 3306
|
||||||
|
env:
|
||||||
|
- name: MYSQL_USER
|
||||||
|
value: wabenhain
|
||||||
|
- name: MYSQL_DATABASE
|
||||||
|
value: wabenhain
|
||||||
|
- name: MYSQL_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wabenhain-secrets
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: MYSQL_RANDOM_ROOT_PASSWORD
|
||||||
|
value: "yes"
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/mysql
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["mysqladmin", "ping", "-h", "localhost", "-uwabenhain", "-p$(MYSQL_PASSWORD)"]
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "0"
|
||||||
|
memory: "0"
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-mysql
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app: wabenhain-mysql
|
||||||
|
ports:
|
||||||
|
- port: 3306
|
||||||
|
targetPort: 3306
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Standard-Datenbank (DATABASE_PROVIDER=postgresql in 01-configmap.yaml).
|
||||||
|
# Nur anwenden, wenn du Postgres nutzt — fuer MySQL stattdessen 03-mysql.yaml
|
||||||
|
# anwenden (nicht beide gleichzeitig).
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-postgres
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
serviceName: wabenhain-postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: wabenhain-postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wabenhain-postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16.15-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: wabenhain
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: wabenhain
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: wabenhain-secrets
|
||||||
|
key: DB_PASSWORD
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "wabenhain"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "0"
|
||||||
|
memory: "0"
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
# Bei Bedarf anpassen — abhaengig vom StorageClass deines Clusters.
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-postgres
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
# Headless (kein eigener ClusterIP) — Standard fuer StatefulSets, DNS-Name
|
||||||
|
# "wabenhain-postgres.wabenhain.svc.cluster.local" bleibt stabil.
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app: wabenhain-postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# image: unten auf den Build gepinnt, siehe Kommentar dort. Kein separater
|
||||||
|
# Migrations-Job mehr noetig: der
|
||||||
|
# Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus,
|
||||||
|
# BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) —
|
||||||
|
# beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos
|
||||||
|
# erneut. Readiness-/Liveness-Probes greifen automatisch erst, sobald Port
|
||||||
|
# 3000 offen ist, also nach abgeschlossener Migration/Seed.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-app
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
# ACHTUNG bevor du hochskalierst: lib/rate-limit.ts haelt Login-/2FA-/
|
||||||
|
# Checkout-Limits aktuell im Prozessspeicher, nicht in einem gemeinsamen
|
||||||
|
# Store. Mit replicas > 1 verteilt sich derselbe Nutzer per Load-Balancing
|
||||||
|
# auf mehrere Prozesse mit je eigenem Zaehler — das Limit wird effektiv um
|
||||||
|
# den Skalierungsfaktor schwaecher (2 Replicas = 2x mehr Versuche moeglich),
|
||||||
|
# ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst
|
||||||
|
# lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
||||||
|
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:
|
||||||
|
matchLabels:
|
||||||
|
app: wabenhain-app
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: wabenhain-app
|
||||||
|
spec:
|
||||||
|
# fsGroup=1001 muss zur "nodejs"-Gruppe (gid 1001) passen, die das
|
||||||
|
# Dockerfile fuer den non-root "nextjs"-Laufzeitbenutzer anlegt: ohne
|
||||||
|
# dieses Feld mountet Kubernetes das "uploads"-Volume (egal ob emptyDir
|
||||||
|
# oder ein echtes PVC) mit root:root-Besitz — das ueberschreibt den im
|
||||||
|
# Image bereits per chown gesetzten Besitzer von /app/uploads
|
||||||
|
# vollstaendig, und der nicht-root laufende Container koennte trotzdem
|
||||||
|
# keine Datei dort anlegen (EACCES bei jedem Admin-Upload). fsGroup
|
||||||
|
# sorgt dafuer, dass Kubernetes das gemountete Volume zusaetzlich der
|
||||||
|
# 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:
|
||||||
|
fsGroup: 1001
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
# 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:
|
||||||
|
- containerPort: 3000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: wabenhain-config
|
||||||
|
- secretRef:
|
||||||
|
name: wabenhain-secrets
|
||||||
|
volumeMounts:
|
||||||
|
# Muss auf denselben Pfad zeigen wie UPLOAD_DIR in
|
||||||
|
# 01-configmap.yaml. Das "uploads"-Volume selbst wird HIER
|
||||||
|
# bewusst nicht definiert (kein PV/PVC in diesem Manifest) — das
|
||||||
|
# haengt vom tatsaechlichen Storage-Backend ab (SMB/NFS/
|
||||||
|
# hostPath/...), das die Betreiberin bereitstellt. Unten im
|
||||||
|
# "volumes:"-Block einen passenden Eintrag mit demselben Namen
|
||||||
|
# ("uploads") ergaenzen, z.B. ueber eine
|
||||||
|
# persistentVolumeClaim.claimName, sonst startet der Pod nicht
|
||||||
|
# (fehlende Volume-Referenz).
|
||||||
|
- name: 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:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health/ready
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 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:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
# initialDelaySeconds/failureThreshold grosszuegiger als sonst
|
||||||
|
# ueblich: der Container migriert/seedet die DB selbst vor dem
|
||||||
|
# Serverstart (siehe Kommentar oben) — genug Spielraum, damit ein
|
||||||
|
# laengerer erster Migrationslauf nicht faelschlich als haengender
|
||||||
|
# Prozess gewertet und der Container neu gestartet wird.
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
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:
|
||||||
|
requests:
|
||||||
|
cpu: "0"
|
||||||
|
memory: "0"
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
volumes:
|
||||||
|
# Bindet den PersistentVolumeClaim aus 04-uploads-pvc.yaml — dieses
|
||||||
|
# Manifest MUSS vor dem Deployment angewendet werden, sonst bleibt
|
||||||
|
# der Pod in "Pending" (unaufloesbare Volume-Referenz).
|
||||||
|
#
|
||||||
|
# Vorher stand hier ein emptyDir-Platzhalter. Das ist kein
|
||||||
|
# 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
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: wabenhain-uploads
|
||||||
|
- name: invoices
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: wabenhain-invoices
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-app
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: wabenhain-app
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3000
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Ingress fuer Traefik (der im Zielcluster eingesetzte Ingress-Controller),
|
||||||
|
# optional zusammen mit cert-manager fuer automatisches TLS. Host unten
|
||||||
|
# anpassen (muss zu NEXT_PUBLIC_SITE_URL in 01-configmap.yaml passen).
|
||||||
|
#
|
||||||
|
# ============================================================================
|
||||||
|
# WICHTIG fuer die Admin-Uploads: eine Einstellung fehlt hier zwangslaeufig
|
||||||
|
# ============================================================================
|
||||||
|
# Der Video-Upload (bis 100 MB, MAX_UPLOAD_VIDEO_BYTES in lib/uploads.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
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: wabenhain
|
||||||
|
namespace: wabenhain
|
||||||
|
annotations:
|
||||||
|
# Nur relevant, falls cert-manager installiert ist — sonst diese Zeile
|
||||||
|
# und den "tls:"-Block unten entfernen und den Host per HTTP betreiben
|
||||||
|
# oder TLS auf andere Weise terminieren.
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
# Erzwingt HTTPS ueber die Middleware unten. Legt die Traefik-Installation
|
||||||
|
# die Umleitung bereits EntryPoint-weit an (Helm-Chart:
|
||||||
|
# ports.web.redirectTo.port=websecure), sind diese Zeile UND die
|
||||||
|
# Middleware am Ende der Datei ueberfluessig. Der Namespace-Praefix im
|
||||||
|
# Wert ist Pflicht — Traefik loest den Namen sonst nicht auf.
|
||||||
|
#
|
||||||
|
# "cloudflare-only" steht bewusst an erster Stelle: wabenhain.com laeuft
|
||||||
|
# ueber den Cloudflare-Proxy, direkte Zugriffe auf den Origin (an
|
||||||
|
# Cloudflare und damit an WAF und Rate-Limits vorbei) beantwortet Traefik
|
||||||
|
# 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:
|
||||||
|
# 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:
|
||||||
|
- hosts:
|
||||||
|
- wabenhain.com
|
||||||
|
secretName: wabenhain-tls
|
||||||
|
rules:
|
||||||
|
- host: wabenhain.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: wabenhain-app
|
||||||
|
port:
|
||||||
|
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
|
||||||
@@ -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
|
||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
# Wabenhain in Kubernetes
|
||||||
|
|
||||||
|
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
|
||||||
|
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
|
||||||
|
|
||||||
|
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` prueft bei jedem
|
||||||
|
Push auf `main` lint, audit, typecheck und test und baut erst danach das
|
||||||
|
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
|
||||||
|
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
|
||||||
|
Migrations-Image mehr.
|
||||||
|
|
||||||
|
Voraussetzung: ein Repo-/Org-Secret `REGISTRY_TOKEN` in Gitea anlegen
|
||||||
|
(Personal Access Token mit `package`-Scope, unter Benutzereinstellungen →
|
||||||
|
Anwendungen → Access Tokens erzeugen, dann unter Repo-Einstellungen →
|
||||||
|
Actions → Secrets als `REGISTRY_TOKEN` hinterlegen). Der automatisch von
|
||||||
|
Gitea Actions bereitgestellte `GITEA_TOKEN` reicht dafuer aktuell NICHT
|
||||||
|
aus (fehlendes Package-Write-Recht, Stand heute).
|
||||||
|
|
||||||
|
2. **Ein Docker-Image ist Provider-uebergreifend**: Prisma generiert den
|
||||||
|
Client Provider-spezifisch — ein `.next/standalone`-Build kann seinen
|
||||||
|
generierten Client nicht zur Laufzeit wechseln. Deshalb generiert
|
||||||
|
`scripts/generate-prisma-clients.mjs` (Teil von `pnpm build`, laeuft also
|
||||||
|
automatisch bei jedem Image-Build) BEIDE Varianten ins selbe Image; die
|
||||||
|
Wahl zwischen ihnen passiert zur Laufzeit ueber `DATABASE_PROVIDER`, siehe
|
||||||
|
`src/lib/prisma.ts`. Ein Image reicht also fuer Postgres UND MySQL — nur
|
||||||
|
`DATABASE_PROVIDER`/`DATABASE_URL` in `01-configmap.yaml`/
|
||||||
|
`02-secret.yaml` muessen zur tatsaechlich gewaehlten Datenbank passen.
|
||||||
|
Beim Container-Start fuehrt derselbe Prozess erst `prisma migrate deploy`
|
||||||
|
und `prisma db seed` aus, BEVOR der Next.js-Server startet (siehe
|
||||||
|
Dockerfile-CMD in der `runner`-Stage) — beide Schritte sind idempotent
|
||||||
|
und bei jedem Neustart gefahrlos, kein separater Migrations-Job noetig.
|
||||||
|
|
||||||
|
3. `k8s/02-secret.example.yaml` nach `k8s/02-secret.yaml` kopieren (per
|
||||||
|
`.gitignore` ausgeschlossen — NIE mit echten Werten committen) und
|
||||||
|
ausfuellen. Alternativ direkt per `kubectl create secret`, siehe
|
||||||
|
Kommentar in der Beispieldatei.
|
||||||
|
|
||||||
|
4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und
|
||||||
|
`NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen.
|
||||||
|
|
||||||
|
5. `04-uploads-pvc.yaml`: `storageClassName` auf eine im Cluster
|
||||||
|
vorhandene StorageClass setzen (`kubectl get storageclass`; die Zeile ist
|
||||||
|
auskommentiert, dann greift die als `(default)` markierte) und die
|
||||||
|
Groesse pruefen. Ohne diesen Claim gibt es keinen dauerhaften Speicher
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/00-namespace.yaml
|
||||||
|
kubectl apply -f k8s/01-configmap.yaml
|
||||||
|
kubectl apply -f k8s/02-secret.yaml
|
||||||
|
|
||||||
|
# Nur EINE der beiden Datenbanken anwenden, passend zu DATABASE_PROVIDER:
|
||||||
|
kubectl apply -f k8s/03-postgres.yaml
|
||||||
|
# kubectl apply -f k8s/03-mysql.yaml
|
||||||
|
|
||||||
|
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/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
|
||||||
|
oben) laufen automatisch beim Container-Start —
|
||||||
|
`kubectl rollout status deployment/wabenhain-app -n wabenhain` zeigt, wann
|
||||||
|
der erste Pod bereit ist.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
# 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
|
||||||
|
(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
|
||||||
|
|
||||||
|
- **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das
|
||||||
|
Login-/2FA-/Checkout-Rate-Limiting (`lib/rate-limit.ts`) haelt seinen
|
||||||
|
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
|
||||||
|
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
|
||||||
|
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
|
||||||
|
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`. Die
|
||||||
|
`NetworkPolicy`s (`default-deny-ingress`, `allow-same-namespace`,
|
||||||
|
`allow-from-traefik`) und die Traefik-Middleware `cloudflare-only` liegen
|
||||||
|
nicht hier, sondern in den Cluster-Manifesten (Haertung vom 2026-10-12,
|
||||||
|
Ordner `rke2-cluster/`, nicht unter Git).
|
||||||
|
- Repo und Cluster weichen voneinander ab (Stand 2026-10-16): Im Cluster
|
||||||
|
sind `wabenhain-uploads` (5Gi) und `wabenhain-invoices` (50Gi)
|
||||||
|
`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.
|
||||||
+97
-1
@@ -1,7 +1,103 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Verhindert den "X-Powered-By: Next.js"-Response-Header — verrimt keinen
|
||||||
|
// funktionalen Vorteil, gibt Angreifern aber unnoetig Framework-Fingerprinting.
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
// "standalone" buendelt eine minimale Node-Laufzeit inkl. Dependencies,
|
||||||
|
// wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt.
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
|
// Pakete, die zur Laufzeit eigene Dateien ueber einen __dirname-relativen
|
||||||
|
// Pfad nachladen. Werden sie vom Bundler (Turbopack/Webpack) mitgebuendelt,
|
||||||
|
// zeigt ihr __dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt
|
||||||
|
// auf das echte Verzeichnis in node_modules — der Zugriff schlaegt dann mit
|
||||||
|
// ENOENT fehl. "serverExternalPackages" laesst Next.js sie stattdessen ganz
|
||||||
|
// 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)
|
||||||
|
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
|
||||||
|
// wiederholt mit der Cloud-Synchronisierung ("Failed to open database" /
|
||||||
|
// mmap-Timeout bei next dev, "Failed to compact database" beim
|
||||||
|
// next-build-Shutdown). Beide Caches werden daher deaktiviert.
|
||||||
|
experimental: {
|
||||||
|
turbopackFileSystemCacheForDev: 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() {
|
||||||
|
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
||||||
|
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
||||||
|
// headers()-Eintrag hier nicht kann.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" },
|
||||||
|
// Wirkt nur ueber HTTPS; im lokalen HTTP-Betrieb ignoriert der Browser den Header.
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
+62
-9
@@ -3,25 +3,78 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node scripts/generate-prisma-clients.mjs && next dev",
|
||||||
"build": "next build",
|
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
||||||
"start": "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",
|
||||||
|
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
||||||
|
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
||||||
|
"db:deploy": "node scripts/prisma-provider.mjs && prisma migrate deploy",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
|
"db:seed:demo": "tsx prisma/seed-demo.ts",
|
||||||
|
"db:studio": "node scripts/prisma-provider.mjs && prisma studio",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"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": {
|
||||||
"next": "16.3.0",
|
"@prisma/adapter-mariadb": "^7.10.0",
|
||||||
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
|
"@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",
|
||||||
|
"fflate": "0.8.2",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
|
"jose": "^6.2.8",
|
||||||
|
"mariadb": "^3.5.4",
|
||||||
|
"motion": "^13.0.0",
|
||||||
|
"next": "16.3.8",
|
||||||
|
"nodemailer": "^10.0.10",
|
||||||
|
"otpauth": "^9.5.1",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
|
"prisma": "^7.10.0",
|
||||||
|
"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.5",
|
||||||
|
"stripe": "^22.4.0",
|
||||||
|
"three": "0.170.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pdfkit": "^0.17.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",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.8",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"tsx": "^4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]"
|
"packageManager": "[email protected]"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
// Startet den Dev-Server automatisch, falls er nicht schon laeuft (z.B. lokal
|
||||||
|
// waehrend der Entwicklung) — CI-Umgebungen ohne laufenden Server profitieren
|
||||||
|
// davon am meisten.
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3987
-189
File diff suppressed because it is too large.
Load diff
+36
-1
@@ -1,3 +1,38 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
sharp: false
|
'@prisma/engines': true
|
||||||
|
esbuild: true
|
||||||
|
ffmpeg-static: true
|
||||||
|
prisma: true
|
||||||
|
sharp: true
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
# GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber
|
||||||
|
# postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest).
|
||||||
|
nanoid: '>=3.3.18'
|
||||||
|
# GHSA-cqhc-2h57-wpxf: mariadb < 3.4.6 leakt das Klartext-Passwort an einen
|
||||||
|
# MitM trotz ssl:true. @prisma/[email protected] (genutzt in
|
||||||
|
# lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine
|
||||||
|
# eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in
|
||||||
|
# den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm
|
||||||
|
# haelt dafuer sonst zwei getrennte Kopien vor. Seit 2026-10-14 >= 3.5.4
|
||||||
|
# (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,15 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('NEU', 'BEZAHLT', 'IN_VORBEREITUNG', 'VERSANDBEREIT', 'VERSENDET', 'ABHOLBEREIT', 'ABGEHOLT', 'STORNIERT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FulfillmentMethod" AS ENUM ('VERSAND', 'ABHOLUNG');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BusinessPackage" AS ENUM ('KLASSIK', 'GESCHENK', 'INDIVIDUELL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InquiryStatus" AS ENUM ('NEU', 'IN_BEARBEITUNG', 'ABGESCHLOSSEN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "customers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "addresses" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL DEFAULT 'Zuhause',
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"street" TEXT NOT NULL,
|
||||||
|
"zip" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL DEFAULT 'DE',
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "harvests" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"harvestDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "harvests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "batches" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"harvestId" TEXT NOT NULL,
|
||||||
|
"fillDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"bestBeforeDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"glassCount" INTEGER NOT NULL,
|
||||||
|
"remainingGlasses" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "batches_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "products" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"harvestId" TEXT NOT NULL,
|
||||||
|
"batchId" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"tasteTags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"intensityMild" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"intensityFloral" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"intensityKraeftig" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"consistency" TEXT NOT NULL DEFAULT 'feincremig',
|
||||||
|
"weightGrams" INTEGER NOT NULL,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"pricePerKgCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "inventory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "orders" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderNumber" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'NEU',
|
||||||
|
"fulfillmentMethod" "FulfillmentMethod" NOT NULL DEFAULT 'VERSAND',
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"street" TEXT NOT NULL,
|
||||||
|
"zip" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL DEFAULT 'DE',
|
||||||
|
"subtotalCents" INTEGER NOT NULL,
|
||||||
|
"shippingCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"stripeCheckoutSessionId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "order_items" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"batchId" TEXT NOT NULL,
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"unitPriceCents" INTEGER NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"weightGrams" INTEGER NOT NULL,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "payments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL DEFAULT 'stripe',
|
||||||
|
"stripePaymentIntentId" TEXT,
|
||||||
|
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"paidAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "shipments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"carrier" TEXT,
|
||||||
|
"trackingNumber" TEXT,
|
||||||
|
"shippedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "shipments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pickups" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
|
||||||
|
"readyAt" TIMESTAMP(3),
|
||||||
|
"pickedUpAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pickups_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "invoices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"invoiceNumber" TEXT NOT NULL,
|
||||||
|
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
"pdfUrl" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "newsletter_subscribers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT,
|
||||||
|
"confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"unsubscribedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "newsletter_subscribers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "business_inquiries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"contactPerson" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"desiredQuantity" INTEGER NOT NULL,
|
||||||
|
"desiredDeliveryDate" TIMESTAMP(3),
|
||||||
|
"package" "BusinessPackage" NOT NULL DEFAULT 'KLASSIK',
|
||||||
|
"message" TEXT,
|
||||||
|
"status" "InquiryStatus" NOT NULL DEFAULT 'NEU',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "business_inquiries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "customers_email_key" ON "customers"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_slug_key" ON "harvests"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_year_type_key" ON "harvests"("year", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "batches_code_key" ON "batches"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "inventory_productId_key" ON "inventory"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "orders_orderNumber_key" ON "orders"("orderNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "payments_orderId_key" ON "payments"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "shipments_orderId_key" ON "shipments"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pickups_orderId_key" ON "pickups"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "invoices_orderId_key" ON "invoices"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "invoices_invoiceNumber_key" ON "invoices"("invoiceNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_email_key" ON "newsletter_subscribers"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_customerId_key" ON "newsletter_subscribers"("customerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "batches" ADD CONSTRAINT "batches_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "payments" ADD CONSTRAINT "payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pickups" ADD CONSTRAINT "pickups_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "newsletter_subscribers" ADD CONSTRAINT "newsletter_subscribers_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "order_items" ALTER COLUMN "batchId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ADD COLUMN "category" "ProductCategory" NOT NULL DEFAULT 'HONIG',
|
||||||
|
ALTER COLUMN "harvestId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "batchId" DROP NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CustomerRole" AS ENUM ('KUNDE', 'ADMIN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "role" "CustomerRole" NOT NULL DEFAULT 'KUNDE';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmationToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "password_reset_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_confirmationToken_key" ON "newsletter_subscribers"("confirmationToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lockedUntil" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "twoFactorSecret" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "two_factor_backup_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "two_factor_backup_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "two_factor_backup_codes_codeHash_key" ON "two_factor_backup_codes"("codeHash");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "two_factor_backup_codes" ADD CONSTRAINT "two_factor_backup_codes_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "blockedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "shipping_tiers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"minQuantity" INTEGER NOT NULL,
|
||||||
|
"maxQuantity" INTEGER,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "shipping_tiers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DiscountType" AS ENUM ('PERCENT', 'FIXED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "discountCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "discountCodeId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "reviews" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"rating" INTEGER NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "discount_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"type" "DiscountType" NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"minOrderCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxUses" INTEGER,
|
||||||
|
"usedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"validFrom" TIMESTAMP(3),
|
||||||
|
"validUntil" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "discount_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "reviews_productId_customerId_key" ON "reviews"("productId", "customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "discount_codes_code_key" ON "discount_codes"("code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "email_verification_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wishlist_items" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"adminId" TEXT,
|
||||||
|
"adminEmail" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT,
|
||||||
|
"detail" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "wishlist_items_customerId_productId_key" ON "wishlist_items"("customerId", "productId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Bestandskonten, die vor Einfuehrung der E-Mail-Verifizierung registriert
|
||||||
|
-- wurden, gelten rueckwirkend als bestaetigt (createdAt als Zeitstempel) --
|
||||||
|
-- sonst waeren sie ploetzlich vom Checkout ausgesperrt, obwohl sie sich nie
|
||||||
|
-- verifizieren mussten.
|
||||||
|
UPDATE "customers" SET "emailVerifiedAt" = "createdAt" WHERE "emailVerifiedAt" IS NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "stock_notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"notifiedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "stock_notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "stock_notifications_productId_email_key" ON "stock_notifications"("productId", "email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "stock_notifications" ADD CONSTRAINT "stock_notifications_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "InquiryStatus" ADD VALUE 'ABGELEHNT';
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReturnStatus" AS ENUM ('ANGEFRAGT', 'ANGENOMMEN', 'ABGELEHNT', 'ERSTATTET');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "return_requests" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"status" "ReturnStatus" NOT NULL DEFAULT 'ANGEFRAGT',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "return_requests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "return_requests_orderId_key" ON "return_requests"("orderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Schema/Migrations-Drift beheben: "images"/"tasteTags" wurden in
|
||||||
|
-- schema.prisma von String[] auf Json umgestellt (MySQL kennt keine nativen
|
||||||
|
-- Skalar-Arrays), die urspruengliche Init-Migration hat die Spalten aber nie
|
||||||
|
-- entsprechend geaendert — sie blieben TEXT[]. "prisma migrate deploy"
|
||||||
|
-- validiert nicht gegen das Schema, daher blieb das unbemerkt bis zum ersten
|
||||||
|
-- echten Seed-Lauf ("malformed array literal" beim Insert eines JSON-Werts
|
||||||
|
-- in eine TEXT[]-Spalte). to_jsonb() wandelt bestehende TEXT[]-Werte 1:1 in
|
||||||
|
-- ein JSON-Array um.
|
||||||
|
ALTER TABLE "harvests"
|
||||||
|
ALTER COLUMN "images" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "images" TYPE JSONB USING to_jsonb("images"),
|
||||||
|
ALTER COLUMN "images" SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE "products"
|
||||||
|
ALTER COLUMN "tasteTags" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "tasteTags" TYPE JSONB USING to_jsonb("tasteTags"),
|
||||||
|
ALTER COLUMN "tasteTags" SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE "products"
|
||||||
|
ALTER COLUMN "images" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "images" TYPE JSONB USING to_jsonb("images"),
|
||||||
|
ALTER COLUMN "images" SET DEFAULT '[]'::jsonb;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Schema/Migrations-Drift beheben: diese Felder stehen in schema.prisma,
|
||||||
|
-- wurden aber nie in einer Migration angelegt (vermutlich per "db push"
|
||||||
|
-- lokal getestet statt per "migrate dev" committet). Gefunden per
|
||||||
|
-- Abgleich aller Modelle in schema.prisma gegen CREATE TABLE/ADD COLUMN in
|
||||||
|
-- prisma/migrations/*/migration.sql — kein weiterer Drift mehr offen.
|
||||||
|
|
||||||
|
-- customers.lastTotpStep (TOTP-Replay-Schutz, siehe schema.prisma-Kommentar)
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "lastTotpStep" INTEGER;
|
||||||
|
|
||||||
|
-- stock_notifications.locale (Sprachwahl fuer die Benachrichtigungs-Mail)
|
||||||
|
ALTER TABLE "stock_notifications" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'de';
|
||||||
|
|
||||||
|
-- orders.locale (Sprachwahl fuer asynchron versendete Bestell-E-Mails)
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'de';
|
||||||
|
|
||||||
|
-- orders.giftMessage (optionale persoenliche Nachricht bei Geschenkbestellungen)
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "giftMessage" TEXT;
|
||||||
|
|
||||||
|
-- discount_codes.minQuantity (Mengenschwelle fuer automatische Mengenrabatte)
|
||||||
|
ALTER TABLE "discount_codes" ADD COLUMN "minQuantity" INTEGER;
|
||||||
|
|
||||||
|
-- site_content: komplette Tabelle fehlte — admin-editierbare Website-Inhalte
|
||||||
|
-- (Firmendaten, Homepage-/Rechtstexte-Overlay ueber die statischen
|
||||||
|
-- i18n-Dictionaries, siehe lib/content-registry.ts) waeren ohne diese
|
||||||
|
-- Tabelle bei jedem Seitenaufruf mit P2021 fehlgeschlagen.
|
||||||
|
CREATE TABLE "site_content" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"valueDe" TEXT NOT NULL,
|
||||||
|
"valueEn" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "site_content_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
-- Draft/Publish-Workflow fuer Ernten: Default true, damit alle VOR dieser
|
||||||
|
-- Migration angelegten Ernten (bislang implizit oeffentlich) sichtbar
|
||||||
|
-- bleiben. Neu aus ApiCore synchronisierte Ernten werden von der
|
||||||
|
-- Sync-Action explizit mit isPublished = false angelegt (siehe
|
||||||
|
-- admin/actions/apicore-sync.ts) und muessen erst von einem Admin ueber
|
||||||
|
-- "Veroeffentlichen" freigegeben werden.
|
||||||
|
ALTER TABLE "harvests" ADD COLUMN "isPublished" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
-- "Meine Bestellungen" (Konto) sowie der Datenexport filtern nach customerId
|
||||||
|
-- und sortieren nach createdAt absteigend — ein zusammengesetzter Index
|
||||||
|
-- bedient beides in einem Indexzugriff statt eines vollen Tabellenscans.
|
||||||
|
CREATE INDEX "orders_customerId_createdAt_idx" ON "orders"("customerId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Statusfilter in der Admin-Bestellliste (exakte Gleichheit) und Basis fuer
|
||||||
|
-- die Status-Auswertungen im Admin-Dashboard.
|
||||||
|
CREATE INDEX "orders_status_idx" ON "orders"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Datumsbereichs-Filter im Admin-Dashboard (Tages-/Monatsumsatz, Umsatztrend)
|
||||||
|
-- sowie Standardsortierung in Bestelllisten ohne weiteren Filter.
|
||||||
|
CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach
|
||||||
|
-- discountCodeId + status).
|
||||||
|
CREATE INDEX "orders_discountCodeId_idx" ON "orders"("discountCodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
||||||
|
-- Rechnung, Datenexport) ueber die order.items-Relation abgefragt.
|
||||||
|
CREATE INDEX "order_items_orderId_idx" ON "order_items"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Adressliste und Standardadresse-Update im Kundenkonto filtern nach
|
||||||
|
-- customerId.
|
||||||
|
CREATE INDEX "addresses_customerId_idx" ON "addresses"("customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
-- Admin-Audit-Log-Seite sortiert eine unbegrenzt wachsende Tabelle nach
|
||||||
|
-- createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
|
||||||
|
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `images` on table `harvests` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
- Made the column `tasteTags` on table `products` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
- Made the column `images` on table `products` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "batches" ADD COLUMN "images" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "harvests" ALTER COLUMN "images" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ALTER COLUMN "tasteTags" SET NOT NULL,
|
||||||
|
ALTER COLUMN "images" SET NOT NULL;
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,212 @@
|
|||||||
|
// Wird direkt per "tsx" aufgerufen (nicht ueber "prisma db seed"), daher
|
||||||
|
// laedt hier — anders als bei prisma.config.ts/seed.ts — niemand sonst die
|
||||||
|
// .env-Datei fuer die lokale Entwicklung.
|
||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaClient as MySqlPrismaClient } from "../src/generated/prisma-mysql/client";
|
||||||
|
import { createDbAdapter } from "../src/lib/db-adapter";
|
||||||
|
|
||||||
|
// Beispieldaten (Ernten, Chargen, Produkte, Lagerbestand, Versandstaffeln)
|
||||||
|
// fuer lokales Testen/Screenshots. BEWUSST GETRENNT von prisma/seed.ts (das
|
||||||
|
// automatisch bei jedem Container-Start laeuft, siehe Dockerfile-CMD in der
|
||||||
|
// runner-Stage) — ein produktiv gebautes Image soll nie automatisch
|
||||||
|
// Fake-Produkte in einer echten Datenbank anlegen. Manuell ausfuehren:
|
||||||
|
// "pnpm db:seed:demo".
|
||||||
|
|
||||||
|
const adapter = createDbAdapter();
|
||||||
|
const prisma: PrismaClient =
|
||||||
|
(process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql"
|
||||||
|
? (new MySqlPrismaClient({ adapter }) as unknown as PrismaClient)
|
||||||
|
: new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Ueberspringt alles, sobald bereits Produkte existieren — legt echte,
|
||||||
|
// im Adminbereich spaeter geaenderte/geloeschte Produkte nie erneut an.
|
||||||
|
const productCount = await prisma.product.count();
|
||||||
|
if (productCount > 0) {
|
||||||
|
console.log("Produkte bereits vorhanden, ueberspringe Demo-Daten-Seed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fruehtracht = await prisma.harvest.upsert({
|
||||||
|
where: { slug: "fruehtracht-2026" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
year: 2026,
|
||||||
|
type: "Frühtracht",
|
||||||
|
slug: "fruehtracht-2026",
|
||||||
|
harvestDate: new Date("2026-05-10"),
|
||||||
|
location: "Altenmünster",
|
||||||
|
description:
|
||||||
|
"Die erste Ernte des Jahres entsteht aus dem Nektar von Obstblüte, Raps und Löwenzahn rund um Altenmünster. Mild, floral und feincremig — der Auftakt jeder Wabenhain-Saison.",
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sommertracht = await prisma.harvest.upsert({
|
||||||
|
where: { slug: "sommertracht-2026" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
year: 2026,
|
||||||
|
type: "Sommertracht",
|
||||||
|
slug: "sommertracht-2026",
|
||||||
|
harvestDate: new Date("2026-07-18"),
|
||||||
|
location: "Altenmünster",
|
||||||
|
description:
|
||||||
|
"Im Hochsommer sammeln unsere Völker Nektar von Linde, Wiesenblumen und Wald. Das Ergebnis ist kräftiger, aromatischer und charaktervoller als die Frühtracht.",
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fhBatch = await prisma.batch.upsert({
|
||||||
|
where: { code: "FH-26-01" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: "FH-26-01",
|
||||||
|
harvestId: fruehtracht.id,
|
||||||
|
fillDate: new Date("2026-06-02"),
|
||||||
|
bestBeforeDate: new Date("2028-06-02"),
|
||||||
|
glassCount: 200,
|
||||||
|
remainingGlasses: 167,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stBatch = await prisma.batch.upsert({
|
||||||
|
where: { code: "ST-26-01" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: "ST-26-01",
|
||||||
|
harvestId: sommertracht.id,
|
||||||
|
fillDate: new Date("2026-08-05"),
|
||||||
|
bestBeforeDate: new Date("2028-08-05"),
|
||||||
|
glassCount: 150,
|
||||||
|
remainingGlasses: 150,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fhProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "fruehtracht-2026" },
|
||||||
|
update: { tasteTags: ["mild", "floral", "feincremig"] },
|
||||||
|
create: {
|
||||||
|
slug: "fruehtracht-2026",
|
||||||
|
name: "Frühtracht 2026",
|
||||||
|
harvestId: fruehtracht.id,
|
||||||
|
batchId: fhBatch.id,
|
||||||
|
description:
|
||||||
|
"Mild, floral und feincremig. Unsere erste Ernte des Jahres aus dem Raum Altenmünster.",
|
||||||
|
tasteTags: ["mild", "floral", "feincremig"],
|
||||||
|
intensityMild: 4,
|
||||||
|
intensityFloral: 4,
|
||||||
|
intensityKraeftig: 2,
|
||||||
|
consistency: "feincremig",
|
||||||
|
weightGrams: 500,
|
||||||
|
priceCents: 990,
|
||||||
|
pricePerKgCents: 1980,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "sommertracht-2026" },
|
||||||
|
update: { tasteTags: ["kräftig", "aromatisch", "charaktervoll"] },
|
||||||
|
create: {
|
||||||
|
slug: "sommertracht-2026",
|
||||||
|
name: "Sommertracht 2026",
|
||||||
|
harvestId: sommertracht.id,
|
||||||
|
batchId: stBatch.id,
|
||||||
|
description: "Kräftiger, aromatischer und charaktervoller Sommerhonig aus Altenmünster.",
|
||||||
|
tasteTags: ["kräftig", "aromatisch", "charaktervoll"],
|
||||||
|
intensityMild: 2,
|
||||||
|
intensityFloral: 2,
|
||||||
|
intensityKraeftig: 5,
|
||||||
|
consistency: "cremig",
|
||||||
|
weightGrams: 500,
|
||||||
|
priceCents: 1090,
|
||||||
|
pricePerKgCents: 2180,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const candleProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "bienenwachskerze-rund" },
|
||||||
|
update: { tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"] },
|
||||||
|
create: {
|
||||||
|
slug: "bienenwachskerze-rund",
|
||||||
|
name: "Bienenwachskerze, rund",
|
||||||
|
category: "KERZEN",
|
||||||
|
description:
|
||||||
|
"Handgegossen aus reinem Bienenwachs unserer eigenen Völker. Brennt gleichmäßig, rußarm und verströmt einen dezenten Honigduft.",
|
||||||
|
tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"],
|
||||||
|
consistency: "fest",
|
||||||
|
weightGrams: 150,
|
||||||
|
priceCents: 1290,
|
||||||
|
pricePerKgCents: 8600,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const propolisProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "propolis-tinktur" },
|
||||||
|
update: { tasteTags: ["schonend verarbeitet", "ohne Zusätze"] },
|
||||||
|
create: {
|
||||||
|
slug: "propolis-tinktur",
|
||||||
|
name: "Propolis-Tinktur",
|
||||||
|
category: "PROPOLIS",
|
||||||
|
description:
|
||||||
|
"Schonend angesetzte Propolis-Tinktur aus dem Harz unserer Bienenstöcke rund um Altenmünster. Ohne synthetische Zusätze.",
|
||||||
|
tasteTags: ["schonend verarbeitet", "ohne Zusätze"],
|
||||||
|
consistency: "flüssig",
|
||||||
|
weightGrams: 30,
|
||||||
|
priceCents: 1490,
|
||||||
|
pricePerKgCents: 49666,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: fhProduct.id },
|
||||||
|
update: { quantity: 167 },
|
||||||
|
create: { productId: fhProduct.id, quantity: 167 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: stProduct.id },
|
||||||
|
update: { quantity: 150 },
|
||||||
|
create: { productId: stProduct.id, quantity: 150 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: candleProduct.id },
|
||||||
|
update: { quantity: 60 },
|
||||||
|
create: { productId: candleProduct.id, quantity: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: propolisProduct.id },
|
||||||
|
update: { quantity: 40 },
|
||||||
|
create: { productId: propolisProduct.id, quantity: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.shippingTier.createMany({
|
||||||
|
data: [
|
||||||
|
{ minQuantity: 0, maxQuantity: 3, priceCents: 700 },
|
||||||
|
{ minQuantity: 4, maxQuantity: 9, priceCents: 850 },
|
||||||
|
{ minQuantity: 10, maxQuantity: 19, priceCents: 1100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log("Versandstaffeln angelegt: 0-3=7,00€, 4-9=8,50€, 10-19=11,00€.");
|
||||||
|
console.log("Demo-Seed abgeschlossen: 2 Ernten, 2 Chargen, 4 Produkte (Honig, Kerzen, Propolis).");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaClient as MySqlPrismaClient } from "../src/generated/prisma-mysql/client";
|
||||||
|
import { createDbAdapter } from "../src/lib/db-adapter";
|
||||||
|
import { isPasswordBreached } from "../src/lib/password-breach-check";
|
||||||
|
|
||||||
|
// Bewusst kein Import aus "@/lib/auth": das Modul zieht next/headers (cookies())
|
||||||
|
// nach sich, das ausserhalb eines Next.js-Request-Kontexts (hier: Standalone-
|
||||||
|
// Skript via tsx) fehlschlaegt. Der Hash-Rundenwert 12 ist mit auth.ts identisch.
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
const adapter = createDbAdapter();
|
||||||
|
// Gleiches Prinzip wie lib/prisma.ts: zwei generierte Clients im Image,
|
||||||
|
// Auswahl zur Laufzeit ueber DATABASE_PROVIDER.
|
||||||
|
const prisma: PrismaClient =
|
||||||
|
(process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql"
|
||||||
|
? (new MySqlPrismaClient({ adapter }) as unknown as PrismaClient)
|
||||||
|
: new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laeuft automatisch bei jedem Container-Start (siehe Dockerfile-CMD in der
|
||||||
|
* runner-Stage) — legt/erneuert NUR das Admin-Konto, keine Beispieldaten.
|
||||||
|
* Ein produktiv gebautes Image soll nie automatisch Fake-Produkte in einer
|
||||||
|
* echten Datenbank anlegen. Beispieldaten fuer lokales Testen/Screenshots:
|
||||||
|
* "pnpm db:seed:demo" (prisma/seed-demo.ts), manuell und getrennt.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (adminEmail && adminPassword) {
|
||||||
|
// Dieselben Mindestanforderungen wie bei der normalen Kundenregistrierung
|
||||||
|
// (registration.ts) — ein Admin-Konto hat Zugriff auf Bestellungen,
|
||||||
|
// Rueckerstattungen und Kundendaten und verdient keine schwaecheren
|
||||||
|
// Passwort-Regeln nur weil es per Seed statt per Formular angelegt wird.
|
||||||
|
if (adminPassword.length < 8) {
|
||||||
|
throw new Error("ADMIN_PASSWORD muss mindestens 8 Zeichen lang sein.");
|
||||||
|
}
|
||||||
|
if (await isPasswordBreached(adminPassword)) {
|
||||||
|
throw new Error(
|
||||||
|
"ADMIN_PASSWORD wurde in bekannten Datenlecks gefunden (Have I Been Pwned). Bitte ein anderes Passwort waehlen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAtEmail = await prisma.customer.findUnique({ where: { email: adminEmail } });
|
||||||
|
|
||||||
|
if (!existingAtEmail) {
|
||||||
|
await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS),
|
||||||
|
firstName: "Admin",
|
||||||
|
lastName: "Wabenhain",
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Admin-Konto angelegt: ${adminEmail} (Passwort aus ADMIN_PASSWORD).`);
|
||||||
|
} else if (existingAtEmail.role === "ADMIN") {
|
||||||
|
// Bereits Admin: Passwort-Rotation ueber ein geaendertes ADMIN_PASSWORD
|
||||||
|
// weiterhin unterstuetzen (dokumentierter Weg, ein Admin-Passwort zu
|
||||||
|
// erneuern) — unproblematisch, da das Konto schon Admin ist.
|
||||||
|
await prisma.customer.update({
|
||||||
|
where: { id: existingAtEmail.id },
|
||||||
|
data: { passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS) },
|
||||||
|
});
|
||||||
|
console.log(`Admin-Passwort synchronisiert: ${adminEmail}.`);
|
||||||
|
} else {
|
||||||
|
// Sicherheitsrelevant: seed befoerdert NIE automatisch ein bestehendes
|
||||||
|
// Nicht-Admin-Konto zu ADMIN, nur weil dessen E-Mail mit ADMIN_EMAIL
|
||||||
|
// uebereinstimmt — sonst koennte sich jemand die Admin-Mail vor dem
|
||||||
|
// ersten Deploy-Seed-Lauf selbst registrieren und beim naechsten
|
||||||
|
// "pnpm prisma db seed" automatisch Admin werden, mit selbstgewaehltem
|
||||||
|
// Passwort. Promotion eines bestehenden Kontos daher nur manuell ueber
|
||||||
|
// die Datenbank, siehe README "Admin-Zugang".
|
||||||
|
console.warn(
|
||||||
|
`WARNUNG: ${adminEmail} existiert bereits als Kundenkonto (nicht Admin) und wird NICHT automatisch befoerdert. ` +
|
||||||
|
'Siehe README, Abschnitt "Admin-Zugang", fuer die manuelle Befoerderung.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Kein Admin-Konto angelegt: ADMIN_EMAIL/ADMIN_PASSWORD sind nicht gesetzt. " +
|
||||||
|
"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()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Contact: mailto:[email protected]
|
||||||
|
Expires: 2027-08-14T00:00:00.000Z
|
||||||
|
Preferred-Languages: de, en
|
||||||
|
Canonical: https://wabenhain.com/.well-known/security.txt
|
||||||
@@ -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 +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 +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 +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 +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 |
@@ -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);
|
||||||
|
});
|
||||||
@@ -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)"
|
||||||
@@ -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"}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Generiert BEIDE Prisma-Client-Varianten (Postgres + MySQL) aus derselben
|
||||||
|
// prisma/schema.prisma, damit ein einzelnes Docker-Image zur Laufzeit per
|
||||||
|
// DATABASE_PROVIDER zwischen beiden waehlen kann (lib/prisma.ts,
|
||||||
|
// prisma/seed.ts) — ein einzelner generierter Client ist fest an EINEN
|
||||||
|
// Provider gebunden (Prisma unterstuetzt keinen Laufzeit-Wechsel desselben
|
||||||
|
// generierten Clients zwischen Providern). Anders als
|
||||||
|
// scripts/prisma-provider.mjs (schreibt genau EINEN Provider fuer
|
||||||
|
// db push/migrate/studio, die gegen eine konkrete Ziel-DB laufen) ist dieses
|
||||||
|
// Skript fuer dev/build/start/postinstall gedacht.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const schemaPath = path.join(projectRoot, "prisma", "schema.prisma");
|
||||||
|
|
||||||
|
const VARIANTS = [
|
||||||
|
{ provider: "postgresql", output: "../src/generated/prisma" },
|
||||||
|
{ provider: "mysql", output: "../src/generated/prisma-mysql" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const originalSchema = readFileSync(schemaPath, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const { provider, output } of VARIANTS) {
|
||||||
|
let schema = originalSchema.replace(
|
||||||
|
/(datasource db \{[^}]*provider\s*=\s*")[a-z]+(")/,
|
||||||
|
`$1${provider}$2`
|
||||||
|
);
|
||||||
|
schema = schema.replace(
|
||||||
|
/(generator client \{[^}]*output\s*=\s*")[^"]*(")/,
|
||||||
|
`$1${output}$2`
|
||||||
|
);
|
||||||
|
writeFileSync(schemaPath, schema);
|
||||||
|
console.log(`Generiere Prisma Client (${provider}) -> ${output} ...`);
|
||||||
|
// execSync (statt execFileSync) laeuft ueber die Shell — auf Windows ist
|
||||||
|
// "npx" ein .cmd-Batch-Skript, das execFileSync ohne shell:true nicht
|
||||||
|
// aufloest.
|
||||||
|
execSync("npx prisma generate", { stdio: "inherit", cwd: projectRoot });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// schema.prisma unveraendert lassen: der eigentliche Provider fuer
|
||||||
|
// db push/migrate/studio wird separat von scripts/prisma-provider.mjs
|
||||||
|
// gesetzt, nicht von diesem Skript.
|
||||||
|
writeFileSync(schemaPath, originalSchema);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Loaded 100 of 740 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user