Compare commits

..
10 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 979d39e677 feat: taegliche Datenbanksicherung mit dokumentierter Wiederherstellung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m57s
Es gab bisher keinerlei Sicherung: Bestellungen, Rechnungen,
Kundenkonten und Chargendaten lagen in einem einzelnen
PersistentVolume ohne Netz. Ein Speicherfehler, ein versehentliches
"kubectl delete pvc" oder ein misslungenes Cluster-Update haetten alles
davon unwiederbringlich geloescht. Rechnungen unterliegen in
Deutschland zusaetzlich einer zehnjaehrigen Aufbewahrungspflicht
(§ 147 AO) — ein Datenverlust waere hier nicht nur aergerlich, sondern
ein Rechtsproblem.

Neu: 07-backup-cronjob.yaml — naechtlicher pg_dump auf ein eigenes
Volume, gzip-komprimiert, 30 Tage Aufbewahrung, concurrencyPolicy
Forbid. Fehlgeschlagene Laeufe werden laenger aufgehoben als
erfolgreiche: nur an ihnen laesst sich per "kubectl logs"
nachvollziehen, warum eine Sicherung ausblieb.

Zwei Details, an denen Sicherungsskripte typischerweise scheitern:

- "set -o pipefail". Ohne das liefert "pg_dump | gzip" den Exit-Code von
  gzip, und gzip gelingt auch dann, wenn pg_dump vorher abgebrochen ist.
  Der Job galte als erfolgreich, im Verzeichnis laege eine
  abgeschnittene Datei, und das faellt erst im Ernstfall auf. Beide
  Varianten mit einem absichtlich abbrechenden Dump geprueft: ohne
  pipefail Exit 0 samt vollstaendig aussehender .sql.gz, mit pipefail
  Exit 1 und keine .sql.gz.
- Schreiben unter ".partial" und erst danach umbenennen, damit ein
  mitten im Lauf abgebrochener Job keine Datei hinterlaesst, die wie
  eine vollstaendige Sicherung aussieht. Liegengebliebene .partial-Reste
  raeumt derselbe Lauf mit auf.

k8s/README.md: neuer Abschnitt "Sicherung und Wiederherstellung" mit
Ansehen, manuellem Ausloesen, Herausziehen aus dem Cluster und dem
vollstaendigen Wiederherstellungsweg (App auf 0 skalieren, Dump
einspielen, wieder hochskalieren) — samt dem Hinweis, dass eine nie
getestete Sicherung keine Sicherung ist. Der Abschnitt "Bekannte
Grenzen" nannte "ohne Replikation/Backup-Automation"; das stimmt jetzt
nur noch zur Haelfte und ist entsprechend praezisiert: die Sicherung
liegt weiterhin im selben Cluster und ersetzt keine Replikation.

Alle Manifeste gegen einen YAML-Parser geprueft.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:21:19 +02:00
Nick SzaunigandClaude Opus 5 21ac8bbc6a fix: Go-Live-Pruefung liest den ausgelieferten Text statt der Quelltext-Vorlage
Die Pruefung der Rechtstexte war falsch gebaut: sie suchte
[PLATZHALTER]-Markierungen in src/i18n/dictionaries/de.ts und en.ts. Diese
Dateien sind aber nur der RUECKFALLWERT. Impressum, Datenschutz, AGB,
Widerrufsbelehrung und die Telefonnummer sind im Admin-Bereich unter
"Inhalte" pflegbar (lib/content-registry.ts, Tabelle SiteContent) — genau
wie die uebrigen Firmendaten.

Zwei Folgen davon:

- Falsch-positiv: Wer sein Impressum im Admin-Bereich ausfuellt — ohne
  Deploy, so wie vorgesehen —, bekam trotzdem dauerhaft einen Blocker
  gemeldet, der sich ueber die Oberflaeche gar nicht schliessen liess.
- Falsch-negativ: Wer einen Platzhaltertext erst im Admin eintraegt, kam
  ungeprueft durch.

Das Skript ist deshalb von .mjs auf .ts gewechselt (ueber tsx, wie die
Seed-Skripte) und fragt jetzt getContentValue() ab — also genau den Text,
den Besucher auf /impressum tatsaechlich zu sehen bekommen, je
Sprachfassung einzeln. Neben dem Impressum werden nun auch Datenschutz,
AGB und Widerrufsbelehrung geprueft; der Befund benennt praezise, welcher
Text in welcher Sprache noch offen ist, statt pauschal eine Datei zu
nennen.

Ist die Datenbank vom ausfuehrenden Rechner aus nicht erreichbar, meldet
das Skript diese Punkte ausdruecklich als UNGEPRUEFT (Warnung) — es raet
weder "in Ordnung" noch "blockiert". Beide Wege gegen die lokale
Datenbank geprueft: mit Verbindung meldet es "Impressum (de), Impressum
(en)" als einzigen offenen Rechtstext, ohne Verbindung die Warnung samt
Ursache.

Kein Top-Level-await: das Projekt hat kein "type": "module", tsx
uebersetzt nach CommonJS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:17:09 +02:00
Nick SzaunigandClaude Opus 5 a78c1cd235 fix: hochgeladene Bilder ueberleben einen Redeploy
Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist
an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy
und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-,
Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade —
die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass
irgendwo ein Fehler aufgetaucht waere.

Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei)
mit einem PersistentVolumeClaim, den das Deployment jetzt bindet.
storageClassName ist bewusst auskommentiert, damit das Manifest ohne
Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt.

Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard
"RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein
ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue
Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating
haengen, und das im k8s/README.md dokumentierte
"kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet
wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie
zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher
gehaltenen Rate-Limits teilen wuerden.

Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell:
claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der
mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein.

preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com"
geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie
stehen, zeigten Sitemap, Canonicals und jeder Link in jeder
Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen
HTTPS-/localhost-Pruefungen angeschlagen haetten.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:13:06 +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 746c12ae68 chore: ungenutzte create-next-app-Platzhaltergrafiken entfernen
next.svg, vercel.svg, globe.svg, window.svg und file.svg stammen
unveraendert aus dem Projektgeruest und werden nirgends referenziert
(weder in src/ noch in e2e/). Sie wurden bisher in jedes Docker-Image
mitgebaut und waren unter der eigenen Domain oeffentlich abrufbar — ein
Next.js- und ein Vercel-Logo auf einer Imkerei-Seite.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:37 +02:00
Nick SzaunigandClaude Opus 5 c9ec2b4998 feat: Go-Live-Pruefung "pnpm preflight"
Die letzten offenen Punkte vor dem Live-Gang sind Inhaber-Aufgaben, keine
Code-Aufgaben — und genau die Sorte, die still durchrutscht, weil der Shop
mit ihnen trotzdem startet. Das Skript macht daraus eine Pruefliste, die
mit Exit-Code 1 abbricht, solange ein Blocker offen ist, und laesst sich
damit unveraendert als Schritt in die Deploy-Pipeline haengen.

Geprueft wird, was einen oeffentlichen Shop unbemerkt unbrauchbar macht:

- SESSION_SECRET/ADMIN_EMAIL/ADMIN_PASSWORD auf Laenge und darauf, dass
  noch der Beispielwert aus .env.example darin steht (ein bekanntes
  Signiergeheimnis macht jede Sitzung faelschbar).
- NEXT_PUBLIC_SITE_URL auf HTTPS und kein localhost — dieser Wert landet
  in jeder Bestellbestaetigung, in sitemap.xml und in allen Canonicals.
- Stripe-Schluessel und Webhook-Secret vorhanden, und ob versehentlich noch
  ein sk_test-Schluessel gesetzt ist. Ohne Webhook-Secret wird keine
  Zahlung bestaetigt: Bestellungen bleiben auf "NEU", ohne Rechnung und
  ohne Bestaetigungsmail.
