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]>
228 lines
12 KiB
Bash
228 lines
12 KiB
Bash
# Datenbank: "postgresql" (Standard) oder "mysql". Nach einer Aenderung
|
|
# einmal `pnpm db:push` ausfuehren (schreibt prisma/schema.prisma neu und
|
|
# generiert den passenden Treiber-Adapter-Client) — siehe README, Abschnitt
|
|
# "Datenbank-Provider wechseln".
|
|
DATABASE_PROVIDER=postgresql
|
|
|
|
# PostgreSQL (Standard). Fuer docker compose siehe POSTGRES_* unten.
|
|
DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public"
|
|
# MySQL/MariaDB stattdessen:
|
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
|
|
|
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
|
POSTGRES_USER=wabenhain
|
|
POSTGRES_PASSWORD=change-me
|
|
POSTGRES_DB=wabenhain
|
|
|
|
# Nur noetig, wenn DATABASE_PROVIDER=mysql: Zugangsdaten fuer den
|
|
# db-mysql-Container ("docker compose --profile mysql up -d db-mysql")
|
|
MYSQL_USER=wabenhain
|
|
MYSQL_PASSWORD=change-me
|
|
MYSQL_DATABASE=wabenhain
|
|
|
|
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
|
|
#
|
|
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig
|
|
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der
|
|
# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel
|
|
# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements —
|
|
# solange es die nicht gibt, waere er Konfiguration, die nichts tut.
|
|
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
|
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
|
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
|
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
|
# Rolle "ADMIN" am Konto (siehe README, Abschnitt "Admin-Zugang"). Diese
|
|
# beiden Werte legt `prisma db seed` als initiales Admin-Konto an, falls es
|
|
# noch nicht existiert. Eine UNVORHERSEHBARE E-Mail waehlen (nicht
|
|
# "admin@..." o.ae.) — sonst koennte sich vor dem ersten Seed-Lauf jemand
|
|
# genau diese Adresse selbst registrieren; prisma/seed.ts befoerdert ein
|
|
# bereits bestehendes Nicht-Admin-Konto zwar nicht mehr automatisch, ein
|
|
# unvorhersehbarer Wert ist trotzdem die robustere erste Verteidigungslinie.
|
|
ADMIN_EMAIL=[email protected]
|
|
ADMIN_PASSWORD=change-me
|
|
|
|
# Geheimer Schluessel zum Signieren der Kundenkonto-JWTs (HS256).
|
|
# In Produktion MINDESTENS 32 Zeichen (wird beim Start erzwungen).
|
|
# Erzeugen z.B. mit: openssl rand -base64 32
|
|
SESSION_SECRET=change-me-to-a-long-random-string
|
|
|
|
# Basis-URL der Seite (fuer absolute Links, z.B. in E-Mails/Sitemaps).
|
|
# Lokale Entwicklung (pnpm dev): http://localhost:3000 (Standard hier).
|
|
# Vollstaendiger Docker-Betrieb: http://localhost (Caddy laeuft auf Port 80,
|
|
# siehe SITE_ADDRESS/Caddyfile unten) bzw. die echte Domain in Produktion.
|
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
|
|
|
# Nur fuer den vollstaendigen Docker-Betrieb (docker-compose.yml, Caddyfile):
|
|
# die oeffentliche Domain, unter der die Seite erreichbar sein soll. Leer
|
|
# gelassen laeuft Caddy lokal per HTTP auf Port 80 ohne TLS — fuer eine
|
|
# echte Domain mit automatischem HTTPS z.B. "wabenhain.com" eintragen.
|
|
SITE_ADDRESS=
|
|
|
|
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
|
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
|
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API".
|
|
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
|
GOOGLE_PLACES_API_KEY=
|
|
GOOGLE_PLACE_ID=
|
|
|
|
# Cloudflare Turnstile (optional). Ohne diese beiden Werte wird auf
|
|
# Registrierung, Newsletter und Firmenanfrage kein CAPTCHA angezeigt und die
|
|
# serverseitige Pruefung uebersprungen. Site-Key ist oeffentlich (NEXT_PUBLIC_),
|
|
# Secret-Key bleibt privat. Einrichtung: https://dash.cloudflare.com/?to=/:account/turnstile
|
|
TURNSTILE_SECRET_KEY=
|
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
|
|
|
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
|
|
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt
|
|
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup.
|
|
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures
|
|
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im
|
|
# SMTP-Modus).
|
|
SMTP_HOST=
|
|
SMTP_PORT=587
|
|
SMTP_USER=
|
|
SMTP_PASSWORD=
|
|
SMTP_FROM="Wabenhain <[email protected]>"
|
|
|
|
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
|
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
|
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
|
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
|
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
|
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
|
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
|
|
# Admin-Zugangs. Benoetigte Berechtigungen, alle nur lesend:
|
|
# harvests:read Ernten
|
|
# products:read Produktkatalog
|
|
# extractions:read Schleuderungen (verbinden Charge und Ernte)
|
|
# bottling:read Abfuellungen = Chargen
|
|
# apiaries:read optional, nur fuer die Standortnamen an den Ernten
|
|
# Details siehe src/lib/apicore-client.ts.
|
|
APICORE_API_URL=
|
|
APICORE_SYNC_EMAIL=
|
|
APICORE_SYNC_PASSWORD=
|
|
|
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
|
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
|
# der Button dort nur den Hinweis "DHL ist noch nicht konfiguriert".
|
|
#
|
|
# So kommt ihr an die Werte:
|
|
# 1. Account anlegen auf https://developer.dhl.com und eine App registrieren,
|
|
# die die API "Parcel DE Shipping" (Post & Parcel Germany) einbindet.
|
|
# Die App liefert DHL_API_KEY (Consumer Key) und DHL_API_SECRET
|
|
# (Consumer Secret). Fuer den Sandbox-Test reichen diese beiden Werte
|
|
# plus die DHL-Testzugangsdaten unten.
|
|
# 2. Fuer den PRODUKTIV-Betrieb zusaetzlich einen DHL-Geschaeftskundenvertrag
|
|
# ("DHL Paket" Versandvertrag) abschliessen — Ansprechpartner ist der
|
|
# DHL-Vertrieb bzw. https://www.dhl.de/de/geschaeftskunden/paket.html.
|
|
# Darueber bekommt ihr:
|
|
# - Zugangsdaten zum Geschaeftskundenportal (GKP) -> DHL_GKP_USERNAME/
|
|
# DHL_GKP_PASSWORD (dieselben Zugangsdaten, mit denen ihr euch auf
|
|
# dhl.de/geschaeftskundenportal einloggt).
|
|
# - Die EKP (10-stellige Kundennummer) plus Verfahren/Teilnahme fuer
|
|
# das gebuchte Produkt (Standard-Inlandspaket V01PAK) -> zusammen
|
|
# ergibt das die 14-stellige DHL_ACCOUNT_NUMBER (billingNumber).
|
|
# 3. DHL_ENVIRONMENT auf "sandbox" lassen, bis alles funktioniert, dann auf
|
|
# "production" umstellen (unterschiedliche Basis-URLs, siehe lib/dhl.ts).
|
|
#
|
|
# DHL-Sandbox-Testzugangsdaten (Stand der Recherche, koennen sich aendern —
|
|
# im Zweifel im DHL-Developer-Portal unter der App nachschauen):
|
|
# DHL_GKP_USERNAME=user-valid
|
|
# DHL_GKP_PASSWORD=SandboxPasswort2023!
|
|
# HINWEIS: Fuer die PACKSTATION-SUCHE im Checkout reicht DHL_API_KEY allein.
|
|
# Sie laeuft ueber die DHL Location Finder API (lib/dhl-locations.ts), einen
|
|
# eigenen Dienst mit eigener Authentifizierung — nur der API-Key im Header,
|
|
# kein OAuth und kein Geschaeftskundenvertrag. Ist der Key gesetzt, bietet der
|
|
# Checkout "An eine Packstation" als Lieferziel an; fehlt er, erscheint die
|
|
# Option gar nicht erst. Die uebrigen DHL_*-Werte braucht erst das Erstellen
|
|
# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
|
|
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
|
|
# cached Ergebnisse 15 Minuten.
|
|
DHL_API_KEY=
|
|
DHL_API_SECRET=
|
|
DHL_GKP_USERNAME=
|
|
DHL_GKP_PASSWORD=
|
|
# 14-stellig: EKP (10-stellig) + Verfahren (2-stellig) + Teilnahme (2-stellig)
|
|
DHL_ACCOUNT_NUMBER=
|
|
DHL_ENVIRONMENT=sandbox
|
|
|
|
# Microsoft Entra ID SSO ("Mit Microsoft anmelden", siehe
|
|
# src/app/api/auth/entra/). Ohne diese vier Werte zeigt der Button auf
|
|
# /konto einen Hinweis, dass SSO noch nicht konfiguriert ist, statt
|
|
# abzustuerzen.
|
|
#
|
|
# So richtet ihr es ein (Owner-Aufgabe im Azure-Portal, https://entra.microsoft.com):
|
|
# 1. Entra ID -> App registrations -> "New registration".
|
|
# - Name frei waehlbar, z.B. "Wabenhain Kundenlogin".
|
|
# - "Supported account types": i.d.R. "Accounts in this organizational
|
|
# directory only" (Single Tenant) fuer eine reine Firmen-SSO-Anbindung.
|
|
# - Redirect URI: Plattform "Web", URL = <eure Domain>/api/auth/entra/callback
|
|
# (lokal: http://localhost:3000/api/auth/entra/callback).
|
|
# 2. Nach dem Erstellen: "Application (client) ID" -> ENTRA_CLIENT_ID,
|
|
# "Directory (tenant) ID" -> ENTRA_TENANT_ID (beide auf der Overview-Seite
|
|
# der App-Registrierung sichtbar).
|
|
# 3. "Certificates & secrets" -> "New client secret" erzeugen -> der
|
|
# angezeigte WERT (nicht die Secret-ID!) -> ENTRA_CLIENT_SECRET. Nur
|
|
# einmal sichtbar, bei Verlust ein neues Secret erzeugen.
|
|
# 4. ENTRA_REDIRECT_URI = exakt dieselbe URL wie die in Schritt 1 hinterlegte
|
|
# Redirect-URI (muss zeichengenau uebereinstimmen).
|
|
# 5. "Enterprise applications" in Entra ID zeigt die App danach automatisch
|
|
# als Unternehmensapplikation (wird beim App-Registration-Schritt
|
|
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
|
|
# (z.B. nur bestimmte Gruppen) einschraenken.
|
|
ENTRA_TENANT_ID=
|
|
ENTRA_CLIENT_ID=
|
|
ENTRA_CLIENT_SECRET=
|
|
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
|
|
|
# Verzeichnis fuer hochgeladene Bilder (Inhalte-Bild in admin/inhalte,
|
|
# Produkt-/Erntenfotos in admin/produkte bzw. admin/ernten — siehe
|
|
# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts). Die App
|
|
# liest/schreibt hier ganz normal per node:fs; sie weiss nichts davon, WAS
|
|
# dieses Verzeichnis in Produktion ist (siehe unten).
|
|
#
|
|
# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per
|
|
# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt).
|
|
#
|
|
# In docker-compose.yml ist UPLOAD_DIR bereits fest auf /app/uploads gesetzt
|
|
# und ueber ein benanntes Docker-Volume persistiert (siehe dort) — hier
|
|
# nichts weiter noetig, dieser Wert wirkt nur fuer lokales "npm run dev".
|
|
#
|
|
# In Produktion per Kubernetes (siehe k8s/05-app-deployment.yaml) zeigt
|
|
# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder
|
|
# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret
|
|
# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des
|
|
# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath
|
|
# zeigen.
|
|
UPLOAD_DIR=./uploads
|
|
|
|
# Ablage der Rechnungs-PDFs, nach Jahr und Kunde sortiert:
|
|
# invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf
|
|
# Lokal reicht der Default "./invoices" (per .gitignore ausgeschlossen).
|
|
#
|
|
# MUSS ein anderes Verzeichnis sein als UPLOAD_DIR: auf die Uploads zeigt
|
|
# eine oeffentliche Route (app/uploads/[...path]), die JEDE Datei darunter
|
|
# ohne Anmeldung ausliefert. Laegen Rechnungen dort, koennte jede Person, die
|
|
# den Pfad erraet, fremde Rechnungen samt Anschrift herunterladen.
|
|
#
|
|
# In Produktion ein eigenes, gesichertes Volume: Rechnungen unterliegen in
|
|
# Deutschland zehn Jahren Aufbewahrungspflicht (§ 147 AO), und das PDF liegt
|
|
# NUR hier — die Datenbank kennt lediglich Nummer, Betrag und Pfad. Siehe
|
|
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
|
INVOICE_DIR=./invoices
|
|
|
|
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
|
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
|
#
|
|
# NICHT hochsetzen, um den Upload zu beschleunigen, ohne gleichzeitig das
|
|
# Speicherlimit des Pods zu pruefen: x264 haelt pro Thread eigene Bildpuffer,
|
|
# bei 4K-Material rund 12 MB je Bild. Ohne Deckel richtet sich ffmpeg nach den
|
|
# Kernen des KUBERNETES-KNOTENS statt nach dem CPU-Anteil des Pods — auf einem
|
|
# grossen Knoten sprengt das sowohl den Speicher des Pods als auch die
|
|
# Liveness-Probe, und der Container wird mitten im Upload neu gestartet.
|
|
# Ausfuehrliche Begruendung in src/lib/video-transcode.ts.
|
|
VIDEO_TRANSCODE_THREADS=
|