robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler. Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine Informationen verfuegbar". Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein noindex. Jetzt greifen beide Mechanismen zusammen statt gegeneinander: - lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie, Bildindexierung und Textausschnitt. - Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate, der Warenkorb noIndex. - Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt feldweise, ein eigener title in einer Unterseite loescht den Wert also nicht. - robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen die Produktbilder, die in der Bildersuche erwuenscht sind. Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie die host-Angabe in robots.txt. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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 |
| 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 |
| 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 undBreadcrumbList-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 undAggregateRating-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 undPayment.statusaufREFUNDEDgesetzt. 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 + RolleADMIN, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, Umsatzverlauf der letzten 14 Tage, Stammkund:innen-Zähler und meistgenutzten Gutscheinen, Bestellliste mit Detailseite (/admin/bestellungen/[id]: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum und verschickt eine Versand-/Abholbereit-E-Mail), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, Benutzerverwaltung (/admin/benutzer, siehe „Admin-Zugang" unten), Versandkosten-Verwaltung (/admin/versand, siehe „Versandkosten" unten), Gutschein-Verwaltung (/admin/gutscheine), Bewertungsmoderation (/admin/bewertungen: freischalten oder löschen), Audit-Log (/admin/audit-log, siehe „Sicherheit" unten) - ApiCore-Synchronisierung: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
- Transaktions-E-Mails (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
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-darkfür helle,text-honey-lightfür dunkle Abschnitte wiebg-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 alletext-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 einenaria-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/reactersetzt, 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: 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.tsprüft bei jedem Zugriff auf/admin/*, ob die Session die RolleADMINträgt, und leitet sonst mit einer erklärenden Meldung zu/kontoum.- 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 enableodernpm 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/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)
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 # 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
- Test-Key aus dem Stripe-Dashboard in
.enveintragen (STRIPE_SECRET_KEY). Einen veröffentlichbarenpk_-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet. - Für Webhooks lokal:
stripe listen --forward-to localhost:3000/api/checkout/webhookund das ausgegebene Secret alsSTRIPE_WEBHOOK_SECRETeintragen. - 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.
Apple Pay / Google Pay: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, stripe.checkout.sessions.create(...) ohne ui_mode: "embedded") laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter Dashboard → Zahlungsmethoden-Domains ist laut Stripe nur für Elements bzw. die eingebettete Checkout-Zahlungsseite nötig, nicht für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain checkout.stripe.com) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code.
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.completedmarkiert die Bestellung nur dann als bezahlt, wennpayment_statusnichtunpaidist (bei Kartenzahlung sofort der Fall).checkout.session.async_payment_succeededmarkiert 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_failedstorniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.checkout.session.expiredbehandelt 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.refundedhält eine Erstattung fest, die von Hand im Stripe-Dashboard ausgelöst wurde. Ohne dieses Abonnement stand die Zahlung im Shop weiter aufSUCCEEDEDund jede Auswertung zählte einen Umsatz mit, den es nicht mehr gab. Eine Teilerstattung setzt den Status bewusst nicht aufREFUNDED(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.createdvermerkt 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.expiredist 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 CronJobwabenhain-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 |
|---|---|
| Klick auf „Abbrechen“ bei Stripe | /checkout/cancel?ref=<Token> schließt die Stripe-Sitzung, bucht den Bestand sofort zurück und löscht die Bestellung |
| 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):
- Status setzen und Lagerbestand zurückbuchen — atomar in einer Transaktion, damit zwei offene Tabs den Bestand nicht doppelt gutschreiben.
- War die Bestellung bezahlt, wird der volle Betrag über die Stripe Refunds API erstattet und
Payment.statusaufREFUNDEDgesetzt. 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. - 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 aufNEU, 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) steht ausschließlich in der cancel_url und wird nach Gebrauch aus der Adresszeile entfernt. 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:
- 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. - 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.
- 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.
- Werte setzen: Schlüssel als
GOOGLE_PLACES_API_KEY(Secret), Place-ID alsGOOGLE_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:
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.
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 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)
Alle öffentlichen Eingabeformulare sind durch Cloudflare 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.
- Im Cloudflare-Dashboard einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
- Den Site Key als
NEXT_PUBLIC_TURNSTILE_SITE_KEYund den Secret Key alsTURNSTILE_SECRET_KEYin.enveintragen.
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 startet bei 0, denn wie viele Gläser in den Webshop gehen und wie viele in Hofladen oder Wiederverkauf, entscheidet Wabenhain.Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
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 werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde
remainingGlassesüberschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand. -
Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
-
Ein in ApiCore ausgelaufenes Produkt (
DISCONTINUED) wird nicht automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel apicore-<ApiCore-ID> als Harvest.slug wiedererkannt, Chargen über die ApiCore-Losnummer als Batch.code, Produkte über Product.apicoreBottlingId.
Einrichtung: APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD in .env setzen (siehe .env.example). ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — deshalb dort ein eigenes, MFA-freies Service-Konto anlegen statt einen echten Admin-Zugang einzutragen. Es braucht ausschließlich Leserechte: harvests:read, products:read, extractions:read, bottling:read und optional apiaries:read (nur für die Standortnamen an den Ernten). Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Details und die Zuordnung Feld für Feld: src/lib/apicore-client.ts, src/lib/apicore-mapping.ts und src/app/admin/actions/apicore-sync.ts.
Vollständiger Docker-Betrieb
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 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
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, kein SMTP 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:
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_EMAILundADMIN_PASSWORDauf Länge und darauf, dass noch der Beispielwert aus.env.exampledarin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar). -
NEXT_PUBLIC_SITE_URLauf HTTPS und darauf, dass dort nichtlocalhoststeht — genau dieser Wert landet in Bestellbestätigungen, insitemap.xmlund 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. -
SMTP vollständig gesetzt — 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 insrc/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 bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und müssen vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
Rechnungen
Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (api/checkout/webhook) die Rechnung, legt sie als PDF ab und hängt sie an die Bestellbestätigung. Ohne bestätigte Zahlung gibt es keine Rechnung — fehlt STRIPE_WEBHOOK_SECRET, bleibt jede Bestellung auf „NEU" stehen.
Ablage unter INVOICE_DIR, nach Jahr und Kunde sortiert:
invoices/2026/mustermann-erika/RE-2026-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. Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume.
Bei mehr als einem Knoten: das PVC
wabenhain-invoicesistReadWriteOnceund lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC aufReadWriteManyumstellen (NFS/SMB/CephFS).
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:
- 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.
- An der Kategorie —
VAT_RATE_PERCENT_HONIG,VAT_RATE_PERCENT_KERZEN,VAT_RATE_PERCENT_PROPOLIS. - 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_PROPOLISbitte 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.
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 baut bei jedem Push auf main automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. k8s/ enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in k8s/README.md.
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,Securein Produktion, trägt Kunden-ID, Rolle undsessionVersion. 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_SECRETmuss in Produktion mindestens 32 Zeichen lang sein (wird beim Start erzwungen). - Session-Widerruf:
Customer.sessionVersionwird 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/checkoutprüftCustomer.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 auditvor 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 transitivenanoid-DoS-Lücke überpnpm-workspace.yamlsoverridesgepatcht). - 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
usedCountauch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einenmaxUses: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 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. 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.tssowie die Zod-Schemas inlib/validation.ts.pnpm testausfü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:e2eausführen — startet bei Bedarf automatischpnpm 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 preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
pnpm db:studio # Prisma Studio (DB-GUI)
pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen