Commit Graph
78 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 1fa8dc9ce2 fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.

1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
   Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
   "NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
   Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
   filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
   kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
   fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
   auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
   diese Bestellung je zu bezahlen.

   Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
   Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
   der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
   Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
   geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
   Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
   Altbestaende trotzdem ab (Filter entfernt).

2. Kaufabbrueche standen als Bestellungen im Kundenkonto
   getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
   abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
   Bestellnummer und Summe in der Liste und sah aus wie ein zustande
   gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
   Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
   "Zahlung ausstehend" statt "Neu".

3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
   Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
   Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
   Payment.status auf REFUNDED.

4. Admin-Storno schloss die offene Stripe-Sitzung nicht
   Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
   Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
   jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
   nicht mehr offene Bestellung laut, statt sie zu verschweigen.

5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
   charge.refunded und charge.dispute.created werden jetzt verarbeitet:
   eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
   eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
   Vermerk an der Bestellung. preflight prueft beide Abonnements mit.

Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.

tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 16:57:18 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-18 02:13:35 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-18 02:07:22 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-18 01:26:18 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 19:17:13 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 19:07:12 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 18:29:54 +02:00
Nick SzaunigandClaude Opus 5 92ac826baa feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.

syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.

Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.

Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.

Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:26:09 +02:00
Nick SzaunigandClaude Opus 5 8f0f06fd13 feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht
Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht.
Neue Server Action deleteProductAction samt Knopf in der Produktliste,
im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog,
Audit-Log-Eintrag, Pruefung vor dem Loeschen.

Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit
onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen
und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen
Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits
gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht
(§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht",
sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt
aus dem Shop und der Beleg bleibt.

Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder
bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste
Datei ist harmloser als ein totes Bild an anderer Stelle.

Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der
Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas
darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft"
bzw. "in Verwendung").

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:10:09 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 17:38:25 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 06:54:26 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 06:32:12 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 04:12:44 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 03:24:02 +02:00
Nick SzaunigandClaude Opus 5 95f5252366 fix: gleichnamige Produkte aus verschiedenen Jahrgaengen brechen das Anlegen nicht mehr ab
Product.slug ist eindeutig und zugleich die oeffentliche Adresse
(/honig/<slug>). Ohne eigene Angabe wird er aus dem Namen gebildet. Bei
jahrgangsweise gefuehrtem Honig kollidiert das zwangslaeufig: "Waldhonig"
ergibt in jedem Jahr denselben Slug "waldhonig".

Bisher schlug das als unbehandelte Prisma-Ausnahme (P2002) durch. Statt
einer Meldung erschien die Fehlerseite des Admin-Bereichs, aus der nicht
hervorgeht, dass lediglich die Adresse belegt ist — und das eingegebene
Formular war verloren.

Jetzt wird der Slug vorher geprueft und die Eingabe mit einem konkreten
Vorschlag abgelehnt ("waldhonig-2026"). Das Formular hat fuer genau diesen
Fall bereits ein eigenes Slug-Feld.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 03:14:15 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 03:06:18 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-17 01:01:37 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-16 18:03:05 +02:00
Nick SzaunigandClaude Opus 5 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 c8ddcec um die Pruefung, die frueher anschlaegt.
checkUploadFiles() erkennt fehlende Dateien — also erst, NACHDEM sie weg
sind. checkUploadVolume() erkennt die Ursache schon vorher: liegt
UPLOAD_DIR im Container statt auf einem gemounteten Volume, sind alle
Bilder und Videos beim naechsten Redeploy verloren, auch wenn gerade noch
jede Datei da ist.

Erkennung ueber die Geraete-Kennung (st_dev): ein eigener Mountpunkt hat
eine andere als sein uebergeordnetes Verzeichnis. Bewusst eine Heuristik
und damit nur ein Hinweis, keine Blockade — ein Bind-Mount innerhalb
desselben Dateisystems kann dieselbe Kennung tragen. Ausserhalb von
NODE_ENV=production immer "gemountet", weil UPLOAD_DIR lokal als
"./uploads" im Projektverzeichnis liegt und dort sonst ein Daueralarm
entstuende.

Die Funktion liegt in lib/uploads.ts statt in lib/upload-health.ts:
Letzteres importiert Prisma und laesst sich ohne DATABASE_URL nicht
testen — dieselbe Ueberlegung wie bei lib/form-fields.ts in ff365c3. Als
reine Funktion neben UPLOAD_DIR ist sie direkt pruefbar, und dort gehoert
sie fachlich ohnehin hin.

