Nick Szaunig 5889f58da7
Docker Image bauen und veroeffentlichen / build-and-push (push) Waiting to run
feat: Docker-, Kubernetes- und Gitea-Actions-Deployment-Pipeline
Dockerfile (Multi-Stage, "output: standalone"), docker-compose.yml mit
Caddy als einzigem nach aussen offenem Reverse-Proxy (verhindert
X-Forwarded-For-Faelschung fuer das App-eigene Rate-Limiting), Gitea
Actions Workflow der bei jedem Push zwei Image-Tags in die Gitea-
Container-Registry baut (schlankes Laufzeit-Image + ein Migrations-
Image mit Prisma-CLI), sowie rohe Kubernetes-Manifeste (Namespace,
ConfigMap, Secret-Vorlage, Postgres-/MySQL-StatefulSet, Migrations-Job,
App-Deployment, Ingress) fuer den Cluster-Betrieb. Siehe k8s/README.md
fuer den vollstaendigen Einrichtungs-/Deployment-Ablauf.
2026-08-17 14:30:46 +02:00
2026-08-17 14:30:28 +02:00
2026-08-17 14:30:28 +02:00
2026-08-08 04:57:59 +02:00
2026-08-08 04:57:59 +02:00
2026-08-17 14:30:28 +02:00

Wabenhain

Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.

Tech-Stack

Bereich Technologie
Sprache TypeScript
Framework Next.js 16 (App Router) + React 19
Styling Tailwind CSS v4
Scroll-/Hero-Animationen GSAP + ScrollTrigger
UI-Animationen Motion for React (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
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, Versand und Zahlung (Platzhaltertexte, rechtlich prüfen lassen)
  • Kundenkonto unter /konto: Registrierung mit Pflicht-E-Mail-Verifizierung (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale Zwei-Faktor-Authentifizierung (TOTP-Authenticator-App + Backup-Codes, /konto/zwei-faktor), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), Wunschliste, Rechnungsansicht als HTML und PDF-Download (/rechnung/[nummer]), Newsletter-Verwaltung mit Double-Opt-In
  • Admin-Backend unter /admin (Zugriff über das normale Kundenkonto-Login + Rolle ADMIN, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, Umsatzverlauf der letzten 14 Tage, Stammkund:innen-Zähler und meistgenutzten Gutscheinen, Bestellliste mit Detailseite (/admin/bestellungen/[id]: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum und verschickt eine Versand-/Abholbereit-E-Mail), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, Benutzerverwaltung (/admin/benutzer, siehe „Admin-Zugang" unten), Versandkosten-Verwaltung (/admin/versand, siehe „Versandkosten" unten), Gutschein-Verwaltung (/admin/gutscheine), Bewertungsmoderation (/admin/bewertungen: freischalten oder löschen), Audit-Log (/admin/audit-log, siehe „Sicherheit" unten)
  • Transaktions-E-Mails (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
  • 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 (24 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: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt.
  • Rechtstexte sind Platzhalter und müssen vor Live-Gang anwaltlich geprüft werden.

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:

UPDATE customers SET role = 'ADMIN' WHERE email = '[email protected]';

oder per Prisma Studio (pnpm db:studio) am Datensatz das Feld role ändern.

Voraussetzungen

  • 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)

Lokale Entwicklung

cp .env.example .env
# .env anpassen (mindestens SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD; Stripe-Keys optional für UI-Arbeit)

pnpm install

# Nur die Datenbank per Docker starten:
docker compose up -d db

pnpm db:migrate     # erstellt Tabellen (Prisma Migrate)
pnpm db:seed        # legt 2 Beispiel-Ernten/Chargen/Produkte + das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) an

pnpm dev            # http://localhost:3000

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:

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):

# 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    # Beispieldaten + Admin-Konto
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 in .env eintragen (STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY).
  2. Für Webhooks lokal: stripe listen --forward-to localhost:3000/api/checkout/webhook und das ausgegebene Secret als STRIPE_WEBHOOK_SECRET eintragen.
  3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.

Zahlungsmethoden (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 SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter Dashboard → Zahlungsmethoden zu aktivieren — dafür ist keine Codeänderung nötig.

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 genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren.

Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; stripe listen leitet lokal ohnehin alle Events weiter.

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 (03 Stück: 7,00 €, 49 Stück: 8,50 €, 1019 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"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig:

  1. In der Google Cloud Console ein Projekt anlegen (oder ein bestehendes verwenden) und darin die „Places API" aktivieren.
  2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als GOOGLE_PLACES_API_KEY in .env eintragen. Empfehlenswert: den Key auf die Places API einschränken.
  3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den Place ID Finder ermitteln und als GOOGLE_PLACE_ID eintragen.

Die Rezensionen werden serverseitig eine Stunde gecacht (src/lib/google-reviews.ts), um API-Kosten gering zu halten.

E-Mail-Versand

Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (src/lib/mail.ts, nodemailer). Ohne SMTP_HOST/SMTP_USER/SMTP_PASSWORD in .env wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren.

Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. SMTP_FROM sollte zur SMTP_USER-Domain passen, sonst landen Mails leicht im Spam.

CAPTCHA (Cloudflare Turnstile)

Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch Cloudflare Turnstile gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar:

  1. Im Cloudflare-Dashboard 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.

Vollständiger Docker-Betrieb

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   # Beispieldaten + legt/befördert das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD)

Die App läuft danach hinter Caddy 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).

Kubernetes / Gitea CI

.gitea/workflows/docker-build.yml baut bei jedem Push auf main automatisch zwei Docker-Image-Tags (schlankes Laufzeit-Image + ein :migrate-*-Tag mit Prisma-CLI für Migrationen) und veröffentlicht sie in der Gitea-eigenen Container-Registry. k8s/ enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, Migrations-Job, App-Deployment, Ingress) für den Betrieb dieser Images im Cluster — Einrichtung und Deployment-Ablauf stehen in 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. 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: pnpm audit vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive nanoid-DoS-Lücke über pnpm-workspace.yamls overrides gepatcht).
  • 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).
  • Admin-Sperre invalidiert aktive Sessions sofort statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
  • Zahlungsbestätigung transaktional und idempotent: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (checkout.session.expired) wie einen fehlgeschlagenen Zahlungseinzug.
  • Admin-Zugriff: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
  • Checkout: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus crypto.randomBytes.
  • 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 für das vollständige Schema: Customer, Address, PasswordResetToken, EmailVerificationToken, TwoFactorBackupCode, Harvest, Batch, Product, Inventory, Review, WishlistItem, StockNotification, Order, OrderItem, Payment, Shipment, Pickup, Invoice, ReturnRequest, ShippingTier, DiscountCode, AuditLog, NewsletterSubscriber, BusinessInquiry.

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, Vitest): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — lib/shipping-calc.ts, lib/discount-calc.ts, lib/format.ts sowie die Zod-Schemas in lib/validation.ts. pnpm test ausführen.
  • E2E-Tests (e2e/*.spec.ts, Playwright): 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.

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

pnpm dev            # Entwicklungsserver
pnpm build           # Produktions-Build
pnpm lint            # ESLint
pnpm db:studio       # Prisma Studio (DB-GUI)
pnpm db:migrate      # neue Migration erstellen/anwenden (Dev)
pnpm audit           # Abhängigkeiten auf bekannte Schwachstellen prüfen
S
Description
No description provided
Readme
503 KiB
Languages
TypeScript 99.1%
JavaScript 0.5%
Dockerfile 0.2%
CSS 0.2%