92ac826baa13e9b2714b1b6598095405840c2cc1
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
6814e9e2c8 |
feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero (home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt, Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder (Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder (Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt statt voller Viewport-Breite. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
3627f4e5c1 |
fix: close remaining TOCTOU races, self-correct a regression from the prior fix
Second silent-failure-hunter pass (stock/auth/admin-action focus,
explicitly scoped away from the payment paths fixed in the previous
commit) plus a self-review of that previous commit's own diff.
Self-correction: my prior fix to fulfillOrder's NEU->BEZAHLT guard
replaced the original atomic `updateMany({status:"NEU"})` claim with a
plain findUnique+update to let a retry backfill Payment/Invoice/email
after BEZAHLT. That accidentally dropped the atomicity the original
code relied on to prevent double-counting a discount code on two
concurrent webhook deliveries. Restored it: the NEU->BEZAHLT
transition is an atomic updateMany claim again (count===1 decides
"firstTime", which now also gates the discount-code increment); a
losing/retry call separately checks for "already BEZAHLT" to still
allow the backfill.
Three further real gaps, confirmed by hand before fixing:
- failOrder (same webhook file) and admin's updateOrderStatusAction
had the identical TOCTOU pattern: findUnique then unconditional
update then restockOrderItems. Two concurrent webhook deliveries
(Stripe documents at-least-once delivery) or two admin tabs
double-clicking "Stornieren" could both pass the stale read before
either write landed, silently double-crediting inventory. Both now
use the same atomic-claim pattern as fulfillOrder.
- 2FA backup-code verification used findFirst({usedAt:null}) then a
separate update — two concurrent submissions of the same code could
both pass the check and both authenticate, defeating single-use.
Replaced with one atomic updateMany claim.
- Two silent no-ops: an invalid/stale order-status update returned
with zero admin-visible feedback despite revalidatePath still
firing; discount-code deletion had no existence check, so a
double-submitted delete threw an unhandled Prisma P2025 (500)
instead of a clear message. Both now use the existing fail() helper.
Verified: tsc clean, 151/151 existing tests still pass.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
||
|
|
8b5d5d095c |
fix: harden payment/shipping paths against partial-failure edge cases
Dispatched a read-only silent-failure audit (silent-failure-hunter agent) over the checkout/webhook/DHL/mail code paths, then verified each finding by hand before fixing. Four confirmed, real gaps: - Stripe webhook (fulfillOrder): Payment/Invoice creation and the confirmation email ran after the order's status flip to "BEZAHLT", outside any try/catch. If any of those three failed (DB blip), the handler threw uncaught -> Stripe retried -> the retry's status==="NEU" guard now found 0 rows and silently skipped the retry forever, leaving a paid order with no invoice, no payment record, and no confirmation email, with nothing logged. Fixed by allowing "BEZAHLT" as a valid retry entry point (idempotent upserts) and wrapping the post-transaction block in try/catch that logs loudly and re-throws (so Stripe still retries). - DHL label creation: the already-billed tracking number's DB write sat outside the try/catch that protects the (paid) DHL API call. A failed write there lost the tracking number with zero record of it, while the claim sentinel permanently blocked any retry through the UI. Fixed by wrapping the write, logging the tracking number for manual reconciliation, and deliberately leaving the claim in place (not resettable) to avoid triggering a second, separately billed label. - Checkout route: a DB write failure for the (currently unread, write-only) stripeCheckoutSessionId field discarded an already live, payable Stripe session and showed the customer a generic error, risking a duplicate order + stock reservation on retry. Made that specific write best-effort/non-fatal. - DHL fetch calls had no request timeout, so a hung (non-erroring) DHL API could block an admin action indefinitely. Added a 20s AbortSignal.timeout to both. Verified: tsc clean, 151/151 existing tests still pass. No dedicated DB-integration test harness exists yet for route/webhook handlers in this codebase (only pure-function unit tests) — building one is a separate, larger undertaking than this fix warrants. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
6143e5704d |
fix: Zahlungsmethoden-Text vereinheitlichen, Retry-Button in Zahlungsfehler-Mail
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types, kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig (die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt. - versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen. - Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig. Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf, hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop. Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich. |
||
|
|
de0f6ee249 |
fix: Preis-/Datumsformat an Kunden-Locale anpassen, Produktgalerie-LCP verbessern
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026". Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell- verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege, Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch. - en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)" jetzt korrekt (vorher z.B. "Open cart, 1 items"). - product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image statt <img> (hochgeladene Produktfotos sind immer same-origin unter /uploads/..., brauchen also keine remotePatterns-Konfiguration wie der entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist. - content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat). - Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung ohne Intervall) -- keine Aenderung noetig. Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich. |
||
|
|
0b2196460a |
feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)
Konkrete Reibungspunkte entlang der Kauf-Journey behoben: - Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig zurueckzusetzen; plus "Suche zuruecksetzen"-Link. - Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite (bisher erst im Warenkorb/Checkout sichtbar). - Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die Vorschau-Kacheln (mobile Kaufentscheidung). - Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht wieder erst erscrollt werden muss. - Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer Endpunkt /api/shop/cart-stock + useCartStock-Hook. - Checkout- und Adressformulare: autoComplete/inputMode/type auf allen Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und passende Bildschirmtastaturen. - Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren auffindbar. Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build bleiben gruen (142 Tests). |
||
|
|
54919d82ed |
feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads). - lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen. - app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- + Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF). - app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine zur Laufzeit hinzugefuegten Dateien bedienen) — mit Path-Traversal-Schutz und nosniff-Header. - content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite- Grafiken, admin/inhalte hat dafuer ein Upload-Widget. - Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer Product.images/Harvest.images (beide Felder existierten im Schema, hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt Platzhalter, sobald vorhanden. - Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt. Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm build clean. Upload-/Serving-Pipeline live gegen einen laufenden Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung, Format-Ablehnung) — ein vollstaendiger authentifizierter Upload end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live durchgespielt, nur per Code-Review verifiziert. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
c59913b75b |
fix: Race Conditions bei Gutscheincode-Einloesung und Retouren-Erstattung
- Checkout: Gutscheincode-Nutzungslimit wird jetzt innerhalb derselben Serializable-Transaktion wie die Bestellanlage frisch nachgeprueft, statt nur einmalig vorab. Zwei gleichzeitige Checkouts mit demselben maxUses:1-Code konnten sich sonst beide durchsetzen. - Admin/Retouren: Erstattungsstatus wird jetzt atomar (bedingtes Update, ANGENOMMEN -> ERSTATTET) VOR dem Stripe-Aufruf gesetzt statt danach. Ein Doppelklick auf "Erstatten" konnte sonst zwei echte Stripe-Erstattungen ausloesen. Bei einem fehlschlagenden Stripe-Aufruf wird der Status wieder zurueckgesetzt, damit erneut versucht werden kann. Beide Luecken kamen aus dem Security-Assessment dieser Session. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
b6db9254a9 |
fix: fehlende Dateien aus vorherigem Commit nachreichen
CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die Harvest-isPublished-Migration waren gestaged aber nicht im letzten Commit enthalten — Gitea-Actions-Build brach mit "Module not found" fuer genau diese Dateien ab. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
4d3100e9ba |
feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
|
||
|
|
c7927f2883 |
feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests. |