Vier Tests: kein Alarm ausserhalb der Produktion, ein gewoehnliches
Verzeichnis wird in Produktion als nicht dauerhaft erkannt, ein fehlendes
Verzeichnis erzeugt keinen Fehlalarm, und der gepruefte Pfad kommt mit
zurueck, damit die Warnung ihn benennen kann.

Das Admin-Dashboard zeigt beide Befunde getrennt: die Vorwarnung als
Hinweis, fehlende Dateien als Fehler.

Nicht enthalten und auch nicht moeglich: das Anwenden der Manifeste im
Cluster. Auf dieser Maschine gibt es weder kubectl noch eine kubeconfig;
die beiden kubectl-Befehle aus k8s/README.md muss jemand mit
Cluster-Zugriff einmalig ausfuehren.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:09:31 +02:00
Nick SzaunigandClaude Opus 5 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 a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.

Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.

Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.

lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.

Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.

Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:06:33 +02:00
Nick SzaunigandClaude Opus 5 bdce9b6a0a feat: Impressum mit den echten Inhaberdaten ausgefuellt
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen,
Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden
Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug
"[Name wie oben]" und nennt jetzt ebenfalls den Namen.

Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war
eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer
vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich
selbst widersprochen.

Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die
Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine
frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin
aenderbar, ohne dass dafuer ein Deploy noetig waere.

Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig —
der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche
Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt.

Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten
werden dir vor Abschluss der Bestellung transparent angezeigt." Das war
der Ersatztext (tierFallback), der nur erschien, solange keine
Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht
leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ
und Verwendung entfernt, nicht nur geleert.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 04:02:45 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-16 00:37:00 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-15 21:10:12 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-15 10:08:15 +02:00
Nick SzaunigandClaude Opus 5 18d1bd28bf fix: Produkt- und Shop-Links in E-Mails ueber dieselbe Domain-Quelle
Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:18 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-15 10:01:06 +02:00
Nick SzaunigandClaude Opus 5 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]>
2026-09-15 10:00:59 +02:00
Nick Szaunig 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.
2026-09-15 02:27:11 +02:00
Nick Szaunig 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.
2026-09-14 22:48:13 +02:00
Nick Szaunig 72d36f4716 perf: Header-Bild der Imkerei-Seite sofort laden
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m1s
Ein Audit ueber alle bildfuehrenden Seiten zeigte /imkerei als einzige
Ausnahme: dort war kein Bild priorisiert. Das Bild steht neben der h1 im
ersten Viewport und ist auf breiten Bildschirmen das groesste Element der
Seite, wurde aber doppelt zurueckgehalten — durch das voreingestellte
Lazy-Loading und durch ScrollReveal, das bei opacity 0 startet und erst
nach der Hydration einblendet.

Es rendert jetzt direkt und mit priority, also loading="eager" und
fetchPriority="high". Die Textspalte daneben behaelt ihre Einblendung, die
kostet nichts. Damit verhaelt sich die Seite wie /herkunft und
/unternehmen, die ihr Header-Bild ohnehin schon ausserhalb von ScrollReveal
rendern.

Stand des Audits danach, im Produktions-Build gemessen: /, /herkunft,
/unternehmen und /imkerei liefern je genau ein Header-Bild mit
loading="eager" und fetchPriority="high", alle uebrigen Bilder lazy, und
auf keiner Seite geht noch ein hochgeladenes Bild am Bildoptimierer vorbei.
2026-09-14 20:59:43 +02:00
Nick Szaunig 0a44fecbe1 perf: Warenkorb-Miniaturen ueber den Bildoptimierer laden
Die letzte Stelle, an der ein hochgeladenes Bild noch als rohes <img>
ausgeliefert wurde. Der Warenkorb lud damit fuer JEDE Position das
vollstaendig gespeicherte Bild — bis MAX_IMAGE_DIMENSION Kantenlaenge, also
durchaus ein paar hundert Kilobyte — nur um es auf 64 bzw. 80 Pixel zu
verkleinern. Bei mehreren Positionen summierte sich das spuerbar, gerade im
Mobilfunknetz, und betraf sowohl die Warenkorb-Schublade als auch die
Warenkorbseite.