- SMTP vollstaendig — fehlt es, landen E-Mails nur im Server-Log statt beim
  Kunden (lib/mail.ts).
- Impressum ohne [PLATZHALTER]/[PLACEHOLDER]-Markierungen. Bewusst beide
  Markierungen: die deutsche Fassung nutzt die eine, die englische die
  andere — eine Pruefung auf nur eine liesse die jeweils andere
  Sprachfassung mit Luecken online gehen. Ein unvollstaendiges Impressum
  nach § 5 TMG ist in Deutschland abmahnfaehig.

Bewusst ohne Datenbankzugriff, damit die Pruefung auch von einer Maschine
ohne Zugang zur Produktionsdatenbank laeuft. Felder, die zusaetzlich im
Admin-Bereich unter "Inhalte" pflegbar sind (Telefonnummer, Anschrift),
sind deshalb Warnung statt Blocker.

README: neue Abschnitte "Vor dem Live-Gang" und "Health-Endpunkte"; in der
Tech-Stack-Tabelle ausserdem die Zeile "GSAP + ScrollTrigger" korrigiert —
GSAP wurde laengst durch motion/react ersetzt und steht weder in
package.json noch im Quellcode.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:37 +02:00
Nick SzaunigandClaude Opus 5 c50e5e0e08 fix: alle 14 bekannten Schwachstellen in Produktionsabhaengigkeiten schliessen
"pnpm audit --prod" meldete 14 Advisories, davon 2 kritisch. Danach: keine.

Direkte Abhaengigkeiten angehoben:
- next 16.3.0 -> 16.3.5        (2x KRITISCH, gepatcht ab 16.3.3)
- eslint-config-next entsprechend
- nodemailer ^9.0.5 -> ^9.1.1  (HIGH + MODERATE: Empfaengerdomain liess sich
                                ueber falsch geparste RFC-5322-Kommentare
                                umbiegen — betrifft jede Transaktionsmail)
- sharp ^0.35.3 -> ^0.35.4     (HIGH)
- prisma/@prisma/client und beide Adapter ^7.9.1 -> ^7.10.0

Drei verbleibende Advisories liessen sich nur transitiv unter der
prisma-CLI aufloesen, deshalb als Overrides in pnpm-workspace.yaml
(dieselbe Stelle und dasselbe Muster wie die bereits vorhandenen
nanoid-/mariadb-Eintraege): mysql2 >=3.23.1, fast-uri >=3.1.6,
deepmerge-ts >=8.0.0.

deepmerge-ts ist ein Hauptversionssprung und haengt unter @prisma/config —
also genau im Pfad von "prisma migrate deploy", das der Container laut
Dockerfile-CMD bei JEDEM Start ausfuehrt. Deshalb nach dem Anheben
"prisma validate" und "prisma generate" (beide Provider) geprueft, dazu
Typecheck, Lint, 206 Tests und ein vollstaendiger Produktions-Build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:25 +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
27 changed files with 1104 additions and 363 deletions

No files matched your search

