dfda94ddfcf1826615ef0bea4592835f0bee69c9
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dfda94ddfc |
feat(ui): Leseerlebnis fuer Wissen und rechtliche Seiten
- 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]> |
||
|
|
e75392c75b |
feat(ui): Warenkorb-Seite mit Fortschritt zum Mengenrabatt
- 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]> |
||
|
|
467984d027 |
feat(ui): Ernten, Chargen-Nachweis und aktive Navigation
- 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]> |
||
|
|
1c5282e84f |
fix: fehlenden STRIPE_PUBLISHABLE_KEY im Server-Log melden
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]>
|
||
|
|
064ca3ff6d |
feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
- 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]> |
||
|
|
4ee5bafe20 |
feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
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]> |
||
|
|
6ac7cc5d1f |
feat(ui): Kundenkonto im Wabenstil
- 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]> |
||
|
|
3de17ca070 |
feat(ui): Startseite mutiger gestaltet
- 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]> |
||
|
|
3cb639c154 |
feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet
- 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]> |
||
|
|
839bbff595 |
feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)
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]> |
||
|
|
fb9c2353c6 |
feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
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]> |
||
|
|
01eaca39dc |
feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/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]> |
||
|
|
2659db64bd |
docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s
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]> |
||
|
|
c35d61bfd6 |
feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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]> |
||
|
|
256e823c4f |
fix: Erntepost-Eingabefeld war auf 34 px zusammengedrueckt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m13s
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]> |
||
|
|
b9f92b5c0d |
feat: Rechtstexte nennen alle Empfaenger, denen tatsaechlich Daten zugehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m11s
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]> |
||
|
|
3393de2286 |
fix: Hero-Text war ueber hellem Bildmaterial nicht lesbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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]>
|
||
|
|
368985c0cf |
fix: Newsletter und Firmenanfrage waeren mit aktivem CAPTCHA unabsendbar gewesen
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]> |
||
|
|
5870c60c50 |
feat: Ruecksendekosten sind geregelt und ausgewiesen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
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]> |
||
|
|
cc267ad8d4 |
fix: ohne JavaScript blieb der gesamte Seiteninhalt unter dem Hero unsichtbar
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]> |
||
|
|
c5e4978fad |
fix: Produkt ohne Geschmacksnoten bekam "Waldhonig — . 500 g" als Beschreibung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m5s
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]> |
||
|
|
7dd42e35bc |
perf: Produktbilder werden als AVIF ausgeliefert
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m43s
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]>
|
||
|
|
73c070f11e |
feat: Produktseiten liefern Google Versandkosten, Ruecknahme und Verkaeufer
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]> |
||
|
|
a8990a872a |
fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex
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]> |
||
|
|
bffcce127d |
fix: "Passwort vergessen" schob sich ueber das CAPTCHA
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m41s
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]>
|
||
|
|
7f9ed7262f |
refactor: Liefergebiet bekommt einen eigenen Abschnitt "Versand"
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]> |
||
|
|
1bdf4df04a |
feat: Gebiete mit Zollpflicht vom Versand ausgenommen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m7s
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]>
|
||
|
|
6d7a77c8dd |
feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
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]>
|
||
|
|
29f59adc6b |
feat: Kommando zum Nachschlagen der Google-Place-ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m37s
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]> |
||
|
|
6639da130e |
fix: Google-Rezensionen auf "Places API (New)" umgestellt
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]>
|
||
|
|
9b10a9f0c5 |
perf: Checkout haelt gleichzeitigem Andrang stand
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]>
|
||
|
|
cbc81caa1a |
fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s
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]>
|
||
|
|
e5cae9b49d |
perf: Firmendaten und Social-Links je Request nur noch einmal laden
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]> |
||
|
|
2bbb38c31a |
fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes
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]>
|
||
|
|
3e81896194 |
fix: Session-Pruefung akzeptierte Token anderer Zwecke
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]>
|
||
|
|
5170e477bc |
fix: leere Telefonnummer wird nirgends mehr angezeigt
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]>
|
||
|
|
dbf146c19f |
test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
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]> |
||
|
|
1fa8dc9ce2 |
fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
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]>
|
||
|
|
dda0a66c6c |
fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
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]> |
||
|
|
a3f91b83fc |
fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
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]>
|
||
|
|
e525db84ef |
feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 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]>
|
||
|
|
7fe1a01550 |
fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s
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]> |
||
|
|
33580b3290 |
feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
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]> |
||
|
|
6135c1f9c0 |
fix(security): Rechnungsablage kann nicht mehr ins oeffentliche Upload-Verzeichnis geraten
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]> |
||
|
|
92ac826baa |
feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
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]> |
||
|
|
8f0f06fd13 |
feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht
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]>
|
||
|
|
dc13bd071b |
fix: abgebrochene Video-Uploads werden als das gemeldet, was sie sind
"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]>
|
||
|
|
425fd76bc1 |
docs: fsGroup wirkt bei SMB/NFS nicht — Rechte gehoeren in die mountOptions
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
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]> |
||
|
|
625b73a8c8 |
feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
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]> |
||
|
|
7c59283ede |
feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
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]>
|
||
|
|
70ede77dde |
feat: Rechnungen werden abgelegt, gesichert und mitversendet
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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]> |
||
|
|
c525735285 |
feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
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]>
|
||
|
|
95f5252366 |
fix: gleichnamige Produkte aus verschiedenen Jahrgaengen brechen das Anlegen nicht mehr ab
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]>
|
||
|
|
479ace1b88 |
docs: Konfigurationsvorlagen vollstaendig und uebernahmefaehig machen
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]> |
||
|
|
990a16e654 |
chore: Manifeste an den laufenden Cluster angleichen
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]> |
||
|
|
b1384c3695 |
fix: Video-Upload startet den Pod nicht mehr neu
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]> |
||
|
|
78c19f1b73 |
feat: Produkte aus ApiCore synchronisieren
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]> |
||
|
|
c61ae92390 |
feat: nicht dauerhaftes Upload-Verzeichnis wird erkannt, bevor Daten verloren gehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Ergaenzt den Abgleich aus |
||
|
|
c8ddcec04c |
feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in
|
||
|
|
bdce9b6a0a |
feat: Impressum mit den echten Inhaberdaten ausgefuellt
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]> |
||
|
|
9dd71ffb9a |
feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
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]>
|
||
|
|
ff365c3cb1 |
feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar
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]> |
||
|
|
979d39e677 |
feat: taegliche Datenbanksicherung mit dokumentierter Wiederherstellung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m57s
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]> |
||
|
|
21ac8bbc6a |
fix: Go-Live-Pruefung liest den ausgelieferten Text statt der Quelltext-Vorlage
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]> |
||
|
|
a78c1cd235 |
fix: hochgeladene Bilder ueberleben einen Redeploy
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]> |
||
|
|
91aee0d609 |
fix: deaktivierte Produkte sind nicht mehr bestellbar
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]> |
||
|
|
746c12ae68 |
chore: ungenutzte create-next-app-Platzhaltergrafiken entfernen
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]> |
||
|
|
c9ec2b4998 |
feat: Go-Live-Pruefung "pnpm preflight"
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]> |
||
|
|
c50e5e0e08 |
fix: alle 14 bekannten Schwachstellen in Produktionsabhaengigkeiten schliessen
"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]>
|
||
|
|
18d1bd28bf |
fix: Produkt- und Shop-Links in E-Mails ueber dieselbe Domain-Quelle
Nachzug zu
|
||
|
|
4b76247648 |
fix: Health-Endpunkte trennen Liveness von Readiness
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]>
|
||
|
|
d3020cd4da |
fix: Sitemap, Canonicals und JSON-LD folgen der echten Domain
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]> |
||
|
|
96bb3d9c50 |
fix: Video-Upload als roher Body statt multipart — beseitigt die 502-Ursache
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
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. |
||
|
|
e8f0bd4f55 |
fix: Ingress auf Traefik umstellen — die nginx-Annotationen waren wirkungslos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m6s
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. |
||
|
|
72d36f4716 |
perf: Header-Bild der Imkerei-Seite sofort laden
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m1s
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. |
||
|
|
0a44fecbe1 |
perf: Warenkorb-Miniaturen ueber den Bildoptimierer laden
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. |
||
|
|
02d493dede |
fix: verbleibende Lint-Befunde ausraeumen (Lint jetzt ohne Beanstandung)
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.
|
||
|
|
107cbadf79 |
perf: Header-Medien sofort und in passender Groesse laden
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. |
||
|
|
2a16b3f962 |
fix: Ingress-Zeitlimits und Speicherbudget fuer den Video-Upload setzen
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. |
||
|
|
713bf48157 |
fix: Video-Upload haelt das Video nicht mehr mehrfach im Arbeitsspeicher
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. |
||
|
|
b11edd7751 |
fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
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. |
||
|
|
be5fb32a4a |
perf: Admin-Bildvorschauen ueber next/image statt als rohes <img>
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
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.
|
||
|
|
cd8e307faf |
perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
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. |
||
|
|
0920d33721 |
fix: Video-Upload scheiterte, weil ffmpeg-static mitgebuendelt wurde
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). |
||
|
|
abdebfc477 |
fix: Admin-Upload scheiterte an der CSRF-Pruefung (403 "Ungueltige Anfrage.")
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). |
||
|
|
a0f61d328c |
fix: Uebersetzungs-Inkonsistenzen vereinheitlichen (spring harvest, intense)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m37s
"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]> |
||
|
|
88a3e367e6 |
fix: letzten AI-Slop-Rest in Honigwissen-Artikel entfernen
"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]>
|
||
|
|
a52faea3ad |
fix: Chargen-Bild-Fallback pro Slot statt alles-oder-nichts
/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]> |
||
|
|
7d5c172487 |
feat: eigene Bilder pro Charge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m21s
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]> |
||
|
|
6742152626 |
fix: ffmpeg-Preset auf veryfast, um CDN-Timeout beim Video-Upload zu vermeiden
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]> |
||
|
|
902f818c6e |
fix: ffmpeg-Binary im Container fehlte (ENOENT bei jedem Video-Upload)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m50s
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]> |
||
|
|
a366ffb544 |
fix: Hero-Video-Loop per JS absichern
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
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]> |
||
|
|
8ddf781d9b |
feat: Hero-Video beim Upload automatisch fuers Web komprimieren (ffmpeg)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m35s
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]> |
||
|
|
1acd8ed4b9 |
fix: Hero-Verlauf verstaerken, damit Text auf hellem Videomaterial lesbar bleibt
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]> |
||
|
|
a3b11cc8e2 |
feat: Ernten und Chargen im Admin loeschbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m27s
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]> |
||
|
|
c7286041a1 |
fix: echten Button statt rohem Datei-Input bei allen Admin-Uploads
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]> |
||
|
|
6a815e85fe |
fix: Video-Upload-Limit auf 100 MB anheben
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]> |
||
|
|
1df62f20c8 |
fix: Klickbarkeit der Wissensdatenbank-Karten sichtbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
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]> |
||
|
|
d03cada256 |
fix: CSRF-Origin-Pruefung faelschlich abgelehnt hinter selbst betriebenem Reverse-Proxy
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]> |
||
|
|
d0c1d0840c |
fix: Admin-Uploads auf dem Live-Server (Berechtigung, Persistenz, Body-Size-Limit)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
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]> |