Der bisherige eslint-disable-Kommentar begruendete das rohe <img> mit einer
"noch unbekannten externen Bildquelle". Das trifft nicht zu: das Bild kommt
aus Product.images[0] und liegt damit immer same-origin unter /uploads/...
(beim Speichern geprueft von isValidUploadPath in lib/uploads.ts).
next/image ist dafuer zustaendig und liefert jetzt eine 80px-Variante statt
der Originaldatei.

Beide Verwendungsstellen bekommen dieselbe leichte Eckenrundung wie die
uebrigen Bilder; vorher war die Schublade rounded-sm und die Warenkorbseite
gar nicht gerundet.
2026-09-14 20:55:50 +02:00
Nick Szaunig 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.
2026-09-14 20:37:01 +02:00
Nick Szaunig 107cbadf79 perf: Header-Medien sofort und in passender Groesse laden
Drei Gruende, warum Hero-Bild und -Video spuerbar spaet erschienen:

1. Das Standbild des Hero-Videos steckte im poster-Attribut. poster nimmt
   nur eine fertige URL und laedt die hochgeladene Datei damit in voller
   gespeicherter Groesse — am Bildoptimierer vorbei, ohne Ruecksicht auf
   das Geraet. Es liegt jetzt als eigene ContentImage-Ebene unter dem
   Video, laeuft also durch next/image. Gemessen: auf einem 500px breiten
   Viewport kommt statt der vollen Datei die 640px-Variante als WebP an.
   Das Video darueber ist transparent, bis es eigene Bilddaten hat — der
   Uebergang bleibt unsichtbar.

2. Die aeusserste Hero-Section startete bei opacity 0 und wurde erst per
   Animation nach der Hydration sichtbar. Das groesste Element im ersten
   Viewport wurde damit um die gesamte JS-Ladezeit zurueckgehalten. Die
   Animation dort ist entfernt; der gestaffelte Aufbau der Textspalte
   bleibt unveraendert.

3. next/image erhielt weiterhin die Prop "priority". Die ist seit
   Next.js 16 abgekuendigt (siehe node_modules/next/dist/docs, Changelog
   v16.0.0) und erzeugt nur noch einen <link rel="preload">, setzt am
   <img> selbst aber weder fetchpriority noch loading — im Trace kam das
   Hero-Bild deshalb trotz "priority" mit Prioritaet "Low" an. Die
   Dokumentation nennt dafuer ausdruecklich loading="eager" und
   fetchPriority="high" als Ersatz; ContentImage bildet seine unveraendert
   heissende priority-Prop jetzt darauf ab, die Produktgalerie ebenso.
   Danach bestand die LCP-Discovery-Pruefung von Chrome vollstaendig,
   vorher scheiterte sie an fetchpriority.

Ausserdem bekommen Inhaltsbilder leicht abgerundete Ecken (8px), Platzhalter
und echtes Bild gleichermassen, damit die Form beim Pflegen eines Bildes
nicht umspringt. Randlose Flaechen (Hero ueber die volle Fensterbreite)
bleiben bewusst eckig — dort saesse die Rundung an der Fensterkante.

Geprueft im Produktions-Build gegen Chrome: CLS 0.00, kein horizontaler
Ueberlauf bei schmalem Viewport, Video spielt (readyState 4), Hero-Bild
loading="eager" + fetchpriority="high", uebrige Bilder lazy.
2026-09-14 20:36:53 +02:00
Nick Szaunig 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.
2026-09-14 16:15:17 +02:00
Nick Szaunig 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.
2026-09-14 00:36:10 +02:00
Nick Szaunig be5fb32a4a perf: Admin-Bildvorschauen ueber next/image statt als rohes <img>
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Die Vorschaukacheln in admin/inhalte, admin/produkte und admin/ernten
luden das vollstaendig gespeicherte Bild, um es auf 80x80 bzw. in einer
Vierer-Rasterkachel darzustellen. Bei einem Produktformular mit mehreren
Bildern summierte sich das zu mehreren Megabyte allein fuer Miniaturen.