+33 -2
View File
@@ -9,8 +9,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
| Sprache | TypeScript | | Sprache | TypeScript |
| Framework | Next.js 16 (App Router) + React 19 | | Framework | Next.js 16 (App Router) + React 19 |
| Styling | Tailwind CSS v4 | | Styling | Tailwind CSS v4 |
| Scroll-/Hero-Animationen | GSAP + ScrollTrigger | | Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) |
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" | | Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) | | ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
| Payment | Stripe Checkout | | Payment | Stripe Checkout |
@@ -204,6 +203,37 @@ docker compose exec app pnpm db:seed:demo # optional: Beispieldaten zum Auspro
Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`). Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`).
## Vor dem Live-Gang
```bash
pnpm preflight
```
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
- `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags.
- Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail.
- SMTP vollständig gesetzt — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung.
- Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig.
Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt).
Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert".
Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen.
## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
| Endpunkt | Prüft | Verwendung |
|---|---|---|
| `/api/health` | nur, ob der Node-Prozess antwortet — **kein** Datenbankzugriff | Liveness-Probe |
| `/api/health/ready` | zusätzlich `SELECT 1` gegen die Datenbank, antwortet sonst mit 503 | Readiness-Probe, Docker-Healthcheck |
Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der Datenbank hängt, macht aus einer vorübergehenden DB-Störung eine Neustart-Schleife, weil der neu startende Container laut `Dockerfile`-CMD zuerst `prisma migrate deploy` gegen genau diese gestörte Datenbank fährt. Die Readiness-Probe darf dagegen ruhig fehlschlagen — Kubernetes nimmt den Pod dann nur aus dem Load-Balancing, statt ihn zu töten. Vorher zeigten beide Probes auf `/`, also alle 10 bzw. 30 Sekunden auf eine `force-dynamic`-Seite samt DB-Abfragen und vollem React-Rendering.
## Kubernetes / Gitea CI ## Kubernetes / Gitea CI
`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md). `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
@@ -259,6 +289,7 @@ Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bu
pnpm dev # Entwicklungsserver pnpm dev # Entwicklungsserver
pnpm build # Produktions-Build pnpm build # Produktions-Build
pnpm lint # ESLint pnpm lint # ESLint
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
pnpm db:studio # Prisma Studio (DB-GUI) pnpm db:studio # Prisma Studio (DB-GUI)
pnpm db:migrate # neue Migration erstellen/anwenden (Dev) pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
+10
View File
@@ -79,6 +79,16 @@ services:
required: false required: false
expose: expose:
- "3000" - "3000"
# Prueft denselben Endpunkt wie die Kubernetes-Readiness-Probe (siehe
# k8s/05-app-deployment.yaml): nicht nur "Prozess laeuft", sondern "App
# erreicht die Datenbank". start_period deckt den Migrations-/Seed-Lauf
# ab, den der Container laut Dockerfile-CMD vor dem Serverstart ausfuehrt.
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 90s
volumes: volumes:
# Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt # Persistiert hochgeladene Admin-Bilder/-Videos (Dockerfile legt
# /app/uploads bereits mit passendem Besitzer fuer den non-root # /app/uploads bereits mit passendem Besitzer fuer den non-root
+40
View File
@@ -0,0 +1,40 @@
# Persistenter Speicher fuer hochgeladene Bilder und Videos (Produkt-,
# Ernten- und Inhaltsfotos aus dem Admin-Bereich, siehe
# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts).
#
# WARUM EIGENES MANIFEST: 05-app-deployment.yaml hatte hier bisher nur einen
# "emptyDir"-Platzhalter. Der ist an die Lebensdauer des Pods gebunden —
# jeder Neustart, jeder Redeploy und jede Node-Verlagerung loescht saemtliche
# hochgeladenen Bilder. Die Datenbank behaelt dabei die Bildpfade, die
# Dateien dahinter sind aber weg: die Seite zeigt danach ueberall kaputte
# Bilder statt der Produktfotos, ohne dass irgendwo ein Fehler auftaucht.
#
# ANZUPASSEN VOR DEM ANWENDEN:
# - storageClassName: auf eine im Cluster vorhandene StorageClass setzen
# ("kubectl get storageclass" zeigt die verfuegbaren; die als
# "(default)" markierte wird verwendet, wenn das Feld ganz entfernt
# wird). Die Zeile ist bewusst auskommentiert, damit das Manifest ohne
# Aenderung gegen die Default-StorageClass anwendbar bleibt.
# - storage: Groesse nach erwartetem Bildbestand. 5Gi reichen fuer einige
# tausend optimierte Produktfotos; das Hero-Video (bis 100 MB roh, siehe
# MAX_UPLOAD_VIDEO_BYTES in lib/uploads.ts) faellt dabei kaum ins
# Gewicht.
#
# ZUGRIFFSMODUS: ReadWriteOnce genuegt, weil das Deployment mit genau einer
# Replica und der Strategie "Recreate" laeuft (siehe 05-app-deployment.yaml)
# — es greift also nie mehr als ein Pod gleichzeitig darauf zu. Soll spaeter
# hochskaliert werden, braucht es hier ReadWriteMany (NFS/SMB/CephFS) UND
# einen gemeinsamen Rate-Limit-Store (siehe Kommentar zu replicas in
# 05-app-deployment.yaml).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-uploads
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
# storageClassName: "dein-storage-class"
resources:
requests:
storage: 5Gi
+41 -14
View File
@@ -19,6 +19,23 @@ spec:
# ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst # ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst
# lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen. # lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen.
replicas: 1 replicas: 1
# PFLICHT in Verbindung mit dem uploads-Volume (siehe 04-uploads-pvc.yaml):
# die Standardstrategie "RollingUpdate" startet den NEUEN Pod, bevor der
# alte beendet ist. Ein ReadWriteOnce-Volume kann aber immer nur an einen
# Pod gebunden sein — der neue Pod bliebe in ContainerCreating mit
# "Multi-Attach error for volume" haengen, und ein
# "kubectl rollout restart" (der im k8s/README.md dokumentierte Weg fuer
# ein neues Image) liefe schlicht in den Timeout.
#
# "Recreate" beendet den alten Pod zuerst. Das kostet waehrend des
# Redeploys ein paar Sekunden Ausfall — bei einer Replica ist der
# Unterschied ohnehin gering, und der Container migriert beim Start erst
# die Datenbank, bevor er Verkehr annimmt. Der Nebeneffekt ist erwuenscht:
# es laufen nie zwei Pods gleichzeitig, die sich sonst die im
# Prozessspeicher gehaltenen Rate-Limits teilen wuerden (siehe Kommentar
# zu replicas oben).
strategy:
type: Recreate
selector: selector:
matchLabels: matchLabels:
app: wabenhain-app app: wabenhain-app
@@ -60,15 +77,26 @@ spec:
# (fehlende Volume-Referenz). # (fehlende Volume-Referenz).
- name: uploads - name: uploads
mountPath: /app/uploads mountPath: /app/uploads
# Prueft die Datenbank mit (siehe app/api/health/ready/route.ts).
# Schlaegt sie fehl, nimmt Kubernetes den Pod nur aus dem
# Load-Balancing, startet ihn aber nicht neu.
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /api/health/ready
port: 3000 port: 3000
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
# Bewusst der DB-FREIE Endpunkt (siehe app/api/health/route.ts):
# eine Liveness-Probe an der Datenbank wuerde aus einer
# voruebergehenden DB-Stoerung eine Neustart-Schleife machen, weil
# der neu startende Container laut Dockerfile-CMD zuerst
# "prisma migrate deploy" gegen dieselbe gestoerte Datenbank faehrt.
# Beide Probes zeigten vorher auf "/" — also auf eine
# force-dynamic-Seite mit DB-Abfragen und vollem React-Rendering
# alle 10 bzw. 30 Sekunden.
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /api/health
port: 3000 port: 3000
# initialDelaySeconds/failureThreshold grosszuegiger als sonst # initialDelaySeconds/failureThreshold grosszuegiger als sonst
# ueblich: der Container migriert/seedet die DB selbst vor dem # ueblich: der Container migriert/seedet die DB selbst vor dem
@@ -105,19 +133,18 @@ spec:
limits: limits:
memory: 1Gi memory: 1Gi
volumes: volumes:
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene # Bindet den PersistentVolumeClaim aus 04-uploads-pvc.yaml — dieses
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen # Manifest MUSS vor dem Deployment angewendet werden, sonst bleibt
# ein SMB-/NFS-Backend oder einen hostPath gebunden ist), etwa: # der Pod in "Pending" (unaufloesbare Volume-Referenz).
# - name: uploads #
# persistentVolumeClaim: # Vorher stand hier ein emptyDir-Platzhalter. Das ist kein
# claimName: wabenhain-uploads-pvc # persistenter Speicher: bei jedem Pod-Neustart, jedem Redeploy und
# jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt- und
# Erntenfotos weg, waehrend die Datenbank ihre Pfade behielt — die
# Seite zeigte danach ueberall kaputte Bilder, ohne Fehlermeldung.
- name: uploads - name: uploads
# CHANGE-ME: obige emptyDir-Zeile durch die echte Volume-Quelle persistentVolumeClaim:
# ersetzen. emptyDir ist NICHT persistent (Inhalt geht bei jedem claimName: wabenhain-uploads
# Pod-Neustart verloren) und dient hier nur als Platzhalter, damit
# dieses Manifest ohne weitere Aenderung "kubectl apply"-faehig
# bleibt.
emptyDir: {}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
+131
View File
@@ -0,0 +1,131 @@
# Taegliche Sicherung der Datenbank.
#
# WARUM: 03-postgres.yaml haelt Bestellungen, Rechnungen, Kundenkonten und
# Chargendaten in einem einzelnen PersistentVolume ohne jede Sicherung. Geht
# dieses Volume verloren (Speicherfehler, versehentliches "kubectl delete
# pvc", ein misslungenes Cluster-Update), sind saemtliche Bestellungen und
# Rechnungen unwiederbringlich weg. Rechnungen unterliegen in Deutschland
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
#
# Diese Sicherung liegt bewusst NUR im Cluster. Sie schuetzt gegen
# Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
# Clusters oder Standorts. Fuer eine vollstaendige Absicherung die
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
# und Wiederherstellung").
#
# MYSQL STATT POSTGRES: laeuft die Installation mit DATABASE_PROVIDER=mysql
# (03-mysql.yaml), stattdessen das Image "mysql:8.4.11" verwenden und den
# pg_dump-Aufruf unten ersetzen durch:
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-db-backup
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
# storageClassName: "dein-storage-class"
resources:
requests:
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
# niedrigen Megabyte-Bereich; 10Gi tragen die 30 Tage Aufbewahrung
# unten mit grossem Puffer.
storage: 10Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: wabenhain-db-backup
namespace: wabenhain
spec:
# 03:15 Uhr — ausserhalb der ueblichen Bestellzeiten. Die Uhrzeit richtet
# sich nach der Zeitzone des Clusters (meist UTC).
schedule: "15 3 * * *"
# Kein zweiter Lauf, solange der vorherige noch laeuft.
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
# Fehlgeschlagene Laeufe laenger aufheben als erfolgreiche: nur an ihnen
# laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung
# ausblieb.
failedJobsHistoryLimit: 5
# Faellt ein Lauf aus (Knoten weg, Cluster-Wartung), gilt er nach einer
# Stunde als verpasst statt ewig nachgeholt zu werden.
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
# 70 ist die UID/GID des "postgres"-Nutzers im offiziellen
# Alpine-Image; fsGroup gibt dem Job Schreibrecht auf dem
# gemounteten Backup-Volume (gleiche Begruendung wie beim
# uploads-Volume in 05-app-deployment.yaml).
securityContext:
runAsUser: 70
runAsGroup: 70
fsGroup: 70
containers:
- name: pg-dump
# Gleiche Hauptversion wie der Server in 03-postgres.yaml —
# ein aelteres pg_dump kann eine neuere Datenbank nicht lesen.
image: postgres:16.15-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: wabenhain-secrets
key: DB_PASSWORD
command: ["/bin/sh", "-c"]
args:
- |
set -eu
# pipefail ist hier das Wichtigste an diesem Skript:
# ohne es liefert "pg_dump | gzip" den Exit-Code von gzip.
# gzip gelingt auch dann, wenn pg_dump vorher abgebrochen
# ist — der Job galte als erfolgreich und im Verzeichnis
# laege eine abgeschnittene, beim Wiederherstellen
# wertlose Datei. Genau so entstehen Sicherungen, die erst
# im Ernstfall als unbrauchbar auffallen.
set -o pipefail
stamp="$(date -u +%Y-%m-%d_%H%M)"
target="/backup/wabenhain-${stamp}.sql.gz"
# Erst unter einem Zwischennamen schreiben, dann
# umbenennen: ein mitten im Lauf abgebrochener Job
# hinterlaesst so keine Datei, die wie eine vollstaendige
# Sicherung aussieht.
pg_dump --host=wabenhain-postgres --username=wabenhain \
--dbname=wabenhain --clean --if-exists \
| gzip -9 > "${target}.partial"
mv "${target}.partial" "${target}"
# Angefangene Dateien frueherer Abbrueche mit aufraeumen.
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
# langfristige Archivierung der Rechnungen (§ 147 AO,
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
# ausgelagerte Sicherungen nutzen.
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
echo "Sicherung abgeschlossen:"
ls -lh /backup
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: backup
persistentVolumeClaim:
claimName: wabenhain-db-backup
+87 -7
View File
@@ -42,7 +42,13 @@ anwendbar. Nummerierung = empfohlene Reihenfolge.
4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und 4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und
`NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen. `NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen.
5. `image:` in `05-app-deployment.yaml` zeigt bereits auf 5. `04-uploads-pvc.yaml`: `storageClassName` auf eine im Cluster
vorhandene StorageClass setzen (`kubectl get storageclass`; die Zeile ist
auskommentiert, dann greift die als `(default)` markierte) und die
Groesse pruefen. Ohne diesen Claim gibt es keinen dauerhaften Speicher
fuer Produkt- und Erntenfotos.
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
`git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen `git.encry.de/nick.rosenloecher/wabenhain` — nur noch den Tag pruefen
(`:latest` passt fuer den jeweils neuesten `main`-Build). In (`:latest` passt fuer den jeweils neuesten `main`-Build). In
`06-ingress.yaml` `shop.example.com` durch die echte Domain ersetzen `06-ingress.yaml` `shop.example.com` durch die echte Domain ersetzen
@@ -61,8 +67,15 @@ kubectl apply -f k8s/03-postgres.yaml
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
# Persistenter Speicher fuer hochgeladene Bilder — MUSS vor dem Deployment
# angewendet werden, sonst bleibt der App-Pod in "Pending" haengen.
kubectl apply -f k8s/04-uploads-pvc.yaml
kubectl apply -f k8s/05-app-deployment.yaml kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml kubectl apply -f k8s/06-ingress.yaml
# Taegliche Datenbanksicherung (siehe "Sicherung und Wiederherstellung").
kubectl apply -f k8s/07-backup-cronjob.yaml
``` ```
Migrationen + Seed (nur Admin-Konto, KEINE Beispieldaten — siehe Punkt 2 Migrationen + Seed (nur Admin-Konto, KEINE Beispieldaten — siehe Punkt 2
@@ -79,6 +92,15 @@ kubectl rollout restart deployment/wabenhain-app -n wabenhain
Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
(idempotent, siehe oben). (idempotent, siehe oben).
Das Deployment nutzt dabei die Strategie `Recreate`, nicht das
Kubernetes-Standardverhalten `RollingUpdate`: der alte Pod wird zuerst
beendet, dann der neue gestartet. Das ist mit dem uploads-Volume zwingend —
ein `ReadWriteOnce`-Claim kann immer nur an einen Pod gebunden sein, ein
parallel hochfahrender neuer Pod bliebe mit
`Multi-Attach error for volume` in `ContainerCreating` haengen und der
Rollout liefe in den Timeout. Der Preis sind wenige Sekunden Ausfall pro
Redeploy.
## Bekannte Grenzen dieser Manifeste ## Bekannte Grenzen dieser Manifeste
- **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das - **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das
@@ -88,9 +110,67 @@ Migrationen + Seed laufen automatisch erneut beim Start jedes neuen Pods
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen. horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`, keine - Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`, keine
`NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen. `NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen.
- Die Postgres-/MySQL-StatefulSets sind Single-Instance ohne Replikation/ - Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
Backup-Automation. Fuer produktiven Betrieb einen Managed-Datenbank-Dienst Replikation**. Eine taegliche Sicherung gibt es inzwischen
oder einen dedizierten DB-Operator (z.B. CloudNativePG) in Erwaegung (`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
ziehen — `DATABASE_URL` in `02-secret.yaml` einfach auf den externen liegt aber im selben Cluster und ersetzt damit weder Replikation noch
Endpunkt zeigen lassen, `03-postgres.yaml`/`03-mysql.yaml` dann nicht eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
anwenden. Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
`03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden.
## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
gzip-komprimierte `pg_dump`-Sicherung auf einem eigenen Volume ab und
haelt sie 30 Tage vor. Ohne diese Sicherung liegen Bestellungen,
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
jedes Netz — und Rechnungen unterliegen in Deutschland einer
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
**Sicherungen ansehen:**
```bash
kubectl get cronjob wabenhain-db-backup -n wabenhain
kubectl logs -n wabenhain -l job-name --tail=50 # Ausgabe des letzten Laufs
```
**Sofort eine Sicherung ausloesen** (etwa vor einem riskanten Eingriff):
```bash
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
```
**Aus dem Cluster herausziehen.** Die Sicherung im Cluster schuetzt gegen
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
Clusters oder Standorts. Deshalb regelmaessig (oder per eigenem Cron auf
einem Rechner ausserhalb) kopieren:
```bash
POD=$(kubectl get pod -n wabenhain -l job-name -o name | head -1)
kubectl exec -n wabenhain "$POD" -- ls -1 /backup
kubectl cp wabenhain/"${POD#pod/}":/backup/wabenhain-2026-01-31_0315.sql.gz ./wabenhain-2026-01-31.sql.gz
```
**Wiederherstellen.** Vorher die App anhalten, damit waehrend des
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
vorhandenen Tabellen zuerst:
```bash
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=0
gunzip -c wabenhain-2026-01-31.sql.gz | kubectl exec -i -n wabenhain wabenhain-postgres-0 -- psql -U wabenhain -d wabenhain
kubectl scale deployment/wabenhain-app -n wabenhain --replicas=1
```
Der App-Container fuehrt beim naechsten Start ohnehin
`prisma migrate deploy` aus (siehe Dockerfile-CMD) — ein Dump aus einem
aelteren Schemastand wird dadurch automatisch nachgezogen.
**Eine ungepruefte Sicherung ist keine Sicherung.** Einmal nach der
Einrichtung und danach gelegentlich einen Wiederherstellungsversuch gegen
eine Wegwerf-Datenbank fahren, statt sich erst im Ernstfall darauf zu
verlassen.
+10 -9
View File
@@ -16,26 +16,27 @@
"db:studio": "node scripts/prisma-provider.mjs && prisma studio", "db:studio": "node scripts/prisma-provider.mjs && prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"preflight": "tsx scripts/preflight.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-mariadb": "^7.9.1", "@prisma/adapter-mariadb": "^7.10.0",
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"jose": "^6.2.8", "jose": "^6.2.8",
"mariadb": "^3.5.3", "mariadb": "^3.5.3",
"motion": "^13.0.0", "motion": "^13.0.0",
"next": "16.3.0", "next": "16.3.5",
"nodemailer": "^9.0.5", "nodemailer": "^9.1.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"pdfkit": "^0.19.1", "pdfkit": "^0.19.1",
"prisma": "^7.9.1", "prisma": "^7.10.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"sharp": "^0.35.3", "sharp": "^0.35.4",
"stripe": "^22.4.0", "stripe": "^22.4.0",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -56,7 +57,7 @@
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.5",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4", "tsx": "^4",
+273 -266
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -17,3 +17,17 @@ overrides:
# den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm # den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm
# haelt dafuer sonst zwei getrennte Kopien vor. # haelt dafuer sonst zwei getrennte Kopien vor.
mariadb: '>=3.4.6' mariadb: '>=3.4.6'
# GHSA-rgwj-5xj2-c3m3 u.a.: mysql2 < 3.23.1. Kommt ausschliesslich ueber die
# prisma-CLI herein (Migrationen/Generate), nicht ueber den Laufzeitpfad der
# Anwendung — die nutzt bei DATABASE_PROVIDER=mysql den mariadb-Treiber
# (siehe lib/db-adapter.ts). Trotzdem angehoben, weil prisma bei jedem
# Containerstart "migrate deploy" ausfuehrt (siehe Dockerfile-CMD) und der
# Sprung innerhalb derselben Hauptversion bleibt.
mysql2: '>=3.23.1'
# GHSA: fast-uri < 3.1.6, transitiv ueber ajv unter der prisma-CLI.
# Reiner Patch-Sprung innerhalb von 3.1.x.
fast-uri: '>=3.1.6'
# deepmerge-ts < 8.0.0, transitiv ueber @prisma/config. Hauptversionssprung,
# deshalb nach dem Anheben mit "prisma validate" + "prisma generate"
# geprueft (beide laufen durch @prisma/config).
deepmerge-ts: '>=8.0.0'
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+220
View File
@@ -0,0 +1,220 @@
/**
* Go-Live-Pruefung: faellt laut durch, statt den Shop still mit
* Platzhaltern oder Test-Zugangsdaten oeffentlich zu stellen.
*
* Aufruf: `pnpm preflight` (liest .env) — gedacht als letzter Schritt vor
* dem ersten oeffentlichen Deploy und als Schritt in der Deploy-Pipeline.
* Endet mit Exit-Code 1, sobald ein Blocker offen ist.
*
* Die Inhaltspruefungen fragen bewusst die DATENBANK und nicht den
* Quelltext: Impressum, Datenschutz, AGB, Widerrufsbelehrung und die
* Telefonnummer sind im Admin-Bereich unter "Inhalte" pflegbar (siehe
* lib/content-registry.ts). Die Texte in i18n/dictionaries/ sind nur der
* Rueckfallwert, solange dort nichts gepflegt ist. Eine Pruefung, die nur
* in den Quelltext schaut, meldet deshalb einen Blocker, den die
* Betreiberin im Admin laengst geschlossen hat — und schweigt umgekehrt,
* wenn jemand einen Platzhaltertext erst im Admin eintraegt.
*
* Ist die Datenbank nicht erreichbar (Aufruf von einer Maschine ohne
* Zugang zur Produktionsdatenbank), werden diese Pruefungen ausdruecklich
* als ungeprueft gemeldet statt geraten.
*/
import "dotenv/config";
import { prisma } from "../src/lib/prisma";
import { getContentValue } from "../src/lib/content";
import { locales } from "../src/i18n/config";
const blockers: string[] = [];
const warnings: string[] = [];
const passed: string[] = [];
function must(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else blockers.push(`${label}\n ${hint}`);
}
function should(label: string, ok: boolean, hint: string): void {
if (ok) passed.push(label);
else warnings.push(`${label}\n ${hint}`);
}
const value = (name: string): string => (process.env[name] ?? "").trim();
const isSet = (name: string): boolean => value(name).length > 0;
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET");
must(
"SESSION_SECRET gesetzt und mindestens 32 Zeichen lang",
sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
);
must(
"SESSION_SECRET ist nicht mehr der Beispielwert",
!sessionSecret.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
);
const adminEmail = value("ADMIN_EMAIL");
must(
"ADMIN_EMAIL gesetzt und kein Beispielwert",
adminEmail.length > 0 && !adminEmail.includes("change-me") && !adminEmail.endsWith("@example.com"),
'Legt das erste Admin-Konto an (prisma db seed). Eine unvorhersehbare Adresse waehlen, nicht "admin@...".'
);
must(
"ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen",
value("ADMIN_PASSWORD").length >= 12 && !value("ADMIN_PASSWORD").includes("change-me"),
"Dieses Konto darf Rueckerstattungen ausloesen und alle Kundendaten einsehen."
);
// --- Oeffentliche Adresse der Seite ---------------------------------------
const siteUrl = value("NEXT_PUBLIC_SITE_URL");
must(
"NEXT_PUBLIC_SITE_URL gesetzt",
siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
);
must(
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
must(
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.includes("example.org"),
'k8s/01-configmap.yaml und k8s/06-ingress.yaml liefern "shop.example.com" als Vorgabe aus. Bleibt der Wert stehen, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.'
);
// --- Zahlung ---------------------------------------------------------------
must(
"STRIPE_SECRET_KEY gesetzt",
isSet("STRIPE_SECRET_KEY"),
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
);
must(
"STRIPE_WEBHOOK_SECRET gesetzt",
isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
);
must(
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
!value("STRIPE_SECRET_KEY").startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
// --- E-Mail ----------------------------------------------------------------
must(
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
should(
"SMTP_FROM zeigt auf eine eigene Domain",
!value("SMTP_FROM").includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
);
// --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
// --- Missbrauchsschutz -----------------------------------------------------
should(
"Cloudflare Turnstile konfiguriert",
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
// die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse
// die jeweils andere Sprachfassung mit Luecken online gehen.
/**
* Erste aussagekraeftige Zeile einer Fehlermeldung. Prisma-Verbindungsfehler
* beginnen mit Leerzeilen — ein blosses split("\n")[0] ergaebe eine leere
* Klammer statt der Ursache.
*/
function firstMeaningfulLine(error: unknown): string {
if (!(error instanceof Error)) return "unbekannter Fehler";
const line = error.message.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
return line ?? error.name;
}
const PLACEHOLDER_MARKERS = ["[PLATZHALTER", "[PLACEHOLDER"];
const hasPlaceholder = (text: string): boolean =>
PLACEHOLDER_MARKERS.some((marker) => text.includes(marker));
const LEGAL_KEYS = [
{ key: "legal.impressum", label: "Impressum" },
{ key: "legal.datenschutz", label: "Datenschutz" },
{ key: "legal.agb", label: "AGB" },
{ key: "legal.widerruf", label: "Widerrufsbelehrung" },
] as const;
// Kein Top-Level-await: das Projekt hat kein "type": "module" in der
// package.json, tsx uebersetzt dieses Skript daher nach CommonJS.
async function checkDatabaseContent(): Promise<void> {
try {
// Verbindung zuerst pruefen, damit ein DB-Ausfall unten nicht faelschlich
// als "Inhalt in Ordnung" durchgeht.
await prisma.$queryRaw`SELECT 1`;
const withPlaceholder: string[] = [];
for (const { key, label } of LEGAL_KEYS) {
for (const locale of locales) {
if (hasPlaceholder(await getContentValue(key, locale))) {
withPlaceholder.push(`${label} (${locale})`);
}
}
}
must(
"Rechtstexte enthalten keine Platzhalter-Markierungen mehr",
withPlaceholder.length === 0,
`Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
);
should(
"Telefonnummer ist keine Platzhalternummer",
!(await getContentValue("company.phone", "de")).includes("000 000000"),
'Sichtbar im Footer, auf der Kontaktseite und im Impressum. Im Admin-Bereich unter "Inhalte" pflegbar.'
);
} catch (error: unknown) {
// Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst
// sich der tatsaechlich ausgelieferte Text nicht feststellen.
warnings.push(
`Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine(
error
)}). Diese Inhalte liegen in der Datenbank, nicht im Quelltext — die Pruefung mit Zugriff auf die Produktionsdatenbank wiederholen oder /impressum im Browser aufrufen.`
);
} finally {
await prisma.$disconnect();
}
}
function report(): void {
// --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`);
if (warnings.length > 0) {
console.log(`\n⚠ Warnungen: ${warnings.length}`);
for (const item of warnings) console.log(` ⚠ ${item}`);
}
if (blockers.length > 0) {
console.log(`\n✖ Blocker: ${blockers.length}`);
for (const item of blockers) console.log(` ✖ ${item}`);
console.log(`\n${line}`);
console.log("Nicht bereit fuer den Live-Betrieb. Die Blocker oben zuerst beheben.\n");
process.exit(1);
}
console.log(`\n${line}`);
console.log(
warnings.length > 0
? "Keine Blocker. Warnungen oben pruefen, dann ist der Live-Betrieb moeglich.\n"
: "Alle Pruefungen bestanden — bereit fuer den Live-Betrieb.\n"
);
}
void checkDatabaseContent().then(report);
+18
View File
@@ -84,6 +84,24 @@ export async function POST(request: Request) {
); );
} }
// Ein im Admin deaktiviertes Produkt (isActive = false) darf nicht mehr
// verkauft werden. getProductsByIds filtert bewusst nicht danach, weil es
// auch fuer bereits bestellte, inzwischen deaktivierte Produkte genutzt
// wird (siehe lib/data.ts) — die Pruefung gehoert deshalb hierher.
//
// Die Warenkorb-Oberflaeche zeigt ein solches Produkt ueber
// /api/shop/cart-stock zwar bereits mit Bestand 0 an, aber das ist nur der
// Client. Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert
// wurde, kam bis hierher ungeprueft durch: die Bestellung wurde angelegt
// und tatsaechlich bezahlt, obwohl die Imkerei den Artikel gerade aus dem
// Verkauf genommen hatte.
if (products.some((product) => !product.isActive)) {
return NextResponse.json(
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
{ status: 400 }
);
}
const orderItemsData = input.items.map((cartItem) => { const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!; const product = products.find((p) => p.id === cartItem.productId)!;
return { return {
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
/**
* Readiness-Probe: darf dieser Pod Kundenverkehr bekommen? Anders als die
* Liveness-Probe (siehe ../route.ts) prueft sie die Datenbank, denn ohne DB
* kann die Seite weder Produkte anzeigen noch eine Bestellung annehmen.
*
* Faellt sie aus, nimmt Kubernetes den Pod nur aus dem Service-Load-Balancing
* heraus und startet ihn NICHT neu — genau das richtige Verhalten bei einer
* voruebergehenden DB-Stoerung.
*
* "SELECT 1" funktioniert unveraendert auf PostgreSQL und MySQL/MariaDB
* (beide Provider sind per DATABASE_PROVIDER moeglich, siehe lib/prisma.ts)
* und beruehrt keine Tabelle, ist also unabhaengig vom Migrationsstand.
*/
export async function GET(): Promise<NextResponse> {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json(
{ status: "ready", database: "up" },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error: unknown) {
// Details nur ins Server-Log, nicht in die Antwort: der Endpunkt ist
// oeffentlich erreichbar und soll keine Verbindungszeichenfolge oder
// internen Hostnamen preisgeben.
console.error("Readiness-Probe: Datenbank nicht erreichbar", error);
return NextResponse.json(
{ status: "not-ready", database: "down" },
{ status: 503, headers: { "Cache-Control": "no-store" } }
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
// Nie zwischengespeichert: eine aus dem Cache beantwortete Probe wuerde einen
// haengenden Prozess als gesund melden.
export const dynamic = "force-dynamic";
/**
* Liveness-Probe: beantwortet ausschliesslich die Frage "laeuft der
* Node-Prozess noch und nimmt er Verbindungen an?" — bewusst OHNE
* Datenbankzugriff.
*
* Grund fuer die Trennung von /api/health/ready: eine Liveness-Probe, die an
* der Datenbank haengt, macht aus einer voruebergehenden DB-Stoerung eine
* Neustart-Schleife. Kubernetes toetet den Container, der Container faehrt
* hoch und laesst dabei laut Dockerfile-CMD erst "prisma migrate deploy" und
* "prisma db seed" gegen genau die Datenbank laufen, die gerade nicht
* erreichbar ist — der Neustart verschlimmert die Lage, statt sie zu
* beheben. Vorher zeigten beide Probes auf "/", also auf eine
* force-dynamic-Seite samt DB-Abfragen und vollstaendigem React-Rendering
* alle 10 bzw. 30 Sekunden.
*/
export function GET(): NextResponse {
return NextResponse.json(
{ status: "ok", uptimeSeconds: Math.round(process.uptime()) },
{ headers: { "Cache-Control": "no-store" } }
);
}
+56 -38
View File
@@ -13,7 +13,7 @@ import { TrustBadges } from "@/components/trust-badges";
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar"; import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
import { getProductBySlug, getRelatedProducts } from "@/lib/data"; import { getProductBySlug, getRelatedProducts } from "@/lib/data";
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format"; import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
import { siteConfig } from "@/lib/site-config"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews"; import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
@@ -41,6 +41,11 @@ export async function generateMetadata(
title: product.name, title: product.name,
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`, description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
alternates: { canonical: `/honig/${slug}` }, alternates: { canonical: `/honig/${slug}` },
// Deaktivierte Produkte bleiben erreichbar (die Bestellhistorie und die
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
// mehr bestellbar ist.
...(product.isActive ? {} : { robots: { index: false, follow: true } }),
}; };
} }
@@ -66,8 +71,12 @@ export default async function ProductDetailPage(
? "was-bedeutet-sommertracht" ? "was-bedeutet-sommertracht"
: null; : null;
const stock = product.inventory?.quantity ?? 0; const stock = product.inventory?.quantity ?? 0;
const inStock = stock > 0; // Ein im Admin deaktiviertes Produkt ist nicht bestellbar, auch wenn noch
const lowStock = inStock && stock <= 5; // Bestand gebucht ist. Es verschwindet bereits aus Shop-Uebersicht und
// Sitemap, seine Detailseite blieb aber voll bestellbar — erreichbar ueber
// Direktlink, Lesezeichen, alte Newsletter und "Zuletzt angesehen".
const isPurchasable = product.isActive && stock > 0;
const lowStock = isPurchasable && stock <= 5;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] = const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
@@ -87,6 +96,7 @@ export default async function ProductDetailPage(
// haengt bei einem kleinen Versandhandel oft direkt daran). // haengt bei einem kleinen Versandhandel oft direkt daran).
const baseShippingCents = resolveShippingCostCents(shippingTiers, 1); const baseShippingCents = resolveShippingCostCents(shippingTiers, 1);
const base = getSiteUrl();
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Product", "@type": "Product",
@@ -101,14 +111,14 @@ export default async function ProductDetailPage(
// vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute // vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute
// URL zu einem echten Produktfoto, keine Platzhaltergrafik). // URL zu einem echten Produktfoto, keine Platzhaltergrafik).
...(product.images.length > 0 ...(product.images.length > 0
? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) } ? { image: product.images.map((src) => `${base}${src}`) }
: {}), : {}),
offers: { offers: {
"@type": "Offer", "@type": "Offer",
priceCurrency: product.currency, priceCurrency: product.currency,
price: (product.priceCents / 100).toFixed(2), price: (product.priceCents / 100).toFixed(2),
availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
url: `https://wabenhain.com/honig/${product.slug}`, url: `${base}/honig/${product.slug}`,
}, },
...(reviewSummary.count > 0 ...(reviewSummary.count > 0
? { ? {
@@ -124,18 +134,18 @@ export default async function ProductDetailPage(
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ "@type": "ListItem", position: 1, name: "Shop", item: `https://${siteConfig.domain}/shop` }, { "@type": "ListItem", position: 1, name: "Shop", item: `${base}/shop` },
{ {
"@type": "ListItem", "@type": "ListItem",
position: 2, position: 2,
name: t.common.categories[product.category], name: t.common.categories[product.category],
item: `https://${siteConfig.domain}/shop?kategorie=${product.category}`, item: `${base}/shop?kategorie=${product.category}`,
}, },
{ {
"@type": "ListItem", "@type": "ListItem",
position: 3, position: 3,
name: product.name, name: product.name,
item: `https://${siteConfig.domain}/honig/${product.slug}`, item: `${base}/honig/${product.slug}`,
}, },
], ],
}; };
@@ -143,7 +153,7 @@ export default async function ProductDetailPage(
return ( return (
<div className="py-16 pb-28 md:pb-16"> <div className="py-16 pb-28 md:pb-16">
<RecordProductView productId={product.id} /> <RecordProductView productId={product.id} />
<StickyAddToCartBar product={product} inStock={inStock} /> <StickyAddToCartBar product={product} inStock={isPurchasable} />
<script <script
type="application/ld+json" type="application/ld+json"
// "<" escapen, damit Produkttexte niemals ein schliessendes // "<" escapen, damit Produkttexte niemals ein schliessendes
@@ -205,7 +215,7 @@ export default async function ProductDetailPage(
</div> </div>
) : null} ) : null}
{inStock ? ( {isPurchasable ? (
<> <>
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" /> <AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
{lowStock ? ( {lowStock ? (
@@ -220,33 +230,41 @@ export default async function ProductDetailPage(
<p className="mt-2 text-xs text-ink/65"> <p className="mt-2 text-xs text-ink/65">
{t.productPage.currentlyUnavailable} {t.productPage.currentlyUnavailable}
</p> </p>
<form action={subscribeStockNotificationAction} className="mt-4 max-w-xs"> {/* Nur bei aktivem, aber ausverkauftem Produkt: die
<input type="hidden" name="productId" value={product.id} /> Benachrichtigung haengt am Lagerbestand (lib/stock-
<label htmlFor="notify-email" className="text-xs text-ink/65"> notifications.ts). Bei einem dauerhaft deaktivierten Produkt
{t.productPage.notifyMeLabel} waere sie ein Versprechen, das nie eingeloest wird. */}
</label> {product.isActive ? (
<div className="mt-2 flex gap-2"> <>
<input <form action={subscribeStockNotificationAction} className="mt-4 max-w-xs">
id="notify-email" <input type="hidden" name="productId" value={product.id} />
name="email" <label htmlFor="notify-email" className="text-xs text-ink/65">
type="email" {t.productPage.notifyMeLabel}
required </label>
placeholder={t.productPage.emailPlaceholder} <div className="mt-2 flex gap-2">
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest" <input
/> id="notify-email"
<button name="email"
type="submit" type="email"
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest" required
> placeholder={t.productPage.emailPlaceholder}
{t.productPage.notifySubmit} className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
</button> />
</div> <button
</form> type="submit"
{notifyError ? ( className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
<p className="mt-2 text-xs text-red-700">{notifyError}</p> >
) : null} {t.productPage.notifySubmit}
{notifyMessage ? ( </button>
<p className="mt-2 text-xs text-forest">{notifyMessage}</p> </div>
</form>
{notifyError ? (
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
) : null}
{notifyMessage ? (
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
) : null}
</>
) : null} ) : null}
</> </>
)} )}
+7 -6
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import { Header, CartDrawer } from "@/components/site-chrome"; import { Header, CartDrawer } from "@/components/site-chrome";
import { Footer } from "@/components/site-footer"; import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent"; import { CookieConsentBanner } from "@/components/cookie-consent";
import { siteConfig } from "@/lib/site-config"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getSocialLinks } from "@/lib/social-links"; import { getSocialLinks } from "@/lib/social-links";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -26,7 +26,7 @@ export async function generateMetadata(): Promise<Metadata> {
const company = await getCompanyInfo(); const company = await getCompanyInfo();
const title = `${company.name} | Regionaler Honig aus Altenmünster`; const title = `${company.name} | Regionaler Honig aus Altenmünster`;
return { return {
metadataBase: new URL(`https://${siteConfig.domain}`), metadataBase: new URL(getSiteUrl()),
title: { default: title, template: `%s | ${company.name}` }, title: { default: title, template: `%s | ${company.name}` },
description: company.description, description: company.description,
openGraph: { openGraph: {
@@ -44,6 +44,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
const locale = await getLocale(); const locale = await getLocale();
const dict = getDictionary(locale); const dict = getDictionary(locale);
const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]); const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]);
const base = getSiteUrl();
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
// auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche // auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche
@@ -54,10 +55,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
const localBusinessJsonLd = { const localBusinessJsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Store", "@type": "Store",
"@id": `https://${siteConfig.domain}/#business`, "@id": `${base}/#business`,
name: company.name, name: company.name,
description: company.description, description: company.description,
url: `https://${siteConfig.domain}`, url: base,
address: { address: {
"@type": "PostalAddress", "@type": "PostalAddress",
streetAddress: company.address.street, streetAddress: company.address.street,
@@ -73,10 +74,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
name: company.name, name: company.name,
url: `https://${siteConfig.domain}`, url: base,
potentialAction: { potentialAction: {
"@type": "SearchAction", "@type": "SearchAction",
target: `https://${siteConfig.domain}/shop?q={search_term_string}`, target: `${base}/shop?q={search_term_string}`,
"query-input": "required name=search_term_string", "query-input": "required name=search_term_string",
}, },
}; };
+10 -2
View File
@@ -1,5 +1,13 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site-config"; import { getSiteUrl } from "@/lib/url";
// Muss zur Laufzeit statt zur Build-Zeit gerendert werden (gleicher Grund wie
// bei sitemap.ts): sonst backt Next.js die Datei beim Build als statische
// Antwort ein — samt dem Wert, den NEXT_PUBLIC_SITE_URL GERADE DA hatte. Im
// Docker-Build ist die Variable nicht gesetzt (siehe Dockerfile), die Sitemap-
// Zeile fror damit dauerhaft auf dem Rueckfallwert aus site-config.ts ein,
// egal unter welcher Domain die Seite spaeter tatsaechlich lief.
export const dynamic = "force-dynamic";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
@@ -10,6 +18,6 @@ export default function robots(): MetadataRoute.Robots {
disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"], disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"],
}, },
], ],
sitemap: `https://${siteConfig.domain}/sitemap.xml`, sitemap: `${getSiteUrl()}/sitemap.xml`,
}; };
} }
+4 -3
View File
@@ -6,7 +6,7 @@ import { ProductCard } from "@/components/product-card";
import { RecentlyViewedSection } from "@/components/recently-viewed-section"; import { RecentlyViewedSection } from "@/components/recently-viewed-section";
import { getActiveProducts, type ProductSort } from "@/lib/data"; import { getActiveProducts, type ProductSort } from "@/lib/data";
import { getReviewSummariesByProductIds } from "@/lib/reviews"; import { getReviewSummariesByProductIds } from "@/lib/reviews";
import { siteConfig } from "@/lib/site-config"; import { getSiteUrl } from "@/lib/url";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import type { ProductCategory } from "@/generated/prisma/client"; import type { ProductCategory } from "@/generated/prisma/client";
@@ -67,12 +67,13 @@ export default async function ShopPage(props: PageProps<"/shop">) {
const products = await getActiveProducts({ query, category, sort, onlyAvailable }); const products = await getActiveProducts({ query, category, sort, onlyAvailable });
const reviewSummaries = await getReviewSummariesByProductIds(products.map((p) => p.id)); const reviewSummaries = await getReviewSummariesByProductIds(products.map((p) => p.id));
const base = getSiteUrl();
const breadcrumbJsonLd = { const breadcrumbJsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ "@type": "ListItem", position: 1, name: "Start", item: `https://${siteConfig.domain}` }, { "@type": "ListItem", position: 1, name: "Start", item: base },
{ "@type": "ListItem", position: 2, name: "Shop", item: `https://${siteConfig.domain}/shop` }, { "@type": "ListItem", position: 2, name: "Shop", item: `${base}/shop` },
], ],
}; };
+3 -2
View File
@@ -1,6 +1,7 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { getActiveProducts, getHarvests } from "@/lib/data"; import { getActiveProducts, getHarvests } from "@/lib/data";
import { wissenSlugs, siteConfig } from "@/lib/site-config"; import { wissenSlugs } from "@/lib/site-config";
import { getSiteUrl } from "@/lib/url";
// priority/changeFrequency sind Hinweise an Crawler, keine Garantien — // priority/changeFrequency sind Hinweise an Crawler, keine Garantien —
// die Startseite und der Shop aendern sich am haeufigsten und sind fuer // die Startseite und der Shop aendern sich am haeufigsten und sind fuer
@@ -27,7 +28,7 @@ const staticRoutes: { path: string; priority: number; changeFrequency: MetadataR
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = `https://${siteConfig.domain}`; const base = getSiteUrl();
const [products, harvests] = await Promise.all([getActiveProducts(), getHarvests()]); const [products, harvests] = await Promise.all([getActiveProducts(), getHarvests()]);
return [ return [
+3 -2
View File
@@ -3,7 +3,8 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { wissenSlugs, siteConfig } from "@/lib/site-config"; import { wissenSlugs } from "@/lib/site-config";
import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -42,7 +43,7 @@ export default async function WissenArticlePage(
const otherArticles = wissenSlugs.filter((s) => s !== slug).slice(0, 2); const otherArticles = wissenSlugs.filter((s) => s !== slug).slice(0, 2);
const company = await getCompanyInfo(); const company = await getCompanyInfo();
const base = `https://${siteConfig.domain}`; const base = getSiteUrl();
// Kein datePublished/image: fuer diese Artikel existiert kein echtes // Kein datePublished/image: fuer diese Artikel existiert kein echtes
// Veroeffentlichungsdatum und keine echte Fotografie — beides zu erfinden // Veroeffentlichungsdatum und keine echte Fotografie — beides zu erfinden
// waere irrefuehrende strukturierte Daten statt hilfreicher. // waere irrefuehrende strukturierte Daten statt hilfreicher.
+3 -3
View File
@@ -1,5 +1,5 @@
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { siteConfig } from "@/lib/site-config"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getEmailStrings } from "@/lib/email-strings"; import { getEmailStrings } from "@/lib/email-strings";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
@@ -284,7 +284,7 @@ interface BackInStockInput {
export async function backInStockEmail(product: BackInStockInput): Promise<EmailContent> { export async function backInStockEmail(product: BackInStockInput): Promise<EmailContent> {
const s = getEmailStrings(product.locale).backInStock; const s = getEmailStrings(product.locale).backInStock;
const url = `https://${siteConfig.domain}/honig/${product.slug}`; const url = `${getSiteUrl()}/honig/${product.slug}`;
const html = await emailLayout( const html = await emailLayout(
product.locale, product.locale,
s.preheader(product.name), s.preheader(product.name),
@@ -357,7 +357,7 @@ export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne // Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
// einen tatsaechlich anklickbaren Button dazu bliebe das ein reiner // einen tatsaechlich anklickbaren Button dazu bliebe das ein reiner
// Lauftext-Hinweis ohne direkten Weg zurueck in den Shop. // Lauftext-Hinweis ohne direkten Weg zurueck in den Shop.
const shopUrl = `https://${siteConfig.domain}/shop`; const shopUrl = `${getSiteUrl()}/shop`;
const html = await emailLayout( const html = await emailLayout(
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach } from "vitest";
import { getSiteUrl, getBaseUrl } from "./url";
import { siteConfig } from "./site-config";
const original = process.env.NEXT_PUBLIC_SITE_URL;
afterEach(() => {
if (original === undefined) delete process.env.NEXT_PUBLIC_SITE_URL;
else process.env.NEXT_PUBLIC_SITE_URL = original;
});
describe("getSiteUrl", () => {
it("nutzt NEXT_PUBLIC_SITE_URL, wenn gesetzt", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de";
expect(getSiteUrl()).toBe("https://shop.beispiel.de");
});
it("entfernt abschliessende Schraegstriche, damit keine doppelten entstehen", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de//";
// Zusammengesetzt ergaebe das sonst "https://shop.beispiel.de//shop" —
// fuer Google eine andere URL als die kanonische Variante.
expect(`${getSiteUrl()}/shop`).toBe("https://shop.beispiel.de/shop");
});
it("faellt auf die konfigurierte Domain zurueck statt auf localhost", () => {
delete process.env.NEXT_PUBLIC_SITE_URL;
// Eine vergessene Umgebungsvariable darf nicht dazu fuehren, dass
// "http://localhost:3000" in einer oeffentlichen Sitemap landet.
expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`);
});
it("ignoriert einen Wert, der nur aus Leerzeichen besteht", () => {
process.env.NEXT_PUBLIC_SITE_URL = " ";
expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`);
});
it("liefert unter getBaseUrl denselben Wert — E-Mails und SEO duerfen nicht auseinanderlaufen", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de";
expect(getBaseUrl()).toBe(getSiteUrl());
});
});
+32 -4
View File
@@ -1,8 +1,36 @@
import { siteConfig } from "@/lib/site-config";
/** /**
* Basis-URL fuer absolute Links in E-Mails (Bestellbestaetigung, * Oeffentliche Basis-URL der Seite, ohne abschliessenden Schraegstrich
* Newsletter-Opt-in, Passwort-Reset). Webhooks/Server Actions haben keinen * (z.B. "https://wabenhain.com").
* verlaesslichen Request-Origin, deshalb NEXT_PUBLIC_SITE_URL statt Header. *
* EINZIGE Quelle fuer absolute Links — sowohl fuer E-Mails (Bestellbestaetigung,
* Newsletter-Opt-in, Passwort-Reset) als auch fuer alles, was Suchmaschinen
* sehen: metadataBase/Canonicals (app/layout.tsx), sitemap.xml, robots.txt und
* saemtliche JSON-LD-Bloecke.
*
* Vorher war das auf zwei Quellen verteilt: E-Mails nutzten
* NEXT_PUBLIC_SITE_URL, SEO-relevante Stellen dagegen ein fest verdrahtetes
* "https://" + siteConfig.domain. Laeuft die Seite unter einer anderen Domain
* (Staging, Vorschau-Host, spaeterer Domainwechsel), zeigten Sitemap,
* Canonical-Tags und strukturierte Daten damit geschlossen auf eine fremde
* Adresse: Google verwirft eine Sitemap mit fremden Hosts, und Canonicals auf
* eine andere Domain weisen die Indexierung aktiv von der echten Seite weg.
*
* siteConfig.domain bleibt als Rueckfallebene erhalten, damit eine vergessene
* Umgebungsvariable in Produktion nicht zu "http://localhost:3000" in einer
* oeffentlichen Sitemap fuehrt.
*/
export function getSiteUrl(): string {
const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim();
if (configured) return configured.replace(/\/+$/, "");
return `https://${siteConfig.domain}`;
}
/**
* Alias von getSiteUrl() unter dem in E-Mail-Code eingebuergerten Namen.
* Beide liefern denselben Wert — der Unterschied ist rein sprachlich.
*/ */
export function getBaseUrl(): string { export function getBaseUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; return getSiteUrl();
} }