Bedarf je Erntefenster = max(geplante Glaeser, laufende Abos x Glaeser),
und nur das naechste Fenster je Angebot. Vorher hielten alle kuenftigen
Fenster ihre geplante Menge sofort fest, und ohne Planung blieben neue
Ernte-Abos unreserviert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Pakete, Abo-Reservierung und Abo-Lieferungen nehmen immer den aktiven,
nicht cremigen Honig der neuesten Ernte mit genug freiem Bestand, bei
gleichem Jahrgang den mit dem meisten Bestand. Feste Honig-Zuordnung
im Admin entfaellt (Spalte bleibt, wird nicht mehr genutzt).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Live angelegt am 2026-10-16 (inaktiv, Stripe-Preise price_1URDJE..., price_1URDJF...).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Ausgerollt am 2026-10-16, Migrationen und Seed beim Start gelaufen.
scripts/sponsorship-bootstrap.ts verknuepft den Paket-Honig und legt die
Abo-Angebote inaktiv samt Stripe-Preis an.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Webhook um Abo-Ereignisse ergaenzt, Kundenportal nur fuer die Zahlungsart
angelegt (ID in der ConfigMap), Zahlungsmethoden-Domains registriert.
Dashboard-Schritte ohne API in EINRICHTUNG.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die Layout-Matrix der Urkunde lief auf dem CI-Runner in die 5-Sekunden-
Grenze; Tests bekommen 30 s. Die Cookie-Knoepfe stapeln sich unter 430 px,
statt ineinanderzulaufen. Der Tropfen im Warenkorb-Effekt wird immer
gerendert und nur bei reduzierter Bewegung nicht animiert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Passende Kuendigungen werden nicht mehr von fremden Fehlversuchen
ausgesperrt. Offene Abo-Abschluesse blockieren einen Platz nur noch 35
Minuten, die Stripe-Sitzung laeuft nach 32 Minuten ab.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
(instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript
Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Feste, mindestens zwei Wochen alte Versionen. fontkit misst die Schriften
der Urkunde, three/@react-three rendern die 3D-Ansicht im Konto, fflate
packt den Download aller Dateien.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Abo-Stufen 1 bis 24 je bezahltem Monat, danach VIP. SEPA fuer Abos mit
Neukunden-Frist, Verzug und Sperre nach Rueckbuchung. Stripe-Einrichtung
erst im Testmodus per Skript, Livemodus nur nach Freigabe.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Teilt den Umfang in fuenf Teilprojekte mit eigener Planung. Abo mit
Lieferung erst nach bezahlten Monaten, Honigkontingent und
Reservierung, Kuendigungsbutton; Medaillen je Patenschaft, Stammkunden-
und Abo-Stufen bis VIP; befristete Uebertragungslinks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
README: Abschnitt Projektstand (Issues #34, #33), veraltete Aussagen zu
Platzhaltern und Rechnungsnummern korrigiert, CI-Gate, Rollout per set
image, Client-IP hinter Cloudflare, VAT_RATE_PERCENT_SONSTIGES, Teststand.
k8s/README: Image-Pinning, Abweichungen Repo/Cluster, Sicherung auf der
Storage Box, SMB 3.1.1 mit seal. EINRICHTUNG: APICORE_WEBHOOK_SECRET,
ERP_EXPORT_TOKEN, Abschnitt Neuen Build ausrollen. .env.example:
DATABASE_POOL_SIZE.
Refs #32
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Ersetzt den bisherigen Implementierungsauftrag nach den verbindlichen
Vorgaben vom 2026-10-16: drei Pakete, keine Besuche oder laufenden
Inhalte, Urkunde als deterministisch erzeugte PDF mit Vorschau aus
derselben Layoutberechnung, idempotente Erzeugung nach Zahlung,
Revisionen und Druckablauf im Admin.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
In der CI fehlt .next/types; tsc fand die globalen PageProps/LayoutProps
nicht (TS2304). "next typegen" vor tsc erzeugt sie ohne Build.
Lokal mit geloeschtem .next/types geprueft.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Der Workflow baute bisher bei jedem Push auf main ungeprueft ein Image.
Jetzt laufen lint (inkl. pnpm audit ab high), typecheck und test auf dem
isolierten Runner (ci-isolated, ohne Docker-Socket und Clusternetz);
build-and-push startet nur, wenn alle drei gruen sind. Muster und
Runner-Pruefskript aus dem VyCept-Repo (docs/BETRIEB.md 11b).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Live seit 2026-10-12: Rate-Limits je Besucher (CF-Connecting-IP nur von
Cloudflare-Edges und nur oeffentlich), IPv6 je /64, X-Real-IP nur ausserhalb
der Produktion.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
CF-Connecting-IP wird nur noch als öffentliche Unicast-Adresse akzeptiert
(kein 0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.168/16,
240/4 "Pseudo IPv4", 255.255.255.255, ::, ::1, fc00::/7, fe80::/10 sowie
IPv4-gemappte Formen); sonst gilt die Gegenstelle aus X-Forwarded-For.
Der X-Real-IP-Fallback gilt nur noch außerhalb von Produktion. In
Produktion ohne X-Forwarded-For wird "unknown" geliefert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Middleware cloudflare-only (ipAllowList: Cloudflare-IPs und private Netze)
vor die HTTPS-Umleitung gesetzt, wie live im Cluster seit 2026-10-12.
Direkte Zugriffe auf den Origin an Cloudflare vorbei bekommen 403.
Die Middleware selbst liegt in rke2-cluster/security.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Seit CF-Connecting-IP gilt, sieht der Limiter echte IPv6-Adressen. Ein
Anschluss bekommt meist ein ganzes /64 und koennte durch Adresswechsel
jedes Limit umgehen (Login, 2FA, Checkout). getClientIp liefert IPv6 deshalb
als /64-Praefix, IPv4-gemappte Adressen wie IPv4 (wie VyCept, #94).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
wabenhain.com laeuft ueber Cloudflare. Der letzte X-Forwarded-For-Eintrag
ist dann die Cloudflare-Edge, die sich viele Besucher teilen: Login-, 2FA-
und Checkout-Limits trafen alle Besucher derselben Edge gemeinsam, ein
einzelner konnte andere aussperren.
resolveClientIp nimmt CF-Connecting-IP, wenn die Gegenstelle eine
Cloudflare-Edge ist; sonst wie bisher den letzten Eintrag (beim
Direktzugriff ist CF-Connecting-IP faelschbar und zaehlt nicht).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sicherheitsreview: ein Tag ist in der Registry ueberschreibbar. Tag plus
Digest bleibt lesbar und unveraenderlich; Cluster auf denselben Stand
gebracht, kubectl diff leer.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Ausgerollt am 2026-10-12 nach Sicherung wabenhain-db-backup-pre-einvoice;
Migration 20261012010000_cancellation_invoices angewendet.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- E-Rechnung: in XML verbotene Steuerzeichen aus Namen/Anschriften entfernen,
sonst waere das eingebettete XML bei einem einzigen Zeichen ungueltig.
- Rechnungskopie: genau eine Adresse; eine Liste ([email protected],[email protected]) wird beim
Speichern abgelehnt und nie an nodemailer gegeben.
- Hinweis bei fehlgeschlagener Stornorechnung korrigiert (ein erneutes
Stornieren ist nicht moeglich, die Stripe-Erstattung holt sie nach).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Image per Digest statt :latest, imagePullPolicy IfNotPresent, Pod- und
Container-securityContext (runAsNonRoot 1001, seccomp RuntimeDefault, keine
Rechteausweitung, alle Capabilities entfernt). kubectl diff gegen den Cluster
ist leer. Neue Builds per kubectl set image ...:sha-<commit>.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Fehlt assets/fonts im Container, entsteht das PDF mit Standardschriften
und eingebettetem XML, nur nicht als PDF/A; der Fehler steht laut im Log.
Eine Rechnung darf nie an den Schriften scheitern.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Zwei nur lesende Endpunkte mit ERP_EXPORT_TOKEN:
- GET /api/erp/kunden: Kundenkonten (ohne Admins, ohne Passwort/2FA) mit
Standardadresse; ERPNext fuehrt sie als Privatkunden.
- GET /api/erp/anfragen: Firmenanfragen; ERPNext fuehrt die Firma als
B2B-Kunden mit Ansprechpartner und je Anfrage eine Chance.
Seitenweise ueber den Cursor "nach"; ERPNext holt jeweils die ganze Liste
und erkennt so auch geloeschte Konten.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).
Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.
ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.
- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
(veraPDF: PDF/A-3b konform, XML valid)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Werkzeug-Image wabenhain-tools (Dockerfile "AS tools", Muster wie
artista-modelcamp-tools): Im App-Image fehlten tsconfig.json und Pakete
wie stripe, Bestandsabgleich und Bestellfreigabe brachen mit
"Cannot find module" ab. Die CI baut es neben dem App-Image.
- Datenbank-Sicherung: PV auf der Storage Box (Customer/Wabenhain), der
Unterordner db-backup entsteht per subPath. Vorher blieb der PVC ohne
StorageClass haengen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ERPNext (collab.vycept.com) now picks up every shop invoice with its PDF,
books it as a Wabenhain sales invoice and matches it to the Stripe payment
via the PaymentIntent, so fees, revenue and the invoice document sit on one
booking.
- GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>: invoices oldest first,
with order, payment intent, customer address (no phone, no notes), items
and the VAT split per rate exactly as printed (lib/vat.ts), including the
shipping and discount share per rate, so ERPNext books the same cents.
- GET /api/erp/rechnungen/<RE-YYYY-NNNN>/pdf: the stored invoice PDF.
- Bearer token ERP_EXPORT_TOKEN (min. 32 chars, timing-safe compare);
without it the export is off. Failed attempts are rate limited.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Requests stehen explizit auf 0 statt zu fehlen, weil Kubernetes einen
fehlenden Request auf das Limit setzt und dann noch mehr reserviert.
Die Limits bleiben als Obergrenze.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.
- Outbox apicore_stock_events, written in the same transaction as the
status change: paid -> SALE, cancelled-after-payment / refunded return
-> RETURN onto the batches of the sale, reopened paid order -> SALE.
Whether a return is due is decided by the open sales in the outbox,
so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
ApiCore deduplicates by event id. Rejected bookings stop and show up
on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
reservations of unpaid orders, in a serializable transaction like the
checkout. Triggered after bookings, by the signed ApiCore webhook
stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
per ApiCore article via Product.apicoreProductId, drafts created by the
sync, existing products linkable in the product form; new category
SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.
- Drop the (year, type) unique index. The slug is now the only unique,
public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
"apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
(decomposed umlauts) and writes out umlauts. The migration repairs
manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
writers cannot produce duplicates; known slugs are skipped up front to
keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Inquiries: completed and rejected inquiries move to an "Archiv" tab;
resetting the status brings them back. Reviews: new "Ablehnen" action
(Review.rejectedAt) instead of only deleting; the archive shows decided
reviews in two columns, accepted and rejected, and either decision can
be reversed. Pending counters in the sidebar and dashboard exclude
rejected reviews, and the product page tells the author their review was
not published instead of "still being checked".
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ApiCore API keys can now be limited to source addresses; a 403 then
usually means this server's address is not on the key's allowlist.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The setup guide still listed APICORE_SYNC_EMAIL/APICORE_SYNC_PASSWORD,
which the client no longer reads since 3bc0693.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Admin texts can now contain the placeholder {{USt-IdNr}}. Wherever
content is read (lib/content.ts), it is replaced with the VAT ID
maintained under Admin > Einstellungen > Firmendaten, so every text
follows a change automatically. The placeholder is matched case- and
space-insensitively; an empty VAT ID inserts nothing, and unknown
placeholders stay visible so typos show up. Images, videos, select
fields and the source field itself are never rewritten.
The default imprint text uses the placeholder instead of a hard-coded
number, and the settings page lists the available placeholders.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The invoice PDF and the invoice page now share one branded layout:
logo (uploaded brand logo, otherwise the drawn honeycomb mark) and
wordmark in the header, honey accents, a forest-green table header,
a meta box with invoice number, date, order number and VAT ID, and a
footer with address, contact, VAT ID and page numbers on every page.
A mixed cart (e.g. honey at 7 % and a beeswax candle at 19 %) lists
its items in one block per VAT rate with a subtotal each. Next to the
totals, a VAT table shows net, VAT and gross per rate plus a sum row.
Long invoices continue on further pages with the table header repeated.
The VAT identification number is a new company field
(company.vatId), editable in Admin > Inhalte, defaulting to the
number already stated in the imprint.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The drawer used to slide in 180 ms after the honey drop landed and
covered the glow and the drip on the cart icon. It now waits until the
drip has finished (about 1.4 s). Without a visible flight (reduced
motion, icon off-screen) it still opens almost at once. The drip
timing lives in cart-flight.tsx so both files share one source.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The batch list on a harvest page no longer states the batch's total
size ("12 von 300 Gläsern verfügbar"). It now reads "Es sind noch 12
Gläser verfügbar", with dedicated wording for a single jar and for a
sold-out batch, which reads scarcer and nudges customers to buy.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The landing glow was a larger, filled basket with a thicker stroke and
a blurred halo, so the icon looked bigger during the effect. The glow
is now an exact copy of the icon (same size, same stroke width, no
fill, no scaling) that only changes colour to honey for a moment.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
After the flying honey drop reaches the header cart icon, the basket
now lights up twice in honey colour with a warm halo, and a small
honey drop gathers at its lower edge, stretches and runs down. With
reduced motion only a single calm glow remains, without the drip.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Adds /konto/einstellungen, which gathers two-factor authentication,
"sign out on all devices", the Erntepost consent, the data export
(Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place.
The account overview now links there through a "Settings" card
instead of listing these blocks inline. Server actions are unchanged
apart from redirecting failures and revalidating the new page.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:
- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
/products, /extractions, /bottling-batches, /apiaries).
A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
APICORE_API_KEY replaces the service-account login: the key is sent as
the bearer token directly, needs no password and no MFA exemption, may
only read the shop feed and can be revoked in ApiCore at any time. A
malformed key is rejected with a clear message before any request.
Email/password stays as fallback for ApiCore versions without keys.
The system status treats the key or the legacy login as configured.
README, .env.example and the k8s secret template describe the key.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The sync now fetches everything from ApiCore's GET /shop-sync in one call:
an allowlisted feed (harvest facts, article master data, bottlings) that
never contains internal notes and needs only the single permission
"shop-sync:read" instead of read access to harvests, extractions etc.
- Contract version is checked; an unknown version stops the run with a
clear message and writes nothing. Extra fields are ignored.
- One consistent snapshot, processed only after it arrived completely: a
failed fetch writes nothing (no more half state of new harvests and old
batches); the batch-load-error path is gone.
- Falls back to the previous per-resource endpoints when ApiCore answers
404, so both packages can be deployed independently.
- .env.example documents the reduced permission.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ApiCore stays the source of truth for beekeeping facts, Wabenhain for
everything sales-related; both remain separate packages talking only over
ApiCore's HTTP API.
- Batches: lot number, fill date, best-before date and filled jar count
now follow ApiCore on every run (before: created once, corrections never
arrived). remainingGlasses - the shop's own stock - is never written; if
it exceeds a corrected fill quantity, the run reports it instead of
silently adjusting.
- Batches are matched by the new Batch.apicoreBottlingId instead of the lot
number, so correcting a lot number in ApiCore no longer creates a second
batch. Existing synced batches are adopted once via their lot number.
- Harvests: date and year always follow ApiCore, also when published or
edited; type, location and text stay as maintained in the admin.
- Products stay entirely shop-owned after creation (weight drives the
legally required unit price, so it is never changed automatically).
- Admin panel: batch update count and stock-conflict notice; intro text
states which system owns which fields.
Migration is additive (nullable unique column).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The last base64url character of a 32-byte HMAC signature partly carries
padding bits that decoders ignore, so the tampered token was sometimes
still valid and the test failed at random. Changing a character in the
middle of the signature always alters the signature bytes.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Harvest descriptions no longer include ApiCore's internal harvest notes.
The description becomes public on "Veröffentlichen", so a draft published
without a careful read could have carried operational notes into the shop.
- A repeated sync overwrote unpublished harvest drafts, undoing the admin's
corrections (season instead of honey type, location, sales text). Drafts
with an admin audit entry are now left alone and reported as protected.
- Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted
the connection but never answered left the admin action hanging.
- The sync panel shows the time of the last complete run.
- Client tests: timeout, failed fetch is never treated as an empty source,
malformed responses are rejected as a whole.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Cancelled orders:
- Moving an order out of STORNIERT is possible again, but only after a
warning is confirmed in the status form (OrderStatusForm); without
the confirmation the server changes nothing.
- Reopening books the counter-entry to the cancellation: goods that the
cancellation restocked are deducted again (atomically, only if enough
stock is left, otherwise the order stays cancelled). A later second
cancellation therefore restocks +1 again instead of leaving +2.
- The warning states that no new payment is collected.
Dashboard figures:
- One definition of a counted sale (lib/sales.ts): not unpaid (NEU),
not cancelled, no refunded return. Used for revenue today/month, sold
glasses, average cart, top products/categories, revenue trend and
repeat customers.
- "Verkaufte Gläser" previously summed every order item, including
cancelled orders and refunded returns.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).
STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
message and moves non-cancel statuses with an atomic
"status <> STORNIERT" update, so a cancellation from a second tab
cannot be undone in between
- the order list and detail page no longer offer the status select for
cancelled orders
Existing stock that was restocked twice has to be corrected by hand.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.
System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
whether each is set up and the names of missing variables, never a
value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
smtp.office365.com:587 and login.microsoftonline.com:443
/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.
- The shop now resolves smtp.office365.com via IPv4 only and connects
to that address; the TLS certificate is still verified against
smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
sticks.
- The email test first probes smtp.office365.com:587 and
login.microsoftonline.com:443 over IPv4 and reports each result, so a
blocked port (hoster firewall, NetworkPolicy) is distinguishable from
a configuration error.
Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:
- The admin accepts the entry as written (plain or obfuscated with
[at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
contact page and every email in legal texts (Impressum, Datenschutz,
Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
with a fresh random key; the browser builds the clickable mailto link
after load (components/protected-email.tsx).
Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
docs/EINRICHTUNG.md walks from nothing to a running shop: tools and
access, generating database/session/admin values, the Entra ID app
registration used for both sign-in and mail (redirect URI, client
secret, SMTP.SendAsApp with admin consent, enterprise app object id),
unlocking the shared mailbox in Exchange Online, Stripe keys and webhook
events, optional services, a complete secret/config map table, rollout,
verification, maintenance (secret expiry) and troubleshooting keyed to
the messages of the admin email test. Linked from README and k8s/README.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.
Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.
- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
preflight and README updated
Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.
- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
concurrent requests share one fetch, forced refresh after a server
rejection, 15 s timeout, tenant id validated before it enters the URL,
client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
Exchange setup) updated; misplaced SMTP_PORT comment moved.
Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:
- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
instructions: security defaults, SMTP AUTH disabled for tenant or
mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
the mailbox), wrong credentials, basic-auth retirement, network, TLS.
The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
signed-in admin only (no free recipient, rate limited, audit logged)
and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
never sent unencrypted, strips a trailing newline from secrets (a
common kubectl/base64 mistake) and logs the diagnosed cause.
Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.
All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
"Widerruf bestätigen", no login, only name, order number and e-mail
required. Prominent footer button on every page, prefilled link per
order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
order number and e-mail fit), mails a receipt with the declaration and
the exact time of receipt, notifies ADMIN_EMAIL. Same response for
matching and non-matching orders (no enumeration), origin check and
rate limit instead of a CAPTCHA that could block a statutory right.
User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.
Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
choosing "essential only" or withdrawing consent deletes it. The
banner no longer promises audience-measurement cookies that do not
exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
most 14 days, withdrawal-function processing, consent timestamps.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Legal pages rendered admin text as bare paragraphs. LegalPage now has a
honeycomb header, a sticky table of contents with scroll tracking (a
collapsible one on mobile), anchor links, a reading progress bar and a
revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives
lists, cookie/provider entries with labelled facts and safe links from
plain text, so the pages stay editable in the admin without markup.
Legal corrections (German law as of 2026):
- Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu
Art. 246a EGBGB): recipient name/address/e-mail filled from company
data, deadline rule, return period, refund details, diminished value
and the model withdrawal form. The incomplete notice extended the
withdrawal period to up to 12 months and 14 days. Honey is no longer
declared perishable.
- Impressum: TMG references replaced by DDG; editorial contact gains an
address.
- Datenschutz: removes the self-hosted vs. hosting-provider
contradiction, adds PayPal/Klarna, reviews, back-in-stock
notifications, storage period, no automated decisions, withdrawal of
consent and the Art. 21 objection notice; newsletter double opt-in.
- AGB: payment methods and delivery area point to the live shipping
page; adds contract-text and input-correction information.
- Cookie policy: storage period and provider for every entry.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Hero: Titel gross auf dem Bienenfoto, darunter "Fuer ..." mit
wechselnder Zielgruppe (Unternehmen, Kanzleien, Praxen, Hotels,
Agenturen, Handwerksbetriebe - die Woerter aus dem Untertitel), eine
Biene fliegt durchs Bild, Knopf springt zur Anfrage.
- Die drei Pakete wieder als Karten, jetzt mit gezeichnetem Honigglas:
Klassik mit Banderole, Geschenk im Karton mit Schleife, Individuell mit
Stift und Funkeln. Beim Hover hebt sich das Glas, die Schleife wippt.
Ein Klick springt zum Formular und waehlt das Paket dort vor
(?paket=, nur bekannte Schluessel).
- Anfrage: links Ueberschrift und das Glaeser-Foto (mitlaufend, leichte
Parallaxe), rechts das Formular.
Neu im Woerterbuch nur Ueberschrift, Knopftext und die Zielgruppenliste;
keine neuen Aussagen. Dunkelmodus und reduzierte Bewegung beruecksichtigt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The policy only named wabenhain_session, although the site sets ten
cookies and three localStorage entries. § 25 TDDDG requires the
information for every access to the user's device, so each entry now
states purpose, provider, storage period, flags and legal basis.
Also adds the legal-basis section (§ 25 Abs. 1/2 TDDDG, Art. 6 DSGVO),
third-party cookies (Stripe __stripe_mid/__stripe_sid, Microsoft,
Cloudflare Turnstile), browser controls, data subject rights, withdrawal
under Art. 7(3) DSGVO and a revision date. Corrects the claim that the
cart never leaves the device: it is sent when an order is submitted.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.
Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.
Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
"Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
leicht mit (Zoom und Parallax, nur transform; components/parallax-media).
Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Das Speichern schrieb den Formularwert immer fest in den Lagerbestand.
Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch
wieder in den Bestand, ohne dass es je zurueckkam.
- Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded)
- Feld unveraendert: Bestand wird nicht angefasst
- Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand
entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert
und der aktuelle Wert angezeigt
- Fehlt quantityLoaded (alter Tab), muss neu geladen werden
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.
- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
(needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Bisher stand dort nur ein Satz und ein Knopf zum Shop. Jetzt steht der
Hinweis als Spalte links, daneben bis zu zwei aktuell verfuegbare
Produkte (Honig zuerst) - dasselbe Muster wie "Aktuelle Ernte" auf der
Startseite. Nutzt die bereits geladenen availableProducts.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Der Knopf stand unter beiden Spalten (Anmelden und Konto erstellen),
obwohl er zur Anmeldung gehoert, und erschien auch ohne eingerichtete
Entra-Konfiguration - der Klick endete dann in der Meldung "SSO ist noch
nicht konfiguriert". Jetzt steht er unter dem Anmeldeformular und nur,
wenn isEntraConfigured() zutrifft. Die serverseitige Pruefung in der
Login-Route bleibt als zweite Absicherung.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die drei Pakete standen doppelt: als Deko-Kacheln (mit grossem
Waben-Wasserzeichen) ueber dem Formular und darin noch einmal als
Auswahlliste. Jetzt waehlt man sie direkt im Formular als Karten mit
sichtbarem Auswahl- und Fokuszustand; die Kacheln entfallen. Feldname
und Werte (package = KLASSIK/GESCHENK/INDIVIDUELL) sind unveraendert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Imkerei, Herkunft und Ernten endeten mit dem letzten Absatz direkt vor
der Fusszeile. Neuer PageOutro: zwei naechste Wege als grosse
Serif-Links mit Kurztext (Ernten/Shop bzw. Herkunft/Shop).
- Kopfzeile: bei 1280 px lief sie 54 px ueber (horizontale Scrollleiste),
weil die nicht umbrechbare Slogan-Zeile unter dem Logo schon ab xl
erschien. Jetzt erst ab 2xl; geprueft 1024 bis 1920 px ohne Ueberlauf.
- Wissen: Inhaltsverzeichnis statt zehn gleicher Bloecke mit je einem
"Weiterlesen" - ganze Zeile ist der Link, Titel und Kurztext
nebeneinander. Ungenutzter Schluessel readMore entfernt.
- Produktseite: bei ein, zwei Empfehlungen steht die Ueberschrift als
erste Rasterspalte statt ueber einer einzelnen verlorenen Karte.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die Galerie lief im Querformat 4:3 neben einer langen Detailspalte und
wirkte daneben schmaechtig. Ab md jetzt 4:5, das Glas traegt die Seite;
auf dem Handy bleibt 4:3, damit der Kaufen-Knopf oben bleibt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Fehlt ein Foto, zeigte die Seite einen Farbverlauf mit Diagonallinien -
ein generisches Muster, das auf Produkt- und Inhaltskacheln wie
unfertige Vorlage wirkte. Jetzt: ruhige Markenflaeche mit leisem
Wabenraster, das gezeichnete Waben-Zeichen und darunter der Name in
gesperrten Serif-Versalien, wie auf dem Etikett. In kleinen Groessen
(Warenkorb, Galerie-Vorschau) bleibt per Container Query nur das Zeichen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Aktuelle Ernte: bei ein, zwei Produkten steht die Ueberschrift als
eigene Spalte daneben, statt ueber einem halb leeren Dreierraster.
- Herkunft: Foto randlos ueber die halbe Seitenbreite, Text daneben,
ohne Rahmen.
- Philosophie: typografische Reihe mit Trennlinien und grossen
Serif-Titeln statt vier Icon-Karten.
- Region: breites Bild, der Text liegt als Tafel ueberlappend darueber.
- Geschaeftskunden: kompakte Tafel mit randlosem Bild statt eines dritten
grossen Bild/Text-Wechsels.
Inhalte, Texte und Content-Schluessel bleiben unveraendert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Jede zweite Ueberschrift trug eine kleine Versalien-Zeile mit Wabenpunkt
("• STIMMEN", "• AKTUELL VERFUEGBAR", "• KUNDENKONTO" ...). Auf 29 Seiten
und Abschnitten wiederholt las sich das wie eine Vorlage. SectionHeading
rendert sie nicht mehr, die Prop ist entfernt; die Ueberschrift traegt den
Abschnitt allein.
Die eyebrow-Texte, die weiterhin als Seitentitel, Bildbeschriftung oder
Breadcrumb dienen, bleiben; 18 danach ungenutzte Eintraege sind aus Typ und
beiden Woerterbuechern entfernt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Admin-Leiste (Desktop und Mobilmenue): Link "Zur Website" oberhalb von
"Angemeldet als". Der Shop-Kopf ist im Admin ausgeblendet, bisher fuehrte
von dort kein sichtbarer Weg zurueck.
- Startseite, Kundenstimmen: das grosse Schriftzeichen "„" sass auf der
Grundlinie und ragte mit leading-[0.6] in die Sternebewertung. Ersetzt
durch ein gezeichnetes SVG mit fester Hoehe und Abstand.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sechs Route-Handler (Hinweis-Weiterleitung, E-Mail-Bestaetigung,
Newsletter bestaetigen/abmelden, Entra-Login und -Callback) bauten ihr
Weiterleitungsziel mit getBaseUrl() absolut zusammen. Ohne gesetztes
NEXT_PUBLIC_SITE_URL (lokal, Staging, Vorschau-Host) sprang man damit auf
https://wabenhain.com, und die gerade gesetzte Flash-Meldung ging auf dem
fremden Host verloren - aufgefallen, als die Admin-Pruefung nach
/api/hinweis umleitete.
Neuer Helfer lib/redirect.ts (redirectWithinSite) mit relativem
Location-Header, analog zu redirect() in Server Actions. Er lehnt alles
ausser Pfaden dieser Seite ab ("//host", "/\host", absolute URLs), damit
daraus keine offene Weiterleitung werden kann. getSiteUrl() bleibt fuer
Adressen, die die Seite verlassen (E-Mails, Stripe, Entra-redirect_uri,
Sitemap, Canonicals).
proxy.ts bleibt bei new URL("/konto", request.url) (eigener Host): der
Proxy verlangt einen absoluten Location-Header, ein relativer endet dort
in "Invalid URL" - als Kommentar festgehalten.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).
- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.
Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Derselbe "Alles synchronisieren"-Knopf stand auf /admin/produkte und
/admin/ernten und sah dadurch wie zwei verschiedene Abgleiche aus. Er
sitzt jetzt einmal auf dem Dashboard, das Ergebnis des Laufs erscheint
dort oben. Die Action springt standardmaessig nach /admin zurueck; die
alten Ziele bleiben in der Whitelist erlaubt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.
Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.
Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.
Benutzer bearbeiten: Sicherheitsbereich mit Status-Badges, Passwort-Reset
als Sekundaerknopf, Sperren/Loeschen in eigenem abgesetzten Bereich.
AdminField/AdminSelect koennen jetzt min und einen Hinweistext.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).
Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste
(dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand
- Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und
Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld
isActive), Speicherleiste bleibt beim Scrollen unten erreichbar
- Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger
Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
"Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
#a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Aktive Seite mit Honig-Wabe statt farbigem border-left, feines Wabenraster
in der Seitenleiste (liegt per isolate/-z-10 unter dem Menue).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Schrittanzeige "Deine Daten -> Bezahlen" mit Waben (rein anzeigend,
gesteuert weiter ueber den bestehenden Zahlungszustand)
- Lieferart als Karten mit Symbol, Auswahl mit Waben-Haekchen, aria-pressed
- Bestelluebersicht abgehoben, Namen und Gesamtsumme in Serifenschrift
- Keine Aenderung an Bezahllogik oder Formularfeldern
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Adressen im Zweierraster, Standardadresse mit Honigrahmen und Wabenmarke,
neues Adressformular in der Formular-Karte
- Retoure: Positionen als Liste mit Waben, Formular in der Karte
- Suche: Vorschlaege gleiten gestaffelt herein, Suchbegriff hervorgehoben,
Lupe wird zum Schliessen-Symbol; das Feld legt sich ueber die Navigation
statt sie zu verschieben (vorher: waagerechter Scrollbalken)
- Cookie-Hinweis als schwebende Karte; "Nur notwendige" und "Alle
akzeptieren" gleich gewichtet (vorher war Akzeptieren Waldgruen auf
Waldgruen und kaum als Knopf erkennbar)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- DHL-Verlauf: neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
unterwegs), aeltere als leere Waben, Eintraege erscheinen gestaffelt
- Bestellung verfolgen: Suchformular in der Formular-Karte, Ergebnis als
abgehobene Karte
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Gemeinsame FormPanel-Karte fuer Passwort- und Zwei-Faktor-Seiten
- Zwei-Faktor-Code: grosses, zentriertes Ziffernfeld
- Backup-Codes als nummerierte Kacheln statt langer Liste
- Newsletter-Bestaetigung: Willkommensrabatt als Gutschein mit
gestricheltem Rand und Kopier-Knopf (bestaetigt "Kopiert")
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Herkunft: atmendes Titelbild mit Wabenraster, Ueberschrift als Karte,
die ins Bild ragt; Aspekte als Liste mit Waben
- Imkerei: Bild mit Honigrahmen, erster Absatz als Einstieg
- Fuer Unternehmen: Pakete als Karten mit Waben-Wasserzeichen, das beim
Hover einrueckt; Anfrage mit mitlaufender Ueberschrift neben dem Formular
- Kontakt: E-Mail gross auf Wald-Karte mit Wabenraster, Abholung daneben
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Artikel: Lesefortschritt als feine Honiglinie am oberen Rand, Initiale
in Serifenschrift und Honig, groesserer Titel, angenehme Zeilenlaenge
- Wissen-Uebersicht und "Weitere Artikel" als ruhige Liste mit grossen
Serifen-Titeln, Unterstrich und gezeichnetem Pfeil statt "→"
- Rechtliche Seiten mit klarerer Ueberschriften-Hierarchie
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Weg zum Mengenrabatt als sich fuellender Balken (gleiche Schwelle wie
bisher der Texthinweis), erreichter Rabatt als Bestaetigung mit Wabe
- Positionen gleiten herein und beim Entfernen hinaus, Entfernen-Knopf
als gezeichnetes Symbol statt Textzeichen
- Artikelnamen und Preise in Serifenschrift, Gesamtsumme tickt bei jeder
Aenderung, Zusammenfassung laeuft beim Scrollen mit
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Ernten: Jahrgang als grosse Serifenzahl mit auslaufender Linie, Karten
heben sich mit Honigschimmer, Pfeil und Unterstrich
- Ernte-Detail: grosser Titel, mitlaufendes Bild, Fuellstandsbalken je
Charge (verfuegbare von abgefuellten Glaesern)
- Chargen-Seite (QR-Code auf dem Glas): Echtheitssiegel stempelt sich ein,
Zeitleiste mit Waben und sich fuellender Linie, Werte in Serifenschrift
- Produktverweise heben sich, gezeichneter Pfeil statt Textzeichen
- Fix: aktive Seite in der Kopfzeile zeigte den Unterstrich in Textfarbe
statt in Honig (unlayered CSS ueberstimmte die Utility-Klasse)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Bisher fiel der fehlende Schluessel nur in der Kasse auf ("Zahlung gerade
nicht verfuegbar"), nie im Log des Pods.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue),
einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten;
weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck
(steht still, solange Warenkorb oder Menue offen sind)
- Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen
- Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl
- Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein
Haekchen in eine Wabe, Fehler werden als Alert angesagt
- 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.
- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Konto-Kacheln: Icon in einer Wabe, die sich beim Hover mit Honig fuellt,
einlaufender Pfeil, Serifen-Titel
- Bestell-Statusleiste: Waben statt Punkte, erledigte Abschnitte fliessen
nacheinander voll, aktueller Schritt pulsiert sanft
- Status-Badge mit pulsierendem Punkt bei laufenden Bestellungen
- Leere Zustaende mit gestrichelter Wabe und Serifen-Titel
- Formularfelder: einheitlicher Honig-Schein beim Fokus
- Groessere Abschnittstitel im Konto, Bestellsummen in Serifen-Preisschrift
- Nur CSS-Animationen (Komponenten rendern auch auf Admin-Serverseiten);
reduzierte Bewegung schaltet Puls und Fuellen ab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Hero: deutlich groessere Headline, langsam atmendes Bild, Wabenraster,
Honigtropfen als Scroll-Hinweis, Hauptaktion in Honig
- "Sechs Schritte" als Weg: eine Honiglinie fuellt sich beim Scrollen und
laesst die Waben der Schritte nacheinander aufleuchten (waagerecht ab lg,
senkrecht auf dem Handy; reduzierte Bewegung: sofort gefuellt)
- Philosophie mit Waben statt Nummern, Verantwortung als geteiltes Layout
mit mitlaufender Ueberschrift
- Bilder mit versetztem Honigrahmen, Wabenraster auf dunklen Flaechen,
grosse Anfuehrungszeichen bei den Kundenstimmen
- Knopf-Varianten "inverted"/"accent" fuer dunkle Flaechen ersetzen
!-Overrides, die den Fuell-Effekt ausgehebelt haetten
- Fix: Ueberschrift der Kundenstimmen war Waldgruen auf Waldgruen
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
Summe, gestalteter leerer Zustand
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.
- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
(FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
(Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
"Deaktivieren".
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit
die aktiven Listen nur zeigen, was noch zu tun ist:
- Bestellungen: versendet/abgeholt/storniert, keine offene Retoure,
seit einem Monat nicht bearbeitet
- Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet
- Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert)
Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle
5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind.
Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand
archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen
landen im Audit-Log.
Neue Felder archivedAt/archivedManually samt Index an orders,
return_requests und products (Migration 20260925010000_archive_fields).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.
- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Haelt die freigegebene Loesung fest: Versandstaffeln haengen kuenftig an der
Produktkategorie statt an einer einzigen globalen Mengenstaffel, weil ein
Honigglas deutlich mehr Versandkosten verursacht als eine Kerze. Bei
gemischten Warenkoerben gilt die teuerste Kategorie, nicht die Summe.
Ausserdem wird die Hoechstbestellmenge je Position im Adminportal pflegbar
(globaler Standardwert plus optionale Uebersteuerung je Produkt) statt als
widerspruechliche Konstanten im Stepper und im Checkout-Schema zu stehen.
Co-Authored-By: Claude Opus 5 <[email protected]>
Stornieren gab es bereits, Loeschen nicht — abgebrochene Checkouts und
Testbestellungen blieben dauerhaft in der Liste stehen.
Loeschbar ist bewusst nur, was nie ein Geschaeftsvorfall war: keine Zahlung
erfasst, keine Rechnung erzeugt, Status NEU oder STORNIERT. Eine bezahlte
Bestellung und ihre Rechnung sind Buchungsbelege (§ 147 AO, § 257 HGB) und
muessen jahrelang vorgelegt werden koennen; ein Loeschknopf darauf wuerde
nicht aufraeumen, sondern Unterlagen vernichten. Dafuer gibt es den Storno:
er erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.
Die Regel steht in lib/order-delete.ts als reine Logik ohne Datenbankzugriff
— dieselbe Trennung wie bei shipping-calc.ts. Server Action und Admin-Seite
teilen sie sich, statt die Bedingung zweimal zu formulieren und irgendwann
auseinanderzulaufen, und sie laesst sich ohne DATABASE_URL testen (13 neue
Faelle, u.a. dass bei Rechnung UND Zahlung die Rechnung als Grund gemeldet
wird).
Drei Dinge, die ein blosses delete() falsch gemacht haette:
- LAGERBESTAND: bei NEU liegt die Ware reserviert und wird zurueckgebucht,
bei STORNIERT ist das beim Stornieren bereits geschehen. Dort erneut
gutzuschreiben wuerde den Bestand erhoehen, ohne dass je ein Glas zurueckkam.
- ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
Stripe-Sitzung haben. Wird sie geloescht, ohne die Sitzung zu schliessen,
kann Minuten spaeter noch bezahlt werden — der Webhook findet dann keine
Bestellung mehr, und das Geld liegt bei Stripe, ohne dass im Shop etwas
davon zu sehen waere. Die Sitzung wird deshalb VOR dem Loeschen geschlossen;
gelingt das nicht sicher, wird gar nicht geloescht. Ist bei Stripe
zwischenzeitlich bezahlt worden, verweigert die Aktion das Loeschen.
- WETTLAUF: die Bedingungen stehen zusaetzlich in der WHERE-Klausel des
DELETE. Zwischen Pruefung und Loeschen koennte der Webhook eine Zahlung
eingetragen haben; die Pruefung sieht nur einen Schnappschuss, die
WHERE-Klausel wird atomar gegen den aktuellen Zeilenstand ausgewertet.
Der Audit-Log-Eintrag ist ausfuehrlicher als bei anderen Aktionen und traegt
Bestellnummer, Status, Betrag, E-Mail und Positionsanzahl — der Datensatz ist
danach weg, dieser Eintrag ist die einzige verbleibende Spur.
Die Oberflaeche zeigt den Knopf am Seitenende in einem abgesetzten Bereich,
damit er nicht neben dem Statusfeld liegt, mit Browser-Rueckfrage ueber
ConfirmForm. Ist die Bestellung nicht loeschbar, steht dort statt des Knopfes
der Grund und der Verweis auf den Storno. Die Bestellliste zeigt jetzt auch
Erfolgsmeldungen, nicht nur Fehler.
tsc: 0 Fehler. vitest: 372/372 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Regression aus dem vorigen Commit (Turnstile per Prop statt process.env):
sobald das CAPTCHA auf der Newsletter-Anmeldung tatsaechlich rendert, sprengt
es die Eingabezeile.
Das Formular war "flex flex-col sm:flex-row gap-3 max-w-lg" ohne flex-wrap,
und das Widget lag als dritter Eintrag in derselben Zeile. Ab 640 px teilten
sich damit Eingabefeld, Knopf und ein rund 300 px breites CAPTCHA eine Zeile
von 512 px. Auf wabenhain.com bei 1440 px nachgemessen: Eingabefeld 34x71,
Knopf 174x71, CAPTCHA 300x71.
Solange kein Sitekey ankam, fiel das nicht auf — das Widget erschien nie, die
Zeile hatte nur zwei Eintraege. Der Fehler lag also schon laenger im Markup
und wurde erst sichtbar, als das CAPTCHA wie vorgesehen erschien.
Jetzt bilden Eingabefeld und Knopf eine eigene Zeile, das CAPTCHA steht
darunter. Die Firmenanfrage ist nicht betroffen, sie stapelt ueber space-y-5.
Dazu zwei Einstellungen am Widget selbst, die von Anfang an gefehlt haben:
- language: Turnstile folgte der BROWSER-Sprache statt der Seitensprache. Auf
der deutschen Seite stand englisch "Verify you are human".
- theme: "light": Turnstile folgte dem Farbschema des Betriebssystems. Wer es
auf "dunkel" stehen hat, bekam einen schwarzen Kasten mitten in einen
Abschnitt, den diese Seite ausschliesslich in Creme und Waldgruen zeichnet.
Die Seite hat keinen Dunkelmodus, "auto" ist hier also immer falsch.
Beides betrifft alle Formulare, nicht nur den Newsletter.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Datenschutzerklaerung beschrieb Hosting, Bestellung, Konto, Newsletter,
Kontaktformular und Google-Rezensionen — drei Empfaenger fehlten, obwohl
Daten an sie gehen. Abgeglichen wurde gegen die Content-Security-Policy in
next.config.ts: sie listet abschliessend, wohin der Browser ueberhaupt
Verbindungen aufbauen darf, und ist damit ein belastbares Inventar.
Neu aufgenommen:
- Cloudflare Turnstile als eigener Abschnitt 8. Das Skript laedt auf jeder
Seite mit Formular aus dem Browser des Besuchers nach; IP-Adresse und
Geraetemerkmale gehen dabei an Cloudflare in den USA. Bisher stand davon
nirgends etwas — weder in der Datenschutzerklaerung noch unter "Externe
Inhalte" in der Cookie-Richtlinie, wo Stripe bereits korrekt beschrieben
ist. Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO, Drittlandtransfer ueber
EU-US Data Privacy Framework.
- DHL in Abschnitt 4: Name und Lieferanschrift, bei Packstationen zusaetzlich
die Standortnummer, und bei der Packstationssuche im Bestellvorgang der
eingegebene Ort.
- Microsoft Entra ID in Abschnitt 5. Die Anmeldung damit ist kundenseitig auf
/konto verlinkt, tauchte aber in keinem Rechtstext auf.
Die bisherigen Abschnitte 8 bis 10 ruecken auf 9 bis 11.
Ausserdem zwei Korrekturen am Bestand:
- Der Verweis auf die EU-Plattform zur Online-Streitbeilegung entfaellt in
Impressum UND AGB. Die ODR-Verordnung ist aufgehoben, die Plattform
eingestellt; ein Link auf einen abgeschalteten Dienst ist schlechter als
keiner. Die Erklaerung nach § 36 Abs. 1 Nr. 1 VSBG bleibt und ist jetzt
ausdruecklich als solche benannt.
- Die Cookie-Richtlinie zitierte das TTDSG. Das Gesetz heisst seit Mai 2024
TDDDG.
Unveraendert bleibt der Cookie-Banner: er bietet eine Auswahl, die derzeit
nichts schaltet, weil keine einwilligungspflichtigen Cookies gesetzt werden —
das ist Absicht, damit spaeter hinzukommende Cookies den Mechanismus schon
vorfinden.
Alle Texte auch auf Englisch. Kein Ersatz fuer eine anwaltliche Pruefung.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Ortszeile ueber der Ueberschrift stand in honey-light auf einem Foto mit
25 % Grundabdunklung plus einem to-tr-Verlauf, der die Textspalte bei rund
44 % Gesamtdeckung liegen liess. Gegen ein helles Motiv (Holz, Himmel)
gerechnet ergab das:
Ortszeile 1,34:1 (WCAG AA verlangt 4,5:1 bei 12 px)
Ueberschrift 2,73:1 (verlangt 3:1 bei grosser Schrift)
Fussnote 1,90:1
Lighthouse meldet davon nichts und gibt der Seite weiterhin 100 fuer
Accessibility — Kontrast von Text ueber Bildern laesst sich nicht statisch
pruefen, weil der Hintergrund vom hochgeladenen Motiv abhaengt. Genau deshalb
muss die Abdunklung den schlechtesten Fall abdecken und nicht das Bild, das
gerade hinterlegt ist.
Drei Aenderungen, alle nachgerechnet:
- Verlauf ab md waagerecht (85 % -> 70 % -> 0) statt diagonal. Die Textspalte
endet bei etwa 45 % Breite und liegt damit ganz im dunklen Teil, waehrend
die rechte Bildhaelfte unangetastet bleibt.
- Auf schmalen Schirmen senkrecht, weil der Text dort ueber die volle Breite
laeuft und rechts sonst in den durchsichtigen Teil geriete.
- Neuer Farbton honey-pale (#e4cfa4) fuer die Ortszeile; honey-light haelt
4,5:1 selbst bei 89 % Abdunklung nicht (4,14:1). Fussnote von cream/60 auf
cream/80.
Ergebnis in der Textspalte: Ortszeile 4,81:1 bis 6,97:1, Ueberschrift 6,41:1
bis 9,28:1, Fussnote 4,80:1 bis 6,59:1 — AA auf allen Breiten.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Beide Formulare lasen NEXT_PUBLIC_TURNSTILE_SITE_KEY direkt aus process.env,
und zwar im Modulrumpf einer Client Component. NEXT_PUBLIC_-Variablen werden
dort zur BUILD-Zeit ins Bundle eingebacken. Gesetzt wird der Wert hier aber
erst zur Laufzeit (k8s/01-configmap.yaml) — im Docker-Build existiert er nicht
und wird auch nicht als Build-Arg hereingereicht.
Der Wert war damit dauerhaft undefined, das Widget erschien nie. Solange
TURNSTILE_SECRET_KEY ebenfalls leer ist, faellt das nicht auf:
verifyTurnstileToken gibt ohne Secret bewusst true zurueck. Sobald das Secret
aber gesetzt wird — also genau beim Scharfschalten des Schutzes —, verlangt
der Server einen Token, den der Browser nirgends erzeugen kann. Newsletter-
Anmeldung und Firmenanfrage haetten dann jede Eingabe abgewiesen, ohne dass
irgendwo ein Fehler sichtbar wird.
Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im Server
Component und reichen ihn als Prop weiter — dort wirkt die Laufzeit-Variable.
Diese beiden folgen jetzt demselben Muster.
Bewusst so und nicht ueber ein Docker-Build-Arg geloest: ein Build-Arg wuerde
den Schluessel ins Image einbacken, womit ein Schluesselwechsel einen Neubau
braeuchte und die ConfigMap fuer diese beiden Stellen wirkungslos bliebe. Ein
einziger Weg fuer alle Formulare ist weniger fehleranfaellig als zwei.
Hinweis fuer den Betrieb: der Schutz ist damit noch nicht aktiv. Dafuer
muessen NEXT_PUBLIC_TURNSTILE_SITE_KEY (ConfigMap) UND TURNSTILE_SECRET_KEY
(Secret) gesetzt sein — beide oder keiner.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bisher sagte kein Text des Shops, wer die Kosten einer Ruecksendung traegt.
Nach § 357 Abs. 6 BGB traegt sie dann der VERKAEUFER: die Belehrung des
Kaeufers ist die Voraussetzung dafuer, sie ihm auferlegen zu duerfen. Ohne
sie war jede Retoure auf eigene Rechnung.
Die Regelung steht bewusst an zwei Stellen, nicht an einer:
- Widerrufsbelehrung: nur der amtliche Mustersatz "Du traegst die
unmittelbaren Kosten der Ruecksendung der Waren." (Anlage 1 zu Art. 246a § 1
Abs. 2 EGBGB), unveraendert uebernommen. Eine Abweichung vom Muster kostet
dessen Gesetzlichkeitsfiktion — die Belehrung waere dann angreifbar.
- Versand und Zahlung, neuer Abschnitt "Rücksendung": derselbe Grundsatz in
Alltagssprache plus die Ausnahme fuer mangelhafte Ware. Geht ein Mangel auf
die Produktion oder auf uns als Verkaeufer zurueck, tragen wir die Kosten
vollstaendig.
Die Ausnahme gehoert NICHT in die Widerrufsbelehrung: sie folgt aus der
Gewaehrleistung (§ 439 Abs. 2 BGB) und gilt unabhaengig davon, ob widerrufen
wird. Beides dort zu vermischen wuerde zwei verschiedene Rechte als eines
darstellen.
Im Product-Schema kommt damit returnFees hinzu, das bisher aus genau diesem
Grund fehlte — ReturnFeesCustomerResponsibility, der Wert fuer "Kaeufer traegt
die Ruecksendung". Er braucht keinen Betrag, was hier auch nicht ginge: die
Ruecksendung laeuft ueber den Kaeufer, nicht ueber unser Versandkonto. Der
guenstigere Sonderfall (Mangel) bleibt im Schema unabgebildet; schema.org
kennt dafuer kein Feld, und ihn generell zuzusagen waere falsch.
Beide Texte auch auf Englisch.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Hero, ScrollReveal und ProductCard starten mit opacity 0 und werden per
motion/react eingeblendet. Diese Startwerte stehen als inline-style bereits im
server-gerenderten HTML — auf wabenhain.com an 38 Elementen nachgezaehlt. Laeuft
kein JavaScript, blendet sie niemand wieder ein: der Besucher sieht das
Hero-Bild und darunter eine leere Seite. Kein Teilausfall, sondern der
komplette Inhalt.
Fuer Suchmaschinen war das unkritisch, der Text steht im HTML. Fuer Menschen
mit deaktiviertem JavaScript war es ein Totalausfall.
Die drei Bausteine tragen jetzt data-reveal, und ein noscript-Block im
Root-Layout hebt darueber die Startwerte auf. Das Attribut statt eines
Treffers auf [style*="opacity:0"] ist der Kern der Sache: Warenkorb-Schublade,
Menue und Cookie-Banner blenden ueber AnimatePresence ebenfalls mit opacity 0
ein, sollen ohne JavaScript aber verborgen BLEIBEN. Sie tragen das Attribut
nicht.
!important ist hier notwendig und nicht nachlaessig — einen inline-style
ueberschreibt sonst nichts.
Grenze der Loesung: noscript deckt "JavaScript deaktiviert" ab. Laedt das
Bundle dagegen und schlaegt erst spaeter fehl, greift es nicht; dafuer waere
ein Zeitgeber noetig, der die Animation im Normalfall stoeren wuerde.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Beschreibung der Produktseite setzt Name, Geschmacksnoten und Fuellmenge
zusammen und rechnet dabei fest damit, dass Noten gepflegt sind. Sind sie es
nicht — ein freiwilliges Admin-Feld —, blieb zwischen Gedankenstrich und
Fuellmenge ein einzelner Punkt ohne Inhalt stehen.
Auf wabenhain.com an "Waldhonig" beobachtet: description und og:description
lauteten "Waldhonig — . 500 g aus eigener Imkerei in Altenmünster." Das ist
genau der Text, den Google als Snippet anzeigt und den jede geteilte Vorschau
uebernimmt.
Derselbe Fall liess unter der Ueberschrift einen leeren Absatz stehen, der
nur Abstand zwischen Produktnamen und Bewertung erzeugte.
Beides faellt jetzt weg, wenn keine Noten gepflegt sind; mit Noten bleibt die
Ausgabe unveraendert.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
next/image lieferte bisher nur WebP (die Next.js-Vorgabe). Am Hero-Bild der
Startseite (wabenhain.com, 1920x1280) nachgemessen, mit genau den
Einstellungen, die Next.js selbst verwendet — WebP q75 gegen AVIF q47
(image-optimizer.js skaliert die Qualitaet mit 50/80) bei effort 3:
Breite WebP AVIF
640 21,2 kB 12,0 kB -43 %
828 30,2 kB 17,4 kB -42 %
1080 44,4 kB 26,5 kB -40 %
1920 105,0 kB 65,7 kB -37 %
Auf einer Shop-Uebersicht mit einem Dutzend Kacheln ist das der groesste
einzelne Posten der Seite. Browser ohne AVIF-Unterstuetzung bekommen
unveraendert WebP, ganz alte das Original; die Auswahl trifft Next.js anhand
des Accept-Headers.
Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in derselben
Messung durchgehend gleichauf oder schneller als WebP (1920 px: 60 ms gegen
122 ms). minimumCacheTTL steigt trotzdem von den voreingestellten vier Wochen
auf ein Jahr — nicht wegen der Kodierdauer, sondern weil hochgeladene Bilder
unveraenderlich sind: lib/uploads.ts legt jede Datei unter einem eigenen
Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL. Ein
kurzes TTL wirft hier nur brauchbare Ergebnisse weg.
Dazu zwei ausdrueckliche Festschreibungen, die das bisherige Verhalten
dokumentieren statt es zu aendern:
- qualities: [75] entspricht der Vorgabe von Next.js 16, die andere Werte
bereits ablehnt (gegen die laufende Seite geprueft: ?q=42 antwortet mit
400). Ausgeschrieben, damit eine spaetere Erweiterung der Liste eine
bewusste Entscheidung ist und nicht unbemerkt Kodier-Last je Wert oeffnet.
- dangerouslyAllowSVG: false entspricht ebenfalls der Vorgabe. Ausgeschrieben,
weil ein SVG ein Dokument ist, das Skripte enthalten kann, und hier
same-origin ausgeliefert wuerde.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Das Product-Schema der Produktseite enthielt Preis, Verfuegbarkeit und
Bewertung, aber keine der Angaben, die Google fuer ein vollstaendiges
Produkt-Suchergebnis erwartet. Praktische Folge: der Preis wurde nach einiger
Zeit als moeglicherweise veraltet eingestuft und ausgeblendet, und
Versandkosten schaetzte Google selbst — bei einem Shop, dessen Versandstaffel
ohnehin auf der Seite steht.
Neu im Offer-Block:
- shippingDetails mit der tatsaechlichen Staffel fuer ein Glas, also demselben
"ab"-Preis, der auf der Seite ausgewiesen ist. Das Liefergebiet kommt aus
der Admin-Einstellung (Deutschland oder EU), nicht aus einer festen Liste.
- hasMerchantReturnPolicy mit dem gesetzlichen Widerrufsrecht: vierzehn Tage
ab Erhalt, Rueckgabe per Versand. returnFees fehlt bewusst — wer die Kosten
der Ruecksendung traegt, sagt die Widerrufsbelehrung nicht, und eine
Annahme an dieser Stelle waere eine Zusage gegenueber Google, die der Shop
nirgends macht.
- priceValidUntil, itemCondition sowie seller als Verweis auf das
Store-Schema aus dem Root-Layout, statt Name und Anschrift ein zweites Mal
auszuschreiben.
- weight als Fuellmenge in Gramm — der Wert, ueber den Kaeufer
Lebensmittelangebote vergleichen.
Ausserdem ein eigener openGraph-Block. Ohne ihn erbte jede Produktseite den
Block aus dem Root-Layout unveraendert: ein geteilter Honig-Link zeigte in
WhatsApp, Facebook oder Slack Titel und Text der STARTSEITE, egal welches
Produkt verlinkt war. Das Feld "images" bleibt bewusst leer, damit die
gestaltete Vorschaukarte aus opengraph-image.tsx erhalten bleibt — ein
Eintrag dort haette sie durch das rohe Produktfoto ersetzt.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
Im Anmeldeformular standen CAPTCHA, Absenden-Knopf und der
"Passwort vergessen"-Link in EINER Flex-Zeile ohne Umbruch:
<div className="flex items-center justify-between gap-4">
Das Turnstile-Widget ist ein iframe mit FESTER Breite von rund 300 px und
schrumpft nicht mit. Die Zeile steht zudem in einer der beiden Spalten
der Kontoseite, hat also nur die halbe Breite zur Verfuegung. Der Platz
reichte daher nie — die Elemente schoben sich uebereinander, und zwar
nicht nur auf schmalen Bildschirmen.
Jetzt steht das Widget auf einer eigenen Zeile, Knopf und Link darunter
in einer Zeile mit "flex-wrap". Das Registrierungsformular daneben hat es
schon immer so gemacht; die beiden sind damit endlich gleich aufgebaut.
Damit das an keiner Stelle wiederkommen kann, traegt das Widget selbst
jetzt "w-fit max-w-full overflow-x-auto". Das ist kein Schoenheitsmittel,
sondern eine Zusicherung: der iframe bleibt in jedem Fall innerhalb
seines Kastens. Ist der zu schmal, laesst sich der Inhalt seitlich
schieben, statt das Layout zu zerlegen — unabhaengig davon, wie eine
kuenftige Seite ihn einbettet.
Die uebrigen sieben Einbindungen (Registrierung, Passwort vergessen,
Passwort zuruecksetzen, Zwei-Faktor-Code, Produktseite, Firmenanfrage,
Newsletter, Checkout) habe ich geprueft: dort steht das Widget bereits
auf einer eigenen Zeile.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Einstellung lag unter "Firmendaten" — zwischen Firmenname, Slogan und
Anschrift. Dort sucht sie niemand: eine Versandregel ist keine
Stammdatenangabe. Genau das ist beim ersten Suchen auch passiert.
Jetzt ein eigener Abschnitt "Versand" (englisch "Shipping") unter
Admin → Inhalte → Texte, direkt hinter den Firmendaten. Der
Feldtyp-Vertrag der Registry bekommt dafuer die Gruppe "shipping".
Rein organisatorisch — Schluessel, Wert und Verhalten bleiben gleich, ein
bereits gepflegter Wert wandert einfach mit.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Vorgabe war: nur dorthin liefern, wo keine Zollabwicklung noetig ist. Die
EU-Einstellung erfuellt das auf Laenderebene — die Union ist eine
Zollunion, eine Sendung von Deutschland nach Frankreich braucht keine
Zollanmeldung. Der Laendercode allein genuegt aber nicht.
Einige Gebiete gehoeren zu einem EU-Mitgliedstaat, liegen aber ausserhalb
des Zoll- und/oder Steuergebiets der Union. Dorthin ist jede Sendung eine
Ausfuhr mit Zollinhaltserklaerung, Handelsrechnung und moeglichen
Einfuhrabgaben beim Empfaenger. Zwei davon liegen in DEUTSCHLAND:
Helgoland (27498) und Büsingen am Hochrhein (78266)
Sie waren also selbst bei der Einstellung "Nur Deutschland" betroffen.
Dazu kommen Kanarische Inseln, Ceuta, Melilla, die franzoesischen
Ueberseegebiete, Livigno, Campione d'Italia, Åland und der Berg Athos.
Erkennbar sind sie nur an der Postleitzahl. findCustomsExcludedArea
prueft sie je Land, denn dieselbe Ziffernfolge bedeutet anderswo etwas
anderes: "22" ist in Finnland Åland, in Italien die Provinz Como. Die
Checkout-Route weist solche Anschriften mit einer Meldung ab, die das
Gebiet benennt und auf die Abholung verweist.
Azoren und Madeira sind bewusst NICHT ausgenommen: sie gehoeren voll zum
Zoll- und Steuergebiet, dorthin gilt der normale Binnenversand. Ein Test
haelt das fest, damit sie nicht versehentlich mitgesperrt werden.
Die Hinweistexte auf "Versand und Zahlung" nennen die Ausnahmen jetzt
ausdruecklich — in beiden Einstellungen und beiden Sprachen.
8 neue Tests in shipping-region.test.ts, 1 in der Checkout-Route
("weist Helgoland ab, obwohl es in Deutschland liegt").
tsc: 0 Fehler. vitest: 359/359 gruen (zuvor 350). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:
1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
einzigen Eintrag waere eine Attrappe.
2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
geht (Adresse im Gebiet oder Abholung).
3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.
Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.
"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.
Technisch dazugekommen:
- lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
shipping-calc.ts), 11 Tests.
- content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
saehe, dass die Umstellung nicht gegriffen hat.
- Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
- 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
Drittland trotzdem abgewiesen).
tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Place-ID steht nicht in der Google Cloud Console — dort gibt es nur
den API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss
ueber die Places API nachgeschlagen werden.
pnpm places:find "Wabenhain Altenmünster"
gibt Name, Anschrift und GOOGLE_PLACE_ID je Treffer aus.
Das Kommando ist zugleich die schnellste Probe auf die Einrichtung:
kommt eine ID heraus, sind "Places API (New)", Abrechnung und Schluessel
richtig gesetzt und die Startseite kann die Rezensionen abrufen. Kommt
eine Ablehnung, nennt sie die drei tatsaechlich moeglichen Ursachen
(API nicht aktiviert, keine Abrechnung, OAuth-Client statt
API-Schluessel bzw. Referrer-Beschraenkung) — statt wie Google pauschal
auf den Schluessel zu zeigen.
Die Feldmaske fordert nur id, displayName und formattedAddress an: die
API rechnet nach angeforderten Feldern ab.
tsc: 0 Fehler. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.
Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.
Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.
Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.
Zwei Verhaltensaenderungen, beide unvermeidbar:
- Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
Rezensionen in Googles eigener Reihenfolge.
- Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
Zuordnung zur Quelle.
README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.
Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.
tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.
1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft
Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.
Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.
2. Zu enger Verbindungspool, zu knappe Transaktionsfristen
Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.
Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.
Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.
3. Drei Regressionstests
Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.
tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.
Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:
const cacheKey = id || src;
// Script has already loaded
if (cacheKey && LoadCache.has(cacheKey)) {
return;
}
Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:
- die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
zwei Widgets — eines davon war immer tot;
- jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
statt ueber F5.
Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".
Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.
Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:
- "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
Containern sucht;
- turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
- ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
abschicken.
Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.
Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.
tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
getCompanyInfo() wurde bei JEDEM Seitenaufruf mehrfach ausgefuehrt: das
Root-Layout braucht es zweimal (einmal in generateMetadata, einmal fuer
das LocalBusiness-Schema im Rumpf), dazu kommt fast jede Seite mit einem
eigenen Aufruf. Jeder davon war eine eigene Datenbankabfrage ueber
dieselben zehn Schluessel — dreimal dasselbe Ergebnis pro gerenderter
Seite, auf jeder Seite des Shops.
Beide Funktionen sind jetzt in React-cache() gehuellt. Die
Deduplizierung gilt je Request und ist KEIN serverweiter
Zwischenspeicher: eine Aenderung im Admin-Bereich wirkt unveraendert
sofort, es gibt nichts zu invalidieren. Beide Funktionen nehmen keine
Argumente, damit greift die Deduplizierung zuverlaessig — cache()
unterscheidet Aufrufe anhand ihrer Argumente.
Geprueft: "next build" laeuft durch, tsc 0 Fehler, vitest 328/328 gruen,
eslint 0 Warnungen.
Nicht geaendert, aber geprueft: die Admin-Listen ohne "take" (Chargen,
Ernten, Gutscheine, Inhalte, Produkte, Versandstaffeln) betreffen
ausnahmslos kleine, langsam wachsende Tabellen — eine Begrenzung waere
dort Vorratsarbeit. Die Listen auf den wachsenden Tabellen (Bestellungen,
Bewertungen, Audit-Log, Retouren, Anfragen, Benutzer) sind bereits
begrenzt.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei
Admin-Konten.
requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu,
wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter
jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein
zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine
vollstaendige Session an und lieferte damit ein Flag, das "geprueft"
behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die
Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des
Entra-Mandanten, die dieser Code nicht nachpruefen kann.
Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben
TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand:
twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer
KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen.
PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard.
Die Startseite des Admin-Bereichs lud bei jedem Aufruf
1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus
fuenf Zeilen "Top-Produkte" zu rechnen, und
2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl
("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma
koenne kein HAVING. Es kann.
Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem
Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je
Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht
mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die
Primaerschluessel der tatsaechlich verkauften Produkte.
PERFORMANCE — fehlende Indizes auf order_items.
PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als
MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten
dadurch zwei:
- productId: hasCustomerPurchased (lib/reviews.ts) prueft den
"verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine
angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan.
- batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict
gegen dieselbe Tabelle.
Migration 20260919010000_order_item_fk_indexes.
tsc: 0 Fehler. vitest: 328/328 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".
Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:
signTwoFactorChallenge("customer_123")
-> verifySessionToken(...) ergab
{ customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }
Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.
Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.
verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.
Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.
Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.
tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ohne im Admin-Bereich gepflegte Nummer griff bisher der Rueckfallwert aus
lib/site-config.ts — und der war eine Platzhalternummer
("+49 (0) 000 000000"). Sie stand damit tatsaechlich auf der Kontaktseite
und im Impressum. Eine Nummer, unter der niemand erreichbar ist, ist
schlimmer als gar keine Angabe.
Der Rueckfallwert ist jetzt leer, und beide Stellen lassen die Zeile bei
leerem Feld weg:
- kontakt/page.tsx rendert das Absatz-Element gar nicht erst.
- impressum/page.tsx nimmt die Telefonzeile aus dem Kontakt-Abschnitt.
§ 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.
Der DHL-Versand war bereits darauf vorbereitet: buildDhlContact setzt
"phone: address.phone || undefined" und laesst das Feld im Aufruf
entsprechend weg (lib/dhl.ts).
Die Go-Live-Pruefung warnt weiterhin bei einer EINGETRAGENEN
Platzhalternummer, aber nicht mehr bei einem leeren Feld — leer ist ab
sofort eine zulaessige Entscheidung, und der Hinweistext sagt das.
tsc: 0 Fehler. vitest: 316/316 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten
bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene
Haken fuer Werte, die gar nicht gesetzt waren.
Tests fuer api/checkout/route.ts (10 Faelle)
Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes
Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die
beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung
aufgeloest und OHNE orderNumber geantwortet; laesst sich die
Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per
Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird.
Tests fuer api/checkout/webhook/route.ts (12 Faelle)
Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt
Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und
Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine
stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung,
fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung.
Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt.
Go-Live-Pruefung: falsch-positive Haken beseitigt
Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert
einfach — eine leere Zeichenkette enthaelt nun einmal kein
"example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist
die echte Domain" als bestanden, direkt neben dem Blocker
"NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt
sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste
Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber
genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen
Folgeregeln bei fehlendem Wert entfallen.
Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung
prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der
ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal
erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf
im Pod.
tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.
1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
"NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
diese Bestellung je zu bezahlen.
Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
Altbestaende trotzdem ab (Filter entfernt).
2. Kaufabbrueche standen als Bestellungen im Kundenkonto
getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
Bestellnummer und Summe in der Liste und sah aus wie ein zustande
gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
"Zahlung ausstehend" statt "Neu".
3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
Payment.status auf REFUNDED.
4. Admin-Storno schloss die offene Stripe-Sitzung nicht
Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
nicht mehr offene Bestellung laut, statt sie zu verschweigen.
5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
charge.refunded und charge.dispute.created werden jetzt verarbeitet:
eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
Vermerk an der Bestellung. preflight prueft beide Abonnements mit.
Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.
tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT"
stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein
echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche
nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen
Vorgänge.
Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein
Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch
nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen
sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf.
Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed.
Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert
(SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout
abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als
"STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim
bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man
selbst abgebrochen hat, verwirrt mehr als sie hilft.
releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter.
Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es
entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts.
Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt,
fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter
keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen,
sonst wäre der Bestand danach nicht mehr zurückzubuchen.
Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt
als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen
auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen,
indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer
Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter
den Händen wegzunehmen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.
Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.
Drei Schichten dagegen, jede für sich wirksam:
1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
30 Minuten" abgewiesen werden.
2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
also die offene Zahlung einer fremden Person abbrechen. Der Token wird
nach Gebrauch aus der Adresszeile entfernt.
{CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
success_url und return_url dokumentiert, nicht für cancel_url — darauf
baut hier nichts.
3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
aus wie jede andere offene.
Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.
Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.
Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.
Weiter in diesem Zug:
- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.
- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
dann meldet die Prüfung "ungeprüft" statt "in Ordnung".
- Packstation-Bestellungen waren vollständig kaputt: die Muster für
Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
Richtige sagte. Drei Tests halten das jetzt fest.
- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
auf.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.
Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.
Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.
Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Anfrage an die Places API setzte "fields=review". Richtig ist "reviews"
(Plural). Google antwortet auf den falschen Wert mit INVALID_REQUEST, die
Funktion gab daraufhin null zurueck, und die Startseite fiel auf die
statischen Beispiel-Stimmen zurueck.
Nach aussen sah das voellig normal aus — eine Bewertungen-Sektion mit
Zitaten und Sternen war ja da. Nur waren es nie die echten. Genau deshalb
blieb der Tippfehler unbemerkt.
Damit das nicht wieder passieren kann, meldet die Anbindung jetzt, WARUM sie
nichts liefert. Vorher fuehrten alle Fehlerfaelle stumm zum selben Bild:
[google-reviews] Places API meldet "REQUEST_DENIED": ...
REQUEST_DENIED (Schluessel ungueltig oder Places API im Projekt nicht
aktiviert), NOT_FOUND (Place-ID stimmt nicht) und OVER_QUERY_LIMIT
(Kontingent erschoepft oder keine Abrechnung hinterlegt) sind damit von
aussen unterscheidbar.
Weitere Korrekturen:
- Zeitlimit von fuenf Sekunden. Ohne das haengt die Startseite an einer
nicht antwortenden fremden API — dieselbe Absicherung wie beim
Passwort-Leak-Abgleich.
- Rezensionen verlinken auf ihre Quelle bei Google. Die Nutzungsbedingungen
der Places API verlangen die Zuordnung; bisher stand ein fremdes Zitat
unbelegt auf der Seite. author_url und profile_photo_url wurden aus der
Antwort schlicht verworfen.
- Rezensionen ohne Text werden uebersprungen. Reine Sternebewertungen sind
in der Antwort enthalten und waeren als Zitat eine leere Sprechblase; "text"
ist laut Google optional, war hier aber als Pflichtfeld typisiert.
README: Einrichtung, Bedeutung der Statuscodes und der Hinweis, dass Google
den genutzten place/details-Endpunkt als Legacy fuehrt — bleibt
REQUEST_DENIED trotz gueltigem Schluessel bestehen, ist die Umstellung auf
die Places API (New) faellig.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem
CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt
offen, die am meisten hergeben:
- Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten
bzw. des sechsstelligen Codes.
- Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein
Weg herauszufinden, welche Adressen registriert sind.
- Checkout — automatisierte Bestellungen blockieren Lagerbestand und
eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests
brauchbar macht.
- Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse
entgegen und loest spaeter Versand an sie aus.
Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte
Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der
Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld
— er schickt kein Formular ab.
Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine
Seite den Feldnamen anders geschrieben, waere das Token leer angekommen —
und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere
der Schutz dort lautlos ausgefallen.
Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein
BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung
nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen
Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein,
waehrend jedes Formular offensteht.
Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein
GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein
Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt
rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse.
Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben
ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches
Raetsel kostete nur Bedienbarkeit.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
app/uploads/[...path] liefert JEDE Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus — das ist fuer Produktfotos gewollt. Dass Rechnungen nicht
darunter liegen, war bisher aber allein eine Frage der richtigen
Konfiguration: ein INVOICE_DIR="/app/uploads/rechnungen" haette genuegt,
damit fremde Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar
sind, der den Pfad erraet.
Das waere im Betrieb nicht aufgefallen, denn funktioniert haette weiterhin
alles: Ablegen, Versenden und Abrufen ueber /rechnung/<nummer>/pdf haetten
sich genauso verhalten wie vorher. Nur haette daneben eine zweite,
ungeschuetzte Tuer zu denselben Dateien gestanden.
Drei Sicherungen statt einer Anmerkung in der Dokumentation:
- lib/invoice-storage.ts wirft beim Laden des Moduls, wenn INVOICE_DIR
innerhalb von UPLOAD_DIR liegt. Lieber startet die Anwendung nicht, als
dass sie Belege oeffentlich ausliefert.
- "pnpm preflight" meldet denselben Fall als Blocker, also bereits vor dem
Deploy statt erst beim Start.
- Ein Test haelt die Trennung fest, damit sie nicht beim naechsten Umbau
unbemerkt verloren geht.
Der uebrige Sicherheitsdurchgang ueber die Aenderungen dieser Sitzung ergab
keine ausnutzbaren Befunde: der Admin-Bereich ist dreifach abgesichert
(Proxy, Layout, jede Server Action einzeln), jeweils autoritativ gegen die
Datenbank und mit erzwungener Zwei-Faktor-Authentifizierung; das returnTo
des Sync-Knopfes wird gegen eine feste Liste geprueft; Pfade aus der
Datenbank werden weiterhin gegen ihr Wurzelverzeichnis validiert.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.
syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.
Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.
Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.
Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht.
Neue Server Action deleteProductAction samt Knopf in der Produktliste,
im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog,
Audit-Log-Eintrag, Pruefung vor dem Loeschen.
Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit
onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen
und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen
Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits
gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht
(§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht",
sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt
aus dem Shop und der Beleg bleibt.
Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder
bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste
Datei ist harmloser als ein totes Bild an anderer Stelle.
Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der
Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas
darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft"
bzw. "in Verwendung").
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"Video-Upload: unerwarteter Fehler. Error: aborted { code: ECONNRESET }"
verdeckte die eigentliche Aussage: nicht die Verarbeitung ist gescheitert,
die Datei ist gar nicht erst vollstaendig angekommen. Der Fall landete in
der allgemeinen Auffanglinie und sah damit aus wie ein Programmfehler.
Ein Abbruch der Verbindung wird jetzt erkannt und mit Mengenangabe geloggt:
[upload-video] Verbindung abgebrochen nach 41943040 von angekuendigten
78643200 Bytes.
Genau diese Zahlen trennen die beiden moeglichen Ursachen. Bricht es immer
bei derselben BYTEZAHL ab, liegt eine Groessengrenze auf dem Weg. Bricht es
nach derselben ZEIT ab, ist es eine Zeitgrenze — und davon liegen mehrere
hintereinander, allen voran Node selbst: dessen requestTimeout beendet einen
Request nach fuenf Minuten und laesst sich nicht konfigurieren. Das ist die
harte Obergrenze fuer die Dauer eines Uploads, unabhaengig davon, dass
MAX_UPLOAD_VIDEO_BYTES 100 MB erlaubt.
Statt eines 500ers antwortet die Route mit 408 und einer Meldung, die zum
Wiederholen und zum vorherigen Verkleinern raet. Die Antwort erreicht die
Kundschaft meist nicht mehr — die Verbindung ist ja weg —, ist aber richtig
fuer den Fall, dass nur eine Zwischenstation aufgegeben hat.
k8s/README.md bekommt dazu einen Abschnitt mit der Tabelle der Zeitgrenzen
und dem ffmpeg-Aufruf, mit dem sich ein Hero-Video vorab auf genau das Mass
bringen laesst, auf das der Server es ohnehin umrechnet. Was darueber
hinausgeht, wird nur uebertragen, um anschliessend weggerechnet zu werden.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Der Kommentar am fsGroup-Feld behauptete, Kubernetes ordne damit JEDES
gemountete Volume der Gruppe 1001 zu. Das stimmt nur fuer blockbasierten
Speicher.
Bei SMB/CIFS und NFS wird fsGroup ignoriert: diese Protokolle kennen keinen
per-Datei-Unix-Besitz, den der Knoten nachtraeglich aendern koennte. Ohne
uid/gid in den mountOptions erscheint dort alles als root:root, und der als
uid 1001 laufende Container scheitert bei jedem Upload mit EACCES — also
genau die Meldung "Die Datei konnte nicht gespeichert werden", ohne dass am
Deployment etwas falsch aussieht.
k8s/README.md bekommt dafuer einen eigenen Abschnitt: Diagnose im laufenden
Pod, die noetigen mountOptions (uid=1001, gid=1001 zusaetzlich zu
dir_mode/file_mode) und der Hinweis, dass Aenderungen daran erst beim
naechsten Einhaengen greifen, ein Rollout-Restart also dazugehoert.
Ebenfalls vermerkt: noperm umgeht die Rechtepruefung, statt den Besitz
richtigzustellen — es ersetzt uid/gid nicht.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz
haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein
Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine
Bienenwachskerze nicht.
Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt:
1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer
Ausnahmen, die sich nicht aus der Kategorie ergeben.
2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS.
3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert.
Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen
Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt.
Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0
bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen
eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem
leeren Feld eine 0 gemacht.
Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im
selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und
Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der
Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt
das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig
auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent
verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen
exakt den Rechnungsbetrag ergibt.
Der Satz wandert von der Rechnung an die BESTELLPOSITION
(OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis:
wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen
unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz
je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte
entstand in der vorigen Migration und wurde nie in Betrieb beschrieben.
VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der
sicherere ist — als Lebensmittel waeren 7 richtig, als
Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen
vermerkt.
Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand"
gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die
Saetze ohne globalen Zustand testen lassen, und greift auf die
Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine
Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.
DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.
Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.
Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.
Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.
Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.
Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem
Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei
Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden
koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein
spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten
oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt.
Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert:
invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein
abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der
relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere
Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar
macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war
ohnehin das falsche Konzept.
INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR:
app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne
Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad
erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen.
Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft
weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung.
Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn
keine da ist — also bei Rechnungen aus der Zeit davor.
Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer
optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben
unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung
trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere
die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und
ein Wiederholungslauf aenderte daran nichts.
Sicherung: der taegliche CronJob packt das Verzeichnis als
rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das
Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass
wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten
einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt
der Sicherungs-Pod lautlos in "Pending".
Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig,
aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine
Umsatzsteuer aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.
In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.
Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.
Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.
Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.
Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Product.slug ist eindeutig und zugleich die oeffentliche Adresse
(/honig/<slug>). Ohne eigene Angabe wird er aus dem Namen gebildet. Bei
jahrgangsweise gefuehrtem Honig kollidiert das zwangslaeufig: "Waldhonig"
ergibt in jedem Jahr denselben Slug "waldhonig".
Bisher schlug das als unbehandelte Prisma-Ausnahme (P2002) durch. Statt
einer Meldung erschien die Fehlerseite des Admin-Bereichs, aus der nicht
hervorgeht, dass lediglich die Adresse belegt ist — und das eingegebene
Formular war verloren.
Jetzt wird der Slug vorher geprueft und die Eingabe mit einem konkreten
Vorschlag abgelehnt ("waldhonig-2026"). Das Formular hat fuer genau diesen
Fall bereits ein eigenes Slug-Feld.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
01-configmap.yaml und 02-secret.example.yaml sind jetzt so geschrieben, dass
sie sich als Produktivkonfiguration uebernehmen lassen: in drei Abschnitte
gegliedert (ANPASSEN / Vorgaben / optional), jeder Wert mit der Angabe, was
ohne ihn nicht funktioniert. Anzupassen sind genau zwei Werte in der
ConfigMap und die mit "CHANGE-ME" markierten im Secret; alles Uebrige traegt
eine brauchbare Vorgabe oder darf leer bleiben.
Beide Dateien decken zusammen jede Umgebungsvariable ab, die die Anwendung
liest, und jeder Schluessel steht in genau einer der beiden. Bisher war das
Handarbeit und driftete: GOOGLE_PLACES_API_KEY lag in der unverschluesselten
ConfigMap, APICORE_API_URL unnoetig im Secret.
Neu ist deshalb src/lib/k8s-manifest-env.test.ts. Der Test liest die
process.env-Zugriffe unter src/ und die Schluessel der beiden Manifeste und
prueft vier Zusagen: keine gelesene Variable fehlt, kein Schluessel steht
doppelt (sonst entschiede allein die Reihenfolge der envFrom-Eintraege,
welcher Wert gewinnt), kein Schluessel ist tot, und die committete
Secret-Vorlage enthaelt keine ausgefuellten Werte.
Beim ersten Lauf hat der Test zwei Dinge gefunden. ADMIN_EMAIL und
ADMIN_PASSWORD liest nicht die App, sondern prisma/seed.ts — das ist
legitim und steht jetzt begruendet in einer Ausnahmeliste, zusammen mit
DB_PASSWORD fuer das Datenbank-StatefulSet. STRIPE_PUBLISHABLE_KEY dagegen
wird von keiner einzigen Code-Stelle gelesen: der Checkout laeuft
vollstaendig serverseitig ueber eine Stripe-Checkout-Session. Der Schluessel
ist aus Secret-Vorlage, .env.example, docker-compose.yml und README
entfernt, mit Hinweis, wofuer man ihn braeuchte.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Manifeste in k8s/ trugen noch Platzhalter, waehrend im Cluster laengst
die echten Werte stehen. Wer sie unveraendert anwendet, ueberschreibt damit
funktionierende Einstellungen.
- Domain: shop.example.com -> wabenhain.com, in 06-ingress.yaml (host und
tls.hosts) und in 01-configmap.yaml (NEXT_PUBLIC_SITE_URL). Beide Stellen
muessen uebereinstimmen; aus NEXT_PUBLIC_SITE_URL entstehen Sitemap,
Canonical-Tags und jeder Link in jeder Bestellbestaetigung.
- GOOGLE_PLACES_API_KEY von der ConfigMap ins Secret. Das ist ein
abrechenbarer Zugangsschluessel, und eine ConfigMap ist unverschluesselt
und in der Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige
GOOGLE_PLACE_ID ist oeffentlich und bleibt in der ConfigMap. Der Cluster
handhabt das bereits so.
- APICORE_API_URL vom Secret in die ConfigMap: eine Adresse ist kein
Geheimnis. Auch das entspricht dem Cluster.
- VIDEO_TRANSCODE_THREADS in die ConfigMap aufgenommen, damit die
Threadbegrenzung des Video-Transcodes (siehe vorheriger Commit) auch ueber
das Deployment einstellbar ist.
Gegenprobe: die Vereinigung aller Schluessel aus ConfigMap und Secret deckt
jetzt genau die Umgebungsvariablen ab, die der Code ueber process.env liest
- keine fehlt. Die beiden Ueberhaenge sind beabsichtigt: DB_PASSWORD liest
der Postgres-StatefulSet, STRIPE_PUBLISHABLE_KEY haelt die Konfiguration
vollstaendig fuer eine kuenftige clientseitige Stripe-Einbindung.
Der Hinweistext der Go-Live-Pruefung nannte shop.example.com als
ausgelieferte Vorgabe; das stimmt nicht mehr. Die Pruefung selbst ist
unveraendert.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ein Video ueber 50 MB im Admin hochzuladen, liess den Pod mitten im Upload
neu starten. Ursache war nicht die Dateigroesse, sondern dass ffmpeg sich
nach den Kernen des KNOTENS bemessen hat statt nach dem CPU-Anteil des Pods.
x264 waehlt seine Threadzahl standardmaessig als das 1,5-fache der vom
Betriebssystem gemeldeten Kerne. In einem Container sind das die Kerne des
Knotens: auf einer 16-Kern-Maschine rund 24 Encoder-Threads mit je eigenen
Bildpuffern, bei 4K-Material rund 12 MB je Bild. Das hatte zwei Folgen, die
von aussen beide gleich aussehen:
1. Der Speicherbedarf sprengte das Speicherlimit des Pods, der Kernel
beendete den Container (OOMKilled).
2. ffmpeg belegte alle Kerne, der Node-Prozess kam nicht mehr zum Zug und
beantwortete die Liveness-Probe nicht rechtzeitig. Kubelet wertete das
als haengenden Prozess und startete den Container neu.
Dekoder-, Filter- und Encoder-Threads sind jetzt gedeckelt (Vorgabe 2, ueber
VIDEO_TRANSCODE_THREADS auf bis zu 8 anhebbar). Der Verbrauch ist damit
konstant und unabhaengig davon, auf welchem Knoten der Pod laeuft. Der
"-threads"-Deckel steht bewusst auch VOR "-i": ffmpeg ordnet Optionen der
naechsten Datei zu, ein Deckel erst hinter "-i" wuerde nur den Encoder
begrenzen und das Dekodieren eines 4K-Videos unbegrenzt lassen.
Zusaetzlich "-nostdin" und stdio ["ignore","ignore","pipe"]: der
Kindprozess bekam von spawn() eine Standardeingabe, die niemand bedient,
und eine unbeachtete stdout-Pipe, die volllaufen kann. Beides konnte
ffmpeg bis zum Zeitlimit blockieren.
Die Argumentliste ist als buildFfmpegArgs() ausgelagert und getestet, damit
die Begrenzungen pruefbar sind, ohne ffmpeg wirklich zu starten.
Beide Probes bekommen ausserdem timeoutSeconds: 5. Ohne den Eintrag gilt
Kubernetes' Vorgabe von einer Sekunde — zu knapp, solange sich der
Node-Prozess die CPU mit einem laufenden Transcode teilt. k8s/README.md
beschreibt die Diagnose fuer den Fall, dass im Cluster noch eine aeltere,
von Hand gepflegte Fassung des Deployments laeuft.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den
Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von
Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte
und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore
die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung
fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas.
ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte
entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein
Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen
die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt
ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe.
Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben:
genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht),
zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde
diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore
ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur
gemeldet, damit der Restbestand abverkauft werden kann.
Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht
haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte
Eingaben landeten wortlos wieder auf dem Formular.
Der Login gegen ApiCore ist jetzt von den Abfragen getrennt
(openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung
auskommt statt mit dreien. Die Feld-Abbildung liegt neu in
lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server"
zu verschwinden.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ergaenzt den Abgleich aus c8ddcec um die Pruefung, die frueher anschlaegt.
checkUploadFiles() erkennt fehlende Dateien — also erst, NACHDEM sie weg
sind. checkUploadVolume() erkennt die Ursache schon vorher: liegt
UPLOAD_DIR im Container statt auf einem gemounteten Volume, sind alle
Bilder und Videos beim naechsten Redeploy verloren, auch wenn gerade noch
jede Datei da ist.
Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat
eine andere als sein uebergeordnetes Verzeichnis. Bewusst eine Heuristik
und damit nur ein Hinweis, keine Blockade — ein Bind-Mount innerhalb
desselben Dateisystems kann dieselbe Kennung tragen. Ausserhalb von
NODE_ENV=production immer "gemountet", weil UPLOAD_DIR lokal als
"./uploads" im Projektverzeichnis liegt und dort sonst ein Daueralarm
entstuende.
Die Funktion liegt in lib/uploads.ts statt in lib/upload-health.ts:
Letzteres importiert Prisma und laesst sich ohne DATABASE_URL nicht
testen — dieselbe Ueberlegung wie bei lib/form-fields.ts in ff365c3. Als
reine Funktion neben UPLOAD_DIR ist sie direkt pruefbar, und dort gehoert
sie fachlich ohnehin hin.
Vier Tests: kein Alarm ausserhalb der Produktion, ein gewoehnliches
Verzeichnis wird in Produktion als nicht dauerhaft erkannt, ein fehlendes
Verzeichnis erzeugt keinen Fehlalarm, und der gepruefte Pfad kommt mit
zurueck, damit die Warnung ihn benennen kann.
Das Admin-Dashboard zeigt beide Befunde getrennt: die Vorwarnung als
Hinweis, fehlende Dateien als Fehler.
Nicht enthalten und auch nicht moeglich: das Anwenden der Manifeste im
Cluster. Auf dieser Maschine gibt es weder kubectl noch eine kubeconfig;
die beiden kubectl-Befehle aus k8s/README.md muss jemand mit
Cluster-Zugriff einmalig ausfuehren.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.
Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.
Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.
lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.
Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.
Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen,
Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden
Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug
"[Name wie oben]" und nennt jetzt ebenfalls den Namen.
Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war
eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer
vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich
selbst widersprochen.
Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die
Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine
frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin
aenderbar, ohne dass dafuer ein Deploy noetig waere.
Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig —
der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche
Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt.
Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten
werden dir vor Abschluss der Bestellung transparent angezeigt." Das war
der Ersatztext (tierFallback), der nur erschien, solange keine
Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht
leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ
und Verwendung entfernt, nicht nur geleert.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.
Aufbau:
- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
(GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
Header, kein OAuth und kein Geschaeftskundenvertrag. Die
Packstation-Auswahl funktioniert damit auch, solange die
Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
(15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
Packstation nur bei Versand, nicht bei Abholung.
Zwei Entscheidungen, die Folgearbeit ersparen:
- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
auf eine Anschrift, die es nicht gibt.
Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.
Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.
Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und
Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in
der allein die Reihenfolge entschied, welches Bild wohin kommt.
Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom
Standort pflegen wollte, bekam es auf Position 0 — also in die linke
Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt,
war im Formular ausserdem nirgends erkennbar.
Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im
Ernten-Formular:
Bild 1 — linke Kachel auf der Chargenseite
Bild 2 — rechte Kachel auf der Chargenseite (Standort)
Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen
eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und
werden in dieser Reihenfolge uebermittelt.
Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt
und filtert leere Werte heraus, wodurch ein leerer erster Slot
verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler,
der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als ""
stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt
weiterhin [] statt ["", ""]).
Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im
Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit
"??" haette dieser leere String als gueltiger Wert gegolten und das
Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]).
strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden
aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben
unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen
Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als
reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt
pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts.
Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt
offen), mit Gegenprobe, dass strArray denselben Fall weiterhin
zusammenrutschen laesst.
Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die
Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Es gab bisher keinerlei Sicherung: Bestellungen, Rechnungen,
Kundenkonten und Chargendaten lagen in einem einzelnen
PersistentVolume ohne Netz. Ein Speicherfehler, ein versehentliches
"kubectl delete pvc" oder ein misslungenes Cluster-Update haetten alles
davon unwiederbringlich geloescht. Rechnungen unterliegen in
Deutschland zusaetzlich einer zehnjaehrigen Aufbewahrungspflicht
(§ 147 AO) — ein Datenverlust waere hier nicht nur aergerlich, sondern
ein Rechtsproblem.
Neu: 07-backup-cronjob.yaml — naechtlicher pg_dump auf ein eigenes
Volume, gzip-komprimiert, 30 Tage Aufbewahrung, concurrencyPolicy
Forbid. Fehlgeschlagene Laeufe werden laenger aufgehoben als
erfolgreiche: nur an ihnen laesst sich per "kubectl logs"
nachvollziehen, warum eine Sicherung ausblieb.
Zwei Details, an denen Sicherungsskripte typischerweise scheitern:
- "set -o pipefail". Ohne das liefert "pg_dump | gzip" den Exit-Code von
gzip, und gzip gelingt auch dann, wenn pg_dump vorher abgebrochen ist.
Der Job galte als erfolgreich, im Verzeichnis laege eine
abgeschnittene Datei, und das faellt erst im Ernstfall auf. Beide
Varianten mit einem absichtlich abbrechenden Dump geprueft: ohne
pipefail Exit 0 samt vollstaendig aussehender .sql.gz, mit pipefail
Exit 1 und keine .sql.gz.
- Schreiben unter ".partial" und erst danach umbenennen, damit ein
mitten im Lauf abgebrochener Job keine Datei hinterlaesst, die wie
eine vollstaendige Sicherung aussieht. Liegengebliebene .partial-Reste
raeumt derselbe Lauf mit auf.
k8s/README.md: neuer Abschnitt "Sicherung und Wiederherstellung" mit
Ansehen, manuellem Ausloesen, Herausziehen aus dem Cluster und dem
vollstaendigen Wiederherstellungsweg (App auf 0 skalieren, Dump
einspielen, wieder hochskalieren) — samt dem Hinweis, dass eine nie
getestete Sicherung keine Sicherung ist. Der Abschnitt "Bekannte
Grenzen" nannte "ohne Replikation/Backup-Automation"; das stimmt jetzt
nur noch zur Haelfte und ist entsprechend praezisiert: die Sicherung
liegt weiterhin im selben Cluster und ersetzt keine Replikation.
Alle Manifeste gegen einen YAML-Parser geprueft.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Pruefung der Rechtstexte war falsch gebaut: sie suchte
[PLATZHALTER]-Markierungen in src/i18n/dictionaries/de.ts und en.ts. Diese
Dateien sind aber nur der RUECKFALLWERT. Impressum, Datenschutz, AGB,
Widerrufsbelehrung und die Telefonnummer sind im Admin-Bereich unter
"Inhalte" pflegbar (lib/content-registry.ts, Tabelle SiteContent) — genau
wie die uebrigen Firmendaten.
Zwei Folgen davon:
- Falsch-positiv: Wer sein Impressum im Admin-Bereich ausfuellt — ohne
Deploy, so wie vorgesehen —, bekam trotzdem dauerhaft einen Blocker
gemeldet, der sich ueber die Oberflaeche gar nicht schliessen liess.
- Falsch-negativ: Wer einen Platzhaltertext erst im Admin eintraegt, kam
ungeprueft durch.
Das Skript ist deshalb von .mjs auf .ts gewechselt (ueber tsx, wie die
Seed-Skripte) und fragt jetzt getContentValue() ab — also genau den Text,
den Besucher auf /impressum tatsaechlich zu sehen bekommen, je
Sprachfassung einzeln. Neben dem Impressum werden nun auch Datenschutz,
AGB und Widerrufsbelehrung geprueft; der Befund benennt praezise, welcher
Text in welcher Sprache noch offen ist, statt pauschal eine Datei zu
nennen.
Ist die Datenbank vom ausfuehrenden Rechner aus nicht erreichbar, meldet
das Skript diese Punkte ausdruecklich als UNGEPRUEFT (Warnung) — es raet
weder "in Ordnung" noch "blockiert". Beide Wege gegen die lokale
Datenbank geprueft: mit Verbindung meldet es "Impressum (de), Impressum
(en)" als einzigen offenen Rechtstext, ohne Verbindung die Warnung samt
Ursache.
Kein Top-Level-await: das Projekt hat kein "type": "module", tsx
uebersetzt nach CommonJS.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist
an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy
und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-,
Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade —
die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass
irgendwo ein Fehler aufgetaucht waere.
Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei)
mit einem PersistentVolumeClaim, den das Deployment jetzt bindet.
storageClassName ist bewusst auskommentiert, damit das Manifest ohne
Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt.
Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard
"RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein
ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue
Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating
haengen, und das im k8s/README.md dokumentierte
"kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet
wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie
zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher
gehaltenen Rate-Limits teilen wuerden.
Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell:
claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der
mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein.
preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com"
geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie
stehen, zeigten Sitemap, Canonicals und jeder Link in jeder
Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen
HTTPS-/localhost-Pruefungen angeschlagen haetten.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ein Produkt, das im Admin-Bereich auf "Inaktiv" gesetzt wird, verschwand
zwar aus der Shop-Uebersicht und aus der Sitemap — seine Detailseite blieb
aber vollstaendig bestellbar. Erreichbar war sie weiterhin ueber
Direktlink, Lesezeichen, alte Newsletter, "Zuletzt angesehen" und den
Google-Index. getProductBySlug filtert isActive nicht, und die Seite
pruefte es nicht nach.
Auch der Checkout selbst pruefte es nicht: getProductsByIds filtert
bewusst nicht danach (es wird auch fuer bereits bestellte, inzwischen
deaktivierte Produkte genutzt, siehe lib/data.ts), und die Route verliess
sich allein auf den Abgleich der Anzahl gefundener Produkte. Die
Warenkorb-Oberflaeche zeigt ein deaktiviertes Produkt ueber
/api/shop/cart-stock zwar mit Bestand 0 an — aber das ist nur der Client.
Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert wurde,
kam ungeprueft durch: die Bestellung wurde angelegt und tatsaechlich
bezahlt, obwohl die Imkerei den Artikel gerade aus dem Verkauf genommen
hatte.
Behoben an beiden Stellen:
- Produktseite: isPurchasable = isActive && Bestand > 0 steuert jetzt
Bestellbutton, Sticky-Leiste und die schema.org-Verfuegbarkeit. Die
Seite bleibt bewusst erreichbar statt 404 zu liefern — Bestellhistorie
und Sendungsverfolgung verlinken hierher zum Bewerten, und wer eine
ausgelaufene Saisonernte gekauft hat, soll sie weiter bewerten koennen.
Stattdessen "robots: noindex", damit sie nicht ueber Google als
vermeintlich bestellbares Produkt gefunden wird.
- Die "Benachrichtige mich"-Anmeldung erscheint nur noch bei einem aktiven,
aber ausverkauften Produkt. Die Benachrichtigung haengt am Lagerbestand
(lib/stock-notifications.ts) — bei einem dauerhaft eingestellten Produkt
waere sie ein Versprechen, das nie eingeloest wird.
- Checkout-Route: lehnt Bestellungen mit deaktivierten Produkten
serverseitig ab, ueber dieselbe Fehlermeldung wie ein nicht mehr
vorhandenes Produkt.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
next.svg, vercel.svg, globe.svg, window.svg und file.svg stammen
unveraendert aus dem Projektgeruest und werden nirgends referenziert
(weder in src/ noch in e2e/). Sie wurden bisher in jedes Docker-Image
mitgebaut und waren unter der eigenen Domain oeffentlich abrufbar — ein
Next.js- und ein Vercel-Logo auf einer Imkerei-Seite.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die letzten offenen Punkte vor dem Live-Gang sind Inhaber-Aufgaben, keine
Code-Aufgaben — und genau die Sorte, die still durchrutscht, weil der Shop
mit ihnen trotzdem startet. Das Skript macht daraus eine Pruefliste, die
mit Exit-Code 1 abbricht, solange ein Blocker offen ist, und laesst sich
damit unveraendert als Schritt in die Deploy-Pipeline haengen.
Geprueft wird, was einen oeffentlichen Shop unbemerkt unbrauchbar macht:
- SESSION_SECRET/ADMIN_EMAIL/ADMIN_PASSWORD auf Laenge und darauf, dass
noch der Beispielwert aus .env.example darin steht (ein bekanntes
Signiergeheimnis macht jede Sitzung faelschbar).
- NEXT_PUBLIC_SITE_URL auf HTTPS und kein localhost — dieser Wert landet
in jeder Bestellbestaetigung, in sitemap.xml und in allen Canonicals.
- Stripe-Schluessel und Webhook-Secret vorhanden, und ob versehentlich noch
ein sk_test-Schluessel gesetzt ist. Ohne Webhook-Secret wird keine
Zahlung bestaetigt: Bestellungen bleiben auf "NEU", ohne Rechnung und
ohne Bestaetigungsmail.
- SMTP vollstaendig — fehlt es, landen E-Mails nur im Server-Log statt beim
Kunden (lib/mail.ts).
- Impressum ohne [PLATZHALTER]/[PLACEHOLDER]-Markierungen. Bewusst beide
Markierungen: die deutsche Fassung nutzt die eine, die englische die
andere — eine Pruefung auf nur eine liesse die jeweils andere
Sprachfassung mit Luecken online gehen. Ein unvollstaendiges Impressum
nach § 5 TMG ist in Deutschland abmahnfaehig.
Bewusst ohne Datenbankzugriff, damit die Pruefung auch von einer Maschine
ohne Zugang zur Produktionsdatenbank laeuft. Felder, die zusaetzlich im
Admin-Bereich unter "Inhalte" pflegbar sind (Telefonnummer, Anschrift),
sind deshalb Warnung statt Blocker.
README: neue Abschnitte "Vor dem Live-Gang" und "Health-Endpunkte"; in der
Tech-Stack-Tabelle ausserdem die Zeile "GSAP + ScrollTrigger" korrigiert —
GSAP wurde laengst durch motion/react ersetzt und steht weder in
package.json noch im Quellcode.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
"pnpm audit --prod" meldete 14 Advisories, davon 2 kritisch. Danach: keine.
Direkte Abhaengigkeiten angehoben:
- next 16.3.0 -> 16.3.5 (2x KRITISCH, gepatcht ab 16.3.3)
- eslint-config-next entsprechend
- nodemailer ^9.0.5 -> ^9.1.1 (HIGH + MODERATE: Empfaengerdomain liess sich
ueber falsch geparste RFC-5322-Kommentare
umbiegen — betrifft jede Transaktionsmail)
- sharp ^0.35.3 -> ^0.35.4 (HIGH)
- prisma/@prisma/client und beide Adapter ^7.9.1 -> ^7.10.0
Drei verbleibende Advisories liessen sich nur transitiv unter der
prisma-CLI aufloesen, deshalb als Overrides in pnpm-workspace.yaml
(dieselbe Stelle und dasselbe Muster wie die bereits vorhandenen
nanoid-/mariadb-Eintraege): mysql2 >=3.23.1, fast-uri >=3.1.6,
deepmerge-ts >=8.0.0.
deepmerge-ts ist ein Hauptversionssprung und haengt unter @prisma/config —
also genau im Pfad von "prisma migrate deploy", das der Container laut
Dockerfile-CMD bei JEDEM Start ausfuehrt. Deshalb nach dem Anheben
"prisma validate" und "prisma generate" (beide Provider) geprueft, dazu
Typecheck, Lint, 206 Tests und ein vollstaendiger Produktions-Build.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Readiness- und Liveness-Probe zeigten beide auf "/" — eine
force-dynamic-Seite samt Datenbankabfragen und vollem React-Rendering, alle
10 bzw. 30 Sekunden.
Der teure Teil ist dabei nicht das eigentliche Problem. Entscheidend ist,
dass damit auch die LIVENESS-Probe an der Datenbank haengt: faellt die DB
voruebergehend aus, wertet Kubernetes den Container als tot und startet ihn
neu — woraufhin der neue Container laut Dockerfile-CMD zuerst
"prisma migrate deploy" und "prisma db seed" gegen genau die gestoerte
Datenbank faehrt. Aus einer voruebergehenden Stoerung wird so eine
Neustart-Schleife.
Jetzt zwei Endpunkte mit klar getrennter Aufgabe:
- /api/health — nur "Prozess antwortet", ohne Datenbankzugriff.
Liveness-Probe.
- /api/health/ready — zusaetzlich "SELECT 1" gegen die Datenbank, sonst 503.
Readiness-Probe und Docker-Healthcheck.
Faellt die Readiness-Probe aus, nimmt Kubernetes den Pod nur aus dem
Load-Balancing und startet ihn nicht neu — genau das richtige Verhalten.
"SELECT 1" laeuft unveraendert auf PostgreSQL und MySQL/MariaDB und
beruehrt keine Tabelle, ist also unabhaengig vom Migrationsstand. Die
Fehlerdetails gehen nur ins Server-Log, nicht in die oeffentliche Antwort.
docker-compose.yml bekommt denselben Readiness-Endpunkt als Healthcheck,
mit start_period fuer den Migrations-/Seed-Lauf vor dem Serverstart.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten
NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes
"https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte
"https://wabenhain.com" im Produkt-JSON-LD).
Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging,
Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host
— zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle
strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft
eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain
weist die Indexierung aktiv von der echten Seite weg.
Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit
siteConfig.domain als Rueckfallebene, damit eine vergessene
Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen
Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in
E-Mail-Code eingebuergerten Namen und liefert denselben Wert.
robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte):
ohne das rendert Next.js die Datei beim Build statisch vor und backt den
Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist
die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem
Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build
geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt
alle dem zur Laufzeit gesetzten Wert.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist
ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy
vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten
im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher
verfolgte Timeout-Spur war damit falsch.
Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die
eigentliche Ursache: request.formData() liest den GESAMTEN Body in den
Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige
Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt
(arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis
100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das
Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess
(OOMKilled), und der Proxy meldet 502.
Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data.
Damit laesst sich request.body direkt auf die Platte durchreichen, und der
Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld
wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die
Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil
Content-Length nur eine Ankuendigung des Clients ist.
Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9
Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB —
also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief.
Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast
immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in
die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert —
502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes
Zeitlimit.
Der Zielcluster nutzt Traefik, nicht ingress-nginx. Saemtliche Annotationen
der Form nginx.ingress.kubernetes.io/* ignoriert Traefik ersatzlos und
still: sie erzeugen keinen Fehler, wirken aber auch nicht. Das betraf das
Upload-Limit (proxy-body-size) ebenso wie die Zeitlimits, und
ingressClassName zeigte auf "nginx".
Damit aendert sich auch die Diagnose des fehlgeschlagenen Video-Uploads.
Belegt aus Traefiks Quelltext (pkg/config/static/static_config.go,
SetDefaults) und der Referenzdokumentation:
- readTimeout des EntryPoints: Default 60s, beschrieben als "maximum
duration for reading the entire request, including the body". Er umfasst
also die gesamte Uebertragung des Uploads. Bei 20-100 MB ueber eine
normale Upload-Leitung ist das schnell ueberschritten — 80 MB bei
10 Mbit/s sind rund 64 Sekunden. Laeuft er ab, kappt Traefik die
Verbindung und antwortet mit einer eigenen HTML-Fehlerseite. Das ist die
wahrscheinlichste Quelle des "Unexpected token '<'" im Browser.
- responseHeaderTimeout: von SetDefaults NICHT gesetzt, bleibt 0, und
"If zero, no timeout exists". Die Dauer des ffmpeg-Transcodings ist bei
Traefik also NICHT begrenzt — anders als zuvor angenommen.
- Body-Groesse: Traefik erzwingt keine Obergrenze (begrenzt ist nur
maxHeaderBytes mit 1 MB fuer die Header). Ein Gegenstueck zu nginx'
proxy-body-size wird nicht gebraucht.
readTimeout gehoert zur statischen EntryPoint-Konfiguration und laesst sich
nicht per Ingress-Annotation setzen. Das Manifest dokumentiert deshalb
ausfuehrlich, welche Einstellung in der Traefik-Installation noetig ist
(Helm: ports.websecure.transport.respondingTimeouts.readTimeout: 600s).
Weiter angepasst: ingressClassName auf "traefik", HTTPS-Umleitung ueber eine
Traefik-Middleware statt der nginx-Annotation, und der Hinweis zu
X-Forwarded-For nennt jetzt Traefiks forwardedHeaders.trustedIPs — davon
haengt ab, ob lib/rate-limit.ts dem Header trauen darf. Die Kommentare in
lib/uploads.ts und lib/csrf.ts verwiesen auf die entfernten
nginx-Einstellungen und sind entsprechend berichtigt.
Ein Audit ueber alle bildfuehrenden Seiten zeigte /imkerei als einzige
Ausnahme: dort war kein Bild priorisiert. Das Bild steht neben der h1 im
ersten Viewport und ist auf breiten Bildschirmen das groesste Element der
Seite, wurde aber doppelt zurueckgehalten — durch das voreingestellte
Lazy-Loading und durch ScrollReveal, das bei opacity 0 startet und erst
nach der Hydration einblendet.
Es rendert jetzt direkt und mit priority, also loading="eager" und
fetchPriority="high". Die Textspalte daneben behaelt ihre Einblendung, die
kostet nichts. Damit verhaelt sich die Seite wie /herkunft und
/unternehmen, die ihr Header-Bild ohnehin schon ausserhalb von ScrollReveal
rendern.
Stand des Audits danach, im Produktions-Build gemessen: /, /herkunft,
/unternehmen und /imkerei liefern je genau ein Header-Bild mit
loading="eager" und fetchPriority="high", alle uebrigen Bilder lazy, und
auf keiner Seite geht noch ein hochgeladenes Bild am Bildoptimierer vorbei.
Die letzte Stelle, an der ein hochgeladenes Bild noch als rohes <img>
ausgeliefert wurde. Der Warenkorb lud damit fuer JEDE Position das
vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION Kantenlaenge, also
durchaus ein paar hundert Kilobyte — nur um es auf 64 bzw. 80 Pixel zu
verkleinern. Bei mehreren Positionen summierte sich das spuerbar, gerade im
Mobilfunknetz, und betraf sowohl die Warenkorb-Schublade als auch die
Warenkorbseite.
Der bisherige eslint-disable-Kommentar begruendete das rohe <img> mit einer
"noch unbekannten externen Bildquelle". Das trifft nicht zu: das Bild kommt
aus Product.images[0] und liegt damit immer same-origin unter /uploads/...
(beim Speichern geprueft von isValidUploadPath in lib/uploads.ts).
next/image ist dafuer zustaendig und liefert jetzt eine 80px-Variante statt
der Originaldatei.
Beide Verwendungsstellen bekommen dieselbe leichte Eckenrundung wie die
uebrigen Bilder; vorher war die Schublade rounded-sm und die Warenkorbseite
gar nicht gerundet.
Vier offene Befunde standen einem sauberen Produktionslauf im Weg:
- recently-viewed-section und site-chrome riefen setState direkt im
Effektrumpf auf, um einen Leerzustand herzustellen ("keine angesehenen
Produkte" bzw. "Eingabe kuerzer als zwei Zeichen"). Beides ergibt sich
unmittelbar aus vorhandenen Werten und wird jetzt beim Rendern
abgeleitet — das spart je einen zusaetzlichen Renderdurchlauf. Bei der
Suche verschwindet die Vorschlagsliste dadurch sofort beim Leeren des
Feldes statt erst nach dem naechsten Effektlauf.
- STAT_KEYS im Admin-Dashboard wurde ausschliesslich als Typ verwendet,
landete als `as const`-Array aber trotzdem im Bundle. Ersetzt durch
einen Typ-Alias.
- Ein DHL-Test importierte DhlApiError, ohne es zu benutzen, und pruefte
nur den Meldungstext — er haette damit jeden beliebigen anderen Fehler
mit passendem Text durchgehen lassen. Er prueft jetzt zusaetzlich den
Fehlertyp, wie seine beiden Geschwistertests.
Stand danach: eslint ohne Beanstandung (vorher 2 Fehler, 2 Warnungen),
tsc ohne Fehler, 201 Tests gruen, Produktions-Build erfolgreich.
Drei Gruende, warum Hero-Bild und -Video spuerbar spaet erschienen:
1. Das Standbild des Hero-Videos steckte im poster-Attribut. poster nimmt
nur eine fertige URL und laedt die hochgeladene Datei damit in voller
gespeicherter Groesse — am Bildoptimierer vorbei, ohne Ruecksicht auf
das Geraet. Es liegt jetzt als eigene ContentImage-Ebene unter dem
Video, laeuft also durch next/image. Gemessen: auf einem 500px breiten
Viewport kommt statt der vollen Datei die 640px-Variante als WebP an.
Das Video darueber ist transparent, bis es eigene Bilddaten hat — der
Uebergang bleibt unsichtbar.
2. Die aeusserste Hero-Section startete bei opacity 0 und wurde erst per
Animation nach der Hydration sichtbar. Das groesste Element im ersten
Viewport wurde damit um die gesamte JS-Ladezeit zurueckgehalten. Die
Animation dort ist entfernt; der gestaffelte Aufbau der Textspalte
bleibt unveraendert.
3. next/image erhielt weiterhin die Prop "priority". Die ist seit
Next.js 16 abgekuendigt (siehe node_modules/next/dist/docs, Changelog
v16.0.0) und erzeugt nur noch einen <link rel="preload">, setzt am
<img> selbst aber weder fetchpriority noch loading — im Trace kam das
Hero-Bild deshalb trotz "priority" mit Prioritaet "Low" an. Die
Dokumentation nennt dafuer ausdruecklich loading="eager" und
fetchPriority="high" als Ersatz; ContentImage bildet seine unveraendert
heissende priority-Prop jetzt darauf ab, die Produktgalerie ebenso.
Danach bestand die LCP-Discovery-Pruefung von Chrome vollstaendig,
vorher scheiterte sie an fetchpriority.
Ausserdem bekommen Inhaltsbilder leicht abgerundete Ecken (8px), Platzhalter
und echtes Bild gleichermassen, damit die Form beim Pflegen eines Bildes
nicht umspringt. Randlose Flaechen (Hero ueber die volle Fensterbreite)
bleiben bewusst eckig — dort saesse die Rundung an der Fensterkante.
Geprueft im Produktions-Build gegen Chrome: CLS 0.00, kein horizontaler
Ueberlauf bei schmalem Viewport, Video spielt (readyState 4), Hero-Bild
loading="eager" + fetchpriority="high", uebrige Bilder lazy.
Zwei Einstellungen im Deployment liessen einen Video-Upload scheitern,
bevor die Anwendung ueberhaupt antworten konnte — beide Male mit einer
HTML-Fehlerseite, die im Browser als "Unexpected token '<'" ankam:
1. Der Ingress trug keinerlei Timeout-Annotation. ingress-nginx wartet
dann nur 60 Sekunden je Phase und liefert danach eine eigene
HTML-504-Seite. Ein Video-Upload braucht regelmaessig laenger: erst
muessen bis zu 100 MB ueber die Leitung, danach rechnet ffmpeg das
Video fuers Web um. proxy-read-timeout und proxy-send-timeout stehen
jetzt auf 600s — bewusst oberhalb des harten ffmpeg-Zeitlimits von 5
Minuten, damit im Zweifel die Anwendung selbst mit einer verwertbaren
JSON-Meldung abbricht statt nginx die Verbindung zu kappen.
2. Das Speicherlimit von 512Mi war fuer einen 100-MB-Upload zu knapp:
request.formData() laedt den multipart-Body vollstaendig in den
Speicher, und der Kernel beendet den Prozess bei Ueberschreitung
(OOMKilled). Limit auf 1Gi, Request auf 512Mi. Der CPU-Request steigt
von 100m auf 500m, weil ffmpeg CPU-gebunden ist und mit 100m ein
Vielfaches der Zeit braucht — bewusst weiterhin ohne CPU-Limit, das
wuerde den Transcode auch auf einem freien Knoten drosseln.
Der Kommentar nennt zusaetzlich das davon unabhaengige Origin-Zeitlimit
eines vorgeschalteten CDN (bei Cloudflare 100 Sekunden, Fehler 524), das
sich hier nicht beeinflussen laesst.
Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig
in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus,
und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem
Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von
512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel
beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt
davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als
"Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien.
Die Datei wandert jetzt streamend auf die Platte (file.stream() ->
createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das
Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile()
als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit
EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume
liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der
ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf
Dateipfaden statt auf Buffern.
Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler
ab und antwortet auch dann mit JSON: eine durchschlagende Exception
liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine
Meldung gewinnen kann.
Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB,
Auslieferung inkl. Range-Anfrage (206) korrekt.
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser
Unexpected token '<', "<!DOCTYPE "... is not valid JSON
und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.
Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.
readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.
Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
Die Vorschaukacheln in admin/inhalte, admin/produkte und admin/ernten
luden das vollstaendig gespeicherte Bild, um es auf 80x80 bzw. in einer
Vierer-Rasterkachel darzustellen. Bei einem Produktformular mit mehreren
Bildern summierte sich das zu mehreren Megabyte allein fuer Miniaturen.
Der urspruengliche Kommentar ("next/image lohnt hier nicht") stimmte, als
Uploads klein waren; gespeichert wird inzwischen bis MAX_IMAGE_DIMENSION
Kantenlaenge. Beide Felder rendern die Vorschau jetzt ueber next/image
mit passendem sizes, der Optimierer liefert damit genau die gebrauchte
Groesse. Layout und Bedienung bleiben unveraendert.
Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen
Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts:
1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen
Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt
sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die
Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab.
Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet
Bereichsanfragen jetzt mit 206 samt Content-Range, meldet
Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416.
2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den
RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro
gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte,
inklusive Content-Length (vorher chunked, ohne Groessenangabe).
3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload
den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304
auf If-None-Match.
4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag
mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses
Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren.
reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange
Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie
hochskaliert).
Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter
dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales
Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig
ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei,
die sich zu Milliarden Pixeln entpackt) bekommt sharp mit
MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die
Byte-Grenze allein nicht abdeckt.
Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt,
jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von
180 ms auf 65 ms.
Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das
neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
Jeder Video-Upload ohne System-ffmpeg im PATH — also jede lokale
Entwicklungsumgebung unter Windows/macOS — endete mit 400 "Das Video
konnte nicht verarbeitet werden.". Im Server-Log stand der eigentliche
Grund:
spawn \ROOT\node_modules\.pnpm\[email protected]\...\ffmpeg.exe ENOENT
ffmpeg-static exportiert den Pfad zu seiner mitgelieferten Binary
__dirname-relativ. Wird das Paket vom Bundler mitgebuendelt, zeigt sein
__dirname auf einen virtuellen Bundle-Pfad ("/ROOT/...") statt auf das
echte Verzeichnis in node_modules — genau dieselbe Ursache, aus der
pdfkit hier schon als serverExternalPackage gefuehrt wird.
ffmpeg-static daher ebenfalls in serverExternalPackages aufgenommen; der
Kommentar erklaert die Ursache jetzt einmal fuer beide Pakete. Der
Standalone-Build kopiert das Paket damit unveraendert nach
node_modules (verifiziert).
Die Same-Origin-Pruefung verglich den Origin-Header gegen
new URL(request.url).host. Next.js rekonstruiert request.url aber aus der
internen Bind-Adresse des Servers, NICHT aus dem Host-Header, den der
Browser tatsaechlich adressiert hat — reproduzierbar liefert request.url
"localhost:3999", waehrend der Browser "127.0.0.1:3999" aufgerufen hat.
Damit schlug jeder Admin-Upload mit 403 fehl, sobald die Seite unter einem
anderen Namen als dieser internen Adresse erreicht wurde: oeffentliche
Domain hinter dem Reverse-Proxy, LAN-IP, oder schlicht 127.0.0.1 statt
localhost. Der 2025 nachgeruestete Fallback auf NEXT_PUBLIC_SITE_URL half
nur, wenn diese Variable gesetzt ist und exakt passt — in .env fehlt sie.
Der Origin wird jetzt gegen alle plausiblen eigenen Hosts geprueft, in
dieser Reihenfolge: X-Forwarded-Host (vom eigenen Proxy gesetzt, Caddy
verwirft einen vom Client mitgeschickten Wert), Host (was der Browser
adressiert hat — derselbe Vergleich, den Next.js fuer Server Actions
intern anstellt), request.url und zuletzt NEXT_PUBLIC_SITE_URL.
Der CSRF-Schutz bleibt dabei vollstaendig: eine fremde Seite kann bei
einem Cross-Site-Request weder Host noch X-Forwarded-Host bestimmen — den
Host setzt der Browser aus der Ziel-URL, eigene Header liessen sich nur
per fetch() setzen, was einen CORS-Preflight ausloest, den dieser
Endpunkt nicht beantwortet. Ein gefaelschter POST traegt also weiterhin
zwangslaeufig einen fremden Origin und wird abgewiesen (durch Test und
manuelle Gegenprobe belegt).
"Spring flow" -> "spring harvest", passend zum Rest der englischen Texte.
"robust" -> "intense" fuer kraeftig in beiden Admin-Produktformularen,
passend zum bereits kundenseitig verwendeten product.tasteIntense.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
"Here's why it happens" war ein Throat-Clearing-Opener, den die
deutsche Originalfassung ("Wir erklaeren, woran das liegt") gar nicht
hatte. Jetzt woertlich angeglichen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
/charge/[code] zeigt zwei Bild-Slots (Produkt-/Standortfoto). Die
bisherige Logik ersetzte bei JEDEM eigenen Chargenfoto beide Slots
komplett durch batch.images — wer nur ein Chargenfoto pflegte, verlor
dadurch das zweite (Standort-)Bild der Ernte statt es weiter anzuzeigen.
Jeder Slot faellt jetzt einzeln auf das jeweilige Erntebild zurueck.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Chargen hatten bisher kein eigenes Bild-Feld (nur Ernten und Produkte) —
neue Migration ergaenzt Batch.images (Json-Array, gleiches Muster wie
Harvest.images/Product.images). Admin kann Chargenfotos jetzt unter
/admin/chargen/[id] hochladen (AdminImageListField, identisch zum
Ernten-Formular). Die oeffentliche Chargen-Seite (/charge/[code]) zeigt
diese Fotos jetzt bevorzugt, faellt ohne eigene Chargenbilder weiterhin
auf die Erntebilder zurueck.
Hinweis fuer den naechsten Live-Deploy: die generierte Migration hat
nebenbei eine bereits laenger bestehende Schema-Abweichung mitkorrigiert
(harvests.images und products.images/tasteTags werden jetzt als NOT NULL
erzwungen, wie im Schema schon laenger deklariert) - "prisma migrate
deploy" schlaegt fehl, falls dort noch echte NULL-Werte stehen sollten.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Fehlerbild "Unexpected token '<' ... is not valid JSON" nach dem
ffmpeg-Fix: kein Absturz mehr, sondern vermutlich ein Timeout - das
CDN/Reverse-Proxy (Cloudflare) vor der Seite bricht selbst ab, wenn der
Origin-Request zu lange nicht antwortet, und liefert dafuer eine eigene
HTML-Fehlerseite statt der JSON-Antwort. "medium"-Preset fuer ein
57-MB-Rohvideo konnte laenger brauchen, als das CDN wartet. "veryfast"
tauscht nur Encodier-Geschwindigkeit gegen Kompressionseffizienz (nicht
gegen das per -crf gesteuerte Qualitaetsziel), reduziert die
Encodierzeit deutlich.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live-Log zeigte: spawn .../ffmpeg-static/ffmpeg ENOENT bei jedem
Transcode-Versuch, unabhaengig vom Videoformat (.mov war nur zufaellig
der zuletzt getestete Fall). Die ffmpeg-static-Binary aus der
migrator-Docker-Stage kam nicht zuverlaessig im finalen Image an.
Root-Fix: ffmpeg jetzt per "apk add ffmpeg" als System-Paket in der
runner-Stage installiert - liegt fest unter /usr/bin auf PATH,
unabhaengig von jeder Node/pnpm-Kopiermechanik. lib/video-transcode.ts
versucht zuerst "ffmpeg" ueber PATH und faellt nur bei ENOENT (kein
System-ffmpeg vorhanden, typischerweise lokale Windows-/Mac-Entwicklung)
auf die per npm mitgelieferte ffmpeg-static-Binary zurueck, damit
"npm run dev" weiterhin ohne manuelle Einrichtung funktioniert. Beide
Pfade lokal gegen ein synthetisches Testvideo verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
loop-Attribut war bereits gesetzt, aber manche Browser (u.a. aeltere
iOS-Safari-Versionen) starten ein Video nach "ended" nicht zuverlaessig
neu, wenn autoplay/muted/playsinline kombiniert sind. onEnded erzwingt
jetzt zusaetzlich den Neustart, damit die Dauerschleife garantiert nie
sichtbar stehen bleibt.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Ein direkt von Kamera/Handy hochgeladenes Video (57 MB) lud spuerbar
langsam: keine Kompression, kein Ton entfernt, und ohne "faststart"
musste der Browser oft die gesamte Datei laden, bevor die Wiedergabe
ueberhaupt beginnen konnte.
Neues lib/video-transcode.ts (ffmpeg-static, per pnpm-workspace.yaml
allowBuilds freigegeben) enkodiert jedes hochgeladene Video jetzt zu
einem web-optimierten MP4 neu: Ton entfernt (Hero-Video ist ohnehin
stumm), auf max. 1920px Breite skaliert, CRF 26 mit Bitratendeckel,
"+faststart" fuer sofortigen Wiedergabestart. Nebeneffekt: ein
erfolgreicher Transcode validiert das Video jetzt genauso vollstaendig
wie reencodeImage() das bei Bildern schon tut (nicht mehr nur
Magic-Bytes). ffmpeg-static-Binary wird in der migrator-Docker-Stage
mitinstalliert (Next.js' Standalone-Tracing erkennt den Pfad hinter dem
pnpm-Store-Symlink nicht zuverlaessig), lokal per npm-Postinstall ohne
manuelle Einrichtung.
Getestet mit synthetischem Testvideo: Transcode laeuft durch, Ausgabe
enthaelt keine Audiospur mehr, ist als H.264/MP4 dekodierbar.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live-Check des hochgeladenen Hero-Videos zeigte: die Headline lag streckenweise
direkt ueber hellen Honigglas-Etiketten, der bisherige rein vertikale
Verlauf (bg-gradient-to-t, 70/10) verdunkelt dort zu wenig, wo die
Textspalte tatsaechlich sitzt (vertikal mittig links). Zusaetzliche
gleichmaessige 25%-Abdunklung ueber die gesamte Flaeche plus staerkerer,
diagonaler Verlauf (to-tr, 70/25) gezielt hinter der Textspalte - visuell
gegen das echte Video verifiziert (Screenshot-Vergleich alt/neu).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger
Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur
loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge
nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung
verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus
Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den
Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit
Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Bild-/Video-Upload-Felder (admin/inhalte, admin/produkte, admin/ernten)
zeigten den unstyled Browser-Standard-Datei-Input — kaum als
Bedienelement erkennbar. Input jetzt versteckt, stattdessen ein
richtiger, zum Admin-Design passender Button, der den Datei-Dialog
oeffnet.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
40 MB war fuer ein reales, nicht web-optimiertes Hero-Video (57 MB) zu
knapp. Ingress-Body-Size-Limit entsprechend mitgezogen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Die Artikel-Karten auf /wissen waren nur per Hover als klickbar erkennbar
(Unterstreichung der Überschrift) — ohne sichtbaren Hinweis im Ruhezustand
war unklar, dass man draufklicken kann. Zeigt jetzt dauerhaft ein
"Weiterlesen →" je Karte.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
isTrustedOrigin() verglich den Origin-Header ausschliesslich gegen den von
Next.js aus request.url rekonstruierten Host. Hinter dem eigenen
Caddy/nginx-Reverse-Proxy (docker-compose.yml, k8s/) ist dieser Wert nicht
in jeder Konstellation zuverlaessig derselbe wie der echte, oeffentliche
Host im Origin-Header - das fuehrte live zu "Ungueltige Anfrage." bei
jedem Admin-Upload, obwohl der Request vom eigenen Frontend kam. Prueft
jetzt zusaetzlich gegen getBaseUrl() (NEXT_PUBLIC_SITE_URL), genau wie
lib/url.ts es fuer denselben Zweck bereits vormacht - als reine
ODER-Erweiterung, damit ein falsch gesetztes NEXT_PUBLIC_SITE_URL nicht
umgekehrt bislang funktionierende Endpunkte wie /api/checkout blockiert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Uploads (Bilder + Hero-Video) schlugen live fehl, lokal aber nicht: der
Container laeuft als non-root "nextjs", /app/uploads existierte nirgends
vorbereitet und gehoert root -> mkdir() scheiterte mit EACCES, unbehandelt
als HTML-500 statt JSON, der Client zeigte nur einen haengenden
"wird hochgeladen"-Zustand ohne Fehlermeldung.
- Dockerfile legt /app/uploads jetzt vor dem User-Wechsel an und
uebereignet es dem nextjs-Nutzer
- docker-compose.yml persistiert Uploads jetzt ueber ein eigenes Volume
(vorher gingen sie bei jedem Redeploy verloren) und setzt UPLOAD_DIR
explizit
- Beide Upload-Routen fangen Dateisystemfehler jetzt ab und liefern eine
echte JSON-Fehlermeldung statt eine unbehandelte Exception
- k8s-Manifeste vorsorglich mitgefixt (fsGroup fuer den Volume-Mount,
Ingress-Body-Size-Limit) fuer den Fall eines spaeteren
Kubernetes-Wechsels
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und
Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular),
statt beides pro Bereich gemischt untereinander. Ausserdem alle
Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den
Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen
ersetzt.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Same gap as /imkerei and /herkunft (previous commit): last remaining
hardcoded PlaceholderImage in the app, now wired to the same
content-registry "pages" group / ContentImage pattern.
Verified: tsc clean, 151/151 tests pass, page still returns 200.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Both pages are the ones most directly meant to give customers "ein
gutes Bild von der Imkerei" (per the active /goal), but their hero
images were still hardcoded PlaceholderImage tiles with no way to
ever swap in a real photo — unlike every homepage section, which
already went through this exact upload flow.
Added imkerei.image / herkunft.image to the content registry (new
"pages" group, since neither belongs under "Homepage") and switched
both pages from PlaceholderImage to ContentImage + getContentValue,
the same pattern already proven on the homepage sections. Falls back
to the identical placeholder tile until a photo is uploaded, so this
is a pure addition with no visual change until the owner uploads one.
Verified: tsc clean, 151/151 tests pass, both pages still render
correctly (curl 200 + screenshot) with the placeholder fallback.
Could not verify the admin upload UI end-to-end live — the local
admin account has 2FA enabled and I don't have the second factor;
confidence instead comes from reusing the identical, already-verified
AdminImageField/ContentImage wiring used for the four home.*.image
fields, plus generic (group-agnostic) admin field loading confirmed
by reading admin/inhalte/page.tsx.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Second silent-failure-hunter pass (stock/auth/admin-action focus,
explicitly scoped away from the payment paths fixed in the previous
commit) plus a self-review of that previous commit's own diff.
Self-correction: my prior fix to fulfillOrder's NEU->BEZAHLT guard
replaced the original atomic `updateMany({status:"NEU"})` claim with a
plain findUnique+update to let a retry backfill Payment/Invoice/email
after BEZAHLT. That accidentally dropped the atomicity the original
code relied on to prevent double-counting a discount code on two
concurrent webhook deliveries. Restored it: the NEU->BEZAHLT
transition is an atomic updateMany claim again (count===1 decides
"firstTime", which now also gates the discount-code increment); a
losing/retry call separately checks for "already BEZAHLT" to still
allow the backfill.
Three further real gaps, confirmed by hand before fixing:
- failOrder (same webhook file) and admin's updateOrderStatusAction
had the identical TOCTOU pattern: findUnique then unconditional
update then restockOrderItems. Two concurrent webhook deliveries
(Stripe documents at-least-once delivery) or two admin tabs
double-clicking "Stornieren" could both pass the stale read before
either write landed, silently double-crediting inventory. Both now
use the same atomic-claim pattern as fulfillOrder.
- 2FA backup-code verification used findFirst({usedAt:null}) then a
separate update — two concurrent submissions of the same code could
both pass the check and both authenticate, defeating single-use.
Replaced with one atomic updateMany claim.
- Two silent no-ops: an invalid/stale order-status update returned
with zero admin-visible feedback despite revalidatePath still
firing; discount-code deletion had no existence check, so a
double-submitted delete threw an unhandled Prisma P2025 (500)
instead of a clear message. Both now use the existing fail() helper.
Verified: tsc clean, 151/151 existing tests still pass.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Dispatched a read-only silent-failure audit (silent-failure-hunter
agent) over the checkout/webhook/DHL/mail code paths, then verified
each finding by hand before fixing. Four confirmed, real gaps:
- Stripe webhook (fulfillOrder): Payment/Invoice creation and the
confirmation email ran after the order's status flip to "BEZAHLT",
outside any try/catch. If any of those three failed (DB blip), the
handler threw uncaught -> Stripe retried -> the retry's
status==="NEU" guard now found 0 rows and silently skipped the
retry forever, leaving a paid order with no invoice, no payment
record, and no confirmation email, with nothing logged. Fixed by
allowing "BEZAHLT" as a valid retry entry point (idempotent
upserts) and wrapping the post-transaction block in try/catch that
logs loudly and re-throws (so Stripe still retries).
- DHL label creation: the already-billed tracking number's DB write
sat outside the try/catch that protects the (paid) DHL API call.
A failed write there lost the tracking number with zero record of
it, while the claim sentinel permanently blocked any retry through
the UI. Fixed by wrapping the write, logging the tracking number
for manual reconciliation, and deliberately leaving the claim in
place (not resettable) to avoid triggering a second, separately
billed label.
- Checkout route: a DB write failure for the (currently unread,
write-only) stripeCheckoutSessionId field discarded an already
live, payable Stripe session and showed the customer a generic
error, risking a duplicate order + stock reservation on retry.
Made that specific write best-effort/non-fatal.
- DHL fetch calls had no request timeout, so a hung (non-erroring)
DHL API could block an admin action indefinitely. Added a 20s
AbortSignal.timeout to both.
Verified: tsc clean, 151/151 existing tests still pass. No dedicated
DB-integration test harness exists yet for route/webhook handlers in
this codebase (only pure-function unit tests) — building one is a
separate, larger undertaking than this fix warrants.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live Lighthouse audit of /konto found the same class of bug as the
prior checkout/cart commit, one shade lighter: text-ink/50 measures
3.08:1 against the cream background, under the 4.5:1 WCAG AA minimum
for normal-size text. Grepped for every other use of the same token
(login divider, language switcher, sold-out label, admin image-upload
status text, admin hint text) and bumped them all to text-ink/70
(5.61:1, same value used in the earlier checkout fix) for one
consistent, compliant muted-text shade. Verified: tsc clean, 151/151
tests pass, /konto Lighthouse accessibility 96 -> 100.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Live Lighthouse + performance-trace audits against the running dev app
found two real, measurable defects:
- /checkout and /warenkorb rendered the cart-store's default empty
state before Zustand's persist middleware finished reading
localStorage, so any customer who already had items in their cart
saw an "empty cart" flash before the real form appeared (CLS 0.82).
Fixed by tracking hasHydrated in the store and rendering the
existing route skeleton until rehydration completes. Verified via
Chrome DevTools performance trace: CLS 0.82 -> 0.00 on both pages.
- axe-core (via Lighthouse) flagged text-ink/60 on the cream
background at 4.09:1 contrast, under the 4.5:1 WCAG AA minimum for
normal-size text. Bumped to text-ink/70 (5.61:1) everywhere the same
token is used at text-xs/text-sm across 7 files.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier
verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types,
kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung
aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig
(die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden
Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt.
- versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten
Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README
aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt
gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen.
- Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei
Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig.
Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf,
hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop.
Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich.
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass
beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann
(Feature existierte bereits, war aber nur im Checkout-Formular selbst
sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen)
- Produktdetailseite (Honig): kontextuelle Links zu passenden
Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht
wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt
einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion
- Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche
Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete"
findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte
eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der
Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner
lib/search-match.ts mit eigenen Tests ausgelagert
- Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/
Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss
(bereits vorhanden auf Produktseite/Warenkorb/Checkout)
Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien,
+9 neue Tests fuer search-match), pnpm build erfolgreich.
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen
jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte
Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt
hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz
gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026".
Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell-
verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege,
Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch.
- en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)"
jetzt korrekt (vorher z.B. "Open cart, 1 items").
- product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image
statt <img> (hochgeladene Produktfotos sind immer same-origin unter
/uploads/..., brauchen also keine remotePatterns-Konfiguration wie der
entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem
sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist.
- content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer
das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat).
- Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits
korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung
ohne Intervall) -- keine Aenderung noetig.
Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich.
Konkrete, verifizierte Luecken auf den Seiten, die Kund:innen von Google
aus oder per Link erreichen:
- Product-JSON-LD auf der Produktdetailseite ergaenzt um "image" (absolute
URLs der hochgeladenen Produktfotos) — fehlte bisher trotz vorhandener
Bilder und ist fuer Google Rich-Results/Merchant-Listing mit Sternebewertung
+ Preis im Suchergebnis erforderlich.
- Alt-Text fuer echte (hochgeladene) Bilder aussagekraeftiger: ContentImage
uebernimmt jetzt das bereits vorhandene, bisher nur auf Platzhalterkacheln
sichtbare "label" (Ernte-Jahr/Typ, Kategorie, Ort) mit in den Alt-Text —
wirkt projektweit auf Produktkarten, Ernte-Uebersicht/-Detail und
Produkt-Teaser. Produktgalerie-Hauptbild zeigt zusaetzlich die
Geschmacksnoten im Alt-Text statt nur des Produktnamens.
- Checkout: allgemeine Fehlermeldung nach fehlgeschlagenem Absenden bekommt
role="alert" und Fokus wird aktiv dorthin verschoben — bisher rein farblich
markiert, Screenreader-Nutzer:innen haetten den Fehler beim geldwerten
Bestellabschluss sonst gar nicht mitbekommen.
- Cart-Drawer und Warenkorb-Seite: Lagerbestand-Hinweise (Ausverkauft/Nur
noch N) treffen asynchron erst nach dem ersten Rendern ein — jetzt in
einem persistenten aria-live-Wrapper, damit der Uebergang tatsaechlich
angesagt wird statt stumm zu bleiben.
Geprueft und bereits solide befunden (keine Aenderung noetig): Shop-Kanonical
ignoriert bewusst Such-/Filter-Query-Parameter (kein Duplicate-Content-Risiko),
Sitemap deckt Produkt- und Ernte-Detailseiten mit echtem lastmod ab,
Produktgalerie-Tastaturbedienung ueber echte <button>-Kacheln mit
aria-label/aria-current, Cart-Drawer-Fokusfalle/Fokus-Rueckgabe, Mengen-Steller-
aria-live, Farbkontrast von text-honey-dark/text-red-700 auf dem Hintergrund
(~4.9:1 bzw. ~6.1:1, beide AA-konform).
tsc/vitest/build bleiben gruen (142 Tests).
Konkrete Reibungspunkte entlang der Kauf-Journey behoben:
- Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur
in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit
lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig
zurueckzusetzen; plus "Suche zuruecksetzen"-Link.
- Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite
(bisher erst im Warenkorb/Checkout sichtbar).
- Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die
Vorschau-Kacheln (mobile Kaufentscheidung).
- Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der
Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht
wieder erst erscrollt werden muss.
- Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und
auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer
Endpunkt /api/shop/cart-stock + useCartStock-Hook.
- Checkout- und Adressformulare: autoComplete/inputMode/type auf allen
Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und
passende Bildschirmtastaturen.
- Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung
verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die
Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren
auffindbar.
Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build
bleiben gruen (142 Tests).
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).
- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
(Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
(public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
zur Laufzeit hinzugefuegten Dateien bedienen) — mit
Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
Product.images/Harvest.images (beide Felder existierten im Schema,
hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
(Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
(Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.
Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Auf Wunsch entfernt: der bislang auf JEDER Rechtstextseite sichtbare
"[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]"-Hinweis
(rechtliche Prüfung ist laut Auskunft bereits erfolgt). LegalPage
zeigt den Hinweis jetzt nur noch an, wenn noticeText nicht leer ist.
Datenschutzbeauftragte/r-Platzhalter durch eine begründete
Standard-Aussage ersetzt (§ 38 BDSG greift bei einem Betrieb dieser
Größe typischerweise nicht). Hosting-Platzhalter durch eine
zutreffende Aussage ersetzt: die Seite laeuft auf selbst betriebener
Infrastruktur, nicht bei einem externen Anbieter — kein AV-Vertrag
noetig.
NICHT ersetzt (siehe Chat): Name/Rechtsform/Handelsregister/USt-IdNr
im Impressum — diese Angaben kenne ich nicht und darf sie nicht
erfinden, eine falsche Handelsregisternummer waere ein echtes
rechtliches Risiko. Dafuer wurden die echten Werte beim Nutzer
angefragt.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich
Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im
ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains
"Charge" (model Batch) entspricht ApiCores "BottlingBatch"
(Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT
"HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code,
date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount).
- lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt
GET /bottling-batches (inkl. verknuepfter HoneyBatch) und
GET /extractions (inkl. verknuepfter Ernten) und loest daraus die
zugehoerige ApiCore-Ernten-ID pro Charge auf.
- admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem
Ernten-Sync (damit frisch angelegte Ernten schon als harvestId
verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als
Harvest hat Batch kein isPublished-Feld, ein Update wuerde
insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter
Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt
nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten
erhalten.
- admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an.
- .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert
(extractions:read, bottling:read).
Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.
Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe
bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape
direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
app/icon.svg deckt moderne Chrome/Android-Browser-Tabs und den
manifest.ts-Eintrag ab, aber iOS/iPadOS liest fuer "Zum Home-Bildschirm
hinzufuegen" ausschliesslich ein dediziertes Apple-Touch-Icon und
akzeptiert dafuer kein SVG (nur PNG/JPG) — ohne diese Datei waere
stattdessen ein Screenshot der Seite als Icon verwendet worden.
Rendert dieselbe Grafik wie icon.svg per next/og ImageResponse als
180x180-PNG. Per pnpm build + manuellem HTTP-Abruf/Sichtpruefung
verifiziert, dass Satori die SVG-Elemente korrekt rendert.
Social-Media-Icons (TikTok/YouTube/Instagram, site-footer.tsx) per
Code-Review geprueft: einfache, universell unterstuetzte SVG-Primitive
(rect/circle/path), werden nur gerendert wenn eine URL konfiguriert
ist — kein Fix noetig.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Datenbank (prisma/migrations/20260904120000_add_performance_indexes):
7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt
(Order.customerId+createdAt, Order.status, Order.createdAt,
Order.discountCodeId, OrderItem.orderId, Address.customerId,
AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht,
Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log.
Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile)
aus dem client-seitigen site-chrome.tsx in eine eigene Server Component
(site-footer.tsx) ausgelagert — brauchte "use client" nur wegen
useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite.
Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf
(Footer steht im Root-Layout).
UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen
fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen
und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete
Standardseiten statt einer zur Seite passenden Nutzerfuehrung.
Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits
bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx,
seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet,
die neue site-weite Fehlerseite nutzt einen eigenen Schluessel
(siteErrorPage).
Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Zwei schnell aufeinanderfolgende Klicks auf "DHL-Versandmarke
erstellen" konnten beide den findUnique-Check bestehen und zwei
echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
wie beim zuvor gefixten Retouren-Erstattungs-Bug). Ein reines
create()-mit-Unique-Constraint waere hier nicht ausreichend gewesen,
da Shipment-Datensaetze bereits ohne DHL existieren koennen
(automatisch bei Statuswechsel auf VERSENDET, oder manuell erfasst).
Fix: Serializable-Transaktion claimt den Datensatz atomar mit einem
Sentinel-Wert, bevor der DHL-Aufruf ueberhaupt startet; bei einem
fehlschlagenden DHL-Aufruf wird der Sentinel wieder entfernt.
Gefunden bei eigener Nachpruefung der neuen Admin-Actions dieser
Session (apicore-sync.ts war bereits sauber idempotent, kein Fix
noetig).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Keine der in dieser Session neu hinzugefuegten sicherheitskritischen
Module (lib/csrf.ts, lib/entra.ts, lib/dhl.ts) hatte bisher Tests.
- csrf.test.ts: Same-Origin-Erkennung, Ablehnung gefaelschter
Cross-Origin-Requests, tolerantes Verhalten bei fehlendem/kaputtem
Origin-Header.
- entra.test.ts: PKCE-Paar-Erzeugung (RFC 7636), Authorize-URL-Aufbau,
Extraktion des verifizierten E-Mail-Claims (nie ein unplausibles
Feld raten).
- dhl.test.ts: Adress-Mapping (Strasse/Hausnummer-Trennung,
ISO2->ISO3-Laendercode), Fehlerbehandlung bei nicht unterstuetztem
Land, DHL-Validierungsfehlern und HTTP-Fehlern, statt einen falschen
Erfolg vorzutaeuschen.
Alle 118 Tests (13 Dateien) gruen, tsc --noEmit clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben
Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt
nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben
maxUses:1-Code konnten sich sonst beide durchsetzen.
- Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update,
ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein
Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen
ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder
zurueckgesetzt, damit erneut versucht werden kann.
Beide Luecken kamen aus dem Security-Assessment dieser Session.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die
Harvest-isPublished-Migration waren gestaged aber nicht im letzten
Commit enthalten — Gitea-Actions-Build brach mit "Module not found"
fuer genau diese Dateien ab.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
gitea.server_url ist in manchen Gitea-Versionen leer (go-gitea/gitea#23276)
und liefert selbst im Erfolgsfall eine volle URL mit https://-Schema statt
des fuer docker login/Image-Tags benoetigten reinen Hostnamens. Registry
jetzt fest als git.encry.de hinterlegt, k8s-Manifeste entsprechend
vorausgefuellt.
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.
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Wabenhain
## Getting Started
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
First, run the development server:
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
## Projektstand
Was gebaut, geprüft, gemergt und ausgerollt ist, was offen ist und was nur der Inhaber erledigen kann, steht in den Gitea-Issues. Einstieg ist das Übersichts-Issue **[Projektstand und Übersicht](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/34)**, die Aufgaben des Inhabers sammelt **[Inhaber-Aufgaben](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/33)**.
Kurzfassung (Stand 2026-10-16):
- Produktiv läuft der Build `sha-0d254db` (Client-IP-Härtung vom 2026-10-12). Die drei neueren Commits auf `main` (Abhängigkeits-Updates, Qualitäts-Gate in der CI) sind gebaut, aber noch nicht ausgerollt.
- Im Cluster laufen App, Datenbank und die tägliche Sicherung `wabenhain-db-backup`. Die CronJobs `wabenhain-release-stale-orders` und `wabenhain-apicore-stock` sind angelegt, aber ausgesetzt (`suspend: true`).
- Für den Bestandsabgleich mit ApiCore fehlt im Cluster-Secret `APICORE_WEBHOOK_SECRET`, für die ERPNext-Schnittstelle `ERP_EXPORT_TOKEN`.
- Offene Spezifikation: [Versandstaffel je Kategorie](docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md) (freigegeben, nicht umgesetzt).
- 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
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
- Honigwissen mit allen 9 im Briefing genannten Artikeln
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Cookie-Richtlinie, Versand und Zahlung sowie die elektronische Widerrufsfunktion „Vertrag widerrufen“ (`/vertrag-widerrufen`, Eingänge unter Admin → Widerrufe). Die Texte sind ausformuliert und im Admin unter „Inhalte“ pflegbar; `pnpm preflight` meldet verbliebene Platzhalter. Eine anwaltliche Prüfung steht noch aus.
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über Microsoft 365 mit OAuth 2.0 (dieselbe App wie die Microsoft-Anmeldung), siehe Abschnitt „E-Mail-Versand" unten
-`sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
- **Chargen-Seite (`/charge/[code]`, QR-Code auf jedem Glas) aufgewertet**: statt einer reinen Datenliste jetzt mit "Verifizierte Charge"-Kennzeichnung, einer Ernte-→-Abfüllung-→-MHD-Zeitleiste und einem Verweis auf das zugehörige Produkt — der Moment, in dem jemand mit dem Glas in der Hand die Herkunft nachprüft, bekommt entsprechend mehr Gewicht. Alle gezeigten Angaben sind echte, bereits vorhandene Chargendaten.
- **Lieferzeit im Checkout sichtbar**: bei Versand zeigt die Lieferart-Auswahl direkt die bereits auf „Versand und Zahlung" dokumentierte übliche Lieferzeit (2–4 Werktage nach Zahlungseingang) an, statt dass Kund:innen dafür extra die Versandseite aufsuchen müssen.
- **Live-Suchvorschläge**: die Kopfzeilen-Suche zeigt beim Tippen (ab 2 Zeichen, debounced) bis zu 5 passende Produkte mit Preis direkt zum Anklicken — `/api/shop/search-suggestions`, rate-limitiert wie jeder andere öffentliche Endpunkt, nutzt dieselbe Suchlogik wie die Shop-Übersicht.
- **Ernten-Detailseiten aufgewertet**: Zurück-Link, Chargen als klickbare Karten statt Fließtext-Links, Produkte über die neue geteilte `ProductTeaserLink`-Komponente (auch auf der Chargen-Seite verwendet — kein doppelter Code für dasselbe Muster).
- **Admin-2FA-Hinweis**: Admin-Konten ohne aktivierte Zwei-Faktor-Authentifizierung sehen einen dauerhaften, nicht blockierenden Hinweis mit Link zur Einrichtung — relevant, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt.
- **`.well-known/security.txt`** (RFC 9116): dokumentierter Kontaktweg für verantwortungsvolle Schwachstellenmeldungen.
- **Escape schließt Overlays + vollständiges Fokus-Management**: Cart-Drawer, mobiles Menü und die Kopfzeilen-Suche lassen sich jetzt auch per Escape-Taste schließen, nicht nur per Maus-Klick. Beim Öffnen wandert der Tastatur-Fokus aktiv in das Overlay hinein (statt auf dem jetzt verdeckten Ausloeser-Button stehen zu bleiben), Tab/Shift+Tab bleiben innerhalb des Overlays gefangen (kein Durchtabben zu unsichtbaren Links dahinter), und beim Schließen springt der Fokus zurück auf den auslösenden Button.
- **Mobile-Layout-Fixes**: die Warenkorb-Zeile (Menge/Preis/Entfernen wurde auf schmalen Viewports komplett aus dem sichtbaren Bereich gedrängt) und Platzhalterbild-Beschriftungen (hartes Abschneiden statt Kürzung mit „…") behoben — gefunden durch echte Viewport-Emulation, nicht nur Desktop-Tests.
- **Kundenkonto-Design**: der Konto-Bereich (Übersicht, Bestellungen, Adressen, Wunschliste, Zwei-Faktor) hat jetzt dieselbe optische Sorgfalt wie Shop/Checkout — Icon-Navigationskarten mit Hover-Anhebung statt reiner Text-Boxen, farbcodierte Bestellstatus-Badges statt Fließtext, und gestaltete Leer-Zustände mit Icon und Weiterleitung zum Shop statt einzelner Hinweissätze. Zusätzlich behoben: der Warenkorb-Button im Header hatte einen `aria-label`, der die sichtbare Artikelanzahl nicht einschloss (gleiches Muster wie bei der Bildergalerie).
- **Admin-Design**: alle 10 Tabellen im Admin-Bereich (Bestellungen, Produkte, Chargen, Ernten, Benutzer, Gutscheine, Versand, Bewertungen, Audit-Log) haben jetzt einen Hover-Zustand auf Zeilenebene statt komplett statischer Reihen. Das Dashboard zeigt jede Kennzahl mit einem passenden Icon, und der wichtigste Wert („Umsatz heute") ist optisch hervorgehoben statt gleichwertig neben sieben anderen Zahlen zu stehen. Die Bestellliste zeigt den Status jetzt zusätzlich als farbcodiertes Badge (wiederverwendet aus dem Kundenkonto) neben dem bestehenden Bearbeiten-Dropdown.
### Bewusst vereinfacht
- **Bilder**: Fotos für Produkte, Ernten, Chargen, Inhalte, Logo und das Hero-Video lädt der Admin hoch (siehe `k8s/README.md` zu Speicher und Video-Upload). Solange zu einer Fläche kein Foto hinterlegt ist, erscheint ein Platzhalter im Stil des Glas-Etiketts.
- Rechtstexte sind ausformuliert, **müssen** aber anwaltlich geprüft werden — das kann kein Skript feststellen.
## Admin-Zugang
Es gibt **kein separates Admin-Login** und keine Basic-Auth mehr. Der `/admin`-Bereich wird über dasselbe Login-Formular wie das Kundenkonto (`/konto`) erreicht — ob ein Konto zusätzlich Admin-Rechte hat, entscheidet einzig das Feld `role` (`KUNDE` | `ADMIN`) am `Customer`-Datensatz:
- Beim Login/bei der Registrierung wird die Rolle des Kontos in die Session (JWT) geschrieben.
-`src/proxy.ts` prüft bei jedem Zugriff auf `/admin/*`, ob die Session die Rolle `ADMIN` trägt, und leitet sonst mit einer erklärenden Meldung zu `/konto` um.
- Jede Admin-Server-Action (`src/app/admin/actions.ts`) und das Admin-Layout prüfen die Rolle zusätzlich **direkt gegen die Datenbank** (`getCurrentAdmin()`) — der Proxy ist nur der schnelle Vorfilter, nicht die einzige Kontrollinstanz.
- Neue Konten über das öffentliche Registrierungsformular erhalten immer die Rolle `KUNDE`; es gibt keinen Weg, sich selbst zum Admin zu machen.
**Erstes Admin-Konto anlegen:**`ADMIN_EMAIL` (unvorhersehbar wählen, nicht „admin@...") und `ADMIN_PASSWORD` in `.env` setzen und `pnpm db:seed` (bzw. `docker compose exec app pnpm prisma db seed`) **vor** dem ersten öffentlichen Start ausführen — legt das Konto neu an, oder erneuert bei einem bereits bestehenden Admin-Konto mit dieser E-Mail-Adresse nur dessen Passwort. Existiert unter dieser E-Mail-Adresse bereits ein **Nicht**-Admin-Konto (z. B. weil sich jemand die Adresse vorregistriert hat), befördert der Seed-Lauf es bewusst **nicht** automatisch — stattdessen erscheint eine Warnung im Log, und die Beförderung muss manuell über die Datenbank erfolgen (siehe unten). So lässt sich ein Admin-Konto nicht durch Vorregistrieren der erwarteten E-Mail-Adresse kapern. Login danach ganz normal über `/konto`.
**Benutzerverwaltung:** Unter `/admin/benutzer` können Admins alle Konten einsehen, anlegen, bearbeiten (inkl. Rolle `KUNDE`/`ADMIN`), sperren/entsperren und löschen. Beim Anlegen wird **kein Passwort vom Admin vergeben** — das neue Konto erhält sofort eine E-Mail mit einem Link, über den die Person ihr eigenes Passwort festlegt (derselbe Mechanismus wie „Passwort vergessen"). Ein Passwort-Reset für bestehende Konten läuft genauso: der Admin-Button löst nur den Versand des Reset-Links aus, ein Admin bekommt nie ein Kunden-Passwort im Klartext zu Gesicht.
Eingebaute Selbstschutz-Regeln verhindern, dass sich ein Admin versehentlich aussperrt:
- Ein Admin kann seine eigene Rolle nicht ändern und sich selbst nicht sperren oder löschen.
- Der letzte verbleibende `ADMIN`-Account kann nicht herabgestuft, gesperrt oder gelöscht werden.
Alternativ weiterhin direkt über die Datenbank möglich:
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
oder per Prisma Studio (`pnpm db:studio`) am Datensatz das Feld `role` ändern.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Voraussetzungen
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- Node.js 20.9+ (getestet mit 24.x)
- pnpm (`corepack enable` oder `npm i -g pnpm`)
- Docker + Docker Compose (für PostgreSQL lokal und für den Produktions-Build)
## Learn More
## Lokale Entwicklung
To learn more about Next.js, take a look at the following resources:
```bash
cp .env.example .env
# .env anpassen (mindestens SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD; Stripe-Keys optional für UI-Arbeit)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
pnpm install
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
pnpm db:seed # legt/erneuert nur das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) an
pnpm db:seed:demo # optional: 2 Beispiel-Ernten/Chargen/Produkte zum Ausprobieren (nicht im Docker-Image, siehe unten)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
pnpm dev # http://localhost:3000
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Admin-Bereich: `http://localhost:3000/admin` — Login mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` über das normale Formular unter `/konto` (siehe „Admin-Zugang" unten).
## Datenbank-Provider wechseln
Die App läuft wahlweise mit PostgreSQL (Standard) oder MySQL/MariaDB — steuerbar über zwei Variablen in `.env`:
Nach einer Änderung von `DATABASE_PROVIDER` (z.B. lokal):
```bash
# Nur die MySQL-Datenbank per Docker starten (statt "docker compose up -d db"):
docker compose --profile mysql up -d db-mysql
pnpm db:push # legt Tabellen beim gewaehlten Provider an (schreibt kurzzeitig die passende provider-Zeile in schema.prisma, siehe unten)
pnpm db:seed # Admin-Konto
pnpm db:seed:demo # optional: Beispieldaten
pnpm dev
```
**Ein Build/Image reicht für beide Provider gleichzeitig**: `scripts/generate-prisma-clients.mjs` generiert bei jedem `dev`/`build`/`start`/`postinstall` automatisch BEIDE Prisma-Client-Varianten (Postgres und MySQL) — `src/lib/prisma.ts`/`prisma/seed.ts` wählen zur Laufzeit rein anhand von `DATABASE_PROVIDER`, welche davon tatsächlich verwendet wird (technisch nötig, weil ein einzelner generierter Prisma-Client fest an einen Provider gebunden ist). Ein bereits gebautes Docker-Image lässt sich also allein durch Ändern von `DATABASE_PROVIDER`/`DATABASE_URL` und einen Neustart umschalten, **ohne Neubau** — praktisch für Kubernetes, siehe „Kubernetes / Gitea CI" unten.
Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich gegen EINE konkrete Datenbank Schema-Änderungen anwenden) schreiben vorher kurzzeitig die passende `provider`-Zeile in `prisma/schema.prisma` (Prisma erlaubt dort kein `env()`, siehe `scripts/prisma-provider.mjs`) — das betrifft nur diese Befehle, nicht die beiden generierten Clients selbst.
**Technischer Hintergrund**: `Harvest.images`, `Product.tasteTags` und `Product.images` sind als `Json` statt `String[]` modelliert, weil MySQL keine nativen Skalar-Array-Spalten kennt (Prisma-Einschränkung). Zur Laufzeit werden diese Felder in `lib/data.ts`/`lib/json-array.ts` transparent zu normalen String-Arrays normalisiert — der Rest der App merkt vom Provider-Wechsel nichts.
## Stripe
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
3. Ohne `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
### Eingebetteter Checkout
Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
Ablauf:
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um PayPal, SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
-`checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
-`checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus).
-`checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
-`checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf.
Zwei weitere Events betreffen Geld, das den Shop wieder verlässt, **ohne dass hier jemand etwas angeklickt hat**:
-`charge.refunded` hält eine Erstattung fest, die von Hand im Stripe-Dashboard ausgelöst wurde. Ohne dieses Abonnement stand die Zahlung im Shop weiter auf `SUCCEEDED` und jede Auswertung zählte einen Umsatz mit, den es nicht mehr gab. Eine **Teilerstattung** setzt den Status bewusst *nicht* auf `REFUNDED` (der Restbetrag ist weiterhin eingezogen), sondern wird nur protokolliert. Der Bestellstatus und der Lagerbestand bleiben in beiden Fällen unangetastet: ob eine Erstattung Storno, Retoure oder Kulanz bedeutet, steht am Event nicht.
-`charge.dispute.created` vermerkt eine Rückbuchung (Chargeback) in den Notizen der Bestellung und schreibt sie ins Server-Log. Die Stellungnahme gegenüber Stripe ist **fristgebunden** — vorher war eine Rückbuchung ausschließlich im Stripe-Dashboard zu sehen.
Für einen produktiven Webhook-Endpoint müssen alle sechs Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter. `pnpm preflight` prüft das Abonnement.
> **`checkout.session.expired` ist nicht optional.** Ohne dieses Abonnement bleibt der beim Checkout reservierte Bestand jedes abgebrochenen Kaufs dauerhaft blockiert. Falls es doch einmal fehlt oder der Endpunkt nicht erreichbar war, fängt der CronJob `wabenhain-release-stale-orders` (siehe unten) das ab.
### Abgebrochener Checkout
Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafür: jeder nicht zu Ende geführte Checkout hält Ware fest, die niemand bezahlt hat. Drei Wege geben sie wieder frei:
| Wann | Was passiert |
|------|--------------|
| „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
Der letzte Fall war eine Lücke: schlug `stripe.checkout.sessions.create` fehl, blieb die Bestellung auf `NEU`**ohne**`stripeCheckoutSessionId` stehen. Damit griff keiner der drei Wege darüber — der Abbruch-Klick braucht die Sitzungs-ID, der CronJob ebenso, und ein Stripe-Event kann es ohne Sitzung nicht geben. Der reservierte Bestand blieb dauerhaft blockiert, und die Oberfläche schickte die Kundschaft dabei auf die Erfolgsseite („wir melden uns"), obwohl es keinen Weg gab, diese Bestellung je zu bezahlen. Heute wird stattdessen ein Fehler angezeigt, der ausdrücklich sagt, dass nichts abgebucht wurde. Lässt sich die Sitzungs-ID nach dem Anlegen nicht speichern, wird die Sitzung sofort wieder geschlossen und dieselbe Auflösung ausgelöst — eine Bestellung auf `NEU` ohne Sitzungs-ID kann so nicht mehr entstehen. Der CronJob räumt Altbestände aus der Zeit davor trotzdem ab.
**Solange die Zahlung nicht bestätigt ist, erscheint die Bestellung nicht im Kundenkonto.**`getOrdersForCustomer` blendet den Status `NEU` aus (`lib/account-data.ts`). Vorher stand ein gerade abgebrochener Kauf dort bis zu 45 Minuten lang mit Bestellnummer und Summe in der Liste und sah aus wie ein zustande gekommener Kauf. Bezahlte Bestellungen sind davon nicht betroffen — der Webhook setzt sie binnen Sekunden auf `BEZAHLT`. In der Bestellverfolgung (`/bestellung-verfolgen`, auch für Gastbestellungen) bleibt eine `NEU`-Bestellung auffindbar, trägt dort aber die Kennzeichnung **„Zahlung ausstehend"** statt eines nichtssagenden „Neu".
**Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu.
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
### Stornierung im Admin-Bereich
Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIERT**, passiert dreierlei in dieser Reihenfolge (`admin/actions/orders.ts`):
1. Status setzen und Lagerbestand zurückbuchen — atomar in einer Transaktion, damit zwei offene Tabs den Bestand nicht doppelt gutschreiben.
2.**War die Bestellung bezahlt, wird der volle Betrag über die Stripe Refunds API erstattet** und `Payment.status` auf `REFUNDED` gesetzt. Vorher unterblieb das: die Bestellung galt als storniert, die Ware lag wieder im Regal, und das Geld blieb beim Shop. Teilerstattungen laufen weiterhin über den Retouren-Bereich, wo sie hingehören.
3.**War die Bestellung noch `NEU`, wird die offene Stripe-Sitzung geschlossen.** Sonst konnte die Kundschaft Minuten später noch bezahlen — der Webhook fand die Bestellung dann nicht mehr auf `NEU`, brach ab, und das Geld lag bei Stripe, ohne dass im Shop irgendetwas davon zu sehen war.
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
## Versandkosten
Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), sondern nach einer im Admin-Bereich unter `/admin/versand` gepflegten Mengenstaffel — die Gesamtmenge aller Positionen im Warenkorb wird dabei zusammengezählt, mehrere verschiedene Produkte also gemeinsam bewertet statt einzeln berechnet. Ab Werk sind drei Staffeln angelegt (0–3 Stück: 7,00 €, 4–9 Stück: 8,50 €, 10–19 Stück: 11,00 €), die jederzeit im Admin-Bereich angepasst, gelöscht oder um weitere Stufen ergänzt werden können — praktisch, wenn sich die Kosten der Versanddienstleister ändern. Eine Staffel ohne "Bis"-Wert gilt als offene, oberste Stufe ("ab X Stück"). Sind gar keine Staffeln gepflegt, greift ein fester Sicherheits-Fallback von 4,90 €, statt den Checkout zu blockieren. Abholung in Altenmünster ist immer versandkostenfrei. Änderungen wirken sich sofort auf Warenkorb, Checkout und die öffentliche Seite `/versand-zahlung` aus.
## Google-Rezensionen
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
**Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren.
**Einrichtung — alle vier Schritte sind nötig:**
1.**API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht.
2.**Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab.
3.**API-Schlüssel erzeugen:***APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe.
4.**Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap).
**Die Place-ID steht nicht in der Cloud Console.** Sie gehört zum Eintrag bei Google Maps, nicht zum Projekt. Mit gesetztem Schlüssel liefert sie dieses Kommando:
```bash
pnpm places:find "Wabenhain Altenmünster"
```
Es gibt Name, Anschrift und `GOOGLE_PLACE_ID=…` je Treffer aus — und ist zugleich die schnellste Probe auf die Einrichtung: kommt eine ID heraus, stimmen API, Abrechnung und Schlüssel. Andernfalls benennt es die Ursache. Ohne Schlüssel geht es auch über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters.
**Fehler werden im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
| Meldung im Log | Bedeutung |
|----------------|-----------|
| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
| `billing … not enabled` | Kein Rechnungskonto am Projekt |
| `HTTP 404` | Die Place-ID passt zu keinem Eintrag |
| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich |
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe.
> **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge.
## E-Mail-Versand
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten.
Ob der Versand funktioniert, zeigt **Admin → Einstellungen → System & Tests**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache.
**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
1.**Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[email protected]`) — braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. `SMTP_USER` und die Adresse in `SMTP_FROM` sind dieses Postfach.
2.**Berechtigung an der SSO-App:** Entra Admin Center → App-Registrierungen → die SSO-App → API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“.
3.**Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die SSO-App → **Objekt-ID** (nicht die der App-Registrierung).
4.**Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten.
5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → Einstellungen → System & Tests**.
6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an.
**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen.
## CAPTCHA (Cloudflare Turnstile)
**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
| Formular | Missbrauch, der damit abgewehrt wird |
| Zwei-Faktor-Code | Durchprobieren des sechsstelligen Codes |
| Passwort vergessen / zurücksetzen | Mailversand an fremde Adressen, Abfragen welche Adressen registriert sind |
| Registrierung | Massenhaft angelegte Konten |
| Checkout | Blockieren von Lagerbestand, Kartentests über die Zahlungsstrecke |
| Newsletter | Eintragen fremder Adressen |
| Firmenanfrage | Spam über das Kontaktformular |
| Verfügbarkeits-Benachrichtigung | Mailversand an fremde Adressen |
Nicht geschützt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile wäre nach dem ersten Aufruf verbraucht. Sie ist stattdessen rate-limitiert und verlangt Bestellnummer **und** zugehörige E-Mail-Adresse.
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`.
## ApiCore-Synchronisierung
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
**Ein Knopf für alles:** „Alles synchronisieren" steht sowohl unter `/admin/ernten` als auch unter `/admin/produkte` und löst denselben Vorgang aus — es geht danach nur auf die Seite zurück, von der er kam.
Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine Charge braucht ihre Ernte als Pflicht-Fremdschlüssel, ein Produkt entsteht je Abfüllung und braucht die zugehörige Charge. Genau deshalb steckt sie im Ablauf und nicht im Kopf der bedienenden Person — getrennt ausgelöst musste man sie kennen und zweimal klicken, sonst meldete der erste Produkt-Lauf reihenweise fehlende Chargen. Nebenbei werden die Abfüllungen nur noch einmal von ApiCore geladen statt zweimal.
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
- **Ernten** entstehen als unveröffentlichte Entwürfe. Eine bereits veröffentlichte, früher synchronisierte Ernte bleibt unangetastet — sonst würde ein erneuter Lauf eine Freigabeentscheidung stillschweigend zurücknehmen.
- **Produkte: ein Jahrgang je Abfüllung.** In ApiCore ist „Waldhonig 500 g" *ein* Katalogartikel, an dem jedes Jahr eine weitere Abfüllung hängt. In Wabenhain gehört ein Produkt dagegen zu genau einer Charge — und das ist richtig so, denn jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt; die Bewertung des Waldhonigs von 2026 sagt über den von 2027 nichts aus. Aus einem ApiCore-Artikel werden deshalb **mehrere** Wabenhain-Produkte, eines je Abfüllung. Sie tragen denselben Namen, unterscheiden sich aber in der Adresse: `waldhonig-2026`, `waldhonig-2027`. Wurde ein Artikel im selben Jahr mehrfach abgefüllt, hängt die Losnummer hinten an.
Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben.
Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand kommt aus ApiCore (siehe „Bestandsabgleich" unten): der Shop verkauft aus dem gesamten Restbestand der Abfüllung.
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt.
- **Chargen**: Losnummer, Abfüll- und MHD-Datum und abgefüllte Menge kommen aus ApiCore. `remainingGlasses` zeigt den Rest laut ApiCore (Bestandsabgleich).
- **Hergestellte Waren** (Kerzen, Propolis-Tinkturen, Salben, … — in ApiCore Artikel der Art „Hergestellte Ware" mit Herstellungschargen): **ein** Shop-Produkt je Artikel, nicht je Charge. Neue Artikel entstehen als deaktivierte Entwürfe ohne Preis; schon vorhandene Shop-Produkte verknüpft man stattdessen im Produkt-Formular („ApiCore-Artikel") — am besten vor dem ersten Sync, sonst entsteht ein zusätzlicher Entwurf.
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
Wiederholte Läufe sind gefahrlos: Ernten werden über `Harvest.apicoreHarvestId` wiedererkannt, Chargen über `Batch.apicoreBottlingId`, Honig-Produkte über `Product.apicoreBottlingId`, hergestellte Waren über `Product.apicoreProductId`.
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
### Bestandsabgleich
**ApiCore führt den Restbestand je Charge; Shop und ApiCore zeigen dieselbe Zahl.** Design: `ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md`.
- **Verkauft = bezahlt.** Beim Bezahlen geht je Position ein Verkauf an ApiCore; Storno einer bezahlten Bestellung und erstattete Retoure buchen zurück — auf genau die Chargen, aus denen verkauft wurde. Unbezahlte Bestellungen erreichen ApiCore nie.
- **Warteschlange:** Jede Buchung landet in `apicore_stock_events`, in derselben Transaktion wie der Statuswechsel, und wird danach gesendet. Ist ApiCore nicht erreichbar, verkauft der Shop weiter; der CronJob `wabenhain-apicore-stock` (alle 10 Minuten, `pnpm stock:apicore-sync`) reicht nach. Jede Buchung trägt eine feste ID — ApiCore bucht sie nur einmal.
- **Shop-Bestand** verknüpfter Produkte = ApiCore-Rest − noch nicht bestätigte Buchungen − Reservierungen unbezahlter Bestellungen. Das Bestandsfeld im Admin ist für sie gesperrt; Entnahmen (Hofladen, Eigenbedarf, Bruch) und Inventur bucht man in ApiCore.
- **Entnahmen in ApiCore** kommen per Webhook `stock.changed` sofort an (`POST /api/apicore/webhook`, HMAC-signiert). Der Webhook nennt nur, *was* sich geändert hat — die Zahlen holt der Shop selbst über die API.
- **Chargen-Zuordnung:** Welche Charge(n) ApiCore einem Verkauf zugeordnet hat, steht an der Bestellposition (`OrderItem.apicoreAllocations`) — für Rückverfolgung und Rückruf.
- **Dashboard:** Buchungen, die länger als 15 Minuten hängen oder von ApiCore abgelehnt wurden, sowie aktive, noch nicht verknüpfte Produkte erscheinen unter „Zu tun".
**Einrichtung zusätzlich zum Sync:**
1. In ApiCore dem Wabenhain-API-Schlüssel die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen" (`shop-sync:write`) geben.
2. In ApiCore unter Automatisierung → Webhooks einen Webhook für `stock.changed` auf `https://<shop>/api/apicore/webhook` anlegen und dessen Secret als `APICORE_WEBHOOK_SECRET` setzen.
3. CronJob `wabenhain-apicore-stock` aus `k8s/07-backup-cronjob.yaml` anwenden.
4. Kerzen/Propolis-Produkte in ApiCore als „Hergestellte Ware" mit Herstellungscharge (aktueller Bestand) anlegen, in Wabenhain verknüpfen, dann synchronisieren.
Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`).
## Vor dem Live-Gang
```bash
pnpm preflight
```
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, keinen Mailversand und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
Der Exit-Code ist dabei derselbe (1, solange ein Blocker offen ist), der Aufruf lässt sich also unverändert als letzter Schritt in die Deploy-Pipeline hängen.
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
- `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags.
- Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail.
- Mailversand eingerichtet (`SMTP_USER` + `ENTRA_*`) — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung.
- Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig.
Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt).
Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert".
Unabhängig davon **müssen** die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) anwaltlich geprüft werden — das kann kein Skript feststellen.
## Rechnungen
Sobald Stripe eine Zahlung bestätigt, erzeugt der Webhook (`api/checkout/webhook`) die Rechnung, legt sie als PDF ab und hängt sie an die Bestellbestätigung. Ohne bestätigte Zahlung gibt es keine Rechnung — fehlt `STRIPE_WEBHOOK_SECRET`, bleibt jede Bestellung auf „NEU" stehen.
**Ablage** unter `INVOICE_DIR`, nach Jahr und Kunde sortiert:
```
invoices/2026/mustermann-erika/RE-2026-0001.pdf
```
Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abgelaufener Jahrgang lässt sich als ganzes Verzeichnis archivieren. Der Pfad wird als `Invoice.pdfPath` gespeichert, damit eine spätere Änderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Zwei Personen mit identischem Namen teilen sich einen Ordner — bewusst, denn ein technisches Kürzel im Ordnernamen macht die Ablage für Menschen schlechter durchsuchbar, und die Rechnungsnummer im Dateinamen ist ohnehin eindeutig.
**`INVOICE_DIR` muss ein anderes Verzeichnis sein als `UPLOAD_DIR`.** Auf die Uploads zeigt mit `app/uploads/[...path]` eine öffentliche Route, die jede Datei darunter ohne Anmeldung ausliefert. Lägen Rechnungen dort, könnte jede Person, die den Pfad errät, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine Route; der Abruf läuft ausschließlich über `/rechnung/<nummer>/pdf` und prüft, ob die Rechnung der angemeldeten Person gehört (Admins sehen alle).
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump auf die Storage Box (30 Tage Aufbewahrung). Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume. Die zehnjährige Aufbewahrung nach § 147 AO deckt das nicht ab. Vorgesehen ist dafür, dass ERPNext jede Rechnung samt PDF über `/api/erp/rechnungen` übernimmt; im Cluster fehlt dafür Stand 2026-10-16 noch `ERP_EXPORT_TOKEN` (siehe „Projektstand“).
> Bei **mehr als einem Knoten**: im Repo ist das PVC `wabenhain-invoices` als `ReadWriteOnce` angelegt und ließe sich nur von Pods auf demselben Knoten einhängen; landet der Sicherungs-Pod woanders als die App, bleibt er ohne Fehlermeldung in „Pending“. Im Cluster sind `wabenhain-invoices` und `wabenhain-uploads` deshalb bereits `ReadWriteMany` auf SMB (Stand 2026-10-16); das Repo-Manifest weicht davon ab.
### Umsatzsteuer
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`, `VAT_RATE_PERCENT_SONSTIGES` (weitere Bienenprodukte wie Salben oder Wachstücher, Vorgabe in `k8s/01-configmap.yaml`: 19).
| `0` | „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." anstelle der Steuerzeilen |
> **`VAT_RATE_PERCENT_PROPOLIS` bitte prüfen.** Voreingestellt sind 19 %, weil der volle Satz im Zweifel der sicherere ist. Als Lebensmittel wären 7 % richtig, als Nahrungsergänzungs- oder Kosmetikprodukt 19 % — das hängt von der konkreten Aufmachung ab.
**Mehrere Sätze auf einer Rechnung** sind der Normalfall, sobald Honig und Kerze im selben Paket liegen. Die Rechnung weist dann je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt werden im Verhältnis der Warenwerte auf die Sätze verteilt — der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung; gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt das Hare-Niemeyer-Verfahren, damit dabei keine Cent verschwinden oder entstehen.
Der Satz wird je **Bestellposition** als `OrderItem.vatRatePercent` festgeschrieben — Teil des Snapshots wie der Preis. Wird ein Produkt später umkategorisiert oder ändern sich die Sätze, bleiben bestehende Rechnungen unverändert.
**Die Shop-Preise sind Bruttopreise.** Gegenüber Verbraucher:innen ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser Betrag steht in `Product.priceCents` und `Order.totalCents`. Die Steuer wird deshalb **herausgerechnet, nicht aufgeschlagen** — ein Aufschlag würde den Endpreis verändern und von dem abweichen, was im Warenkorb stand. Gerechnet wird auf der Gesamtsumme, damit sich Rundungen einzelner Positionen nicht aufsummieren; der Steuerbetrag ergibt sich als Rest, sodass Netto + Steuer immer exakt den Bruttobetrag ergibt. Der Versand trägt den Satz der Ware (umsatzsteuerliche Nebenleistung).
`pnpm preflight` blockiert einen fehlenden oder unplausiblen `VAT_RATE_PERCENT`.
### Rechnungsnummer
Die Nummer hat die Form `RE-<Jahr>-<laufende Nummer>`, zum Beispiel `RE-2026-0001`. § 14 Abs. 4 Nr. 4 UStG verlangt eine **fortlaufende** Nummer — eine bloß eindeutige genügt nicht, weil sich mit ihr bei einer Betriebsprüfung nicht zeigen ließe, dass keine Rechnung fehlt. Die Zählung beginnt in jedem Jahr wieder bei `0001`; das ist zulässig, solange das Jahr Teil der Nummer ist.
Die laufende Nummer kommt aus der Tabelle `invoice_counters` (eine Zeile je Jahr) und wird zusammen mit der Rechnung in **einer** Transaktion vergeben. Eine Vergabe nach dem Muster „höchste vorhandene Nummer + 1“ wäre anfällig dafür, dass zwei gleichzeitig eintreffende Stripe-Webhooks dieselbe Nummer lesen und zweimal vergeben; die Zeilensperre beim Hochzählen schließt das aus.
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine Rechnung existiert. Stripe stellt denselben Webhook bei ausbleibender Bestätigung erneut zu — ohne diese Prüfung verbrauchte jeder Wiederholungsversuch eine Nummer und riße eine Lücke in die Folge.
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
### E-Rechnung
Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang).
- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten.
- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image.
- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log.
- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig.
### Rechnungskopie
Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie.
### Stornorechnung
Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST-<Jahr>-<laufende Nummer>` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log).
ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`.
### Kunden und Firmenanfragen im CRM (ERPNext)
Zwei weitere nur lesende Endpunkte mit demselben `ERP_EXPORT_TOKEN` (`lib/erp-crm-export.ts`):
| Endpunkt | Inhalt | In ERPNext |
|---|---|---|
| `GET /api/erp/kunden?nach=<id>&limit=<1-200>` | Kundenkonten (ohne Admins, ohne Passwort/2FA), Standardadresse | Kunde in der Gruppe **Privat** mit Kontakt und Adresse |
| `GET /api/erp/anfragen?nach=<id>&limit=<1-200>` | Firmenanfragen der Geschäftskunden-Seite | Kunde (Firma) in der Gruppe **Gewerblich** mit Ansprechpartner, je Anfrage eine Chance |
ERPNext holt bei jedem Lauf die vollständige Liste (Cursor `nach` = letzte ID der vorigen Seite, `next` in der Antwort). Ein im Shop gelöschtes Konto ohne Rechnung verschwindet dadurch auch aus ERPNext; mit Rechnung bleibt der Kunde wegen der Aufbewahrungspflicht stehen.
## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
| Endpunkt | Prüft | Verwendung |
|---|---|---|
| `/api/health` | nur, ob der Node-Prozess antwortet — **kein** Datenbankzugriff | Liveness-Probe |
| `/api/health/ready` | zusätzlich `SELECT 1` gegen die Datenbank, antwortet sonst mit 503 | Readiness-Probe, Docker-Healthcheck |
Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der Datenbank hängt, macht aus einer vorübergehenden DB-Störung eine Neustart-Schleife, weil der neu startende Container laut `Dockerfile`-CMD zuerst `prisma migrate deploy` gegen genau diese gestörte Datenbank fährt. Die Readiness-Probe darf dagegen ruhig fehlschlagen — Kubernetes nimmt den Pod dann nur aus dem Load-Balancing, statt ihn zu töten. Vorher zeigten beide Probes auf `/`, also alle 10 bzw. 30 Sekunden auf eine `force-dynamic`-Seite samt DB-Abfragen und vollem React-Rendering.
## Kubernetes / Gitea CI
`.gitea/workflows/docker-build.yml` prüft bei jedem Push auf `main` zuerst `pnpm lint`, `pnpm audit --prod --audit-level high`, `pnpm typecheck` und `pnpm test` (Runner `ci-isolated`). Nur wenn alle grün sind, baut der Job `build-and-push` (Runner `docker-builder`) zwei Images und veröffentlicht sie in der Gitea-eigenen Container-Registry: `wabenhain` (App) und `wabenhain-tools` (Skripte für die CronJobs), jeweils mit den Tags `latest` und `sha-<kurzer Commit>`. Der App-Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig.
Die Pipeline wendet **keine** Manifeste an und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt; ein neuer Build geht per `kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>` in Betrieb, danach wird `image:` in `k8s/05-app-deployment.yaml` nachgezogen. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
## Sicherheit
- **Sessions als JWT (HS256, `jose`)**: httpOnly-Cookie, `SameSite=Lax`, `Secure` in Produktion, trägt Kunden-ID, Rolle und `sessionVersion`. Gültigkeit 30 Tage für Kundenkonten, **7 Tage für Admin-Konten** (kürzeres Zeitfenster für ein gestohlenes/vergessenes Cookie, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt). `SESSION_SECRET` muss in Produktion mindestens 32 Zeichen lang sein (wird beim Start erzwungen).
- **Session-Widerruf**: `Customer.sessionVersion` wird bei Passwort-Reset, 2FA-Ein-/Ausschalten und "Alle Geräte abmelden" hochgezählt; `getCurrentCustomer()` vergleicht diesen Wert gegen das JWT-Claim und behandelt eine Abweichung als abgelaufen — ein technisch noch gültiges Token verliert so sofort seine Wirkung, ohne eine serverseitige Session-Tabelle für jeden Request führen zu müssen.
- **Zwei-Faktor-Authentifizierung (TOTP)**: optional pro Konto unter `/konto/zwei-faktor`, kompatibel mit gängigen Authenticator-Apps (RFC 6238, `otpauth`). Bei Aktivierung werden 8 Einmal-Backup-Codes generiert (nur als SHA-256-Hash gespeichert, Klartext einmalig direkt nach der Einrichtung angezeigt). Login mit aktivem 2FA läuft zweistufig: Passwort korrekt → kurzlebiger Zwischenschritt-Cookie (5 Minuten, eigener Token-Typ, niemals mit einer vollständigen Session verwechselbar) → Code- oder Backup-Code-Eingabe auf `/konto/zwei-faktor-code`.
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Ist dieser letzte Eintrag eine Cloudflare-Edge, gilt stattdessen `CF-Connecting-IP`; IPv6-Adressen zählen je /64-Netz (`lib/rate-limit.ts`). Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
- **Abhängigkeits-Sicherheitsscan**: Die CI bricht ab, sobald `pnpm audit --prod --audit-level high` eine Lücke ab „high“ in Laufzeit-Abhängigkeiten findet; ohne grünen Audit entsteht kein Image. Lokal mit `pnpm audit` prüfen (siehe „Nützliche Befehle“ unten). Zuletzt geschlossen wurden High-/Critical-Advisories in Commit `835323e` (2026-10-14).
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren); hinter Cloudflare aus `CF-Connecting-IP`, siehe Rate-Limiting oben.
- **Nur über Cloudflare erreichbar**: Der Ingress hängt an der Middleware `wabenhain-cloudflare-only` (nur Cloudflare-Adressen und private Netze), direkte Zugriffe auf den Origin beantwortet Traefik mit 403. Die Middleware liegt nicht in diesem Repo, sondern in den Cluster-Manifesten (siehe Kommentar in `k8s/06-ingress.yaml`).
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestellnummern (`WH-<Jahr>-<8 Hex-Zeichen>`) kommen aus `crypto.randomBytes`; Rechnungsnummern sind dagegen fortlaufend (siehe „Rechnungsnummer“).
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
## Datenmodell
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
## Tests
- **Unit-/Integrationstests** (`src/**/*.test.ts(x)`, [Vitest](https://vitest.dev/), Stand 2026-10-16: 65 Testdateien, die meisten unter `src/lib`): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — unter anderem Versand- und Rabattberechnung, Umsatzsteuer, Rechnung und E-Rechnung, Checkout-, Abbruch- und Webhook-Route, Bestandsabgleich mit ApiCore, Sessions, CSRF, Client-IP und die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen; die CI führt sie bei jedem Push aus.
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. Die CI führt sie bisher **nicht** aus.
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bug ans Licht: der Cart-Store persistierte versehentlich auch den rein transienten „ist das Drawer offen"-Zustand in `localStorage` (`persist()` ohne `partialize`). Dadurch konnte sich das Cart-Drawer nach einem Seiten-Reload ungewollt wieder öffnen und die Seite dahinter blockieren, wenn es beim letzten Verlassen offen war. Behoben in `src/store/cart-store.ts`, indem nur noch `items` persistiert wird.
## Nützliche Befehle
```bash
pnpm dev # Entwicklungsserver
pnpm build # Produktions-Build
pnpm lint # ESLint
pnpm typecheck # TypeScript ohne Ausgabe (erzeugt vorher Prisma-Clients und Next-Routentypen)
pnpm test # Vitest (Unit-/Komponententests)
pnpm test:e2e # Playwright gegen eine laufende Instanz
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
Diese Anleitung führt durch alles, was außerhalb des Codes eingerichtet werden muss, damit der Shop in Kubernetes vollständig läuft: Geheimnisse (Secrets), Microsoft-Anmeldung, Mailversand über Microsoft 365, Stripe und die optionalen Dienste. Sie setzt nichts voraus, was nicht hier steht.
**Zeitbedarf:** etwa 2 Stunden für Pflicht und Mailversand, plus Wartezeiten bei Microsoft (bis zu 30 Minuten) und Stripe.
**Reihenfolge:**
1. [Werkzeuge und Zugänge bereitlegen](#1-werkzeuge-und-zugänge)
7. [Secret und ConfigMap ausfüllen](#7-secret-und-configmap-ausfüllen)
8. [Ausrollen](#8-ausrollen)
9. [Prüfen](#9-prüfen)
10. [Wartung: was abläuft und was zu tun ist](#10-wartung)
11. [Fehlerbehebung](#11-fehlerbehebung)
> **Sicherheitsregel für das ganze Dokument:** Echte Werte gehören ausschließlich in `k8s/02-secret.yaml` (per `.gitignore` ausgeschlossen) bzw. direkt ins Cluster. Nie committen, nie in Chats, Tickets oder Screenshots kopieren. Die ConfigMap (`k8s/01-configmap.yaml`) ist unverschlüsselt und im Cluster für jeden mit Lesezugriff im Klartext sichtbar — dort steht nichts Geheimes.
---
## 1. Werkzeuge und Zugänge
**Auf deinem Rechner:**
| Werkzeug | Wofür | Prüfen mit |
|---|---|---|
| `kubectl` mit Zugriff auf das Cluster | Secrets und Manifeste anwenden | `kubectl get nodes` |
| Microsoft Entra Admin Center (entra.microsoft.com) | Anwendungsadministrator oder Globaler Administrator | App-Registrierung, Administratorzustimmung |
| Microsoft 365 Admin Center (admin.microsoft.com) | Exchange-Administrator | Freigegebenes Postfach anlegen und freischalten |
- `ADMIN_EMAIL`: eine **echte, nicht erratbare** Adresse, z. B. `n.rosenloecher-admin@wabenhain.com` — **nicht** `admin@…` oder `info@…`. Sonst könnte jemand diese Adresse vorher als Kundenkonto registrieren. An diese Adresse gehen außerdem die Benachrichtigungen über eingegangene Widerrufe.
Das Konto wird beim ersten Start angelegt. Beim ersten Login verlangt der Admin-Bereich die Einrichtung der Zwei-Faktor-Authentifizierung (Authenticator-App bereithalten).
---
## 3. Microsoft Entra ID: App-Registrierung
**Eine** App-Registrierung erledigt zwei Aufgaben:
- **„Mit Microsoft anmelden“** (Single Sign-on im Kundenkonto)
- **Mailversand** über Microsoft 365 per OAuth 2.0 — ohne Passwort
Eine zweite Unternehmensanwendung ist nicht nötig.
3. **Unterstützte Kontotypen:** **„Nur Konten in diesem Organisationsverzeichnis“** (Single Tenant).
4. **Umleitungs-URI:** Plattform **Web**, Adresse **`https://wabenhain.com/api/auth/entra/callback`** — zeichengenau, mit `https`, ohne Schrägstrich am Ende.
`ENTRA_REDIRECT_URI` ist die Adresse aus Schritt 4: `https://wabenhain.com/api/auth/entra/callback`.
### 3.2 Geheimen Clientschlüssel anlegen
1. In der App → **Zertifikate & Geheimnisse** → **Geheime Clientschlüssel** → **Neuer geheimer Clientschlüssel**.
2. Beschreibung z. B. `Shop 2026`, Ablauf **24 Monate** (Maximum).
3. **Sofort** die Spalte **„Wert“** kopieren → `ENTRA_CLIENT_SECRET`.
> ⚠ **Nicht die „Geheimnis-ID“** kopieren — das ist der häufigste Fehler (Microsoft meldet dann `AADSTS7000215`). Der Wert wird nur dieses eine Mal angezeigt; ist er weg, einen neuen Schlüssel anlegen.
>
> **Ablaufdatum sofort in den Kalender eintragen**, mit Erinnerung vier Wochen vorher. Läuft der Schlüssel ab, fallen Microsoft-Anmeldung **und** Mailversand gleichzeitig aus (siehe [Wartung](#10-wartung)).
### 3.3 API-Berechtigungen
In der App → **API-Berechtigungen**. Es braucht zwei Arten:
| Berechtigung | API | Typ | Wofür |
|---|---|---|---|
| `openid`, `email`, `profile`, `User.Read` | Microsoft Graph | **Delegiert** | Anmeldung („Mit Microsoft anmelden“) |
**Danach:** **„Administratorzustimmung für <Organisation> erteilen“** klicken und bestätigen. In der Liste muss bei jeder Berechtigung ein grünes **„Erteilt für …“** stehen.
> **Was `SMTP.SendAsApp` erlaubt:** Die App darf per SMTP senden — aber **nur** mit Postfächern, die ihr in Exchange ausdrücklich freigegeben werden (Abschnitt 4). Ohne diese Freigabe kann sie nichts senden.
### 3.4 Objekt-ID der Unternehmensanwendung notieren
2. Filter „Anwendungstyp“ auf **Alle Anwendungen** stellen, nach `Wabenhain Shop` suchen, öffnen.
3. Auf der **Übersicht** die **Objekt-ID** kopieren.
> ⚠ Das ist **eine andere Objekt-ID** als die auf der Seite der App-Registrierung. Für Exchange zählt nur die der **Unternehmensanwendung**.
---
## 4. Microsoft 365: Absender-Postfach freischalten
Alle Shop-Mails (Bestellbestätigung, Versandmeldung, Passwort-Reset, Widerrufsbestätigung …) gehen von **einem** Postfach aus, z. B. `hallo@wabenhain.com`.
**Empfohlen: ein freigegebenes Postfach (Shared Mailbox).** Es braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. Die App sendet ohne Anmeldung einer Person.
### 4.1 Freigegebenes Postfach anlegen (falls noch nicht vorhanden)
**Empfohlene Absicherung:** Authentifiziertes SMTP für alle **anderen** Postfächer abschalten. Die Einstellung am Absender-Postfach aus Schritt 3 hat Vorrang, der Shop sendet weiter.
Server (`smtp.office365.com`), Port (587, STARTTLS) und Anmeldeverfahren (OAuth 2.0) sind fest im Code — dafür gibt es keine Einstellungen. Ein SMTP-Passwort gibt es nicht.
---
## 5. Stripe
Im **Stripe-Dashboard**, **Live-Modus** (Schalter oben rechts, nicht „Testmodus“).
Sicherer als der volle geheime Schlüssel ist ein **eingeschränkter Schlüssel** (`rk_live_…`) mit Schreibrecht auf Checkout Sessions, Refunds und Leserecht auf Charges/Payment Intents.
> Beide Schlüssel müssen aus **demselben Modus** stammen. Mit `sk_test` sehen Bestellungen erfolgreich aus, ohne dass Geld fließt.
Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOOK_SECRET` im Secret.
> Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „Neu“, es gibt weder Rechnung noch Bestätigungsmail.
### 5.3 Zahlungsarten
**Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren.
### 5.4 Abo der Bienenpatenschaft
**Stand Livekonto (2026-10-16, eingerichtet):**
- Webhook `https://wabenhain.com/api/checkout/webhook` mit allen zwölf
Ereignissen (die sechs bisherigen plus `charge.dispute.closed`,
`invoice.payment_failed`). Das Signatur-Geheimnis bleibt dasselbe.
- Kundenportal `bpc_1URCIfEvHaDWQ1U0nD4jVWXc`: nur Zahlungsart ändern, keine
Kündigung, kein Tarifwechsel, keine Kundendaten, keine Rechnungshistorie.
Steht als `STRIPE_PORTAL_CONFIGURATION_ID` in `k8s/01-configmap.yaml`.
- Zahlungsmethoden-Domains `wabenhain.com` und `www.wabenhain.com` registriert
(Apple Pay, Google Pay, PayPal, Link, Klarna im eingebetteten Checkout).
**Nur im Dashboard möglich (Stripe bietet dafür keine API):**
1. Einstellungen → Billing → Abos und E-Mails → Wiederholungsversuche:
Smart Retries an, nach dem letzten Versuch **Abo kündigen**.
2. Einstellungen → Billing → Abos und E-Mails → Kunden-E-Mails: Mails zu
erfolgreichen Zahlungen, anstehenden Verlängerungen und fehlgeschlagenen
Zahlungen **aus** (Wabenhain verschickt eigene Rechnungen und Mails).
3. Einstellungen → Zahlungsmethoden: Google Pay ist in der Konfiguration
„Default“ als nicht verfügbar markiert — dort aktivieren.
Das Abo läuft über Stripe Billing (Checkout im Modus „subscription“). Produkte und Monatspreise der Abo-Angebote legt Wabenhain selbst an, sobald ein Abo-Angebot im Admin gespeichert oder zum ersten Mal abgeschlossen wird — **nicht** von Hand im Dashboard anlegen.
**Einrichtungsskript** (wiederholbar, schreibt ohne `--apply` nichts):
Das Skript liest `STRIPE_SECRET_KEY` aus der Umgebung (`.env`) und
1. ergänzt den Webhook-Endpunkt `<BASE_URL>/api/checkout/webhook` um die Abo-Ereignisse (bestehende bleiben): `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.dispute.closed`. Legt es den Endpunkt neu an, steht das Signaturgeheimnis nur im Dashboard → `STRIPE_WEBHOOK_SECRET`.
2. legt eine Kundenportal-Konfiguration an, die **nur das Ändern der Zahlungsart** erlaubt (keine Kündigung, kein Tarifwechsel, keine Kundendaten, kein Rechnungsverlauf). Die App findet sie über die Metadaten `wabenhain=abo-zahlungsart`; alternativ `STRIPE_PORTAL_CONFIGURATION_ID` setzen. Ohne diese Konfiguration bietet das Konto „Zahlungsart ändern“ nicht an — die Standardkonfiguration wird bewusst nie benutzt.
**Nur im Dashboard** (Checkliste, das Skript gibt sie ebenfalls aus):
- [ ] **Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen:** Smart Retries an; nach dem letzten erfolglosen Versuch **Abo kündigen** (nicht „unbezahlt markieren“). Wabenhain beendet das Abo dann über `customer.subscription.deleted`.
- [ ] **… Kunden-E-Mails:** Stripe-Mails zu erfolgreichen Zahlungen/Rechnungen, bevorstehenden Verlängerungen und fehlgeschlagenen Zahlungen **aus** — Wabenhain verschickt eigene Rechnungen (Nummernkreis RE-…) und Mails.
- [ ] **Einstellungen → Zahlungsmethoden:** Karte, Apple Pay, Google Pay und SEPA-Lastschrift aktiv. Im Abo zeigt Checkout nur Zahlarten, die wiederkehrende Zahlungen können. Google Pay prüfen; für Apple Pay/Google Pay im eingebetteten Checkout die Domain registrieren.
- [ ] **Stripe Tax** nur mit aktiver Registrierung einschalten (Preise sind Bruttopreise; ohne Registrierung berechnet Stripe nichts).
**Im Admin:** SEPA-Neukunden-Frist (Vorgabe 56 Tage, 0 schaltet ab), Kontingente und Erntefenster der Abo-Angebote.
**Was automatisch passiert:** Jede bezahlte Monatsrechnung wird eine Wabenhain-Bestellung mit Rechnung und Mail; die erste erzeugt die Urkunde. Nach drei bezahlten Monaten (Ernte-Variante: im Erntefenster nach sechs) legt der Hintergrundlauf alle zehn Minuten eine 0-€-Lieferbestellung an, die in der normalen Pack- und DHL-Liste erscheint. Eine Rückbuchung setzt das Abo in Verzug, sperrt SEPA für das Konto und beendet das Abo nach 14 Tagen ohne Ausgleich. Kündigungen kommen aus dem Konto oder von `/vertraege-kuendigen` (Footer „Verträge hier kündigen“); öffentliche Kündigungen stehen im Audit-Log (`subscription.cancellation_request`) und gehen zusätzlich an `ADMIN_EMAIL`.
---
## 6. Optionale Dienste
Alle folgenden Werte dürfen leer bleiben. Die Funktion ruht dann, der Shop läuft weiter. Ausführliche Anleitungen stehen in `.env.example` im jeweiligen Abschnitt.
| Dienst | Werte | Ohne sie | Einrichtung |
|---|---|---|---|
| **Cloudflare Turnstile** (CAPTCHA) — *dringend empfohlen* | `TURNSTILE_SECRET_KEY` (Secret), `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (ConfigMap) | Keine Bot-Abwehr an Formularen | dash.cloudflare.com → Turnstile → Widget für `wabenhain.com` anlegen. Beide setzen oder beide leer. |
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
| **ApiCore-Synchronisierung** | `APICORE_API_KEY` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | API-Schlüssel aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel, nur Berechtigung `shop-sync:read`; kein Benutzerkonto, kein Passwort |
| **Bestandsabgleich mit ApiCore** | zusätzlich `APICORE_WEBHOOK_SECRET` (Secret), Berechtigung `shop-sync:write` am Schlüssel, CronJob `wabenhain-apicore-stock` | Der Webhook `/api/apicore/webhook` antwortet mit 503, Entnahmen in ApiCore kommen erst mit dem nächsten Abgleich an | ApiCore → Automatisierung → Webhooks: Webhook für `stock.changed` auf `https://wabenhain.com/api/apicore/webhook`, dessen Secret hier eintragen. Einzelheiten im README, Abschnitt „Bestandsabgleich“ |
| **ERPNext-Export** (Rechnungen, Stornorechnungen, Kunden, Firmenanfragen) | `ERP_EXPORT_TOKEN` (Secret), mindestens 32 Zeichen | Die Endpunkte unter `/api/erp/…` lehnen jeden Abruf ab, ERPNext übernimmt nichts | Zufallswert erzeugen (wie `SESSION_SECRET` in Abschnitt 2) und denselben Wert in ERPNext als Token für Wabenhain hinterlegen |
---
## 7. Secret und ConfigMap ausfüllen
### 7.1 Secret (`k8s/02-secret.yaml`)
```bash
cp k8s/02-secret.example.yaml k8s/02-secret.yaml
```
`k8s/02-secret.yaml` öffnen und jeden Wert aus der Tabelle eintragen. Werte in **doppelten Anführungszeichen** lassen (`stringData` — Kubernetes kodiert selbst, **kein** base64 von Hand).
| Schlüssel | Pflicht | Inhalt | Quelle |
|---|---|---|---|
| `DATABASE_URL` | ja | Verbindungs-URL mit **URL-kodiertem** Passwort | Abschnitt 2 |
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
**Neuen Build ausrollen:** Die Pipeline baut nur Images und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt, ein `rollout restart` startet deshalb nur denselben Build neu. Einen neuen Build setzt man so ein (Digest aus der Gitea-Registry, Paket `wabenhain`, Tag `sha-<kurzer Commit>`):
```bash
kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
kubectl -n wabenhain rollout status deploy/wabenhain-app
```
Danach `image:` in `k8s/05-app-deployment.yaml` auf denselben Wert ziehen. Einzelheiten: `k8s/README.md`, Abschnitt „Bei einem neuen Image“.
---
## 9. Prüfen
Der Reihe nach:
1. **Vollständigkeit der Konfiguration** im laufenden Pod:
4. **Mailversand und Anbindungen:** **Admin → Einstellungen → System & Tests**. Oben steht, welche Dienste eingerichtet sind (fehlende Werte werden benannt). Darunter „Test-Mail senden“: zuerst prüft der Test, ob `smtp.office365.com:587` und `login.microsoftonline.com:443` per IPv4 erreichbar sind, dann den Versand. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
6. **Firmendaten:** **Admin → Einstellungen → Firma & Versand** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
---
## 10. Wartung
| Was | Läuft ab / ändert sich | Folge, wenn verpasst | Was tun |
|---|---|---|---|
| **Entra-Clientschlüssel** (`ENTRA_CLIENT_SECRET`) | nach höchstens **24 Monaten** | Microsoft-Anmeldung **und** Mailversand fallen gleichzeitig aus | Vier Wochen vorher neuen Schlüssel anlegen (3.2), Secret ersetzen, Pod neu starten, **E-Mail-Test**, danach alten Schlüssel in Entra löschen |
| **Stripe-Schlüssel** | bei Verdacht auf Abfluss | Fremde könnten Erstattungen auslösen | Im Stripe-Dashboard „Schlüssel rollen“, Secret ersetzen, Pod neu starten |
| **`SESSION_SECRET`** | bei Verdacht auf Abfluss | Sitzungen fälschbar | Neuen Wert erzeugen, ersetzen, neu starten (meldet alle ab) |
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → Einstellungen → System & Tests**.
---
## 11. Fehlerbehebung
### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
| Meldung / Ursache | Lösung |
|---|---|
| „nicht vollständig eingerichtet“ + Liste fehlender Werte | Genannte Werte ins Secret, Pod neu starten |
| `AADSTS7000215` — Client-Secret falsch | In `ENTRA_CLIENT_SECRET` steht die Geheimnis-ID statt des **Werts** → neuen Schlüssel anlegen (3.2) |
| `AADSTS7000222` — Client-Secret abgelaufen | Neuen Schlüssel anlegen (3.2), ersetzen, neu starten |
| `AADSTS700016` — App nicht gefunden | `ENTRA_CLIENT_ID` und `ENTRA_TENANT_ID` gehören nicht zusammen |
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
| „nicht erreichbar“ (Netzwerk) | Die Netzwerkprüfung im Test zeigt, welcher Weg fehlt. Der Cluster muss ausgehend per IPv4 `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dort die Freigabe von Port 587 beantragen. |
| `ENETUNREACH 2603:…` (IPv6-Adresse) | Stammte aus älteren Versionen, die zufällig eine IPv6-Adresse wählten; der Shop verbindet sich inzwischen nur per IPv4. Tritt es trotzdem auf: aktuelles Image ausrollen. |
| Knopf „Mit Microsoft anmelden“ fehlt | Einer der vier `ENTRA_*`-Werte fehlt |
| Microsoft meldet „redirect_uri mismatch“ (`AADSTS50011`) | `ENTRA_REDIRECT_URI` und die Umleitungs-URI in der App-Registrierung (3.1) müssen zeichengenau gleich sein |
### Sonstiges
| Symptom | Lösung |
|---|---|
| Pod startet nicht, Log nennt Datenbankfehler | `DATABASE_URL`: Passwort URL-kodiert? Host passend zum Datenbank-Manifest? |
| Bestellungen bleiben auf „Neu“ | Stripe-Webhook (5.2): URL, Ereignisse und `STRIPE_WEBHOOK_SECRET` prüfen; im Stripe-Dashboard unter Webhooks die Zustellversuche ansehen |
| Bilder weg nach Neustart | `k8s/README.md`, Abschnitt „Bilder/Videos verschwinden nach jedem Redeploy“ |
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bienenpatenschaften als Einmalkauf und Abo verkaufen, mit deterministisch erzeugter Urkunde, Medaillen, 3D-Ansicht im Konto, Übertragung, Treuestufen und Stripe-Abo.
**Architecture:** Eine reine Layout-Engine (`src/lib/certificate`) erzeugt aus Personalisierung, Textbausteinen und Vorlagenversion positionierte Elemente; daraus entstehen PDF (pdfkit) und Bilder (SVG mit Glyphenpfaden → sharp). Einmal-Angebote hängen an je einem `Product` der neuen Kategorie `PATENSCHAFT`, damit Warenkorb, Checkout, Rechnung, ERP-Export und ApiCore-Buchung weiterlaufen; der Honig eines Pakets wird beim Checkout als eigene Position des verknüpften Honigprodukts angelegt (Steueraufteilung, Bestandsreservierung und ApiCore-Abbuchung ohne Sonderweg). Abos laufen über Stripe Billing; jede bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung mit Rechnung, jede fällige Lieferung zu einer 0-€-Bestellung mit Honigpositionen.
Abweichung vom Standard „ein Plan je Teilprojekt“: Der Inhaber hat alle fünf Teilprojekte auf einmal freigegeben. Der Plan ist deshalb in Teile A–E gegliedert, jeder Teil ist für sich lauffähig und testbar und wird einzeln committet.
## Global Constraints
- Kein Versprechen von Besuchen, Events, Videos, Fotos, Berichten, Umweltwirkungen – in keinem Text, keiner Mail, keiner Urkunde.
- Urkunde: A4 hochkant (595.28 × 841.89 pt), genau eine Seite, Inhalt ≥ 15 mm (42.52 pt) vom Rand, Rahmen ≥ 12 mm (34.02 pt), kleinste Schrift 8 pt, Fließtext ≥ 11 pt.
- Schriften Cormorant Garamond und Inter, statische TTF unter SIL OFL, mit Lizenzdatei in `assets/fonts/`.
- Farben: Forest `#24352b`, Honey Dark `#8a6531`, Honey `#b88a44`, Sage `#788774`; auf Papier kein Verlauf, keine Folienimitation.
- Patenschaften nur mit angemeldetem, verifiziertem Konto. Gastbestellungen anderer Produkte bleiben.
- PDFs in `CERTIFICATE_DIR` (nie unter `UPLOAD_DIR`), Downloads nur nach Besitzprüfung, fremde IDs → 404.
- Wasserzeichen der Vorschau: „Unverbindliche Vorschau“ / „Preview – not binding“.
- Abo: Lieferanspruch nach 3 (vierteljährlich) bzw. 6 (Ernte) bezahlten Monaten; SEPA-Neukunden-Frist 56 Tage (Admin, 0 = aus); Rückbuchung → `IM_VERZUG`, nach 14 Tagen ohne Ausgleich Ende; Konto mit Rückbuchung → kein SEPA mehr.
- Abo-Stufen 1–24 je bezahltem Monat, danach VIP; Stammkunden-Stufen ab 2/5/10/25 bezahlten Bestellungen; beides im Admin pflegbar.
- Übertragungslinks: 24 h / 7 Tage / 30 Tage, einmalig, widerrufbar, Token ≥ 128 Bit, nur Hash gespeichert.
- Zahlarten nie per `payment_method_types`; Stripe-Livekonto nur über Einrichtungsskript bzw. Stripe-Anbindung ändern.
- Codekommentare und Texte auf Deutsch, Stil wie im Repo (Warum-Kommentare, keine console.log zum Debuggen).
- Agents committen nicht; der Hauptlauf committet zentral.
## Review Focus
1. Zwei Patenschaften desselben Pakets im Warenkorb dürfen nicht verschmelzen → Tests in A7 (Warenkorbschlüssel) und A8 (Checkout mit doppelter productId).
2. Webhook mehrfach oder parallel: genau eine Patenschaft, genau eine Mail → Test in A9.
3. Manipulierte Personalisierung (überlange Widmung, fremde Zeichen, `<script>`): Checkout lehnt ab, Urkunde zeigt nur Text → Tests in A2 und A8.
4. Abo gekündigt, während eine Lieferung fällig wird: keine Lieferung über bezahlte Monate hinaus → Test in D4.
5. Honig knapp, während Abos reserviert sind: Einzelverkauf und Pakete können reservierte Gläser nicht kaufen → Test in D5.
---
## Teil A – Urkunde und Einmal-Patenschaft (Teilprojekt 1)
`src/lib/sponsorship/offers.ts` (`getActiveOffers`, `getOfferBySlug`), Admin `/admin/patenschaften/angebote`, Upsert legt das `Product` (Kategorie `PATENSCHAFT`, Preis = Paketpreis − Honiganteil) mit an; Seed der drei Pakete; `PATENSCHAFT` aus Shop-Listen und Produktseite filtern.
### Task A6: Vorschau-API und Verkaufsseite
`/api/patenschaft/vorschau` (Origin, Rate-Limit 60/10 min, Schema + Fit, WebP 900 px mit Wasserzeichen, `no-store`); `/patenschaft` mit Konfigurator (Einmalig/Abo, Schritte, Zähler, Debounce 400 ms, Bestätigung, Anmeldezwang mit Entwurf in `sessionStorage``wabenhain-sponsorship-draft`).
### Task A7: Warenkorb
`CartItem.key`, `CartItem.sponsorship?: { offerSlug; personalization }`, Operationen über `key`, `persist` Version 2 mit `migrate`, Patenschaft Menge fest 1. Tests: zwei gleiche Pakete bleiben zwei Positionen; alter Speicherstand wird migriert.
### Task A8: Checkout
`expandSponsorshipItems(...)` in `src/lib/sponsorship/checkout-expand.ts`: Hauptposition ohne Reservierung, Honigposition aus verknüpftem Produkt (`unitPriceCents = honeyShareCentsPerJar`, Menge = Gläser, Steuer des Honigs), Kontopflicht, erneute Prüfung, reine Digitalbestellung → `DIGITAL` ohne Versand, doppelte productIds erlaubt. Tests inkl. Manipulation.
### Task A9: Ausstellung nach Zahlung
`createSponsorshipsForOrder(tx, orderId)` im Claim-Lauf; `issuePendingSponsorships({orderId?, limit?})`; Mail-Claim über `mailSentAt`; Backoff `min(2^n × 5 min, 12 h)`, 8 Versuche → `FEHLER`; `scripts/sponsorship-jobs.ts` + CronJob `wabenhain-sponsorship`; `CERTIFICATE_DIR` mit Startprüfung. Tests: doppelter Aufruf, Fehler beim Rendern, Wiederholung.
### Task A10: Kontobereich und Download
`/konto/patenschaft`, `/konto/patenschaft/[id]`, PDF-Route mit Besitzprüfung, `getOwnedSponsorship(customerId, id)`.
- D6 Kündigung im Konto und unter `/vertraege-kuendigen`, `cancel_at_period_end`, Mail, ENDED-Revision bei Wirksamkeit.
- D7 `scripts/stripe-setup.ts` und Livekonto über die Stripe-Anbindung (Webhook-Ereignisse, Kundenportal nur Zahlungsart), Checkliste in `docs/EINRICHTUNG.md`.
Steueraufteilung, Medaillenstufe. Es kann beliebig viele Angebote beider Arten
geben.
### Einmal-Patenschaften
| Paket | Testpreis | Leistungen | Versand |
|---|---:|---|---|
| Bienenfreund | 19 € | symbolische Namensgebung, PDF-Urkunde, Medaille, Konto mit 3D-Ansicht und Downloads | keiner |
| Honigfreund | 39 € | digitale Leistungen, gedruckte Urkunde, 1 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
| Wabenfreund | 59 € | digitale Leistungen, gedruckte Urkunde, 3 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
- Laufzeit zwölf Monate ab Startdatum, endet automatisch.
- Nach Ablauf trägt die Urkunde weiter ihr Von-bis-Datum. Urkunde, Medaille
und Downloads bleiben im Konto verfügbar.
- Alle Pakete bekommen dieselbe Urkunde in derselben Qualität. Sie
unterscheiden sich in den physischen Leistungen und in der Medaillenstufe.
### Abo-Patenschaften
Zwei Ausgangsangebote, Preise vom Inhaber festzulegen:
| Angebot | Zahlung | Honig | Lieferung |
|---|---|---|---|
| Patenschaft im Abo, vierteljährlich | monatlich | z. B. 2 × 500 g je Lieferung | nach jeweils 3 bezahlten Monaten |
| Patenschaft im Abo, nach der Ernte | monatlich | z. B. 3 × 500 g je Lieferung | in den Erntefenstern, frühestens nach 6 bezahlten Monaten seit der letzten Lieferung |
- Monatlich kündbar zum Ende des bezahlten Monats.
- Versand ist im Monatspreis enthalten. Die Verkaufsseite sagt das deutlich.
- Ein Abo gilt einer Biene mit einem Namen und hat eine Urkunde.
- Abo und Einmalkauf werden getrennt bezahlt (eigene Stripe-Sitzung je Abo).
- Ein Abo lässt sich in dieser Fassung nicht übertragen, weil die Zahlung am
Konto des Käufers hängt.
### Grußkarte
Vorgedruckt, nicht personalisiert, im Wabenhain-Design. Persönliche Worte
stehen in der Widmung auf der Urkunde. So entsteht kein Handschreiben.
## Kundenkonto ist Pflicht
- Patenschaften kauft nur, wer angemeldet ist. Das betrifft Einmal- und
Abo-Angebote. Für andere Produkte bleibt die Gastbestellung wie heute.
- Die Personalisierung lässt sich ohne Anmeldung ausprobieren, samt Vorschau.
Beim Klick auf „In den Warenkorb“ oder „Abo abschließen“ führt die Seite zur
Anmeldung oder Registrierung. Die Eingaben bleiben dabei erhalten
(Entwurf im `sessionStorage`, nach der Anmeldung zurückgeschrieben).
- Der Checkout prüft serverseitig, dass ein Konto angemeldet ist, sobald eine
Patenschaft im Warenkorb liegt.
## Personalisierung
### Felder
| Feld | Pflicht | Hinweis |
|---|---|---|
| Name der Patenbiene | ja | symbolischer Name |
| Name der Person auf der Urkunde | ja | für wen die Urkunde ausgestellt ist |
| Schenkende | nein | eine oder mehrere Personen als Freitext („Anna und Ben“) |
| Widmung | nein | wird unverändert übernommen |
| Anlass | ja | für mich selbst, Geburtstag, Weihnachten, Dankeschön, allgemeines Geschenk |
| Startdatum | ja | Einmal: frühestens Bestelltag, spätestens sechs Monate danach. Abo: Tag der ersten Zahlung |
Bei „für mich selbst“ blendet das Formular „Schenkende“ aus.
### Eingabelimits
Die endgültigen Limits ergeben sich aus der Layout-Testmatrix. Startwerte:
- Bienenname: 2–24 Zeichen
- Name auf der Urkunde: 2–60 Zeichen
- Schenkende: bis 80 Zeichen
- Widmung: bis 280 Zeichen und höchstens 5 Zeilenumbrüche
Das Formular zeigt jedes Limit vor der Bestellung als Zeichenzähler an. Server
und Client prüfen mit demselben zod-Schema.
### Zeichen
- Eingaben werden nach Unicode NFC normalisiert, Steuerzeichen entfernt,
Mehrfach-Leerzeichen zusammengefasst.
- Zulässig ist jedes Zeichen, für das die eingebetteten Schriften eine Glyphe
haben. Die Prüfung läuft serverseitig über die Font-Metriken (fontkit).
Fehlt eine Glyphe (zum Beispiel Emoji), lehnt das Formular die Eingabe mit
klarer Meldung ab, statt ein Ersatzkästchen zu drucken.
- Umlaute, ß, Akzente, Bindestriche und Apostrophe funktionieren.
## Urkundentext
Der Text entsteht aus hinterlegten Bausteinen. Es gibt keine KI-Anfrage pro
Bestellung. Der Kunde sieht den endgültigen Wortlaut vor dem Kauf in der
Vorschau.
### Regeln
- Eigene und verschenkte Patenschaft haben unterschiedliche Formulierungen.
- Geburtstag, Weihnachten und Dankeschön haben je eine kurze Anlasszeile.
„Für mich selbst“ und „allgemeines Geschenk“ haben keine.
- Leere Felder erzeugen keine Platzhalter, keine leeren Zeilen und keine
verwaisten Bezeichnungen wie „Von:“ ohne Namen.
- Keine erfundenen Eigenschaften, Beziehungen oder Ereignisse.
- Keine Geschlechtszuordnung: keine Pronomen, keine aus dem Namen
abgeleiteten Anreden.
- Keine Formulierung, die eine individuelle Betreuung oder Verfolgung einer
einzelnen Biene behauptet.
- Die Widmung wird wörtlich übernommen, nur sicher dargestellt (kein HTML,
kein Markup) und sauber umbrochen.
### Bausteine (Entwurf, vor Abnahme sprachlich final prüfen)
Überschrift: „Bienenpatenschaft“, darunter klein „Urkunde“.
// Ueberspringt alles, sobald bereits Produkte existieren — legt echte,
// im Adminbereich spaeter geaenderte/geloeschte Produkte nie erneut an.
constproductCount=awaitprisma.product.count();
if(productCount>0){
console.log("Produkte bereits vorhanden, ueberspringe Demo-Daten-Seed.");
return;
}
constfruehtracht=awaitprisma.harvest.upsert({
where:{slug:"fruehtracht-2026"},
update:{},
create:{
year: 2026,
type:"Frühtracht",
slug:"fruehtracht-2026",
harvestDate: newDate("2026-05-10"),
location:"Altenmünster",
description:
"Die erste Ernte des Jahres entsteht aus dem Nektar von Obstblüte, Raps und Löwenzahn rund um Altenmünster. Mild, floral und feincremig — der Auftakt jeder Wabenhain-Saison.",
images:[],
},
});
constsommertracht=awaitprisma.harvest.upsert({
where:{slug:"sommertracht-2026"},
update:{},
create:{
year: 2026,
type:"Sommertracht",
slug:"sommertracht-2026",
harvestDate: newDate("2026-07-18"),
location:"Altenmünster",
description:
"Im Hochsommer sammeln unsere Völker Nektar von Linde, Wiesenblumen und Wald. Das Ergebnis ist kräftiger, aromatischer und charaktervoller als die Frühtracht.",
'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.'
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
);
must(
"STRIPE_WEBHOOK_SECRET gesetzt",
isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
);
mustWhenSet(
"STRIPE_SECRET_KEY",
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
(raw)=>!raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
must(
"STRIPE_PUBLISHABLE_KEY gesetzt",
isSet("STRIPE_PUBLISHABLE_KEY"),
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
);
mustWhenSet(
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
(raw)=>raw.startsWith("pk_live_"),
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
// pruefen, wenn tatsaechlich ein eigener Wert hinterlegt ist.
shouldWhenSet(
"SMTP_FROM",
"SMTP_FROM zeigt auf eine eigene Domain",
(raw)=>!raw.includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
"Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
// die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse
// die jeweils andere Sprachfassung mit Luecken online gehen.
"Rechtstexte enthalten keine Platzhalter-Markierungen mehr",
withPlaceholder.length===0,
`Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
);
// Ein LEERES Feld ist ausdruecklich in Ordnung und loest keine Warnung aus:
// ohne gepflegte Nummer entfaellt die Zeile auf der Kontaktseite und im
// Impressum einfach (siehe lib/site-config.ts). Gemeldet wird nur eine
// tatsaechlich eingetragene Platzhalternummer — die sieht nach einem
// Kontaktweg aus, an dessen Ende niemand abnimmt.
'Eingetragen ist eine Platzhalternummer. Entweder die echte Nummer eintragen oder das Feld LEEREN — dann entfaellt die Angabe ueberall. Im Admin-Bereich unter "Inhalte" pflegbar.'
);
}catch(error: unknown){
// Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst
// sich der tatsaechlich ausgelieferte Text nicht feststellen.
warnings.push(
`Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine(
"Kein aktiver Stripe-Webhook-Endpunkt gefunden\n Ohne ihn wird keine Zahlung bestaetigt und kein abgebrochener Checkout aufgeraeumt. Unter https://dashboard.stripe.com/webhooks einen Endpunkt auf /api/checkout/webhook anlegen. Hinweis: mit einem eingeschraenkten Schluessel (rk_...) kann diese Pruefung die Liste moeglicherweise nicht sehen, obwohl ein Endpunkt existiert."
);
return;
}
must(
`Stripe-Webhook abonniert alle ${REQUIRED_WEBHOOK_EVENTS.length} benoetigten Ereignisse`,
missing.length===0,
`Nicht abonniert: ${missing.join(", ")}. Besonders checkout.session.expired: ohne dieses Ereignis bleibt der Lagerbestand jedes abgebrochenen Checkouts blockiert. Unter https://dashboard.stripe.com/webhooks am Endpunkt nachtragen.`
);
}catch(error: unknown){
// Weder Blocker noch stilles Bestehen: der Schluessel darf die Liste
// vielleicht gar nicht lesen (eingeschraenkter Schluessel), und daraus
// laesst sich nichts ueber die tatsaechliche Konfiguration ableiten.
warnings.push(
`Stripe-Webhook-Ereignisse konnten NICHT geprueft werden\n ${firstMeaningfulLine(
error
)}.ImDashboardunterhttps://dashboard.stripe.com/webhooks von Hand nachsehen, ob ${REQUIRED_WEBHOOK_EVENTS.join(
Loaded 100 of 740 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.