Der urspruengliche Kommentar ("next/image lohnt hier nicht") stimmte, als
Uploads klein waren; gespeichert wird inzwischen bis MAX_IMAGE_DIMENSION
Kantenlaenge. Beide Felder rendern die Vorschau jetzt ueber next/image
mit passendem sizes, der Optimierer liefert damit genau die gebrauchte
Groesse. Layout und Bedienung bleiben unveraendert.
2026-09-13 23:16:35 +02:00
Nick Szaunig 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.
2026-09-13 23:16:23 +02:00
Nick Szaunig 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).
2026-09-13 23:15:28 +02:00
Nick SzaunigandClaude Sonnet 5 a0f61d328c fix: Uebersetzungs-Inkonsistenzen vereinheitlichen (spring harvest, intense)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m37s
"Spring flow" -> "spring harvest", passend zum Rest der englischen Texte.
"robust" -> "intense" fuer kraeftig in beiden Admin-Produktformularen,
passend zum bereits kundenseitig verwendeten product.tasteIntense.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 00:34:57 +02:00
Nick SzaunigandClaude Sonnet 5 88a3e367e6 fix: letzten AI-Slop-Rest in Honigwissen-Artikel entfernen
"Here's why it happens" war ein Throat-Clearing-Opener, den die
deutsche Originalfassung ("Wir erklaeren, woran das liegt") gar nicht
hatte. Jetzt woertlich angeglichen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:14:02 +02:00
Nick SzaunigandClaude Sonnet 5 a52faea3ad fix: Chargen-Bild-Fallback pro Slot statt alles-oder-nichts
/charge/[code] zeigt zwei Bild-Slots (Produkt-/Standortfoto). Die
bisherige Logik ersetzte bei JEDEM eigenen Chargenfoto beide Slots
komplett durch batch.images — wer nur ein Chargenfoto pflegte, verlor
dadurch das zweite (Standort-)Bild der Ernte statt es weiter anzuzeigen.
Jeder Slot faellt jetzt einzeln auf das jeweilige Erntebild zurueck.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 18:39:43 +02:00
Nick SzaunigandClaude Sonnet 5 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]>
2026-09-12 04:08:39 +02:00
Nick SzaunigandClaude Sonnet 5 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]>
2026-09-12 03:56:07 +02:00
Nick SzaunigandClaude Sonnet 5 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]>
2026-09-12 03:38:06 +02:00
Nick SzaunigandClaude Sonnet 5 a366ffb544 fix: Hero-Video-Loop per JS absichern
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
loop-Attribut war bereits gesetzt, aber manche Browser (u.a. aeltere
iOS-Safari-Versionen) starten ein Video nach "ended" nicht zuverlaessig
neu, wenn autoplay/muted/playsinline kombiniert sind. onEnded erzwingt
jetzt zusaetzlich den Neustart, damit die Dauerschleife garantiert nie
sichtbar stehen bleibt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 02:19:34 +02:00
Nick SzaunigandClaude Sonnet 5 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]>
2026-09-12 00:30:42 +02:00
Nick SzaunigandClaude Sonnet 5 1acd8ed4b9 fix: Hero-Verlauf verstaerken, damit Text auf hellem Videomaterial lesbar bleibt
Live-Check des hochgeladenen Hero-Videos zeigte: die Headline lag streckenweise
direkt ueber hellen Honigglas-Etiketten, der bisherige rein vertikale
Verlauf (bg-gradient-to-t, 70/10) verdunkelt dort zu wenig, wo die
Textspalte tatsaechlich sitzt (vertikal mittig links). Zusaetzliche
gleichmaessige 25%-Abdunklung ueber die gesamte Flaeche plus staerkerer,
diagonaler Verlauf (to-tr, 70/25) gezielt hinter der Textspalte - visuell
gegen das echte Video verifiziert (Screenshot-Vergleich alt/neu).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 00:18:25 +02:00
Nick SzaunigandClaude Sonnet 5 a3b11cc8e2 feat: Ernten und Chargen im Admin loeschbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m27s
Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger
Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur
loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge
nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung
verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus
Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den
Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit
Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:45:23 +02:00
Nick SzaunigandClaude Sonnet 5 c7286041a1 fix: echten Button statt rohem Datei-Input bei allen Admin-Uploads
Bild-/Video-Upload-Felder (admin/inhalte, admin/produkte, admin/ernten)
zeigten den unstyled Browser-Standard-Datei-Input — kaum als
Bedienelement erkennbar. Input jetzt versteckt, stattdessen ein
richtiger, zum Admin-Design passender Button, der den Datei-Dialog
oeffnet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 23:30:32 +02:00
Nick SzaunigandClaude Sonnet 5 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]>
2026-09-11 23:13:04 +02:00