dda0a66c6c546c03000f36c5f48114c47dfbb972
46
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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
|
||
|
|
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]> |
||
|
|
18d1bd28bf |
fix: Produkt- und Shop-Links in E-Mails ueber dieselbe Domain-Quelle
Nachzug zu
|
||
|
|
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]> |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
286387d5ec |
refactor: /admin/inhalte in Bilder/Text trennen, KI-untypische Formulierungen in Website-Texten
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular), statt beides pro Bereich gemischt untereinander. Ausserdem alle Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen ersetzt. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
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]> |
||
|
|
398db5c5ec |
feat: make /unternehmen hero image admin-editable too
Same gap as /imkerei and /herkunft (previous commit): last remaining hardcoded PlaceholderImage in the app, now wired to the same content-registry "pages" group / ContentImage pattern. Verified: tsc clean, 151/151 tests pass, page still returns 200. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
8d988515f3 |
feat: make /imkerei and /herkunft hero images admin-editable
Both pages are the ones most directly meant to give customers "ein gutes Bild von der Imkerei" (per the active /goal), but their hero images were still hardcoded PlaceholderImage tiles with no way to ever swap in a real photo — unlike every homepage section, which already went through this exact upload flow. Added imkerei.image / herkunft.image to the content registry (new "pages" group, since neither belongs under "Homepage") and switched both pages from PlaceholderImage to ContentImage + getContentValue, the same pattern already proven on the homepage sections. Falls back to the identical placeholder tile until a photo is uploaded, so this is a pure addition with no visual change until the owner uploads one. Verified: tsc clean, 151/151 tests pass, both pages still render correctly (curl 200 + screenshot) with the placeholder fallback. Could not verify the admin upload UI end-to-end live — the local admin account has 2FA enabled and I don't have the second factor; confidence instead comes from reusing the identical, already-verified AdminImageField/ContentImage wiring used for the four home.*.image fields, plus generic (group-agnostic) admin field loading confirmed by reading admin/inhalte/page.tsx. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
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. |
||
|
|
a579ebabeb |
feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann (Feature existierte bereits, war aber nur im Checkout-Formular selbst sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen) - Produktdetailseite (Honig): kontextuelle Links zu passenden Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion - Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete" findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner lib/search-match.ts mit eigenen Tests ausgelagert - Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/ Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss (bereits vorhanden auf Produktseite/Warenkorb/Checkout) Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien, +9 neue Tests fuer search-match), pnpm build erfolgreich. |
||
|
|
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. |
||
|
|
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]> |
||
|
|
d52753cfef |
feat: ApiCore-Sync auf Chargen erweitern
Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains "Charge" (model Batch) entspricht ApiCores "BottlingBatch" (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT "HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code, date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount). - lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt GET /bottling-batches (inkl. verknuepfter HoneyBatch) und GET /extractions (inkl. verknuepfter Ernten) und loest daraus die zugehoerige ApiCore-Ernten-ID pro Charge auf. - admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem Ernten-Sync (damit frisch angelegte Ernten schon als harvestId verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als Harvest hat Batch kein isPublished-Feld, ein Update wuerde insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten erhalten. - admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an. - .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert (extractions:read, bottling:read). Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean. Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
04cc3061cf |
perf: DB-Indizes, Footer als Server Component, Site-weite Fehlerseiten
Datenbank (prisma/migrations/20260904120000_add_performance_indexes): 7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt (Order.customerId+createdAt, Order.status, Order.createdAt, Order.discountCodeId, OrderItem.orderId, Address.customerId, AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht, Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log. Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile) aus dem client-seitigen site-chrome.tsx in eine eigene Server Component (site-footer.tsx) ausgelagert — brauchte "use client" nur wegen useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite. Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf (Footer steht im Root-Layout). UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete Standardseiten statt einer zur Seite passenden Nutzerfuehrung. Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx, seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet, die neue site-weite Fehlerseite nutzt einen eigenen Schluessel (siteErrorPage). Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
154b75e0d8 |
test: Testabdeckung fuer CSRF-Schutz, Entra-ID-SSO und DHL-Client
Keine der in dieser Session neu hinzugefuegten sicherheitskritischen Module (lib/csrf.ts, lib/entra.ts, lib/dhl.ts) hatte bisher Tests. - csrf.test.ts: Same-Origin-Erkennung, Ablehnung gefaelschter Cross-Origin-Requests, tolerantes Verhalten bei fehlendem/kaputtem Origin-Header. - entra.test.ts: PKCE-Paar-Erzeugung (RFC 7636), Authorize-URL-Aufbau, Extraktion des verifizierten E-Mail-Claims (nie ein unplausibles Feld raten). - dhl.test.ts: Adress-Mapping (Strasse/Hausnummer-Trennung, ISO2->ISO3-Laendercode), Fehlerbehandlung bei nicht unterstuetztem Land, DHL-Validierungsfehlern und HTTP-Fehlern, statt einen falschen Erfolg vorzutaeuschen. Alle 118 Tests (13 Dateien) gruen, tsc --noEmit clean. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
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. |