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