Compare commits
121
Commits
bffcce127d
...
main
No files matched your search
+75
-29
@@ -9,6 +9,11 @@ DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=p
|
|||||||
# MySQL/MariaDB stattdessen:
|
# MySQL/MariaDB stattdessen:
|
||||||
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
||||||
|
|
||||||
|
# Optional: Groesse des Datenbank-Verbindungspools PRO Instanz (Standard 20,
|
||||||
|
# siehe src/lib/db-adapter.ts). Beim Hochskalieren mitziehen, damit
|
||||||
|
# Instanzen x Poolgroesse plus CronJobs unter max_connections bleiben.
|
||||||
|
# DATABASE_POOL_SIZE=20
|
||||||
|
|
||||||
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
||||||
POSTGRES_USER=wabenhain
|
POSTGRES_USER=wabenhain
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
@@ -22,14 +27,20 @@ MYSQL_DATABASE=wabenhain
|
|||||||
|
|
||||||
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
|
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
|
||||||
#
|
#
|
||||||
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig
|
# Serverseitiger Schluessel fuer Checkout-Sessions, Erstattungen und Webhooks.
|
||||||
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der
|
# Empfohlen: ein eingeschraenkter Schluessel (rk_...) statt des vollen sk_...
|
||||||
# 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
|
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Oeffentlicher Schluessel fuer das eingebettete Zahlungsformular auf /checkout.
|
||||||
|
# Bewusst OHNE NEXT_PUBLIC_: wird zur Laufzeit gelesen, nicht beim Build
|
||||||
|
# eingebrannt. Muss zum Modus des geheimen Schluessels passen (test/live).
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Optional: Kundenportal-Konfiguration (bpc_...) fuer "Zahlungsart aendern" im
|
||||||
|
# Abo, angelegt von `pnpm tsx scripts/stripe-setup.ts --apply`. Leer = die App
|
||||||
|
# findet sie ueber ihre Metadaten; fehlt sie ganz, bietet das Konto "Zahlungsart
|
||||||
|
# aendern" nicht an (die Stripe-Standardkonfiguration wird nie benutzt).
|
||||||
|
STRIPE_PORTAL_CONFIGURATION_ID=
|
||||||
|
|
||||||
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
||||||
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
||||||
@@ -78,35 +89,40 @@ GOOGLE_PLACE_ID=
|
|||||||
TURNSTILE_SECRET_KEY=
|
TURNSTILE_SECRET_KEY=
|
||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||||
|
|
||||||
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
|
# Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
|
||||||
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt
|
# Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben
|
||||||
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup.
|
# App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort
|
||||||
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures
|
# zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README
|
||||||
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im
|
# "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die
|
||||||
# SMTP-Modus).
|
# ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer
|
||||||
SMTP_HOST=
|
# lokale Entwicklung ohne Mail-Setup.
|
||||||
SMTP_PORT=587
|
# SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe
|
||||||
|
# Adresse tragen.
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
|
||||||
SMTP_FROM="Wabenhain <[email protected]>"
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
|
|
||||||
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
|
||||||
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
|
||||||
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
|
||||||
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
|
||||||
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
|
||||||
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
|
||||||
# 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.
|
# Details siehe src/lib/apicore-client.ts.
|
||||||
APICORE_API_URL=
|
APICORE_API_URL=
|
||||||
APICORE_SYNC_EMAIL=
|
APICORE_API_KEY=
|
||||||
APICORE_SYNC_PASSWORD=
|
# Bestandsabgleich: der Schluessel braucht zusaetzlich "Verkaeufe und
|
||||||
|
# Rueckbuchungen des Shops buchen" (shop-sync:write). In ApiCore einen Webhook
|
||||||
|
# fuer "stock.changed" auf https://<shop>/api/apicore/webhook anlegen und das
|
||||||
|
# dort angezeigte Secret hier eintragen. Ohne Secret wird der Webhook
|
||||||
|
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
|
||||||
|
APICORE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Rechnungsexport fuer die Buchhaltung (ERPNext, collab.vycept.com): ERPNext
|
||||||
|
# holt damit stuendlich alle Rechnungen samt PDF ab und ordnet sie der
|
||||||
|
# Stripe-Zahlung zu (GET /api/erp/rechnungen, nur lesend, siehe
|
||||||
|
# src/lib/erp-export.ts). Mindestens 32 Zeichen, z. B. `openssl rand -hex 32`;
|
||||||
|
# derselbe Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus.
|
||||||
|
ERP_EXPORT_TOKEN=
|
||||||
|
|
||||||
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||||
@@ -145,6 +161,15 @@ APICORE_SYNC_PASSWORD=
|
|||||||
# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
|
# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
|
||||||
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
|
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
|
||||||
# cached Ergebnisse 15 Minuten.
|
# cached Ergebnisse 15 Minuten.
|
||||||
|
# SENDUNGSVERFOLGUNG auf /bestellung-verfolgen: laeuft ueber die DHL Shipment
|
||||||
|
# Tracking – Unified API (lib/dhl-tracking.ts), ebenfalls nur mit API-Key.
|
||||||
|
# Die DHL-App muss dafuer im Developer Portal zusaetzlich das Produkt
|
||||||
|
# "Shipment Tracking - Unified" abonniert haben. Ist es in einer EIGENEN App
|
||||||
|
# freigeschaltet, deren Key in DHL_TRACKING_API_KEY eintragen — sonst wird
|
||||||
|
# DHL_API_KEY verwendet. Ohne freigeschalteten Key zeigt die Seite wie bisher
|
||||||
|
# nur Sendungsnummer + Link zu dhl.de. Ergebnisse werden pro Sendung
|
||||||
|
# 10 Minuten (zugestellt: 12 Stunden) gecached, um das Kontingent zu schonen.
|
||||||
|
DHL_TRACKING_API_KEY=
|
||||||
DHL_API_KEY=
|
DHL_API_KEY=
|
||||||
DHL_API_SECRET=
|
DHL_API_SECRET=
|
||||||
DHL_GKP_USERNAME=
|
DHL_GKP_USERNAME=
|
||||||
@@ -177,6 +202,11 @@ DHL_ENVIRONMENT=sandbox
|
|||||||
# als Unternehmensapplikation (wird beim App-Registration-Schritt
|
# als Unternehmensapplikation (wird beim App-Registration-Schritt
|
||||||
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
|
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
|
||||||
# (z.B. nur bestimmte Gruppen) einschraenken.
|
# (z.B. nur bestimmte Gruppen) einschraenken.
|
||||||
|
# 6. Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben): dafuer
|
||||||
|
# die Anwendungsberechtigung "Office 365 Exchange Online -> SMTP.SendAsApp"
|
||||||
|
# mit Administratorzustimmung ergaenzen und in Exchange das Postfach
|
||||||
|
# freigeben (README "E-Mail-Versand"). Laeuft das Secret ab, sind
|
||||||
|
# Anmeldung UND Mailversand betroffen.
|
||||||
ENTRA_TENANT_ID=
|
ENTRA_TENANT_ID=
|
||||||
ENTRA_CLIENT_ID=
|
ENTRA_CLIENT_ID=
|
||||||
ENTRA_CLIENT_SECRET=
|
ENTRA_CLIENT_SECRET=
|
||||||
@@ -218,6 +248,18 @@ UPLOAD_DIR=./uploads
|
|||||||
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
||||||
INVOICE_DIR=./invoices
|
INVOICE_DIR=./invoices
|
||||||
|
|
||||||
|
# Ablage der Urkunden-PDFs der Bienenpatenschaft (lib/sponsorship/storage.ts).
|
||||||
|
# Aus demselben Grund wie INVOICE_DIR NIE unterhalb von UPLOAD_DIR — eine
|
||||||
|
# Urkunde traegt Namen und eine persoenliche Widmung. Die Anwendung startet
|
||||||
|
# nicht, wenn das Verzeichnis im Upload-Verzeichnis liegt. In Kubernetes ein
|
||||||
|
# Unterordner des gesicherten Rechnungs-Volumes (siehe k8s/01-configmap.yaml).
|
||||||
|
CERTIFICATE_DIR=./certificates
|
||||||
|
|
||||||
|
# Hintergrundaufgaben im App-Prozess (src/instrumentation.ts): Urkunden
|
||||||
|
# nachholen, Abo-Lieferungen anlegen, Verzug pruefen. "off" schaltet sie ab,
|
||||||
|
# z.B. fuer eine zweite Instanz, die nur Seiten ausliefern soll.
|
||||||
|
BACKGROUND_JOBS=on
|
||||||
|
|
||||||
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
|
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
|
||||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||||
# 19 = voller Satz
|
# 19 = voller Satz
|
||||||
@@ -243,6 +285,10 @@ VAT_RATE_PERCENT=7
|
|||||||
VAT_RATE_PERCENT_HONIG=7
|
VAT_RATE_PERCENT_HONIG=7
|
||||||
VAT_RATE_PERCENT_KERZEN=19
|
VAT_RATE_PERCENT_KERZEN=19
|
||||||
VAT_RATE_PERCENT_PROPOLIS=19
|
VAT_RATE_PERCENT_PROPOLIS=19
|
||||||
|
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher)
|
||||||
|
VAT_RATE_PERCENT_SONSTIGES=19
|
||||||
|
# Bienenpatenschaft (Leistung, Regelsatz; Honiganteil eigene Position).
|
||||||
|
VAT_RATE_PERCENT_PATENSCHAFT=19
|
||||||
|
|
||||||
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
||||||
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
||||||
|
|||||||
@@ -3,14 +3,89 @@ name: Docker Image bauen und veroeffentlichen
|
|||||||
# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>")
|
# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>")
|
||||||
# und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell
|
# und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell
|
||||||
# ueber "Run workflow" in der Gitea-Weboberflaeche ebenfalls startbar.
|
# ueber "Run workflow" in der Gitea-Weboberflaeche ebenfalls startbar.
|
||||||
|
#
|
||||||
|
# Qualitaets-Gate: Ein Image entsteht nur, wenn lint (inkl. audit), typecheck
|
||||||
|
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
|
||||||
|
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
|
||||||
|
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
|
||||||
|
- run: pnpm audit --prod --audit-level high
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
# postinstall erzeugt beide Prisma-Clients (Postgres + MySQL), die tsc braucht.
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
# Unit- und Komponententests (jsdom), ohne Datenbank.
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
runs-on: docker-builder
|
runs-on: docker-builder
|
||||||
env:
|
env:
|
||||||
# Fest hinterlegt statt ${{ gitea.server_url }}: das hat in manchen
|
# Fest hinterlegt statt ${{ gitea.server_url }}: das hat in manchen
|
||||||
@@ -77,6 +152,16 @@ jobs:
|
|||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Metadaten Werkzeuge
|
||||||
|
id: meta-tools
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}-tools
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
# Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres +
|
# Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres +
|
||||||
# MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe
|
# MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe
|
||||||
# src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile-
|
# src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile-
|
||||||
@@ -86,8 +171,22 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Werkzeug-Image fuer die CronJobs (Dockerfile "AS tools"); nutzt die
|
||||||
|
# gemeinsamen Build-Stufen aus dem Cache des ersten Builds.
|
||||||
|
- name: Werkzeuge bauen und pushen
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: tools
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-tools.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-tools.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -56,3 +56,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
# Lokal abgelegte Rechnungs-PDFs (INVOICE_DIR) — Belege gehoeren nie ins Repository.
|
||||||
/invoices
|
/invoices
|
||||||
|
|
||||||
|
# Lokal abgelegte Urkunden-PDFs der Bienenpatenschaft (CERTIFICATE_DIR).
|
||||||
|
/certificates
|
||||||
+24
@@ -52,6 +52,27 @@ RUN pnpm add --save-prod \
|
|||||||
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
|
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
|
||||||
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3
|
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3
|
||||||
|
|
||||||
|
# --- Werkzeuge (CronJobs) -------------------------------------------------
|
||||||
|
# Eigenes Image <repo>-tools fuer die Skripte unter scripts/, die als
|
||||||
|
# CronJob laufen (Bestandsabgleich mit ApiCore, Freigabe haengender
|
||||||
|
# Bestellungen), nach dem Muster von artista-modelcamp-tools. Das runner-
|
||||||
|
# Image enthaelt nur den Standalone-Output: dort fehlen tsconfig.json (der
|
||||||
|
# Pfad-Alias "@/") und Pakete wie stripe, die Skripte brechen dort mit
|
||||||
|
# "Cannot find module" ab. Steht VOR runner, damit ein Build ohne --target
|
||||||
|
# weiterhin das App-Image liefert.
|
||||||
|
FROM base AS tools
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/assets ./assets
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./
|
||||||
|
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||||
|
USER 1001
|
||||||
|
CMD ["tsx", "scripts/apicore-stock-sync.ts"]
|
||||||
|
|
||||||
# --- Runtime --------------------------------------------------------------
|
# --- Runtime --------------------------------------------------------------
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -67,6 +88,9 @@ RUN addgroup --system --gid 1001 nodejs \
|
|||||||
&& adduser --system --uid 1001 nextjs
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
# Eingebettete Schriften der Rechnungs-PDFs (PDF/A-3, lib/einvoice/facturx-pdf.ts
|
||||||
|
# liest sie zur Laufzeit aus process.cwd()/assets/fonts).
|
||||||
|
COPY --from=builder /app/assets ./assets
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
||||||
|
|
||||||
|
> **Neu einrichten?** Alle Secrets, die Microsoft-Anmeldung, den Mailversand über Microsoft 365, Stripe und das Ausrollen beschreibt Schritt für Schritt **[docs/EINRICHTUNG.md](docs/EINRICHTUNG.md)**.
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
Was gebaut, geprüft, gemergt und ausgerollt ist, was offen ist und was nur der Inhaber erledigen kann, steht in den Gitea-Issues. Einstieg ist das Übersichts-Issue **[Projektstand und Übersicht](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/34)**, die Aufgaben des Inhabers sammelt **[Inhaber-Aufgaben](https://git.encry.de/Nick.Rosenloecher/Wabenhain/issues/33)**.
|
||||||
|
|
||||||
|
Kurzfassung (Stand 2026-10-16):
|
||||||
|
|
||||||
|
- Produktiv läuft der Build `sha-0d254db` (Client-IP-Härtung vom 2026-10-12). Die drei neueren Commits auf `main` (Abhängigkeits-Updates, Qualitäts-Gate in der CI) sind gebaut, aber noch nicht ausgerollt.
|
||||||
|
- Im Cluster laufen App, Datenbank und die tägliche Sicherung `wabenhain-db-backup`. Die CronJobs `wabenhain-release-stale-orders` und `wabenhain-apicore-stock` sind angelegt, aber ausgesetzt (`suspend: true`).
|
||||||
|
- Für den Bestandsabgleich mit ApiCore fehlt im Cluster-Secret `APICORE_WEBHOOK_SECRET`, für die ERPNext-Schnittstelle `ERP_EXPORT_TOKEN`.
|
||||||
|
- Offene Spezifikation: [Versandstaffel je Kategorie](docs/superpowers/specs/2026-09-22-versandstaffel-je-kategorie-design.md) (freigegeben, nicht umgesetzt).
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
|
|
||||||
| Bereich | Technologie |
|
| Bereich | Technologie |
|
||||||
@@ -12,7 +25,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) |
|
| Animationen | Motion for React (Scroll-Reveal, Hero, 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, eingebettet (`ui_mode: "embedded_page"`) |
|
||||||
| Hosting | Docker / Node.js |
|
| Hosting | Docker / Node.js |
|
||||||
|
|
||||||
## Was ist enthalten
|
## Was ist enthalten
|
||||||
@@ -36,11 +49,11 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
||||||
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
||||||
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
||||||
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**)
|
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Cookie-Richtlinie, Versand und Zahlung sowie die elektronische Widerrufsfunktion „Vertrag widerrufen“ (`/vertrag-widerrufen`, Eingänge unter Admin → Widerrufe). Die Texte sind ausformuliert und im Admin unter „Inhalte“ pflegbar; `pnpm preflight` meldet verbliebene Platzhalter. Eine anwaltliche Prüfung steht noch aus.
|
||||||
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
|
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
|
||||||
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
||||||
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
|
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
|
||||||
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
|
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über Microsoft 365 mit OAuth 2.0 (dieselbe App wie die Microsoft-Anmeldung), siehe Abschnitt „E-Mail-Versand" unten
|
||||||
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
|
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
|
||||||
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
|
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
|
||||||
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
|
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
|
||||||
@@ -57,8 +70,8 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
|||||||
|
|
||||||
### Bewusst vereinfacht
|
### Bewusst vereinfacht
|
||||||
|
|
||||||
- **Bilder**: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt.
|
- **Bilder**: Fotos für Produkte, Ernten, Chargen, Inhalte, Logo und das Hero-Video lädt der Admin hoch (siehe `k8s/README.md` zu Speicher und Video-Upload). Solange zu einer Fläche kein Foto hinterlegt ist, erscheint ein Platzhalter im Stil des Glas-Etiketts.
|
||||||
- Rechtstexte sind Platzhalter und **müssen** vor Live-Gang anwaltlich geprüft werden.
|
- Rechtstexte sind ausformuliert, **müssen** aber anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
## Admin-Zugang
|
## Admin-Zugang
|
||||||
|
|
||||||
@@ -142,15 +155,27 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
|
|||||||
|
|
||||||
## Stripe
|
## Stripe
|
||||||
|
|
||||||
1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet.
|
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
|
||||||
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
|
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
|
||||||
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.
|
3. Ohne `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
|
||||||
|
|
||||||
### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna)
|
### Eingebetteter Checkout
|
||||||
|
|
||||||
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig.
|
Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
|
||||||
|
|
||||||
**Apple Pay / Google Pay**: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, `stripe.checkout.sessions.create(...)` ohne `ui_mode: "embedded"`) laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains) ist laut Stripe nur für Elements bzw. die *eingebettete* Checkout-Zahlungsseite nötig, **nicht** für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain `checkout.stripe.com`) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code.
|
Ablauf:
|
||||||
|
|
||||||
|
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
|
||||||
|
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
|
||||||
|
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
|
||||||
|
|
||||||
|
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
|
||||||
|
|
||||||
|
### Zahlungsmethoden (PayPal, Karte, SEPA-Lastschrift, Klarna)
|
||||||
|
|
||||||
|
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um PayPal, SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
|
||||||
|
|
||||||
|
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
|
||||||
|
|
||||||
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
|
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
|
||||||
|
|
||||||
@@ -174,7 +199,7 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
|
|||||||
|
|
||||||
| Wann | Was passiert |
|
| Wann | Was passiert |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
|
| „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
|
||||||
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
|
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
|
||||||
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
|
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
|
||||||
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
|
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
|
||||||
@@ -197,7 +222,7 @@ Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIE
|
|||||||
|
|
||||||
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
|
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
|
||||||
|
|
||||||
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
|
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
|
||||||
|
|
||||||
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
|
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
|
||||||
|
|
||||||
@@ -246,9 +271,28 @@ Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die K
|
|||||||
|
|
||||||
## E-Mail-Versand
|
## E-Mail-Versand
|
||||||
|
|
||||||
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren.
|
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten.
|
||||||
|
|
||||||
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam.
|
Ob der Versand funktioniert, zeigt **Admin → Einstellungen → System & Tests**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache.
|
||||||
|
|
||||||
|
**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
|
||||||
|
|
||||||
|
1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[email protected]`) — braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. `SMTP_USER` und die Adresse in `SMTP_FROM` sind dieses Postfach.
|
||||||
|
2. **Berechtigung an der SSO-App:** Entra Admin Center → App-Registrierungen → die SSO-App → API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“.
|
||||||
|
3. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die SSO-App → **Objekt-ID** (nicht die der App-Registrierung).
|
||||||
|
4. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
|
||||||
|
```powershell
|
||||||
|
Install-Module ExchangeOnlineManagement # einmalig
|
||||||
|
Connect-ExchangeOnline
|
||||||
|
New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID aus Schritt 3> -DisplayName "Wabenhain Shop"
|
||||||
|
Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 3> -AccessRights FullAccess
|
||||||
|
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
|
||||||
|
```
|
||||||
|
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten.
|
||||||
|
5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → Einstellungen → System & Tests**.
|
||||||
|
6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an.
|
||||||
|
|
||||||
|
**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen.
|
||||||
|
|
||||||
## CAPTCHA (Cloudflare Turnstile)
|
## CAPTCHA (Cloudflare Turnstile)
|
||||||
|
|
||||||
@@ -293,18 +337,37 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C
|
|||||||
|
|
||||||
Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben.
|
Wiedererkannt wird ein Jahrgang über `Product.apicoreBottlingId` (die ApiCore-ID der Abfüllung), nicht über den Slug — der ist die öffentliche Adresse und soll lesbar bleiben.
|
||||||
|
|
||||||
Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand startet bei 0, denn wie viele Gläser in den Webshop gehen und wie viele in Hofladen oder Wiederverkauf, entscheidet Wabenhain.
|
Jeder Jahrgang entsteht als deaktivierter Entwurf **ohne Preis**: ApiCore führt überhaupt keine Verkaufspreise, das ist reine Betriebsdatenhaltung. Den Preis kann nur ein Mensch setzen. Solange er fehlt, lässt sich das Produkt nicht aktiv schalten (`admin/actions/products.ts`), und der Checkout lehnt es zusätzlich ab (`api/checkout/route.ts`) — ein importiertes Produkt kann also nie versehentlich für 0 € verkauft werden. Gewicht kommt aus der Glasgröße der Abfüllung, nicht aus dem Artikel: sie sagt, was tatsächlich in dieses Glas kam. Der Lagerbestand kommt aus ApiCore (siehe „Bestandsabgleich" unten): der Shop verkauft aus dem gesamten Restbestand der Abfüllung.
|
||||||
|
|
||||||
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
|
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
|
||||||
|
|
||||||
Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt.
|
Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt.
|
||||||
- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
|
- **Chargen**: Losnummer, Abfüll- und MHD-Datum und abgefüllte Menge kommen aus ApiCore. `remainingGlasses` zeigt den Rest laut ApiCore (Bestandsabgleich).
|
||||||
|
- **Hergestellte Waren** (Kerzen, Propolis-Tinkturen, Salben, … — in ApiCore Artikel der Art „Hergestellte Ware" mit Herstellungschargen): **ein** Shop-Produkt je Artikel, nicht je Charge. Neue Artikel entstehen als deaktivierte Entwürfe ohne Preis; schon vorhandene Shop-Produkte verknüpft man stattdessen im Produkt-Formular („ApiCore-Artikel") — am besten vor dem ersten Sync, sonst entsteht ein zusätzlicher Entwurf.
|
||||||
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
|
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
|
||||||
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
|
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
|
||||||
|
|
||||||
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
|
Wiederholte Läufe sind gefahrlos: Ernten werden über `Harvest.apicoreHarvestId` wiedererkannt, Chargen über `Batch.apicoreBottlingId`, Honig-Produkte über `Product.apicoreBottlingId`, hergestellte Waren über `Product.apicoreProductId`.
|
||||||
|
|
||||||
**Einrichtung:** `APICORE_API_URL`, `APICORE_SYNC_EMAIL` und `APICORE_SYNC_PASSWORD` in `.env` setzen (siehe `.env.example`). ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — deshalb dort ein **eigenes, MFA-freies Service-Konto** anlegen statt einen echten Admin-Zugang einzutragen. Es braucht ausschließlich Leserechte: `harvests:read`, `products:read`, `extractions:read`, `bottling:read` und optional `apiaries:read` (nur für die Standortnamen an den Ernten). Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
|
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
|
||||||
|
|
||||||
|
### Bestandsabgleich
|
||||||
|
|
||||||
|
**ApiCore führt den Restbestand je Charge; Shop und ApiCore zeigen dieselbe Zahl.** Design: `ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md`.
|
||||||
|
|
||||||
|
- **Verkauft = bezahlt.** Beim Bezahlen geht je Position ein Verkauf an ApiCore; Storno einer bezahlten Bestellung und erstattete Retoure buchen zurück — auf genau die Chargen, aus denen verkauft wurde. Unbezahlte Bestellungen erreichen ApiCore nie.
|
||||||
|
- **Warteschlange:** Jede Buchung landet in `apicore_stock_events`, in derselben Transaktion wie der Statuswechsel, und wird danach gesendet. Ist ApiCore nicht erreichbar, verkauft der Shop weiter; der CronJob `wabenhain-apicore-stock` (alle 10 Minuten, `pnpm stock:apicore-sync`) reicht nach. Jede Buchung trägt eine feste ID — ApiCore bucht sie nur einmal.
|
||||||
|
- **Shop-Bestand** verknüpfter Produkte = ApiCore-Rest − noch nicht bestätigte Buchungen − Reservierungen unbezahlter Bestellungen. Das Bestandsfeld im Admin ist für sie gesperrt; Entnahmen (Hofladen, Eigenbedarf, Bruch) und Inventur bucht man in ApiCore.
|
||||||
|
- **Entnahmen in ApiCore** kommen per Webhook `stock.changed` sofort an (`POST /api/apicore/webhook`, HMAC-signiert). Der Webhook nennt nur, *was* sich geändert hat — die Zahlen holt der Shop selbst über die API.
|
||||||
|
- **Chargen-Zuordnung:** Welche Charge(n) ApiCore einem Verkauf zugeordnet hat, steht an der Bestellposition (`OrderItem.apicoreAllocations`) — für Rückverfolgung und Rückruf.
|
||||||
|
- **Dashboard:** Buchungen, die länger als 15 Minuten hängen oder von ApiCore abgelehnt wurden, sowie aktive, noch nicht verknüpfte Produkte erscheinen unter „Zu tun".
|
||||||
|
|
||||||
|
**Einrichtung zusätzlich zum Sync:**
|
||||||
|
|
||||||
|
1. In ApiCore dem Wabenhain-API-Schlüssel die Berechtigung „Verkäufe und Rückbuchungen des Shops buchen" (`shop-sync:write`) geben.
|
||||||
|
2. In ApiCore unter Automatisierung → Webhooks einen Webhook für `stock.changed` auf `https://<shop>/api/apicore/webhook` anlegen und dessen Secret als `APICORE_WEBHOOK_SECRET` setzen.
|
||||||
|
3. CronJob `wabenhain-apicore-stock` aus `k8s/07-backup-cronjob.yaml` anwenden.
|
||||||
|
4. Kerzen/Propolis-Produkte in ApiCore als „Hergestellte Ware" mit Herstellungscharge (aktueller Bestand) anlegen, in Wabenhain verknüpfen, dann synchronisieren.
|
||||||
|
|
||||||
## Vollständiger Docker-Betrieb
|
## Vollständiger Docker-Betrieb
|
||||||
|
|
||||||
@@ -326,7 +389,7 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc
|
|||||||
pnpm preflight
|
pnpm preflight
|
||||||
```
|
```
|
||||||
|
|
||||||
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, kein SMTP und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
|
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, keinen Mailversand und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
|
||||||
|
|
||||||
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
|
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
|
||||||
|
|
||||||
@@ -341,14 +404,14 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc
|
|||||||
- `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).
|
- `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.
|
- `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.
|
- 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.
|
- Mailversand eingerichtet (`SMTP_USER` + `ENTRA_*`) — 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.
|
- 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).
|
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".
|
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.
|
Unabhängig davon **müssen** die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) anwaltlich geprüft werden — das kann kein Skript feststellen.
|
||||||
|
|
||||||
## Rechnungen
|
## Rechnungen
|
||||||
|
|
||||||
@@ -366,16 +429,16 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge
|
|||||||
|
|
||||||
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
|
**Abgelegt wird genau das Dokument, das versendet wurde.** Der Abruf liefert die Datei aus und rendert nur dann neu, wenn keine vorhanden ist. Sonst sähe dieselbe Rechnungsnummer nach einer Änderung an Firmendaten oder Layout anders aus als der Beleg beim Kunden.
|
||||||
|
|
||||||
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump. Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume.
|
**Sicherung:** der CronJob in `k8s/07-backup-cronjob.yaml` packt das Verzeichnis täglich als `rechnungen-<Zeitstempel>.tar.gz` neben den Datenbank-Dump auf die Storage Box (30 Tage Aufbewahrung). Ein Datenbank-Dump allein genügt nicht — die Datenbank kennt nur Nummer, Betrag und Pfad, das PDF liegt ausschließlich auf dem Volume. Die zehnjährige Aufbewahrung nach § 147 AO deckt das nicht ab. Vorgesehen ist dafür, dass ERPNext jede Rechnung samt PDF über `/api/erp/rechnungen` übernimmt; im Cluster fehlt dafür Stand 2026-10-16 noch `ERP_EXPORT_TOKEN` (siehe „Projektstand“).
|
||||||
|
|
||||||
> Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS).
|
> Bei **mehr als einem Knoten**: im Repo ist das PVC `wabenhain-invoices` als `ReadWriteOnce` angelegt und ließe sich nur von Pods auf demselben Knoten einhängen; landet der Sicherungs-Pod woanders als die App, bleibt er ohne Fehlermeldung in „Pending“. Im Cluster sind `wabenhain-invoices` und `wabenhain-uploads` deshalb bereits `ReadWriteMany` auf SMB (Stand 2026-10-16); das Repo-Manifest weicht davon ab.
|
||||||
|
|
||||||
### Umsatzsteuer
|
### Umsatzsteuer
|
||||||
|
|
||||||
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
|
Der Steuersatz hängt an der Art der Ware, nicht am Betrieb: Honig ist ein Lebensmittel und ermäßigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird deshalb in drei Stufen bestimmt — die erste, die etwas liefert, gewinnt:
|
||||||
|
|
||||||
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
|
1. **Am Produkt** — Feld „Umsatzsteuersatz" unter Admin → Produkte. Für Ausnahmen, die sich nicht aus der Kategorie ergeben, etwa ein gemischtes Geschenkset. Leer lassen ist der Normalfall.
|
||||||
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`.
|
2. **An der Kategorie** — `VAT_RATE_PERCENT_HONIG`, `VAT_RATE_PERCENT_KERZEN`, `VAT_RATE_PERCENT_PROPOLIS`, `VAT_RATE_PERCENT_SONSTIGES` (weitere Bienenprodukte wie Salben oder Wachstücher, Vorgabe in `k8s/01-configmap.yaml`: 19).
|
||||||
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
|
3. **Gemeinsamer Rückfallwert** — `VAT_RATE_PERCENT`, sonst 7.
|
||||||
|
|
||||||
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
|
Der Vorteil der Kategorie-Stufe: ändern sich die gesetzlichen Sätze, genügt eine Änderung an der Konfiguration statt an jedem einzelnen Produkt.
|
||||||
@@ -406,6 +469,36 @@ Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
|
|||||||
|
|
||||||
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
|
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
|
||||||
|
|
||||||
|
### E-Rechnung
|
||||||
|
|
||||||
|
Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang).
|
||||||
|
|
||||||
|
- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten.
|
||||||
|
- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image.
|
||||||
|
- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log.
|
||||||
|
- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig.
|
||||||
|
|
||||||
|
### Rechnungskopie
|
||||||
|
|
||||||
|
Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie.
|
||||||
|
|
||||||
|
### Stornorechnung
|
||||||
|
|
||||||
|
Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST-<Jahr>-<laufende Nummer>` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log).
|
||||||
|
|
||||||
|
ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`.
|
||||||
|
|
||||||
|
### Kunden und Firmenanfragen im CRM (ERPNext)
|
||||||
|
|
||||||
|
Zwei weitere nur lesende Endpunkte mit demselben `ERP_EXPORT_TOKEN` (`lib/erp-crm-export.ts`):
|
||||||
|
|
||||||
|
| Endpunkt | Inhalt | In ERPNext |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/erp/kunden?nach=<id>&limit=<1-200>` | Kundenkonten (ohne Admins, ohne Passwort/2FA), Standardadresse | Kunde in der Gruppe **Privat** mit Kontakt und Adresse |
|
||||||
|
| `GET /api/erp/anfragen?nach=<id>&limit=<1-200>` | Firmenanfragen der Geschäftskunden-Seite | Kunde (Firma) in der Gruppe **Gewerblich** mit Ansprechpartner, je Anfrage eine Chance |
|
||||||
|
|
||||||
|
ERPNext holt bei jedem Lauf die vollständige Liste (Cursor `nach` = letzte ID der vorigen Seite, `next` in der Antwort). Ein im Shop gelöschtes Konto ohne Rechnung verschwindet dadurch auch aus ERPNext; mit Rechnung bleibt der Kunde wegen der Aufbewahrungspflicht stehen.
|
||||||
|
|
||||||
## Health-Endpunkte
|
## Health-Endpunkte
|
||||||
|
|
||||||
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
|
||||||
@@ -419,7 +512,9 @@ Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der D
|
|||||||
|
|
||||||
## 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` prüft bei jedem Push auf `main` zuerst `pnpm lint`, `pnpm audit --prod --audit-level high`, `pnpm typecheck` und `pnpm test` (Runner `ci-isolated`). Nur wenn alle grün sind, baut der Job `build-and-push` (Runner `docker-builder`) zwei Images und veröffentlicht sie in der Gitea-eigenen Container-Registry: `wabenhain` (App) und `wabenhain-tools` (Skripte für die CronJobs), jeweils mit den Tags `latest` und `sha-<kurzer Commit>`. Der App-Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig.
|
||||||
|
|
||||||
|
Die Pipeline wendet **keine** Manifeste an und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt; ein neuer Build geht per `kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>` in Betrieb, danach wird `image:` in `k8s/05-app-deployment.yaml` nachgezogen. `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).
|
||||||
|
|
||||||
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
|
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
|
||||||
|
|
||||||
@@ -431,36 +526,37 @@ Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe
|
|||||||
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
|
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
|
||||||
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
|
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
|
||||||
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
|
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
|
||||||
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Ist dieser letzte Eintrag eine Cloudflare-Edge, gilt stattdessen `CF-Connecting-IP`; IPv6-Adressen zählen je /64-Netz (`lib/rate-limit.ts`). Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
||||||
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
|
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
|
||||||
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
|
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
|
||||||
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
|
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
|
||||||
- **Abhängigkeits-Sicherheitsscan**: `pnpm audit` vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive `nanoid`-DoS-Lücke über `pnpm-workspace.yaml`s `overrides` gepatcht).
|
- **Abhängigkeits-Sicherheitsscan**: Die CI bricht ab, sobald `pnpm audit --prod --audit-level high` eine Lücke ab „high“ in Laufzeit-Abhängigkeiten findet; ohne grünen Audit entsteht kein Image. Lokal mit `pnpm audit` prüfen (siehe „Nützliche Befehle“ unten). Zuletzt geschlossen wurden High-/Critical-Advisories in Commit `835323e` (2026-10-14).
|
||||||
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
|
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
|
||||||
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
|
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
|
||||||
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
|
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
|
||||||
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
|
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
|
||||||
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
|
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
|
||||||
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
||||||
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren).
|
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren); hinter Cloudflare aus `CF-Connecting-IP`, siehe Rate-Limiting oben.
|
||||||
|
- **Nur über Cloudflare erreichbar**: Der Ingress hängt an der Middleware `wabenhain-cloudflare-only` (nur Cloudflare-Adressen und private Netze), direkte Zugriffe auf den Origin beantwortet Traefik mit 403. Die Middleware liegt nicht in diesem Repo, sondern in den Cluster-Manifesten (siehe Kommentar in `k8s/06-ingress.yaml`).
|
||||||
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
||||||
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
|
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
|
||||||
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
||||||
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`.
|
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestellnummern (`WH-<Jahr>-<8 Hex-Zeichen>`) kommen aus `crypto.randomBytes`; Rechnungsnummern sind dagegen fortlaufend (siehe „Rechnungsnummer“).
|
||||||
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
||||||
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
|
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
|
||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
|
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
|
||||||
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `Payment`, `Shipment`, `Pickup`, `Invoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `NewsletterSubscriber`, `BusinessInquiry`.
|
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `ApiCoreStockEvent`, `Payment`, `Shipment`, `Pickup`, `InvoiceCounter`, `Invoice`, `CancellationCounter`, `CancellationInvoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `SiteContent`, `NewsletterSubscriber`, `BusinessInquiry`, `WithdrawalDeclaration`.
|
||||||
|
|
||||||
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
|
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- **Unit-/Integrationstests** (`src/**/*.test.ts`, [Vitest](https://vitest.dev/)): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — `lib/shipping-calc.ts`, `lib/discount-calc.ts`, `lib/format.ts` sowie die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen.
|
- **Unit-/Integrationstests** (`src/**/*.test.ts(x)`, [Vitest](https://vitest.dev/), Stand 2026-10-16: 65 Testdateien, die meisten unter `src/lib`): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — unter anderem Versand- und Rabattberechnung, Umsatzsteuer, Rechnung und E-Rechnung, Checkout-, Abbruch- und Webhook-Route, Bestandsabgleich mit ApiCore, Sessions, CSRF, Client-IP und die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen; die CI führt sie bei jedem Push aus.
|
||||||
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden.
|
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden. Die CI führt sie bisher **nicht** aus.
|
||||||
|
|
||||||
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
|
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
|
||||||
|
|
||||||
@@ -472,7 +568,13 @@ 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 typecheck # TypeScript ohne Ausgabe (erzeugt vorher Prisma-Clients und Next-Routentypen)
|
||||||
|
pnpm test # Vitest (Unit-/Komponententests)
|
||||||
|
pnpm test:e2e # Playwright gegen eine laufende Instanz
|
||||||
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
|
pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang")
|
||||||
|
pnpm orders:release-stale # haengende, bei Stripe abgelaufene Bestellungen freigeben (wie CronJob)
|
||||||
|
pnpm stock:apicore-sync # Bestandsabgleich mit ApiCore nachholen (wie CronJob)
|
||||||
|
pnpm places:find "Name Ort" # Google-Place-ID nachschlagen
|
||||||
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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
Digitized data copyright (c) 2010 Google Corporation
|
||||||
|
with Reserved Font Arimo, Tinos and Cousine.
|
||||||
|
Copyright (c) 2012 Red Hat, Inc.
|
||||||
|
with Reserved Font Name Liberation.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
|
||||||
|
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
||||||
|
worldwide development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to provide
|
||||||
|
a free and open framework in which fonts may be shared and improved in
|
||||||
|
partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves.
|
||||||
|
The fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such.
|
||||||
|
This may include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components
|
||||||
|
as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting ? in part or in whole ?
|
||||||
|
any of the components of the Original Version, by changing formats or
|
||||||
|
by porting the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical writer
|
||||||
|
or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole, must
|
||||||
|
be distributed entirely under this license, and must not be distributed
|
||||||
|
under any other license. The requirement for fonts to remain under
|
||||||
|
this license does not apply to any document created using the Font
|
||||||
|
Software.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are not met.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||||
|
DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-3
@@ -109,6 +109,7 @@ services:
|
|||||||
# gelesen (siehe .env.example).
|
# gelesen (siehe .env.example).
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||||
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
|
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
|
||||||
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
|
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
|
||||||
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
|
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
|
||||||
@@ -119,11 +120,14 @@ services:
|
|||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
|
||||||
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY}
|
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY}
|
||||||
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID}
|
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID}
|
||||||
SMTP_HOST: ${SMTP_HOST}
|
# Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
# App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example).
|
||||||
SMTP_USER: ${SMTP_USER}
|
SMTP_USER: ${SMTP_USER}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
|
||||||
SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>}
|
SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>}
|
||||||
|
ENTRA_TENANT_ID: ${ENTRA_TENANT_ID}
|
||||||
|
ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID}
|
||||||
|
ENTRA_CLIENT_SECRET: ${ENTRA_CLIENT_SECRET}
|
||||||
|
ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
# Wabenhain einrichten — von null bis live
|
||||||
|
|
||||||
|
Diese Anleitung führt durch alles, was außerhalb des Codes eingerichtet werden muss, damit der Shop in Kubernetes vollständig läuft: Geheimnisse (Secrets), Microsoft-Anmeldung, Mailversand über Microsoft 365, Stripe und die optionalen Dienste. Sie setzt nichts voraus, was nicht hier steht.
|
||||||
|
|
||||||
|
**Zeitbedarf:** etwa 2 Stunden für Pflicht und Mailversand, plus Wartezeiten bei Microsoft (bis zu 30 Minuten) und Stripe.
|
||||||
|
|
||||||
|
**Reihenfolge:**
|
||||||
|
|
||||||
|
1. [Werkzeuge und Zugänge bereitlegen](#1-werkzeuge-und-zugänge)
|
||||||
|
2. [Grundwerte erzeugen](#2-grundwerte-erzeugen) (Datenbank, Sitzungen, Admin)
|
||||||
|
3. [Microsoft Entra ID: App-Registrierung](#3-microsoft-entra-id-app-registrierung) (Anmeldung **und** Mailversand)
|
||||||
|
4. [Microsoft 365: Absender-Postfach freischalten](#4-microsoft-365-absender-postfach-freischalten)
|
||||||
|
5. [Stripe](#5-stripe)
|
||||||
|
6. [Optionale Dienste](#6-optionale-dienste)
|
||||||
|
7. [Secret und ConfigMap ausfüllen](#7-secret-und-configmap-ausfüllen)
|
||||||
|
8. [Ausrollen](#8-ausrollen)
|
||||||
|
9. [Prüfen](#9-prüfen)
|
||||||
|
10. [Wartung: was abläuft und was zu tun ist](#10-wartung)
|
||||||
|
11. [Fehlerbehebung](#11-fehlerbehebung)
|
||||||
|
|
||||||
|
> **Sicherheitsregel für das ganze Dokument:** Echte Werte gehören ausschließlich in `k8s/02-secret.yaml` (per `.gitignore` ausgeschlossen) bzw. direkt ins Cluster. Nie committen, nie in Chats, Tickets oder Screenshots kopieren. Die ConfigMap (`k8s/01-configmap.yaml`) ist unverschlüsselt und im Cluster für jeden mit Lesezugriff im Klartext sichtbar — dort steht nichts Geheimes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Werkzeuge und Zugänge
|
||||||
|
|
||||||
|
**Auf deinem Rechner:**
|
||||||
|
|
||||||
|
| Werkzeug | Wofür | Prüfen mit |
|
||||||
|
|---|---|---|
|
||||||
|
| `kubectl` mit Zugriff auf das Cluster | Secrets und Manifeste anwenden | `kubectl get nodes` |
|
||||||
|
| `openssl` | Zufallswerte erzeugen | `openssl version` |
|
||||||
|
| Node.js | Datenbank-Passwort URL-kodieren | `node --version` |
|
||||||
|
| PowerShell mit Modul `ExchangeOnlineManagement` | Postfach für den Mailversand freischalten | `Get-Module -ListAvailable ExchangeOnlineManagement` |
|
||||||
|
|
||||||
|
Modul einmalig installieren (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Install-Module ExchangeOnlineManagement -Scope CurrentUser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugänge, die du brauchst:**
|
||||||
|
|
||||||
|
| Zugang | Rolle | Wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| Microsoft Entra Admin Center (entra.microsoft.com) | Anwendungsadministrator oder Globaler Administrator | App-Registrierung, Administratorzustimmung |
|
||||||
|
| Microsoft 365 Admin Center (admin.microsoft.com) | Exchange-Administrator | Freigegebenes Postfach anlegen und freischalten |
|
||||||
|
| Stripe-Dashboard (dashboard.stripe.com) | Administrator | API-Schlüssel, Webhook |
|
||||||
|
| Gitea (`git.encry.de`) | Schreibrecht am Repository | Image-Build (siehe `k8s/README.md`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Grundwerte erzeugen
|
||||||
|
|
||||||
|
Diese Werte denkst du dir nicht aus, sondern lässt sie erzeugen. Notiere sie vorübergehend in einem Passwort-Manager, nicht in einer Textdatei.
|
||||||
|
|
||||||
|
### Datenbank-Passwort (`DB_PASSWORD` und `DATABASE_URL`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32 | tr -d '=+/' | head -c 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Passwort steht an **zwei** Stellen im Secret:
|
||||||
|
|
||||||
|
- `DB_PASSWORD`: **roh**, genau wie erzeugt.
|
||||||
|
- `DATABASE_URL`: **URL-kodiert**. Sonderzeichen würden sonst als Trennzeichen der URL gelesen.
|
||||||
|
```bash
|
||||||
|
node -e "console.log(encodeURIComponent('HIER-DAS-PASSWORT'))"
|
||||||
|
```
|
||||||
|
Ergebnis einsetzen:
|
||||||
|
`postgresql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-postgres:5432/wabenhain?schema=public`
|
||||||
|
|
||||||
|
Bei MySQL statt Postgres: `mysql://wabenhain:<KODIERTES-PASSWORT>@wabenhain-mysql:3306/wabenhain` und in der ConfigMap `DATABASE_PROVIDER: "mysql"`.
|
||||||
|
|
||||||
|
### Sitzungsschlüssel (`SESSION_SECRET`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Signiert alle Login-Cookies. Wird er bekannt, lassen sich Sitzungen fälschen — auch die eines Admins. Ihn zu ändern meldet alle nur ab.
|
||||||
|
|
||||||
|
### Erstes Admin-Konto (`ADMIN_EMAIL`, `ADMIN_PASSWORD`)
|
||||||
|
|
||||||
|
- `ADMIN_EMAIL`: eine **echte, nicht erratbare** Adresse, z. B. `n.rosenloecher-admin@wabenhain.com` — **nicht** `admin@…` oder `info@…`. Sonst könnte jemand diese Adresse vorher als Kundenkonto registrieren. An diese Adresse gehen außerdem die Benachrichtigungen über eingegangene Widerrufe.
|
||||||
|
- `ADMIN_PASSWORD`:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 24 | tr -d '=+/' | head -c 28
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Konto wird beim ersten Start angelegt. Beim ersten Login verlangt der Admin-Bereich die Einrichtung der Zwei-Faktor-Authentifizierung (Authenticator-App bereithalten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Microsoft Entra ID: App-Registrierung
|
||||||
|
|
||||||
|
**Eine** App-Registrierung erledigt zwei Aufgaben:
|
||||||
|
|
||||||
|
- **„Mit Microsoft anmelden“** (Single Sign-on im Kundenkonto)
|
||||||
|
- **Mailversand** über Microsoft 365 per OAuth 2.0 — ohne Passwort
|
||||||
|
|
||||||
|
Eine zweite Unternehmensanwendung ist nicht nötig.
|
||||||
|
|
||||||
|
### 3.1 App registrieren
|
||||||
|
|
||||||
|
1. **Entra Admin Center** → Identität → Anwendungen → **App-Registrierungen** → **Neue Registrierung**.
|
||||||
|
2. **Name:** z. B. `Wabenhain Shop`.
|
||||||
|
3. **Unterstützte Kontotypen:** **„Nur Konten in diesem Organisationsverzeichnis“** (Single Tenant).
|
||||||
|
4. **Umleitungs-URI:** Plattform **Web**, Adresse **`https://wabenhain.com/api/auth/entra/callback`** — zeichengenau, mit `https`, ohne Schrägstrich am Ende.
|
||||||
|
5. **Registrieren**.
|
||||||
|
|
||||||
|
Auf der Seite **Übersicht** notieren:
|
||||||
|
|
||||||
|
| Angezeigt als | Gehört in |
|
||||||
|
|---|---|
|
||||||
|
| Anwendungs-ID (Client-ID) | `ENTRA_CLIENT_ID` |
|
||||||
|
| Verzeichnis-ID (Mandanten-ID) | `ENTRA_TENANT_ID` |
|
||||||
|
|
||||||
|
`ENTRA_REDIRECT_URI` ist die Adresse aus Schritt 4: `https://wabenhain.com/api/auth/entra/callback`.
|
||||||
|
|
||||||
|
### 3.2 Geheimen Clientschlüssel anlegen
|
||||||
|
|
||||||
|
1. In der App → **Zertifikate & Geheimnisse** → **Geheime Clientschlüssel** → **Neuer geheimer Clientschlüssel**.
|
||||||
|
2. Beschreibung z. B. `Shop 2026`, Ablauf **24 Monate** (Maximum).
|
||||||
|
3. **Sofort** die Spalte **„Wert“** kopieren → `ENTRA_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
> ⚠ **Nicht die „Geheimnis-ID“** kopieren — das ist der häufigste Fehler (Microsoft meldet dann `AADSTS7000215`). Der Wert wird nur dieses eine Mal angezeigt; ist er weg, einen neuen Schlüssel anlegen.
|
||||||
|
>
|
||||||
|
> **Ablaufdatum sofort in den Kalender eintragen**, mit Erinnerung vier Wochen vorher. Läuft der Schlüssel ab, fallen Microsoft-Anmeldung **und** Mailversand gleichzeitig aus (siehe [Wartung](#10-wartung)).
|
||||||
|
|
||||||
|
### 3.3 API-Berechtigungen
|
||||||
|
|
||||||
|
In der App → **API-Berechtigungen**. Es braucht zwei Arten:
|
||||||
|
|
||||||
|
| Berechtigung | API | Typ | Wofür |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `openid`, `email`, `profile`, `User.Read` | Microsoft Graph | **Delegiert** | Anmeldung („Mit Microsoft anmelden“) |
|
||||||
|
| `SMTP.SendAsApp` | **Office 365 Exchange Online** | **Anwendung** | Mailversand |
|
||||||
|
|
||||||
|
`User.Read` ist meist schon vorhanden. Die übrigen hinzufügen:
|
||||||
|
|
||||||
|
**Für die Anmeldung** (falls noch nicht vorhanden):
|
||||||
|
1. **Berechtigung hinzufügen** → **Microsoft Graph** → **Delegierte Berechtigungen**.
|
||||||
|
2. `openid`, `email`, `profile` anhaken → **Berechtigungen hinzufügen**.
|
||||||
|
|
||||||
|
**Für den Mailversand:**
|
||||||
|
1. **Berechtigung hinzufügen** → Reiter **„Von meiner Organisation verwendete APIs“**.
|
||||||
|
2. Nach **Office 365 Exchange Online** suchen und auswählen. (Die Berechtigung liegt **nicht** unter Microsoft Graph.)
|
||||||
|
3. **Anwendungsberechtigungen** (nicht „Delegierte“) → **`SMTP.SendAsApp`** anhaken → **Berechtigungen hinzufügen**.
|
||||||
|
|
||||||
|
**Danach:** **„Administratorzustimmung für <Organisation> erteilen“** klicken und bestätigen. In der Liste muss bei jeder Berechtigung ein grünes **„Erteilt für …“** stehen.
|
||||||
|
|
||||||
|
> **Was `SMTP.SendAsApp` erlaubt:** Die App darf per SMTP senden — aber **nur** mit Postfächern, die ihr in Exchange ausdrücklich freigegeben werden (Abschnitt 4). Ohne diese Freigabe kann sie nichts senden.
|
||||||
|
|
||||||
|
### 3.4 Objekt-ID der Unternehmensanwendung notieren
|
||||||
|
|
||||||
|
1. Entra → Identität → Anwendungen → **Unternehmensanwendungen**.
|
||||||
|
2. Filter „Anwendungstyp“ auf **Alle Anwendungen** stellen, nach `Wabenhain Shop` suchen, öffnen.
|
||||||
|
3. Auf der **Übersicht** die **Objekt-ID** kopieren.
|
||||||
|
|
||||||
|
> ⚠ Das ist **eine andere Objekt-ID** als die auf der Seite der App-Registrierung. Für Exchange zählt nur die der **Unternehmensanwendung**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Microsoft 365: Absender-Postfach freischalten
|
||||||
|
|
||||||
|
Alle Shop-Mails (Bestellbestätigung, Versandmeldung, Passwort-Reset, Widerrufsbestätigung …) gehen von **einem** Postfach aus, z. B. `hallo@wabenhain.com`.
|
||||||
|
|
||||||
|
**Empfohlen: ein freigegebenes Postfach (Shared Mailbox).** Es braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. Die App sendet ohne Anmeldung einer Person.
|
||||||
|
|
||||||
|
### 4.1 Freigegebenes Postfach anlegen (falls noch nicht vorhanden)
|
||||||
|
|
||||||
|
1. **Microsoft 365 Admin Center** → **Teams und Gruppen** → **Freigegebene Postfächer** → **Freigegebenes Postfach hinzufügen**.
|
||||||
|
2. Name `Wabenhain`, E-Mail `hallo@wabenhain.com` → **Änderungen speichern**.
|
||||||
|
3. Optional: Mitglieder hinzufügen, die Antworten der Kundschaft lesen sollen.
|
||||||
|
|
||||||
|
### 4.2 App für genau dieses Postfach freischalten
|
||||||
|
|
||||||
|
In **PowerShell** (als Exchange-Administrator). Platzhalter ersetzen:
|
||||||
|
|
||||||
|
- `<CLIENT-ID>` = `ENTRA_CLIENT_ID` aus 3.1
|
||||||
|
- `<OBJEKT-ID>` = Objekt-ID der **Unternehmensanwendung** aus 3.4
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Connect-ExchangeOnline
|
||||||
|
|
||||||
|
# 1. App in Exchange bekannt machen
|
||||||
|
New-ServicePrincipal -AppId <CLIENT-ID> -ObjectId <OBJEKT-ID> -DisplayName "Wabenhain Shop"
|
||||||
|
|
||||||
|
# 2. Zugriff NUR auf das Absender-Postfach
|
||||||
|
Add-MailboxPermission -Identity "[email protected]" -User <OBJEKT-ID> -AccessRights FullAccess
|
||||||
|
|
||||||
|
# 3. Authentifiziertes SMTP für dieses Postfach erlauben (auch OAuth läuft darüber)
|
||||||
|
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empfohlene Absicherung:** Authentifiziertes SMTP für alle **anderen** Postfächer abschalten. Die Einstellung am Absender-Postfach aus Schritt 3 hat Vorrang, der Shop sendet weiter.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-TransportConfig -SmtpClientAuthenticationDisabled $true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Kontrolle
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ServicePrincipal | Where-Object DisplayName -eq "Wabenhain Shop"
|
||||||
|
Get-MailboxPermission -Identity "[email protected]" | Where-Object { $_.User -like "*Wabenhain*" }
|
||||||
|
Get-CASMailbox -Identity "[email protected]" | Format-List SmtpClientAuthenticationDisabled # muss False sein
|
||||||
|
Disconnect-ExchangeOnline -Confirm:$false
|
||||||
|
```
|
||||||
|
|
||||||
|
Änderungen in Exchange brauchen **bis zu 30 Minuten**, bis sie greifen.
|
||||||
|
|
||||||
|
**Ergebnis für die Konfiguration:**
|
||||||
|
|
||||||
|
| Wert | Wo | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `SMTP_USER` | Secret | `hallo@wabenhain.com` |
|
||||||
|
| `SMTP_FROM` | ConfigMap | `Wabenhain <hallo@wabenhain.com>` — **dieselbe Adresse** wie `SMTP_USER` |
|
||||||
|
|
||||||
|
Server (`smtp.office365.com`), Port (587, STARTTLS) und Anmeldeverfahren (OAuth 2.0) sind fest im Code — dafür gibt es keine Einstellungen. Ein SMTP-Passwort gibt es nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stripe
|
||||||
|
|
||||||
|
Im **Stripe-Dashboard**, **Live-Modus** (Schalter oben rechts, nicht „Testmodus“).
|
||||||
|
|
||||||
|
### 5.1 API-Schlüssel
|
||||||
|
|
||||||
|
**Entwickler** → **API-Schlüssel**:
|
||||||
|
|
||||||
|
| Angezeigt als | Gehört in | Wo |
|
||||||
|
|---|---|---|
|
||||||
|
| Veröffentlichbarer Schlüssel (`pk_live_…`) | `STRIPE_PUBLISHABLE_KEY` | ConfigMap (nicht geheim) |
|
||||||
|
| Geheimer Schlüssel (`sk_live_…`) | `STRIPE_SECRET_KEY` | Secret |
|
||||||
|
|
||||||
|
Sicherer als der volle geheime Schlüssel ist ein **eingeschränkter Schlüssel** (`rk_live_…`) mit Schreibrecht auf Checkout Sessions, Refunds und Leserecht auf Charges/Payment Intents.
|
||||||
|
|
||||||
|
> Beide Schlüssel müssen aus **demselben Modus** stammen. Mit `sk_test` sehen Bestellungen erfolgreich aus, ohne dass Geld fließt.
|
||||||
|
|
||||||
|
### 5.2 Webhook
|
||||||
|
|
||||||
|
**Entwickler** → **Webhooks** → **Endpunkt hinzufügen**:
|
||||||
|
|
||||||
|
- **URL:** `https://wabenhain.com/api/checkout/webhook`
|
||||||
|
- **Ereignisse:**
|
||||||
|
- `checkout.session.completed`
|
||||||
|
- `checkout.session.async_payment_succeeded`
|
||||||
|
- `checkout.session.async_payment_failed`
|
||||||
|
- `checkout.session.expired`
|
||||||
|
- `charge.refunded`
|
||||||
|
- `charge.dispute.created`
|
||||||
|
|
||||||
|
Nach dem Anlegen **Signaturgeheimnis** anzeigen (`whsec_…`) → `STRIPE_WEBHOOK_SECRET` im Secret.
|
||||||
|
|
||||||
|
> Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „Neu“, es gibt weder Rechnung noch Bestätigungsmail.
|
||||||
|
|
||||||
|
### 5.3 Zahlungsarten
|
||||||
|
|
||||||
|
**Einstellungen** → **Zahlungsmethoden**: gewünschte Arten aktivieren (Karte, SEPA-Lastschrift, PayPal, Klarna, Apple Pay, Google Pay). Der Shop zeigt automatisch an, was dort aktiv ist. Für Apple Pay die Domain `wabenhain.com` unter **Apple Pay** registrieren.
|
||||||
|
|
||||||
|
### 5.4 Abo der Bienenpatenschaft
|
||||||
|
|
||||||
|
**Stand Livekonto (2026-10-16, eingerichtet):**
|
||||||
|
|
||||||
|
- Webhook `https://wabenhain.com/api/checkout/webhook` mit allen zwölf
|
||||||
|
Ereignissen (die sechs bisherigen plus `charge.dispute.closed`,
|
||||||
|
`customer.subscription.created/updated/deleted`, `invoice.paid`,
|
||||||
|
`invoice.payment_failed`). Das Signatur-Geheimnis bleibt dasselbe.
|
||||||
|
- Kundenportal `bpc_1URCIfEvHaDWQ1U0nD4jVWXc`: nur Zahlungsart ändern, keine
|
||||||
|
Kündigung, kein Tarifwechsel, keine Kundendaten, keine Rechnungshistorie.
|
||||||
|
Steht als `STRIPE_PORTAL_CONFIGURATION_ID` in `k8s/01-configmap.yaml`.
|
||||||
|
- Zahlungsmethoden-Domains `wabenhain.com` und `www.wabenhain.com` registriert
|
||||||
|
(Apple Pay, Google Pay, PayPal, Link, Klarna im eingebetteten Checkout).
|
||||||
|
|
||||||
|
**Nur im Dashboard möglich (Stripe bietet dafür keine API):**
|
||||||
|
|
||||||
|
1. Einstellungen → Billing → Abos und E-Mails → Wiederholungsversuche:
|
||||||
|
Smart Retries an, nach dem letzten Versuch **Abo kündigen**.
|
||||||
|
2. Einstellungen → Billing → Abos und E-Mails → Kunden-E-Mails: Mails zu
|
||||||
|
erfolgreichen Zahlungen, anstehenden Verlängerungen und fehlgeschlagenen
|
||||||
|
Zahlungen **aus** (Wabenhain verschickt eigene Rechnungen und Mails).
|
||||||
|
3. Einstellungen → Zahlungsmethoden: Google Pay ist in der Konfiguration
|
||||||
|
„Default“ als nicht verfügbar markiert — dort aktivieren.
|
||||||
|
|
||||||
|
Das Abo läuft über Stripe Billing (Checkout im Modus „subscription“). Produkte und Monatspreise der Abo-Angebote legt Wabenhain selbst an, sobald ein Abo-Angebot im Admin gespeichert oder zum ersten Mal abgeschlossen wird — **nicht** von Hand im Dashboard anlegen.
|
||||||
|
|
||||||
|
**Einrichtungsskript** (wiederholbar, schreibt ohne `--apply` nichts):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Probelauf: zeigt, was fehlt
|
||||||
|
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
|
||||||
|
# Schreiben (Testmodus)
|
||||||
|
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply
|
||||||
|
# Livekonto: nur nach Freigabe des Inhabers, zusätzlich --live-ok
|
||||||
|
pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com --apply --live-ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liest `STRIPE_SECRET_KEY` aus der Umgebung (`.env`) und
|
||||||
|
|
||||||
|
1. ergänzt den Webhook-Endpunkt `<BASE_URL>/api/checkout/webhook` um die Abo-Ereignisse (bestehende bleiben): `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.dispute.closed`. Legt es den Endpunkt neu an, steht das Signaturgeheimnis nur im Dashboard → `STRIPE_WEBHOOK_SECRET`.
|
||||||
|
2. legt eine Kundenportal-Konfiguration an, die **nur das Ändern der Zahlungsart** erlaubt (keine Kündigung, kein Tarifwechsel, keine Kundendaten, kein Rechnungsverlauf). Die App findet sie über die Metadaten `wabenhain=abo-zahlungsart`; alternativ `STRIPE_PORTAL_CONFIGURATION_ID` setzen. Ohne diese Konfiguration bietet das Konto „Zahlungsart ändern“ nicht an — die Standardkonfiguration wird bewusst nie benutzt.
|
||||||
|
|
||||||
|
**Nur im Dashboard** (Checkliste, das Skript gibt sie ebenfalls aus):
|
||||||
|
|
||||||
|
- [ ] **Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen:** Smart Retries an; nach dem letzten erfolglosen Versuch **Abo kündigen** (nicht „unbezahlt markieren“). Wabenhain beendet das Abo dann über `customer.subscription.deleted`.
|
||||||
|
- [ ] **… Kunden-E-Mails:** Stripe-Mails zu erfolgreichen Zahlungen/Rechnungen, bevorstehenden Verlängerungen und fehlgeschlagenen Zahlungen **aus** — Wabenhain verschickt eigene Rechnungen (Nummernkreis RE-…) und Mails.
|
||||||
|
- [ ] **Einstellungen → Zahlungsmethoden:** Karte, Apple Pay, Google Pay und SEPA-Lastschrift aktiv. Im Abo zeigt Checkout nur Zahlarten, die wiederkehrende Zahlungen können. Google Pay prüfen; für Apple Pay/Google Pay im eingebetteten Checkout die Domain registrieren.
|
||||||
|
- [ ] **Stripe Tax** nur mit aktiver Registrierung einschalten (Preise sind Bruttopreise; ohne Registrierung berechnet Stripe nichts).
|
||||||
|
|
||||||
|
**Im Admin:** SEPA-Neukunden-Frist (Vorgabe 56 Tage, 0 schaltet ab), Kontingente und Erntefenster der Abo-Angebote.
|
||||||
|
|
||||||
|
**Was automatisch passiert:** Jede bezahlte Monatsrechnung wird eine Wabenhain-Bestellung mit Rechnung und Mail; die erste erzeugt die Urkunde. Nach drei bezahlten Monaten (Ernte-Variante: im Erntefenster nach sechs) legt der Hintergrundlauf alle zehn Minuten eine 0-€-Lieferbestellung an, die in der normalen Pack- und DHL-Liste erscheint. Eine Rückbuchung setzt das Abo in Verzug, sperrt SEPA für das Konto und beendet das Abo nach 14 Tagen ohne Ausgleich. Kündigungen kommen aus dem Konto oder von `/vertraege-kuendigen` (Footer „Verträge hier kündigen“); öffentliche Kündigungen stehen im Audit-Log (`subscription.cancellation_request`) und gehen zusätzlich an `ADMIN_EMAIL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Optionale Dienste
|
||||||
|
|
||||||
|
Alle folgenden Werte dürfen leer bleiben. Die Funktion ruht dann, der Shop läuft weiter. Ausführliche Anleitungen stehen in `.env.example` im jeweiligen Abschnitt.
|
||||||
|
|
||||||
|
| Dienst | Werte | Ohne sie | Einrichtung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Cloudflare Turnstile** (CAPTCHA) — *dringend empfohlen* | `TURNSTILE_SECRET_KEY` (Secret), `NEXT_PUBLIC_TURNSTILE_SITE_KEY` (ConfigMap) | Keine Bot-Abwehr an Formularen | dash.cloudflare.com → Turnstile → Widget für `wabenhain.com` anlegen. Beide setzen oder beide leer. |
|
||||||
|
| **Google-Rezensionen** | `GOOGLE_PLACES_API_KEY` (Secret), `GOOGLE_PLACE_ID` (ConfigMap) | Statische Beispiel-Stimmen | Google Cloud: „Places API (New)“ aktivieren, Abrechnung an, API-Schlüssel ohne Referrer-Beschränkung |
|
||||||
|
| **DHL** (Versandmarken, Packstation, Sendungsverfolgung) | `DHL_API_KEY`, `DHL_API_SECRET`, `DHL_GKP_USERNAME`, `DHL_GKP_PASSWORD`, `DHL_ACCOUNT_NUMBER`, `DHL_TRACKING_API_KEY` (Secret), `DHL_ENVIRONMENT` (ConfigMap) | Kein Etikettendruck, keine Packstation im Checkout | developer.dhl.com, Geschäftskundenvertrag — Schritt für Schritt in `.env.example` |
|
||||||
|
| **ApiCore-Synchronisierung** | `APICORE_API_KEY` (Secret), `APICORE_API_URL` (ConfigMap) | Sync-Knopf meldet Konfigurationsfehler | API-Schlüssel aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel, nur Berechtigung `shop-sync:read`; kein Benutzerkonto, kein Passwort |
|
||||||
|
| **Bestandsabgleich mit ApiCore** | zusätzlich `APICORE_WEBHOOK_SECRET` (Secret), Berechtigung `shop-sync:write` am Schlüssel, CronJob `wabenhain-apicore-stock` | Der Webhook `/api/apicore/webhook` antwortet mit 503, Entnahmen in ApiCore kommen erst mit dem nächsten Abgleich an | ApiCore → Automatisierung → Webhooks: Webhook für `stock.changed` auf `https://wabenhain.com/api/apicore/webhook`, dessen Secret hier eintragen. Einzelheiten im README, Abschnitt „Bestandsabgleich“ |
|
||||||
|
| **ERPNext-Export** (Rechnungen, Stornorechnungen, Kunden, Firmenanfragen) | `ERP_EXPORT_TOKEN` (Secret), mindestens 32 Zeichen | Die Endpunkte unter `/api/erp/…` lehnen jeden Abruf ab, ERPNext übernimmt nichts | Zufallswert erzeugen (wie `SESSION_SECRET` in Abschnitt 2) und denselben Wert in ERPNext als Token für Wabenhain hinterlegen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Secret und ConfigMap ausfüllen
|
||||||
|
|
||||||
|
### 7.1 Secret (`k8s/02-secret.yaml`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp k8s/02-secret.example.yaml k8s/02-secret.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`k8s/02-secret.yaml` öffnen und jeden Wert aus der Tabelle eintragen. Werte in **doppelten Anführungszeichen** lassen (`stringData` — Kubernetes kodiert selbst, **kein** base64 von Hand).
|
||||||
|
|
||||||
|
| Schlüssel | Pflicht | Inhalt | Quelle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DATABASE_URL` | ja | Verbindungs-URL mit **URL-kodiertem** Passwort | Abschnitt 2 |
|
||||||
|
| `DB_PASSWORD` | ja | Datenbank-Passwort **roh** | Abschnitt 2 |
|
||||||
|
| `SESSION_SECRET` | ja | ≥ 32 zufällige Zeichen | Abschnitt 2 |
|
||||||
|
| `ADMIN_EMAIL` | ja | nicht erratbare Admin-Adresse | Abschnitt 2 |
|
||||||
|
| `ADMIN_PASSWORD` | ja | ≥ 12 Zeichen, zufällig | Abschnitt 2 |
|
||||||
|
| `STRIPE_SECRET_KEY` | ja | `sk_live_…` bzw. `rk_live_…` | 5.1 |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | ja | `whsec_…` | 5.2 |
|
||||||
|
| `SMTP_USER` | ja | `hallo@wabenhain.com` | 4 |
|
||||||
|
| `ENTRA_TENANT_ID` | ja* | Verzeichnis-ID | 3.1 |
|
||||||
|
| `ENTRA_CLIENT_ID` | ja* | Anwendungs-ID | 3.1 |
|
||||||
|
| `ENTRA_CLIENT_SECRET` | ja* | **Wert** des Clientschlüssels | 3.2 |
|
||||||
|
| `ENTRA_REDIRECT_URI` | ja* | `https://wabenhain.com/api/auth/entra/callback` | 3.1 |
|
||||||
|
| `TURNSTILE_SECRET_KEY` | empfohlen | Turnstile-Secret | 6 |
|
||||||
|
| `GOOGLE_PLACES_API_KEY` | nein | Google-API-Schlüssel | 6 |
|
||||||
|
| `APICORE_API_KEY` | nein | ApiCore-API-Schlüssel (`ack_…`) | 6 |
|
||||||
|
| `APICORE_WEBHOOK_SECRET` | nein | Secret des ApiCore-Webhooks `stock.changed` | 6 |
|
||||||
|
| `ERP_EXPORT_TOKEN` | nein | Bearer-Token für den ERPNext-Export, ≥ 32 Zeichen | 6 |
|
||||||
|
| `DHL_*` (6 Werte) | nein | DHL-Zugangsdaten | 6 |
|
||||||
|
|
||||||
|
\* Ohne die `ENTRA_*`-Werte gibt es weder „Mit Microsoft anmelden“ **noch Mailversand** — keine Bestellbestätigungen, keine Passwort-Resets. Für den Live-Betrieb daher Pflicht.
|
||||||
|
|
||||||
|
**Nicht mehr verwendet** (aus älteren Secrets entfernen): `SMTP_HOST`, `SMTP_PORT`, `SMTP_PASSWORD`, `SMTP_OAUTH_TENANT_ID`, `SMTP_OAUTH_CLIENT_ID`, `SMTP_OAUTH_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
### 7.2 ConfigMap (`k8s/01-configmap.yaml`)
|
||||||
|
|
||||||
|
Nur diese Werte anpassen, der Rest sind sinnvolle Vorgaben:
|
||||||
|
|
||||||
|
| Schlüssel | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` | `https://wabenhain.com` — muss zu `host` in `k8s/06-ingress.yaml` passen |
|
||||||
|
| `SMTP_FROM` | `Wabenhain <hallo@wabenhain.com>` — dieselbe Adresse wie `SMTP_USER` |
|
||||||
|
| `DATABASE_PROVIDER` | `postgresql` (oder `mysql`, passend zu `DATABASE_URL`) |
|
||||||
|
| `STRIPE_PUBLISHABLE_KEY` | `pk_live_…` |
|
||||||
|
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Turnstile-Site-Key (empfohlen) |
|
||||||
|
| `VAT_RATE_PERCENT*` | Umsatzsteuersätze prüfen (Honig 7 %, Kerzen 19 %, Propolis je nach Einordnung) |
|
||||||
|
| `DHL_ENVIRONMENT` | `sandbox`, bis DHL getestet ist, dann `production` |
|
||||||
|
| `GOOGLE_PLACE_ID`, `APICORE_API_URL` | nur bei Nutzung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ausrollen
|
||||||
|
|
||||||
|
Erstmalig, in dieser Reihenfolge (Details und Hintergründe: `k8s/README.md`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/00-namespace.yaml
|
||||||
|
kubectl apply -f k8s/01-configmap.yaml
|
||||||
|
kubectl apply -f k8s/02-secret.yaml
|
||||||
|
|
||||||
|
# Nur EINE Datenbank, passend zu DATABASE_PROVIDER:
|
||||||
|
kubectl apply -f k8s/03-postgres.yaml
|
||||||
|
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
|
||||||
|
|
||||||
|
kubectl apply -f k8s/04-uploads-pvc.yaml
|
||||||
|
kubectl apply -f k8s/05-app-deployment.yaml
|
||||||
|
kubectl apply -f k8s/06-ingress.yaml
|
||||||
|
kubectl apply -f k8s/07-backup-cronjob.yaml
|
||||||
|
|
||||||
|
kubectl rollout status deployment/wabenhain-app -n wabenhain
|
||||||
|
```
|
||||||
|
|
||||||
|
Datenbank-Migrationen und das Anlegen des Admin-Kontos laufen beim Start des Pods automatisch.
|
||||||
|
|
||||||
|
**Später einen Wert ändern:** Datei anpassen, erneut anwenden und den Pod neu starten — die App liest Secrets und ConfigMap nur beim Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/02-secret.yaml # bzw. 01-configmap.yaml
|
||||||
|
kubectl rollout restart deployment/wabenhain-app -n wabenhain
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzelnen Wert ohne Datei ändern bzw. entfernen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n wabenhain patch secret wabenhain-secrets --type=merge -p '{"stringData":{"SMTP_USER":"[email protected]"}}'
|
||||||
|
kubectl -n wabenhain patch secret wabenhain-secrets --type=json -p '[{"op":"remove","path":"/data/SMTP_PASSWORD"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Wer `k8s/02-secret.yaml` lokal pflegt, trägt jede Änderung auch dort ein — sonst setzt das nächste `kubectl apply` den alten Stand zurück.
|
||||||
|
|
||||||
|
**Neuen Build ausrollen:** Die Pipeline baut nur Images und rollt nichts aus. Das Deployment ist auf Tag und Digest gepinnt, ein `rollout restart` startet deshalb nur denselben Build neu. Einen neuen Build setzt man so ein (Digest aus der Gitea-Registry, Paket `wabenhain`, Tag `sha-<kurzer Commit>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
|
||||||
|
kubectl -n wabenhain rollout status deploy/wabenhain-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `image:` in `k8s/05-app-deployment.yaml` auf denselben Wert ziehen. Einzelheiten: `k8s/README.md`, Abschnitt „Bei einem neuen Image“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Prüfen
|
||||||
|
|
||||||
|
Der Reihe nach:
|
||||||
|
|
||||||
|
1. **Vollständigkeit der Konfiguration** im laufenden Pod:
|
||||||
|
```bash
|
||||||
|
kubectl -n wabenhain exec deploy/wabenhain-app -- node_modules/.bin/tsx scripts/preflight.ts
|
||||||
|
```
|
||||||
|
Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung.
|
||||||
|
2. **Shop erreichbar:** `https://wabenhain.com` öffnen.
|
||||||
|
3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich.
|
||||||
|
4. **Mailversand und Anbindungen:** **Admin → Einstellungen → System & Tests**. Oben steht, welche Dienste eingerichtet sind (fehlende Werte werden benannt). Darunter „Test-Mail senden“: zuerst prüft der Test, ob `smtp.office365.com:587` und `login.microsoftonline.com:443` per IPv4 erreichbar sind, dann den Versand. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
|
||||||
|
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
|
||||||
|
6. **Firmendaten:** **Admin → Einstellungen → Firma & Versand** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
|
||||||
|
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
|
||||||
|
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Wartung
|
||||||
|
|
||||||
|
| Was | Läuft ab / ändert sich | Folge, wenn verpasst | Was tun |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Entra-Clientschlüssel** (`ENTRA_CLIENT_SECRET`) | nach höchstens **24 Monaten** | Microsoft-Anmeldung **und** Mailversand fallen gleichzeitig aus | Vier Wochen vorher neuen Schlüssel anlegen (3.2), Secret ersetzen, Pod neu starten, **E-Mail-Test**, danach alten Schlüssel in Entra löschen |
|
||||||
|
| **Stripe-Schlüssel** | bei Verdacht auf Abfluss | Fremde könnten Erstattungen auslösen | Im Stripe-Dashboard „Schlüssel rollen“, Secret ersetzen, Pod neu starten |
|
||||||
|
| **`SESSION_SECRET`** | bei Verdacht auf Abfluss | Sitzungen fälschbar | Neuen Wert erzeugen, ersetzen, neu starten (meldet alle ab) |
|
||||||
|
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
|
||||||
|
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
|
||||||
|
|
||||||
|
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → Einstellungen → System & Tests**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fehlerbehebung
|
||||||
|
|
||||||
|
### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
|
||||||
|
|
||||||
|
| Meldung / Ursache | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| „nicht vollständig eingerichtet“ + Liste fehlender Werte | Genannte Werte ins Secret, Pod neu starten |
|
||||||
|
| `AADSTS7000215` — Client-Secret falsch | In `ENTRA_CLIENT_SECRET` steht die Geheimnis-ID statt des **Werts** → neuen Schlüssel anlegen (3.2) |
|
||||||
|
| `AADSTS7000222` — Client-Secret abgelaufen | Neuen Schlüssel anlegen (3.2), ersetzen, neu starten |
|
||||||
|
| `AADSTS700016` — App nicht gefunden | `ENTRA_CLIENT_ID` und `ENTRA_TENANT_ID` gehören nicht zusammen |
|
||||||
|
| `AADSTS90002` / `900023` — Tenant ungültig | `ENTRA_TENANT_ID` prüfen (Verzeichnis-ID) |
|
||||||
|
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
|
||||||
|
| „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false` |
|
||||||
|
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
|
||||||
|
| „nicht erreichbar“ (Netzwerk) | Die Netzwerkprüfung im Test zeigt, welcher Weg fehlt. Der Cluster muss ausgehend per IPv4 `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dort die Freigabe von Port 587 beantragen. |
|
||||||
|
| `ENETUNREACH 2603:…` (IPv6-Adresse) | Stammte aus älteren Versionen, die zufällig eine IPv6-Adresse wählten; der Shop verbindet sich inzwischen nur per IPv4. Tritt es trotzdem auf: aktuelles Image ausrollen. |
|
||||||
|
|
||||||
|
Ausführliche Fehler stehen außerdem im Pod-Log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl logs deploy/wabenhain-app -n wabenhain | grep "\[mail\]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Microsoft-Anmeldung
|
||||||
|
|
||||||
|
| Symptom | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| Knopf „Mit Microsoft anmelden“ fehlt | Einer der vier `ENTRA_*`-Werte fehlt |
|
||||||
|
| Microsoft meldet „redirect_uri mismatch“ (`AADSTS50011`) | `ENTRA_REDIRECT_URI` und die Umleitungs-URI in der App-Registrierung (3.1) müssen zeichengenau gleich sein |
|
||||||
|
|
||||||
|
### Sonstiges
|
||||||
|
|
||||||
|
| Symptom | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| Pod startet nicht, Log nennt Datenbankfehler | `DATABASE_URL`: Passwort URL-kodiert? Host passend zum Datenbank-Manifest? |
|
||||||
|
| Bestellungen bleiben auf „Neu“ | Stripe-Webhook (5.2): URL, Ereignisse und `STRIPE_WEBHOOK_SECRET` prüfen; im Stripe-Dashboard unter Webhooks die Zustellversuche ansehen |
|
||||||
|
| Bilder weg nach Neustart | `k8s/README.md`, Abschnitt „Bilder/Videos verschwinden nach jedem Redeploy“ |
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Bienenpatenschaft Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Bienenpatenschaften als Einmalkauf und Abo verkaufen, mit deterministisch erzeugter Urkunde, Medaillen, 3D-Ansicht im Konto, Übertragung, Treuestufen und Stripe-Abo.
|
||||||
|
|
||||||
|
**Architecture:** Eine reine Layout-Engine (`src/lib/certificate`) erzeugt aus Personalisierung, Textbausteinen und Vorlagenversion positionierte Elemente; daraus entstehen PDF (pdfkit) und Bilder (SVG mit Glyphenpfaden → sharp). Einmal-Angebote hängen an je einem `Product` der neuen Kategorie `PATENSCHAFT`, damit Warenkorb, Checkout, Rechnung, ERP-Export und ApiCore-Buchung weiterlaufen; der Honig eines Pakets wird beim Checkout als eigene Position des verknüpften Honigprodukts angelegt (Steueraufteilung, Bestandsreservierung und ApiCore-Abbuchung ohne Sonderweg). Abos laufen über Stripe Billing; jede bezahlte Monatsrechnung wird zu einer Wabenhain-Bestellung mit Rechnung, jede fällige Lieferung zu einer 0-€-Bestellung mit Honigpositionen.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 (App Router, Server Actions), Prisma 7 (Postgres + MySQL), pdfkit, fontkit, sharp, three + @react-three/fiber + drei, fflate, Stripe (API 2026-08-26.dahlia), vitest, Playwright.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md`
|
||||||
|
|
||||||
|
Abweichung vom Standard „ein Plan je Teilprojekt“: Der Inhaber hat alle fünf Teilprojekte auf einmal freigegeben. Der Plan ist deshalb in Teile A–E gegliedert, jeder Teil ist für sich lauffähig und testbar und wird einzeln committet.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Kein Versprechen von Besuchen, Events, Videos, Fotos, Berichten, Umweltwirkungen – in keinem Text, keiner Mail, keiner Urkunde.
|
||||||
|
- Urkunde: A4 hochkant (595.28 × 841.89 pt), genau eine Seite, Inhalt ≥ 15 mm (42.52 pt) vom Rand, Rahmen ≥ 12 mm (34.02 pt), kleinste Schrift 8 pt, Fließtext ≥ 11 pt.
|
||||||
|
- Schriften Cormorant Garamond und Inter, statische TTF unter SIL OFL, mit Lizenzdatei in `assets/fonts/`.
|
||||||
|
- Farben: Forest `#24352b`, Honey Dark `#8a6531`, Honey `#b88a44`, Sage `#788774`; auf Papier kein Verlauf, keine Folienimitation.
|
||||||
|
- Eingabelimits (Start): Bienenname 2–24, Name 2–60, Schenkende ≤ 80, Widmung ≤ 280 Zeichen und ≤ 5 Zeilenumbrüche; NFC, Steuerzeichen raus, Glyphenprüfung.
|
||||||
|
- Keine Pronomen, keine abgeleiteten Anreden, keine Behauptung individueller Betreuung einer Biene.
|
||||||
|
- Patenschaftsnummer `WH-P-JJJJ-NNNNN`. Vorlagenkennung `<name>@<version>`.
|
||||||
|
- Patenschaften nur mit angemeldetem, verifiziertem Konto. Gastbestellungen anderer Produkte bleiben.
|
||||||
|
- PDFs in `CERTIFICATE_DIR` (nie unter `UPLOAD_DIR`), Downloads nur nach Besitzprüfung, fremde IDs → 404.
|
||||||
|
- Wasserzeichen der Vorschau: „Unverbindliche Vorschau“ / „Preview – not binding“.
|
||||||
|
- Abo: Lieferanspruch nach 3 (vierteljährlich) bzw. 6 (Ernte) bezahlten Monaten; SEPA-Neukunden-Frist 56 Tage (Admin, 0 = aus); Rückbuchung → `IM_VERZUG`, nach 14 Tagen ohne Ausgleich Ende; Konto mit Rückbuchung → kein SEPA mehr.
|
||||||
|
- Abo-Stufen 1–24 je bezahltem Monat, danach VIP; Stammkunden-Stufen ab 2/5/10/25 bezahlten Bestellungen; beides im Admin pflegbar.
|
||||||
|
- Übertragungslinks: 24 h / 7 Tage / 30 Tage, einmalig, widerrufbar, Token ≥ 128 Bit, nur Hash gespeichert.
|
||||||
|
- Zahlarten nie per `payment_method_types`; Stripe-Livekonto nur über Einrichtungsskript bzw. Stripe-Anbindung ändern.
|
||||||
|
- Codekommentare und Texte auf Deutsch, Stil wie im Repo (Warum-Kommentare, keine console.log zum Debuggen).
|
||||||
|
- Agents committen nicht; der Hauptlauf committet zentral.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Zwei Patenschaften desselben Pakets im Warenkorb dürfen nicht verschmelzen → Tests in A7 (Warenkorbschlüssel) und A8 (Checkout mit doppelter productId).
|
||||||
|
2. Webhook mehrfach oder parallel: genau eine Patenschaft, genau eine Mail → Test in A9.
|
||||||
|
3. Manipulierte Personalisierung (überlange Widmung, fremde Zeichen, `<script>`): Checkout lehnt ab, Urkunde zeigt nur Text → Tests in A2 und A8.
|
||||||
|
4. Abo gekündigt, während eine Lieferung fällig wird: keine Lieferung über bezahlte Monate hinaus → Test in D4.
|
||||||
|
5. Honig knapp, während Abos reserviert sind: Einzelverkauf und Pakete können reservierte Gläser nicht kaufen → Test in D5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A – Urkunde und Einmal-Patenschaft (Teilprojekt 1)
|
||||||
|
|
||||||
|
### Task A1: Schriften, Personalisierungsschema, Textbausteine
|
||||||
|
|
||||||
|
**Files:** Create `assets/fonts/CormorantGaramond-{Regular,Italic,SemiBold,SemiBoldItalic}.ttf`, `assets/fonts/Inter-{Regular,Medium}.ttf`, Lizenzdateien; `src/lib/certificate/{schema,text,fonts}.ts`, `src/types/fontkit.d.ts`; Tests `src/lib/certificate/{schema,text}.test.ts`
|
||||||
|
|
||||||
|
**Produces:**
|
||||||
|
- `OCCASIONS = ["SELBST","GEBURTSTAG","WEIHNACHTEN","DANKESCHOEN","GESCHENK"] as const`, `type Occasion`
|
||||||
|
- `CERTIFICATE_LIMITS = { beeName:{min:2,max:24}, recipientName:{min:2,max:60}, giverNames:{max:80}, dedication:{max:280,maxLineBreaks:5} }`
|
||||||
|
- `normalizeCertificateText(value: string, opts?: { multiline?: boolean }): string`
|
||||||
|
- `personalizationSchema` (zod) → `Personalization = { beeName; recipientName; giverNames?; dedication?; occasion: Occasion; startDate: "YYYY-MM-DD" }`; `giverNames` wird bei `SELBST` verworfen
|
||||||
|
- `CertificateTerm = {kind:"FIXED";start;end} | {kind:"OPEN";start} | {kind:"ENDED";start;end}`, `fixedTermFor(start)` (Ende = Start + 12 Monate − 1 Tag)
|
||||||
|
- `composeCertificateText({ personalization, locale, term, number }): CertificateText` = `{ title; subtitle; occasionLine|null; leadLine|null; beforeBee; beeName; afterBee; giverLine|null; dedication|null; termLine; numberLine; notice }`
|
||||||
|
- `loadCertificateFonts(): CertificateFonts` (gecacht; Schlüssel `serif`, `serifItalic`, `serifBold`, `serifBoldItalic`, `sans`, `sansMedium`), `findUnsupportedCharacters(text, fontKeys): string[]`
|
||||||
|
|
||||||
|
- [ ] Tests (Schema-Grenzen, „Zoë“, Steuerzeichen, giverNames bei SELBST, 6 Umbrüche, Emoji; Text: eigene/verschenkte Variante, Anlasszeilen, leere Felder ohne Zeile, drei Laufzeitformen de/en, Hinweis wörtlich)
|
||||||
|
- [ ] Implementieren, `pnpm vitest run src/lib/certificate` grün
|
||||||
|
|
||||||
|
### Task A2: Layout-Engine
|
||||||
|
|
||||||
|
**Files:** `src/lib/certificate/layout.ts`, `src/lib/certificate/templates/{index,<vorlage>}.ts`; Test `layout.test.ts`
|
||||||
|
|
||||||
|
**Produces:**
|
||||||
|
- `LayoutElement = {kind:"text"; font; size; color; x; y; width; text; align; role} | {kind:"path"; d; stroke?; fill?; strokeWidth?; opacity?} | {kind:"image"; src:"logo"; x; y; width; height}`
|
||||||
|
- `CertificateLayout = { templateVersion; width: 595.28; height: 841.89; elements }`
|
||||||
|
- `CertificateTemplate { version; layout(text, fonts): CertificateLayout }`, `CURRENT_TEMPLATE_VERSION`, `getTemplate(version)`
|
||||||
|
- `layoutCertificate(text, templateVersion?)`, `CertificateLayoutError { field }`, `checkCertificateFits(p, locale)`
|
||||||
|
|
||||||
|
- [ ] Testmatrix der Spec: keine Überlappung, Sicherheitszone, Mindestgrößen, Namen ≤ 2 Zeilen, Umbruch nur an Leerzeichen/Bindestrich
|
||||||
|
- [ ] Implementieren, grün
|
||||||
|
|
||||||
|
### Task A3: Renderer und Designvarianten
|
||||||
|
|
||||||
|
**Files:** `src/lib/certificate/{render-pdf,render-image}.ts`, `scripts/render-certificate-samples.ts`; Test `render.test.ts`
|
||||||
|
|
||||||
|
**Produces:** `renderCertificatePdf(layout, { title, logo? }): Promise<Buffer>`, `renderCertificateSvg(layout, { watermark? }): string` (Glyphenpfade), `renderCertificateImage(layout, { widthPx, watermark?, format }): Promise<Buffer>`, `layoutLines(layout): string[]`
|
||||||
|
|
||||||
|
- [ ] Tests: `%PDF`, genau eine Seite, eingebettete Fonts; SVG ohne `<text>`; Wasserzeichen; Bildbreite
|
||||||
|
- [ ] Drei Vorlagen rendern, ansehen, eine wählen, andere entfernen
|
||||||
|
|
||||||
|
### Task A4: Datenmodell
|
||||||
|
Enums `SponsorshipOfferKind`, `SponsorshipCycle`, `MedalTier`, `SponsorshipStatus`; `ProductCategory.PATENSCHAFT`; `FulfillmentMethod.DIGITAL`; Modelle `SponsorshipOffer`, `Sponsorship`, `SponsorshipRevision`, `SponsorshipCounter`; `OrderItem.personalization`, `OrderItem.sponsorshipOfferId`, `OrderItem.sponsorshipRole`. Migration `prisma/migrations/20261016010000_sponsorship`. `DIGITAL` überall behandeln, `pnpm typecheck` grün.
|
||||||
|
|
||||||
|
### Task A5: Angebote im Admin + Startdaten
|
||||||
|
`src/lib/sponsorship/offers.ts` (`getActiveOffers`, `getOfferBySlug`), Admin `/admin/patenschaften/angebote`, Upsert legt das `Product` (Kategorie `PATENSCHAFT`, Preis = Paketpreis − Honiganteil) mit an; Seed der drei Pakete; `PATENSCHAFT` aus Shop-Listen und Produktseite filtern.
|
||||||
|
|
||||||
|
### Task A6: Vorschau-API und Verkaufsseite
|
||||||
|
`/api/patenschaft/vorschau` (Origin, Rate-Limit 60/10 min, Schema + Fit, WebP 900 px mit Wasserzeichen, `no-store`); `/patenschaft` mit Konfigurator (Einmalig/Abo, Schritte, Zähler, Debounce 400 ms, Bestätigung, Anmeldezwang mit Entwurf in `sessionStorage` `wabenhain-sponsorship-draft`).
|
||||||
|
|
||||||
|
### Task A7: Warenkorb
|
||||||
|
`CartItem.key`, `CartItem.sponsorship?: { offerSlug; personalization }`, Operationen über `key`, `persist` Version 2 mit `migrate`, Patenschaft Menge fest 1. Tests: zwei gleiche Pakete bleiben zwei Positionen; alter Speicherstand wird migriert.
|
||||||
|
|
||||||
|
### Task A8: Checkout
|
||||||
|
`expandSponsorshipItems(...)` in `src/lib/sponsorship/checkout-expand.ts`: Hauptposition ohne Reservierung, Honigposition aus verknüpftem Produkt (`unitPriceCents = honeyShareCentsPerJar`, Menge = Gläser, Steuer des Honigs), Kontopflicht, erneute Prüfung, reine Digitalbestellung → `DIGITAL` ohne Versand, doppelte productIds erlaubt. Tests inkl. Manipulation.
|
||||||
|
|
||||||
|
### Task A9: Ausstellung nach Zahlung
|
||||||
|
`createSponsorshipsForOrder(tx, orderId)` im Claim-Lauf; `issuePendingSponsorships({orderId?, limit?})`; Mail-Claim über `mailSentAt`; Backoff `min(2^n × 5 min, 12 h)`, 8 Versuche → `FEHLER`; `scripts/sponsorship-jobs.ts` + CronJob `wabenhain-sponsorship`; `CERTIFICATE_DIR` mit Startprüfung. Tests: doppelter Aufruf, Fehler beim Rendern, Wiederholung.
|
||||||
|
|
||||||
|
### Task A10: Kontobereich und Download
|
||||||
|
`/konto/patenschaft`, `/konto/patenschaft/[id]`, PDF-Route mit Besitzprüfung, `getOwnedSponsorship(customerId, id)`.
|
||||||
|
|
||||||
|
### Task A11: Admin Patenschaften, Druck, Korrektur
|
||||||
|
Liste/Filter, Detail, Druck-PDF, „gedruckt“, „erneut erzeugen“, „Mail erneut“, `reviseSponsorship(...)` mit Revision und Audit-Log; Aufmerksamkeitsliste.
|
||||||
|
|
||||||
|
## Teil B – Medaillen, 3D, Gesamtdownload (Teilprojekt 2)
|
||||||
|
- B1 `src/lib/medals/`: `renderMedalSvg(input: MedalInput)`, `renderMedalPng(input, sizePx = 2048)`; `MedalInput` = Patenschaft (tier, beeName, year, number) | Stammkunde (level, name) | Abo (months) | VIP.
|
||||||
|
- B2 Routen `textur.webp`, `medaille.png`, `medaille.svg`, `alles.zip` (fflate) mit Besitzprüfung.
|
||||||
|
- B3 `src/components/patenschaft-3d/`: `<SponsorshipStage textureUrl backTextureUrl medalSvgUrl tier fallbackImageUrl />`, dynamisch geladen, Wölbung, Reflex, Ziehen mit Nachschwung, Doppelklick wendet, Zoom begrenzt, Neigung nach Tipp, reduced motion, WebGL-Fallback, Tastatur, Pause außerhalb der Sicht.
|
||||||
|
|
||||||
|
## Teil C – Übertragung (Teilprojekt 3)
|
||||||
|
`SponsorshipTransfer`, `createTransferAction(id, validity)`, `revokeTransferAction`, `/patenschaft/uebernehmen/[token]`, Audit-Log, neutrale Fehlerseite. Tests: Ablauf, Widerruf, Doppel-Einlösung, Abo nicht übertragbar.
|
||||||
|
|
||||||
|
## Teil D – Abo (Teilprojekt 4)
|
||||||
|
- D1 Modelle `SponsorshipSubscription`, `SubscriptionPayment`, `SubscriptionDelivery`, `HarvestWindow`; `Customer.stripeCustomerId`, `Customer.sepaBlockedAt`; Einstellung SEPA-Frist (56).
|
||||||
|
- D2 Stripe Product/Price aus Admin; `POST /api/patenschaft/abo` (Checkout `mode: "subscription"`, eingebettet; `excluded_payment_method_types: ["sepa_debit"]` nur bei Sperre).
|
||||||
|
- D3 Webhooks Abo/Rechnung/Rückbuchung idempotent; `invoice.paid` → Zahlung + Bestellung `DIGITAL` + Rechnung + Mail; erste Zahlung → Patenschaft (OPEN).
|
||||||
|
- D4 `computeDeliveryEntitlement(...)`, täglicher Lauf erzeugt 0-€-Lieferbestellungen.
|
||||||
|
- D5 `availableForSale(productId)` = Bestand − Abo-Reservierung; Kontingent; Deckungsanzeige; Stopp neuer Abos.
|
||||||
|
- D6 Kündigung im Konto und unter `/vertraege-kuendigen`, `cancel_at_period_end`, Mail, ENDED-Revision bei Wirksamkeit.
|
||||||
|
- D7 `scripts/stripe-setup.ts` und Livekonto über die Stripe-Anbindung (Webhook-Ereignisse, Kundenportal nur Zahlungsart), Checkliste in `docs/EINRICHTUNG.md`.
|
||||||
|
|
||||||
|
## Teil E – Treuestufen (Teilprojekt 5)
|
||||||
|
`LoyaltyLevel`, Seed (2/5/10/25; Abo 1–24 + VIP), `getCustomerLoyalty(customerId)`, Medaillenleiste, Admin-Pflege. Tests: Erstattung senkt Stufe, Kündigung entfernt Abo-Stufe.
|
||||||
|
|
||||||
|
## Abschluss
|
||||||
|
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` grün
|
||||||
|
- [ ] Browserprüfung Desktop + 390 px, reduced motion, Tastatur
|
||||||
|
- [ ] Code-Review und `code-simplifier` auf den Diff
|
||||||
|
- [ ] Commits, Push, CI grün; Rollout nur nach Freigabe
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Versandstaffel je Artikelkategorie und konfigurierbare Höchstbestellmenge
|
||||||
|
|
||||||
|
Datum: 2026-09-22
|
||||||
|
Status: freigegeben, Implementierung ausstehend
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Die Versandkosten richten sich heute nach einer einzigen, global gepflegten
|
||||||
|
Mengenstaffel: `resolveShippingCostCents` in `src/lib/shipping-calc.ts`
|
||||||
|
bekommt die Summe aller Stück über alle Positionen hinweg und sucht dazu die
|
||||||
|
passende Zeile aus `ShippingTier`. Das setzt voraus, dass jedes Stück
|
||||||
|
ungefähr gleich viel Versandkosten verursacht. Das trifft nicht zu: ein
|
||||||
|
500-Gramm-Honigglas wiegt ein Vielfaches einer Bienenwachskerze, und eine
|
||||||
|
Staffel, die für Honig kostendeckend ist, verteuert Kerzenbestellungen ohne
|
||||||
|
Grund.
|
||||||
|
|
||||||
|
Zweitens ist die Höchstbestellmenge je Position nirgends pflegbar. Sie steckt
|
||||||
|
als Vorgabewert `max = 10` im `QuantityStepper` und als `.max(20)` im
|
||||||
|
Checkout-Schema — zwei Zahlen im Code, die sich widersprechen und die der
|
||||||
|
Betreiber nicht ändern kann, wenn eine Sorte knapp wird.
|
||||||
|
|
||||||
|
## Ziele
|
||||||
|
|
||||||
|
1. Versandstaffeln je Produktkategorie (HONIG, KERZEN, PROPOLIS) pflegbar
|
||||||
|
machen, mit einer Standardstaffel für Kategorien ohne eigene Zeilen.
|
||||||
|
2. Die Höchstbestellmenge je Position im Adminportal festlegen: ein globaler
|
||||||
|
Standardwert plus eine optionale Übersteuerung je Produkt.
|
||||||
|
|
||||||
|
Nicht Ziel: gewichtsgenaue Versandberechnung, Versandklassen unabhängig von
|
||||||
|
der Verkaufskategorie, getrennte Pakete je Kategorie.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
### Kategorie statt Gewicht
|
||||||
|
|
||||||
|
Die Staffeln hängen an der Kategorie, nicht am Gewicht. Die Kategorie liegt
|
||||||
|
bereits an jedem Produkt (`Product.category`), ist im Adminportal vertraut und
|
||||||
|
lässt sich Kunden in Stück statt in Gramm erklären. Eine gewichtsbasierte
|
||||||
|
Staffel wäre genauer, verlangt vom Betreiber aber das Denken in Gramm und vom
|
||||||
|
Shop eine weniger anschauliche Preisauskunft.
|
||||||
|
|
||||||
|
### Gemischter Warenkorb: die teuerste Kategorie gewinnt
|
||||||
|
|
||||||
|
Enthält eine Bestellung mehrere Kategorien, wird je Kategorie gestaffelt und
|
||||||
|
anschließend der höchste dieser Preise berechnet — nicht ihre Summe. Es geht
|
||||||
|
ein Paket hinaus, also wird ein Versandpreis berechnet. Das Addieren würde
|
||||||
|
gemischte Bestellungen bestrafen, obwohl sie den Betrieb kaum mehr kosten.
|
||||||
|
Der Preis ist dadurch nie niedriger als der teuerste Einzelbestandteil und
|
||||||
|
nie höher als die Summe.
|
||||||
|
|
||||||
|
### Standardstaffel als Rückfallebene
|
||||||
|
|
||||||
|
`ShippingTier.category` ist optional. Zeilen ohne Kategorie bilden die
|
||||||
|
Standardstaffel und gelten für jede Kategorie, für die keine eigenen Zeilen
|
||||||
|
gepflegt sind. So genügt es, für Honig abweichende Staffeln anzulegen,
|
||||||
|
während Kerzen und Propolis sich die Standardstaffel teilen.
|
||||||
|
|
||||||
|
### Höchstmenge: global mit Produkt-Übersteuerung
|
||||||
|
|
||||||
|
Ein globaler Standardwert deckt den Normalfall ab, ein optionales Feld am
|
||||||
|
Produkt die Ausnahme (knappe Sorte). Das entspricht dem Muster, das
|
||||||
|
`Product.vatRatePercent` gegenüber dem Satz der Kategorie bereits verwendet:
|
||||||
|
leer bedeutet "es gilt der allgemeine Wert", und eine spätere Änderung des
|
||||||
|
allgemeinen Werts wirkt ohne Nachpflege auf alle Produkte.
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
- `ShippingTier.category ProductCategory?` — null bedeutet Standardstaffel.
|
||||||
|
Dazu `@@index([category, minQuantity])`, weil künftig je Kategorie gesucht
|
||||||
|
wird.
|
||||||
|
- `Product.maxOrderQuantity Int?` — null bedeutet "globaler Standardwert".
|
||||||
|
|
||||||
|
Der globale Standardwert wird als Registry-Schlüssel `shipping.maxOrderQuantity`
|
||||||
|
in `src/lib/content-registry.ts` geführt, Gruppe `shipping`. Dafür bekommt
|
||||||
|
`ContentFieldType` den neuen Typ `number`, der im Admin-Formular ein
|
||||||
|
Zahlenfeld rendert und beim Speichern als ganze Zahl größer null geprüft
|
||||||
|
wird. Eine eigene Einstellungstabelle wäre für einen einzelnen Integer zu
|
||||||
|
schwer; die Gruppe "Versand" existiert im Adminbereich bereits.
|
||||||
|
|
||||||
|
Gelesen wird der Wert über einen Parser analog zu `parseShippingRegion`: ein
|
||||||
|
unlesbarer oder fehlender Wert fällt auf die im Code hinterlegte Vorgabe von
|
||||||
|
20 Stück zurück, statt den Checkout zu blockieren. Zwanzig ist der Wert, den
|
||||||
|
das Checkout-Schema heute schon kennt, die Umstellung ändert also zunächst
|
||||||
|
nichts am Verhalten.
|
||||||
|
|
||||||
|
## Berechnung
|
||||||
|
|
||||||
|
`src/lib/shipping-calc.ts` wechselt die Signatur von
|
||||||
|
`(tiers, totalQuantity)` auf `(tiers, items)`, wobei ein Item aus Kategorie
|
||||||
|
und Menge besteht. Der Ablauf:
|
||||||
|
|
||||||
|
1. Mengen je Kategorie summieren.
|
||||||
|
2. Je Kategorie die Staffeln dieser Kategorie heranziehen; gibt es keine, die
|
||||||
|
Standardstaffeln (Kategorie null).
|
||||||
|
3. Innerhalb der gewählten Staffeln greift die bisherige Zuordnung
|
||||||
|
unverändert, einschließlich des Verhaltens oberhalb der höchsten
|
||||||
|
gepflegten Staffel.
|
||||||
|
4. Das Ergebnis ist das Maximum der Kategoriepreise.
|
||||||
|
|
||||||
|
Sonderfälle: ein leerer Warenkorb kostet null; sind überhaupt keine Staffeln
|
||||||
|
gepflegt, gilt weiterhin `FALLBACK_SHIPPING_CENTS`.
|
||||||
|
|
||||||
|
`getNextShippingTier` bezieht sich künftig auf diejenige Kategorie, die den
|
||||||
|
Preis bestimmt. Andernfalls verspräche der Hinweis "noch X Stück bis zur
|
||||||
|
nächsten Stufe" im Warenkorb eine Schwelle, die den gezeigten Preis gar nicht
|
||||||
|
beeinflusst.
|
||||||
|
|
||||||
|
Der Warenkorb muss die Kategorie kennen: `CartItem` in `src/store/cart-store.ts`
|
||||||
|
bekommt das Feld `category`, gefüllt von `toCartItem` in
|
||||||
|
`src/components/product-card.tsx`. Eine Migration bestehender Warenkörbe aus
|
||||||
|
localStorage ist nicht nötig, weil der Shop noch nicht live ist und die
|
||||||
|
Datenbank vor dem Start neu aufgesetzt wird.
|
||||||
|
|
||||||
|
Bekannte Einschränkung: wird die Kategorie eines Produkts im Adminbereich
|
||||||
|
geändert, während ein Warenkorb offen ist, zeigt die Vorschau kurzzeitig den
|
||||||
|
alten Versandpreis. Der Server rechnet beim Anlegen der Bestellung mit den
|
||||||
|
Kategorien aus der Datenbank, der Kunde wird also korrekt belastet, und der
|
||||||
|
Zustand heilt sich, sobald der Artikel erneut hinzugefügt wird.
|
||||||
|
|
||||||
|
## Durchsetzung der Höchstmenge
|
||||||
|
|
||||||
|
Die Grenze wird an drei Stellen durchgesetzt, weil die ersten beiden allein
|
||||||
|
umgehbar wären:
|
||||||
|
|
||||||
|
1. Im `QuantityStepper` als obere Grenze, gebildet aus dem kleineren Wert von
|
||||||
|
Lagerbestand und wirksamer Höchstmenge. Das ist reine Bedienführung.
|
||||||
|
2. Im Checkout-Schema in `src/lib/validation.ts` als harte, vom Produkt
|
||||||
|
unabhängige Obergrenze von 999 Stück je Position — sie schützt gegen
|
||||||
|
absurde Mengen, bevor überhaupt Produkte geladen werden. Sie ersetzt das
|
||||||
|
heutige `.max(20)`, das dort zur festen Grenze nicht mehr taugt, sobald
|
||||||
|
der Betreiber den Standardwert selbst höher setzen kann.
|
||||||
|
3. In `src/app/api/checkout/route.ts` je Position gegen
|
||||||
|
`maxOrderQuantity ?? globaler Standard`. Das ist die verbindliche
|
||||||
|
Prüfung, denn erst dort sind die echten Produktdaten bekannt. Eine
|
||||||
|
Überschreitung führt zu einer Antwort mit Status 400 und einer
|
||||||
|
lokalisierten Meldung.
|
||||||
|
|
||||||
|
Eine reine UI-Begrenzung genügt nicht: die Checkout-Anfrage lässt sich frei
|
||||||
|
stellen, und ohne serverseitige Prüfung wäre die Höchstmenge eine Anzeige
|
||||||
|
ohne Wirkung.
|
||||||
|
|
||||||
|
## Adminportal
|
||||||
|
|
||||||
|
- `src/app/admin/versand/page.tsx` gruppiert die Liste nach Kategorie und
|
||||||
|
zeigt die Standardstaffeln als eigenen Block.
|
||||||
|
- `src/app/admin/versand/neu/page.tsx` und
|
||||||
|
`src/app/admin/versand/[id]/page.tsx` bekommen ein Auswahlfeld
|
||||||
|
"Kategorie (leer = alle)".
|
||||||
|
- `src/app/admin/actions/shipping.ts` und `shippingTierSchema` in
|
||||||
|
`src/lib/validation.ts` nehmen das Feld auf; ein unbekannter Wert wird
|
||||||
|
abgelehnt statt stillschweigend als Standardstaffel gespeichert.
|
||||||
|
- Das Produktformular unter `src/app/admin/produkte/` bekommt das Feld
|
||||||
|
"Max. Bestellmenge (leer = Standard)". Es wird in
|
||||||
|
`adminProductNumbersSchema` wie `vatRatePercent` behandelt: der leere
|
||||||
|
String wird zu null und darf nicht als Menge null verstanden werden.
|
||||||
|
- Admin, Inhalte, Gruppe Versand: das neue Zahlenfeld
|
||||||
|
"Standard-Höchstbestellmenge".
|
||||||
|
|
||||||
|
## Storefront
|
||||||
|
|
||||||
|
`src/app/versand-zahlung/page.tsx` stellt die Staffeln je Kategorie dar und
|
||||||
|
nennt ausdrücklich, dass bei gemischten Bestellungen die teuerste Staffel
|
||||||
|
gilt. Ohne diesen Satz wirkt der berechnete Preis willkürlich, sobald ein
|
||||||
|
Warenkorb mehrere Kategorien enthält.
|
||||||
|
|
||||||
|
Warenkorb, Warenkorb-Schublade und Checkout-Formular übergeben der
|
||||||
|
Berechnung künftig die Positionen statt der Gesamtmenge.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `src/lib/shipping-calc.test.ts`: Staffel je Kategorie, Rückfall auf die
|
||||||
|
Standardstaffel, gemischter Warenkorb ergibt das Maximum, leerer Warenkorb
|
||||||
|
ergibt null, keine Staffeln ergibt den Notfallpreis.
|
||||||
|
- Anzupassen wegen der geänderten Signatur:
|
||||||
|
`src/app/warenkorb/warenkorb-content.test.tsx` und
|
||||||
|
`src/app/checkout/checkout-form.test.tsx`.
|
||||||
|
- Neu: die Checkout-Route lehnt eine Menge oberhalb des globalen Standards ab
|
||||||
|
und ebenso oberhalb einer produktspezifischen Übersteuerung.
|
||||||
|
- `prisma/seed-demo.ts` legt eigene Honig-Staffeln und Standardstaffeln für
|
||||||
|
die übrigen Kategorien an.
|
||||||
|
- Neue Texte in `src/i18n/dictionaries/de.ts`, `src/i18n/dictionaries/en.ts`
|
||||||
|
und `src/i18n/dictionary-type.ts`.
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
# Bienenpatenschaft: Urkunde, Abo, Medaillen und 3D-Ansicht im Konto
|
||||||
|
|
||||||
|
Datum: 2026-10-16
|
||||||
|
Status: Entwurf zur Freigabe, Implementierung ausstehend
|
||||||
|
Gitea: #31
|
||||||
|
|
||||||
|
Dieses Dokument ersetzt den bisherigen Implementierungsauftrag zur
|
||||||
|
Bienenpatenschaft vollständig. Grundlage sind die verbindlichen Vorgaben des
|
||||||
|
Inhabers vom 2026-10-16 und seine Ergänzungen vom selben Tag (Abo,
|
||||||
|
Kundenkonto, 3D-Ansicht, Medaillen, Treuestufen, Übertragung). Wo ältere
|
||||||
|
Fassungen etwas anderes sagen, gilt dieses Dokument.
|
||||||
|
|
||||||
|
## Problem und Ziel
|
||||||
|
|
||||||
|
Wabenhain verkauft symbolische Bienenpatenschaften als Geschenk oder für sich
|
||||||
|
selbst, einmalig bezahlt oder als monatliches Abo. Das zentrale Produkt ist
|
||||||
|
eine hochwertige, persönlich gestaltete Urkunde. Im Kundenkonto erleben
|
||||||
|
Kunden ihre Urkunde und ihre Medaillen in einer interaktiven 3D-Ansicht und
|
||||||
|
laden alles herunter.
|
||||||
|
|
||||||
|
Nach dem Kauf läuft alles automatisch: Zahlung, Urkunde, Medaillen,
|
||||||
|
Abo-Abrechnung, Rechnungen, Lieferaufträge für den Abo-Honig. Die Handarbeit
|
||||||
|
des Inhabers beschränkt sich auf Drucken, Verpacken und Versenden.
|
||||||
|
|
||||||
|
Erfolg heißt: Eine Testbestellung erzeugt ohne manuelle Nachbearbeitung eine
|
||||||
|
korrekt personalisierte, hochwertige und druckfähige Urkunde, die sofort im
|
||||||
|
Konto in 3D erscheint. Ein Test-Abo rechnet monatlich ab und erzeugt die
|
||||||
|
Honiglieferung erst, wenn sie bezahlt ist.
|
||||||
|
|
||||||
|
## Teilprojekte und Reihenfolge
|
||||||
|
|
||||||
|
Der Umfang ist zu groß für einen Plan. Jedes Teilprojekt bekommt einen
|
||||||
|
eigenen Umsetzungsplan und wird einzeln geprüft und ausgerollt:
|
||||||
|
|
||||||
|
| Nr. | Teilprojekt | Hängt ab von | Schätzung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Urkunde, Einmal-Patenschaft, Kontopflicht, Kontobereich mit Downloads, Admin (Druck, Korrektur) | – | 4–5 Tage |
|
||||||
|
| 2 | 3D-Ansicht von Urkunde und Medaille, Patenschafts-Medaillen je Paket, „Alles herunterladen“ | 1 | 3–4 Tage |
|
||||||
|
| 3 | Übertragung an Beschenkte per befristetem Link | 1 | 1 Tag |
|
||||||
|
| 4 | Abo: Stripe-Abrechnung, Kündigungsbutton, Lieferzyklen, Honigkontingent, Rechnungen | 1 | 5–7 Tage |
|
||||||
|
| 5 | Treuestufen: Stammkunden-Stufen, Abo-Stufen, VIP | 2, 4 | 2 Tage |
|
||||||
|
|
||||||
|
Teilprojekt 1 ist allein verkaufsfähig. Jedes weitere macht das Angebot
|
||||||
|
reicher, ohne das vorherige umzubauen.
|
||||||
|
|
||||||
|
## Nicht im Umfang
|
||||||
|
|
||||||
|
Diese Leistungen entfallen. Sie werden weder auf der Verkaufsseite noch in
|
||||||
|
E-Mails, Urkunden, FAQs oder Rechtstexten versprochen:
|
||||||
|
|
||||||
|
- Besuche beim Imker oder am Bienenstand
|
||||||
|
- Patentage, Veranstaltungen, Terminbuchungen
|
||||||
|
- Videos, individuelle Bienenfotos, Saisonberichte
|
||||||
|
- jede Pflicht zur laufenden Erstellung von Inhalten
|
||||||
|
- Veranstaltungs- oder Teilnehmerverwaltung
|
||||||
|
- Zusagen zu Naturschutzprojekten oder unbelegte Umweltversprechen
|
||||||
|
- ein Vorlagen-Auswahlschritt für Kunden (es gibt genau eine Gestaltung)
|
||||||
|
- Gastbestellungen von Patenschaften
|
||||||
|
|
||||||
|
Später möglich, jetzt nicht gebaut: Bilder oder andere Medien im
|
||||||
|
Patenschaftsbereich des Kontos. Das Datenmodell lässt dafür Platz (siehe
|
||||||
|
„Erweiterbarkeit“), es gibt aber keine Oberfläche und kein Versprechen.
|
||||||
|
|
||||||
|
## Angebote
|
||||||
|
|
||||||
|
Alle Angebote sind im Admin pflegbar: Name, Beschreibung, Leistungsliste,
|
||||||
|
Preis, aktiv/inaktiv, Reihenfolge, verknüpftes Honigprodukt, Gläser,
|
||||||
|
Steueraufteilung, Medaillenstufe. Es kann beliebig viele Angebote beider Arten
|
||||||
|
geben.
|
||||||
|
|
||||||
|
### Einmal-Patenschaften
|
||||||
|
|
||||||
|
| Paket | Testpreis | Leistungen | Versand |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| Bienenfreund | 19 € | symbolische Namensgebung, PDF-Urkunde, Medaille, Konto mit 3D-Ansicht und Downloads | keiner |
|
||||||
|
| Honigfreund | 39 € | digitale Leistungen, gedruckte Urkunde, 1 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
|
||||||
|
| Wabenfreund | 59 € | digitale Leistungen, gedruckte Urkunde, 3 × 500 g Honig, Grußkarte, Geschenkverpackung | ja |
|
||||||
|
|
||||||
|
- Laufzeit zwölf Monate ab Startdatum, endet automatisch.
|
||||||
|
- Nach Ablauf trägt die Urkunde weiter ihr Von-bis-Datum. Urkunde, Medaille
|
||||||
|
und Downloads bleiben im Konto verfügbar.
|
||||||
|
- Alle Pakete bekommen dieselbe Urkunde in derselben Qualität. Sie
|
||||||
|
unterscheiden sich in den physischen Leistungen und in der Medaillenstufe.
|
||||||
|
|
||||||
|
### Abo-Patenschaften
|
||||||
|
|
||||||
|
Zwei Ausgangsangebote, Preise vom Inhaber festzulegen:
|
||||||
|
|
||||||
|
| Angebot | Zahlung | Honig | Lieferung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Patenschaft im Abo, vierteljährlich | monatlich | z. B. 2 × 500 g je Lieferung | nach jeweils 3 bezahlten Monaten |
|
||||||
|
| Patenschaft im Abo, nach der Ernte | monatlich | z. B. 3 × 500 g je Lieferung | in den Erntefenstern, frühestens nach 6 bezahlten Monaten seit der letzten Lieferung |
|
||||||
|
|
||||||
|
- Monatlich kündbar zum Ende des bezahlten Monats.
|
||||||
|
- Versand ist im Monatspreis enthalten. Die Verkaufsseite sagt das deutlich.
|
||||||
|
- Ein Abo gilt einer Biene mit einem Namen und hat eine Urkunde.
|
||||||
|
- Abo und Einmalkauf werden getrennt bezahlt (eigene Stripe-Sitzung je Abo).
|
||||||
|
- Ein Abo lässt sich in dieser Fassung nicht übertragen, weil die Zahlung am
|
||||||
|
Konto des Käufers hängt.
|
||||||
|
|
||||||
|
### Grußkarte
|
||||||
|
|
||||||
|
Vorgedruckt, nicht personalisiert, im Wabenhain-Design. Persönliche Worte
|
||||||
|
stehen in der Widmung auf der Urkunde. So entsteht kein Handschreiben.
|
||||||
|
|
||||||
|
## Kundenkonto ist Pflicht
|
||||||
|
|
||||||
|
- Patenschaften kauft nur, wer angemeldet ist. Das betrifft Einmal- und
|
||||||
|
Abo-Angebote. Für andere Produkte bleibt die Gastbestellung wie heute.
|
||||||
|
- Die Personalisierung lässt sich ohne Anmeldung ausprobieren, samt Vorschau.
|
||||||
|
Beim Klick auf „In den Warenkorb“ oder „Abo abschließen“ führt die Seite zur
|
||||||
|
Anmeldung oder Registrierung. Die Eingaben bleiben dabei erhalten
|
||||||
|
(Entwurf im `sessionStorage`, nach der Anmeldung zurückgeschrieben).
|
||||||
|
- Der Checkout prüft serverseitig, dass ein Konto angemeldet ist, sobald eine
|
||||||
|
Patenschaft im Warenkorb liegt.
|
||||||
|
|
||||||
|
## Personalisierung
|
||||||
|
|
||||||
|
### Felder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Name der Patenbiene | ja | symbolischer Name |
|
||||||
|
| Name der Person auf der Urkunde | ja | für wen die Urkunde ausgestellt ist |
|
||||||
|
| Schenkende | nein | eine oder mehrere Personen als Freitext („Anna und Ben“) |
|
||||||
|
| Widmung | nein | wird unverändert übernommen |
|
||||||
|
| Anlass | ja | für mich selbst, Geburtstag, Weihnachten, Dankeschön, allgemeines Geschenk |
|
||||||
|
| Startdatum | ja | Einmal: frühestens Bestelltag, spätestens sechs Monate danach. Abo: Tag der ersten Zahlung |
|
||||||
|
|
||||||
|
Bei „für mich selbst“ blendet das Formular „Schenkende“ aus.
|
||||||
|
|
||||||
|
### Eingabelimits
|
||||||
|
|
||||||
|
Die endgültigen Limits ergeben sich aus der Layout-Testmatrix. Startwerte:
|
||||||
|
|
||||||
|
- Bienenname: 2–24 Zeichen
|
||||||
|
- Name auf der Urkunde: 2–60 Zeichen
|
||||||
|
- Schenkende: bis 80 Zeichen
|
||||||
|
- Widmung: bis 280 Zeichen und höchstens 5 Zeilenumbrüche
|
||||||
|
|
||||||
|
Das Formular zeigt jedes Limit vor der Bestellung als Zeichenzähler an. Server
|
||||||
|
und Client prüfen mit demselben zod-Schema.
|
||||||
|
|
||||||
|
### Zeichen
|
||||||
|
|
||||||
|
- Eingaben werden nach Unicode NFC normalisiert, Steuerzeichen entfernt,
|
||||||
|
Mehrfach-Leerzeichen zusammengefasst.
|
||||||
|
- Zulässig ist jedes Zeichen, für das die eingebetteten Schriften eine Glyphe
|
||||||
|
haben. Die Prüfung läuft serverseitig über die Font-Metriken (fontkit).
|
||||||
|
Fehlt eine Glyphe (zum Beispiel Emoji), lehnt das Formular die Eingabe mit
|
||||||
|
klarer Meldung ab, statt ein Ersatzkästchen zu drucken.
|
||||||
|
- Umlaute, ß, Akzente, Bindestriche und Apostrophe funktionieren.
|
||||||
|
|
||||||
|
## Urkundentext
|
||||||
|
|
||||||
|
Der Text entsteht aus hinterlegten Bausteinen. Es gibt keine KI-Anfrage pro
|
||||||
|
Bestellung. Der Kunde sieht den endgültigen Wortlaut vor dem Kauf in der
|
||||||
|
Vorschau.
|
||||||
|
|
||||||
|
### Regeln
|
||||||
|
|
||||||
|
- Eigene und verschenkte Patenschaft haben unterschiedliche Formulierungen.
|
||||||
|
- Geburtstag, Weihnachten und Dankeschön haben je eine kurze Anlasszeile.
|
||||||
|
„Für mich selbst“ und „allgemeines Geschenk“ haben keine.
|
||||||
|
- Leere Felder erzeugen keine Platzhalter, keine leeren Zeilen und keine
|
||||||
|
verwaisten Bezeichnungen wie „Von:“ ohne Namen.
|
||||||
|
- Keine erfundenen Eigenschaften, Beziehungen oder Ereignisse.
|
||||||
|
- Keine Geschlechtszuordnung: keine Pronomen, keine aus dem Namen
|
||||||
|
abgeleiteten Anreden.
|
||||||
|
- Keine Formulierung, die eine individuelle Betreuung oder Verfolgung einer
|
||||||
|
einzelnen Biene behauptet.
|
||||||
|
- Die Widmung wird wörtlich übernommen, nur sicher dargestellt (kein HTML,
|
||||||
|
kein Markup) und sauber umbrochen.
|
||||||
|
|
||||||
|
### Bausteine (Entwurf, vor Abnahme sprachlich final prüfen)
|
||||||
|
|
||||||
|
Überschrift: „Bienenpatenschaft“, darunter klein „Urkunde“.
|
||||||
|
|
||||||
|
Anlasszeile: „Zum Geburtstag“, „Zu Weihnachten“, „Als Dankeschön“.
|
||||||
|
|
||||||
|
Haupttext, eigene Patenschaft:
|
||||||
|
|
||||||
|
> {Name auf der Urkunde} hat einer Biene der Imkerei Wabenhain symbolisch den
|
||||||
|
> Namen
|
||||||
|
> **{Bienenname}**
|
||||||
|
> gegeben und unterstützt damit die Imkerei und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Haupttext, verschenkte Patenschaft:
|
||||||
|
|
||||||
|
> Für {Name auf der Urkunde}
|
||||||
|
> Einer Biene der Imkerei Wabenhain wurde symbolisch der Name
|
||||||
|
> **{Bienenname}**
|
||||||
|
> gegeben. Die Patenschaft unterstützt die Imkerei und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Schenkende, nur wenn ausgefüllt: „Geschenkt von {Schenkende}“.
|
||||||
|
|
||||||
|
Widmung, nur wenn ausgefüllt: eigener, typografisch abgesetzter Block.
|
||||||
|
|
||||||
|
Laufzeit:
|
||||||
|
|
||||||
|
- Einmal: „Patenschaft vom {Start} bis {Ende}“, Ende = Start plus zwölf Monate
|
||||||
|
minus ein Tag.
|
||||||
|
- Abo, aktiv: „Patenschaft seit {Start}“.
|
||||||
|
- Abo, gekündigt: „Patenschaft vom {Start} bis {Ende}“, Ende = letzter
|
||||||
|
bezahlter Tag. Diese Fassung entsteht automatisch als neue Revision, sobald
|
||||||
|
die Kündigung wirksam wird (siehe „Abo“).
|
||||||
|
|
||||||
|
Kennung: „Nr. {Patenschaftsnummer}“, Format `WH-P-JJJJ-NNNNN`, fortlaufend über
|
||||||
|
einen eigenen Zähler (wie `InvoiceCounter`).
|
||||||
|
|
||||||
|
Hinweis, immer vorhanden und gut lesbar:
|
||||||
|
|
||||||
|
> Die Namensgebung ist symbolisch. Sie gilt keiner einzeln begleiteten Biene,
|
||||||
|
> sondern unterstützt die Imkerei Wabenhain und ihre Bienenvölker.
|
||||||
|
|
||||||
|
Sprache: Die Urkunde erscheint in der beim Kauf gewählten Sprache (de/en).
|
||||||
|
Beide Fassungen durchlaufen dieselbe Testmatrix.
|
||||||
|
|
||||||
|
## Gestaltung der Urkunde
|
||||||
|
|
||||||
|
### Anspruch
|
||||||
|
|
||||||
|
Die Urkunde soll wie ein rahmenwürdiges Geschenk wirken:
|
||||||
|
|
||||||
|
- elegante Typografie mit klarer Hierarchie
|
||||||
|
- großzügiger Weißraum, ausgewogene Proportionen
|
||||||
|
- Bienenname und Name auf der Urkunde als gestalterischer Mittelpunkt
|
||||||
|
- dezente Bienen-, Blüten- oder Wabenillustration als feine Strichgrafik
|
||||||
|
- zurückhaltende Markenfarben, feine Rahmen oder Ornamente
|
||||||
|
- harmonisch eingebundene Widmung
|
||||||
|
- diskrete Patenschaftsnummer und Datumsangaben
|
||||||
|
- keine Cliparts, keine Office-Vorlagenoptik, keine erfundenen Gütesiegel,
|
||||||
|
keine Unterschrift
|
||||||
|
|
||||||
|
### Markengrundlage
|
||||||
|
|
||||||
|
- Schriften: Cormorant Garamond für Überschrift, Namen und Widmung, Inter für
|
||||||
|
Kleintext. Beide stehen unter der SIL Open Font License und dürfen in PDFs
|
||||||
|
eingebettet werden. Die TTF-Dateien kommen samt Lizenz nach `assets/fonts/`.
|
||||||
|
- Farben aus `src/app/globals.css`: Forest `#24352b` für Text, Honey Dark
|
||||||
|
`#8a6531` für Akzente und Linien, Honey `#b88a44` nur klein, Sage `#788774`
|
||||||
|
für Ornamente. Gold ist auf Papier eine flache Druckfarbe, nie Verlauf oder
|
||||||
|
Folienimitat. Die Urkunde verspricht keine Goldfolie.
|
||||||
|
- Logo: das hochgeladene Markenlogo (`getBrandAssets`), für den Druck als SVG
|
||||||
|
oder als PNG mit mindestens 300 dpi in Druckgröße. Fehlt es, nutzt die
|
||||||
|
Urkunde das gezeichnete Waben-Zeichen aus `lib/brand-icon.ts`. Das Logo
|
||||||
|
wird nicht verzerrt, umgefärbt oder nachgezeichnet.
|
||||||
|
- Weißes Papier, keine randlosen Farbflächen.
|
||||||
|
|
||||||
|
### Drei Varianten, dann eine Entscheidung
|
||||||
|
|
||||||
|
Zuerst entstehen drei Varianten mit identischen Beispieldaten:
|
||||||
|
|
||||||
|
1. Klassisch-editorial: zentrierte Achse, feiner Doppelrahmen in Honey Dark,
|
||||||
|
kleine Biene als Strichzeichnung über der Überschrift.
|
||||||
|
2. Wabe: asymmetrischer Aufbau, feines Hexagon-Linienraster als Eckmotiv,
|
||||||
|
Namen linksbündig groß.
|
||||||
|
3. Botanisch: Lindenblüten- oder Kleezweig als Strichillustration, die den
|
||||||
|
Textblock an zwei Ecken einfasst.
|
||||||
|
|
||||||
|
Alle drei werden als echte PDFs gerendert und nebeneinander beurteilt. Die
|
||||||
|
passendste Variante wird vollständig umgesetzt. Die anderen bleiben nicht im
|
||||||
|
Produktcode. Illustrationen werden selbst als Vektor gezeichnet. Es gibt
|
||||||
|
keine fremden Bildrechte und keine Rasterillustrationen.
|
||||||
|
|
||||||
|
## Druck- und PDF-Anforderungen
|
||||||
|
|
||||||
|
- A4 hochkant, genau eine Seite
|
||||||
|
- Text als echter Text mit eingebetteten Schrift-Teilmengen, Illustrationen
|
||||||
|
und Rahmen als Vektor
|
||||||
|
- Rasterbilder (nur das Logo, falls kein SVG) mit mindestens 300 dpi
|
||||||
|
- sicherer Rand: Inhalt mindestens 15 mm vom Blattrand, Rahmenlinien
|
||||||
|
mindestens 12 mm
|
||||||
|
- keine abgeschnittenen Namen, keine Überlagerungen
|
||||||
|
- kleinste Schrift 8 pt (Kennung, Hinweis), Fließtext mindestens 11 pt
|
||||||
|
- PDF-Metadaten: Titel „Bienenpatenschaft {Bienenname}“, Autor „Imkerei
|
||||||
|
Wabenhain“
|
||||||
|
|
||||||
|
Lange Inhalte löst das Layout über Regeln, nicht durch Verkleinern der ganzen
|
||||||
|
Seite:
|
||||||
|
|
||||||
|
1. Layoutvarianten: ohne Widmung, mit kurzer Widmung, mit langer Widmung. Die
|
||||||
|
Variante folgt aus der gemessenen Höhe.
|
||||||
|
2. Kontrollierte Umbrüche: Namen brechen nur an Leerzeichen oder Bindestrich,
|
||||||
|
höchstens auf zwei Zeilen, nie mitten im Wort.
|
||||||
|
3. Begrenzte Schriftstufen: jedes Feld hat eine feste Liste von Größen (zum
|
||||||
|
Beispiel Bienenname 54, 46, 40 pt). Passt der Inhalt in der kleinsten
|
||||||
|
Stufe nicht, ist das ein Validierungsfehler. Die Eingabelimits verhindern
|
||||||
|
diesen Fall im Betrieb.
|
||||||
|
|
||||||
|
## Medaillen und Treuestufen
|
||||||
|
|
||||||
|
Es gibt drei Arten von Medaillen. Alle werden deterministisch als Vektor
|
||||||
|
erzeugt (SVG), im Konto in 3D gezeigt und als PNG (2048 px, transparent) und
|
||||||
|
SVG heruntergeladen. Auf dem Bildschirm dürfen Medaillen metallisch glänzen.
|
||||||
|
Das betrifft nur die digitale Darstellung, nicht den Druck.
|
||||||
|
|
||||||
|
### 1. Patenschafts-Medaille (Teilprojekt 2)
|
||||||
|
|
||||||
|
- Eine je Patenschaft, mit Bienenname, Jahr und Patenschaftsnummer.
|
||||||
|
- Die Stufe richtet sich nach dem Angebot: Bienenfreund, Honigfreund und
|
||||||
|
Wabenfreund haben eigene Prägung und eigenes Metall (zum Beispiel Bronze,
|
||||||
|
Silber, Gold). Abo-Angebote haben eine eigene Prägung. Die Zuordnung ist am
|
||||||
|
Angebot im Admin einstellbar.
|
||||||
|
- Sie gehört zur Patenschaft und bleibt dauerhaft, auch nach Ablauf oder
|
||||||
|
Kündigung. Bei einer Übertragung wandert sie mit.
|
||||||
|
|
||||||
|
### 2. Stammkunden-Stufen (Teilprojekt 5)
|
||||||
|
|
||||||
|
- Für alle Kunden mit Konto, über den ganzen Shop.
|
||||||
|
- Grundlage: Anzahl bezahlter, nicht erstatteter oder stornierter
|
||||||
|
Bestellungen. Abo-Monatszahlungen zählen hier nicht, sie haben eigene
|
||||||
|
Stufen.
|
||||||
|
- Stufen und Schwellen im Admin pflegbar. Vorschlag: „Blütenbesuch“ ab 2,
|
||||||
|
„Wabenbau“ ab 5, „Honigschatz“ ab 10, „Goldwabe“ ab 25 Bestellungen.
|
||||||
|
- Die Stufe bleibt, sie sinkt nur, wenn Bestellungen erstattet oder storniert
|
||||||
|
werden.
|
||||||
|
|
||||||
|
### 3. Abo-Stufen und VIP (Teilprojekt 5)
|
||||||
|
|
||||||
|
- Grundlage: ununterbrochen bezahlte Abo-Monate.
|
||||||
|
- Vorgabe: Stufe 1 bis 24, je bezahltem Monat eine Stufe, danach eine Stufe
|
||||||
|
ohne Zahl, „VIP“, als Zeichen besonders hoher Wertschätzung. Die Schwellen
|
||||||
|
sind im Admin pflegbar und lassen sich ohne Codeänderung anpassen.
|
||||||
|
- VIP bekommt eine eigene Medaille und eine VIP-Kennzeichnung im Konto. Weitere
|
||||||
|
Vorteile (Rabatt, Versand) gehören nicht zum Umfang und lassen sich später
|
||||||
|
über die bestehenden Rabattcodes ergänzen.
|
||||||
|
- Wird das Abo gekündigt, verschwindet die Abo-Stufe samt Medaille, sobald die
|
||||||
|
Kündigung wirksam ist. Ein neues Abo beginnt wieder bei null.
|
||||||
|
- Ausfälle durch fehlgeschlagene Zahlung unterbrechen die Dauer nur, wenn das
|
||||||
|
Abo deshalb endet.
|
||||||
|
|
||||||
|
Stufen werden aus den Daten berechnet, nicht als Zustand gespeichert. So gibt
|
||||||
|
es keine Abweichung zwischen Stufe und tatsächlichen Bestellungen oder
|
||||||
|
Zahlungen.
|
||||||
|
|
||||||
|
## Kontobereich „Patenschaft“
|
||||||
|
|
||||||
|
Neuer Bereich `/konto/patenschaft` neben „Bestellungen“ und „Wunschliste“.
|
||||||
|
|
||||||
|
### Übersicht
|
||||||
|
|
||||||
|
- alle Patenschaften des Kontos mit Bienenname, Art (einmalig/Abo), Laufzeit
|
||||||
|
und Status (aktiv, abgelaufen, gekündigt)
|
||||||
|
- Medaillenleiste: Patenschafts-Medaillen, aktuelle Stammkunden-Stufe,
|
||||||
|
aktuelle Abo-Stufe oder VIP
|
||||||
|
- bei Abos: nächste Abbuchung, nächste Lieferung, Fortschritt bis zur
|
||||||
|
nächsten Lieferung („2 von 3 Monaten bezahlt“), Kündigungsbutton
|
||||||
|
|
||||||
|
### Detail einer Patenschaft
|
||||||
|
|
||||||
|
- 3D-Ansicht der eigenen Urkunde und der Patenschafts-Medaille (Teilprojekt 2;
|
||||||
|
in Teilprojekt 1 eine statische Ansicht)
|
||||||
|
- Downloads: Urkunde (PDF), Medaille (PNG, SVG), „Alles herunterladen“ (ZIP)
|
||||||
|
- Revisionen sind für Kunden nicht sichtbar. Sie sehen immer die aktuelle
|
||||||
|
Fassung.
|
||||||
|
- Menüpunkt „Übertragen“ (nur Einmal-Patenschaften, Teilprojekt 3)
|
||||||
|
|
||||||
|
### 3D-Ansicht (Teilprojekt 2)
|
||||||
|
|
||||||
|
Ziel: Die Ansicht soll beeindrucken und sich hochwertig anfühlen, ohne die
|
||||||
|
Urkunde zu verfälschen.
|
||||||
|
|
||||||
|
- Technik: three.js mit `@react-three/fiber` und `@react-three/drei`, nur auf
|
||||||
|
dieser Seite und erst im Browser geladen (dynamischer Import), damit der
|
||||||
|
übrige Shop nicht schwerer wird.
|
||||||
|
- Urkunde als Papierblatt mit leichter Wölbung, weichem Schatten und einem
|
||||||
|
langsam wandernden Lichtreflex. Die Textur ist ein hochaufgelöstes Bild
|
||||||
|
(2048 px breit) derselben Revision, aus demselben Layout wie die PDF und
|
||||||
|
ohne Wasserzeichen. Sie wird nur angemeldeten Besitzern ausgeliefert.
|
||||||
|
- Rückseite der Urkunde: Wabenhain-Zeichen und Patenschaftsnummer.
|
||||||
|
- Medaille als Münze mit Rand und erhabener Prägung (SVG-Pfade extrudiert),
|
||||||
|
Metall mit Umgebungsspiegelung. Die Umgebungstextur liegt lokal im Projekt,
|
||||||
|
kein Abruf von fremden Servern.
|
||||||
|
- Bewegung: im Ruhezustand sanftes Schweben und Drehen. Der Mauszeiger neigt
|
||||||
|
das Blatt leicht. Ziehen dreht mit Nachschwung. Doppelklick oder
|
||||||
|
Doppeltippen wendet Urkunde oder Medaille. Zoom per Mausrad oder Zwei-Finger-
|
||||||
|
Geste, begrenzt.
|
||||||
|
- Smartphone: Wischen dreht, Neigen des Geräts bewegt die Szene erst nach
|
||||||
|
einem Tipp auf „Bewegung aktivieren“ (iOS verlangt die Erlaubnis).
|
||||||
|
- `prefers-reduced-motion`: kein Schweben, keine automatische Drehung,
|
||||||
|
Ziehen bleibt möglich.
|
||||||
|
- Ohne WebGL oder bei Fehlern: statisches Bild der Urkunde und der Medaille,
|
||||||
|
Downloads funktionieren unverändert.
|
||||||
|
- Leistung: Pixeldichte höchstens 2, Darstellung pausiert, wenn der Tab im
|
||||||
|
Hintergrund oder die Szene außer Sicht ist. Ziel 60 fps auf einem
|
||||||
|
Mittelklasse-Smartphone.
|
||||||
|
- Bedienbarkeit: Tastatur dreht (Pfeiltasten) und wendet (Leertaste), die
|
||||||
|
Szene hat eine Textbeschreibung für Screenreader, alle Downloads sind
|
||||||
|
normale Links.
|
||||||
|
|
||||||
|
### Erweiterbarkeit
|
||||||
|
|
||||||
|
`Sponsorship` bekommt später eine Medienliste. Die Detailseite ist so
|
||||||
|
aufgebaut, dass ein weiterer Abschnitt („Bilder“) ohne Umbau hinzukommen
|
||||||
|
kann. In diesem Auftrag wird dafür nichts gebaut.
|
||||||
|
|
||||||
|
## Übertragung an Beschenkte (Teilprojekt 3)
|
||||||
|
|
||||||
|
Nur Einmal-Patenschaften lassen sich übertragen.
|
||||||
|
|
||||||
|
1. Der Besitzer wählt im Menü der Patenschaft „Übertragen“.
|
||||||
|
2. Er wählt die Gültigkeit des Links: 24 Stunden, 1 Woche oder 30 Tage.
|
||||||
|
3. Ein Bestätigungsdialog fragt: „Möchtest du den Übertragungslink wirklich
|
||||||
|
erstellen? Jede Person, die diesen Link hat, kann die Patenschaft in ihr
|
||||||
|
Konto übernehmen. Gib ihn nur an die beschenkte Person weiter. Die
|
||||||
|
Weitergabe geschieht auf eigene Verantwortung.“ (Wortlaut rechtlich
|
||||||
|
prüfen.)
|
||||||
|
4. Erst nach Bestätigung entsteht der Link. Er ist einmal nutzbar, enthält ein
|
||||||
|
zufälliges Token mit mindestens 128 Bit, gespeichert wird nur dessen Hash.
|
||||||
|
5. Bis zur Einlösung sieht der Besitzer den offenen Link mit Ablaufzeit und
|
||||||
|
kann ihn widerrufen.
|
||||||
|
6. Die beschenkte Person öffnet den Link, meldet sich an oder registriert sich
|
||||||
|
und bestätigt die Übernahme. Danach gehören Urkunde, Medaille und
|
||||||
|
Downloads ihrem Konto. Beim bisherigen Besitzer verschwindet die
|
||||||
|
Patenschaft aus dem Bereich „Patenschaft“. Bestellung und Rechnung bleiben
|
||||||
|
bei ihm.
|
||||||
|
7. Abgelaufene, widerrufene oder eingelöste Links zeigen eine neutrale Meldung
|
||||||
|
ohne Daten der Patenschaft.
|
||||||
|
8. Jede Übertragung steht im Audit-Log (erstellt, widerrufen, eingelöst, von
|
||||||
|
welchem Konto an welches).
|
||||||
|
|
||||||
|
Eine Übertragung verändert die Urkunde nicht. Die Stammkunden-Stufe zählt den
|
||||||
|
Kauf weiter beim Käufer.
|
||||||
|
|
||||||
|
## Abo (Teilprojekt 4)
|
||||||
|
|
||||||
|
### Abrechnung
|
||||||
|
|
||||||
|
- Stripe Billing: Jedes Abo-Angebot hat einen Stripe-Preis, der beim
|
||||||
|
Speichern im Admin angelegt wird. Eine Preisänderung erzeugt einen neuen
|
||||||
|
Stripe-Preis für neue Abos. Laufende Abos behalten ihren Preis.
|
||||||
|
- `Customer` bekommt eine `stripeCustomerId`.
|
||||||
|
- Der Abschluss läuft über eine Stripe-Checkout-Sitzung im Modus
|
||||||
|
`subscription`.
|
||||||
|
- Neue Webhook-Ereignisse: `customer.subscription.created`,
|
||||||
|
`customer.subscription.updated`, `customer.subscription.deleted`,
|
||||||
|
`invoice.paid`, `invoice.payment_failed`. Jedes Ereignis wird über die
|
||||||
|
Stripe-ID idempotent verarbeitet.
|
||||||
|
- Jede bezahlte Monatsrechnung erzeugt eine Wabenhain-Rechnung im bestehenden
|
||||||
|
Rechnungssystem (Nummernkreis, PDF, E-Rechnung) und eine Mail.
|
||||||
|
- Zahlungsart ändern: über das Stripe-Kundenportal, nur für die Zahlungsart.
|
||||||
|
|
||||||
|
### Schutz vor Verlust: Lieferung erst nach Bezahlung
|
||||||
|
|
||||||
|
Der Kunde kann monatlich kündigen, bekommt aber nie mehr Honig, als er bezahlt
|
||||||
|
hat:
|
||||||
|
|
||||||
|
- Vierteljährlich: Der Anspruch auf eine Lieferung entsteht nach jeweils drei
|
||||||
|
bezahlten Monaten. Wer nach zwei Monaten kündigt, hatte zwei Monate
|
||||||
|
Patenschaft, aber keinen Honig.
|
||||||
|
- Nach der Ernte: Der Inhaber legt im Admin die Erntefenster fest. Geliefert
|
||||||
|
wird im nächsten Fenster, sobald seit der letzten Lieferung mindestens sechs
|
||||||
|
Monate bezahlt sind.
|
||||||
|
- Bezahlt heißt: Stripe meldet die Rechnung als bezahlt, bei SEPA zusätzlich
|
||||||
|
die Regeln unter „SEPA-Lastschrift und Rückbuchungen“.
|
||||||
|
- Fehlgeschlagene Zahlung: Stripe versucht es automatisch erneut. Solange das
|
||||||
|
Abo im Verzug ist, entsteht keine Lieferung. Scheitern alle Versuche, endet
|
||||||
|
das Abo wie bei einer Kündigung.
|
||||||
|
- Die Verkaufsseite und die Abo-Bedingungen sagen klar: „Der Honig wird nach
|
||||||
|
jeweils drei bezahlten Monaten verschickt“ beziehungsweise die
|
||||||
|
entsprechende Regel für die Ernte-Variante.
|
||||||
|
- Widerruf innerhalb von 14 Tagen: Bis dahin ist kein Honig verschickt, es
|
||||||
|
entsteht kein Warenverlust.
|
||||||
|
|
||||||
|
### SEPA-Lastschrift und Rückbuchungen
|
||||||
|
|
||||||
|
Abos lassen sich mit Karte, Apple Pay, Google Pay und SEPA-Lastschrift
|
||||||
|
bezahlen.
|
||||||
|
|
||||||
|
Grenze, die keine Einstellung aufhebt: Bei einer SEPA-Lastschrift kann der
|
||||||
|
Kunde acht Wochen lang ohne Angabe von Gründen bei seiner Bank die Erstattung
|
||||||
|
verlangen, bei einer nicht autorisierten Lastschrift 13 Monate lang. Das ist
|
||||||
|
Bankrecht. Stripe kann es nicht verhindern und solche Rückbuchungen nicht
|
||||||
|
anfechten. Stripe berechnet dafür außerdem eine Gebühr. Der Schutz liegt
|
||||||
|
deshalb in der Lieferlogik von Wabenhain:
|
||||||
|
|
||||||
|
1. Lieferung nur gegen bezahlte Monate (siehe oben). Eine Rückbuchung kann
|
||||||
|
höchstens Honig betreffen, der schon bezahlt erschien.
|
||||||
|
2. Neukunden-Frist: Bei SEPA zählt die erste Abo-Zahlung eines Kontos erst
|
||||||
|
nach einer Frist für den Lieferanspruch. Vorgabe 56 Tage, also die
|
||||||
|
Acht-Wochen-Frist. Die erste Lieferung kommt dadurch bei SEPA etwas später,
|
||||||
|
alle weiteren normal. Die Frist ist im Admin einstellbar (0 schaltet sie
|
||||||
|
ab). Die Verkaufsseite nennt sie bei der Zahlart.
|
||||||
|
3. Rückbuchung erkannt (`charge.dispute.created`, im Webhook schon
|
||||||
|
abonniert): Das Abo geht sofort auf `IM_VERZUG`, offene Lieferungen werden
|
||||||
|
gestoppt und ihre Reservierung freigegeben, die Abo-Stufe ruht. Der Kunde
|
||||||
|
bekommt eine Mail mit der Bitte, den Betrag zu begleichen. Der Fall
|
||||||
|
erscheint in der Aufmerksamkeitsliste.
|
||||||
|
4. Wird der Betrag nicht binnen 14 Tagen bezahlt, endet das Abo wie bei einer
|
||||||
|
Kündigung. Der Anspruch auf den Betrag bleibt bestehen. Ob er eingefordert
|
||||||
|
wird, entscheidet der Inhaber.
|
||||||
|
5. Ein Konto mit einer Rückbuchung kann neue Abos nur noch mit Karte oder
|
||||||
|
Wallet abschließen. Der Admin kann diese Sperre aufheben.
|
||||||
|
|
||||||
|
### Stripe-Einrichtung
|
||||||
|
|
||||||
|
Die Einrichtung erfolgt in Teilprojekt 4, zuerst im Testmodus, dann
|
||||||
|
identisch im Livemodus. Was per API geht, legt ein Einrichtungsskript im Repo
|
||||||
|
an (wiederholbar, ohne Geheimnisse im Code). Was nur im Dashboard geht,
|
||||||
|
steht als Checkliste in `docs/EINRICHTUNG.md`.
|
||||||
|
|
||||||
|
- Zahlarten für Abo-Checkout: Karte, Apple Pay, Google Pay, SEPA-Lastschrift
|
||||||
|
(Mandat wird im Checkout erteilt).
|
||||||
|
- Billing: Smart Retries für fehlgeschlagene Zahlungen, nach dem letzten
|
||||||
|
Versuch Abo kündigen. Stripe-eigene Kundenmails zu Rechnungen aus, weil
|
||||||
|
Wabenhain eigene Rechnungen und Mails schickt.
|
||||||
|
- Kundenportal: nur Zahlungsart ändern. Kündigung und Tarifwechsel im Portal
|
||||||
|
aus, weil die Kündigung über den eigenen Kündigungsbutton läuft.
|
||||||
|
- Webhook-Endpunkt: bestehende Ereignisse plus
|
||||||
|
`customer.subscription.created`, `customer.subscription.updated`,
|
||||||
|
`customer.subscription.deleted`, `invoice.paid`,
|
||||||
|
`invoice.payment_failed`, `charge.dispute.closed`.
|
||||||
|
- Produkte und Preise der Abo-Angebote legt der Admin über Wabenhain an,
|
||||||
|
nicht von Hand im Stripe-Dashboard.
|
||||||
|
- Tests mit Stripe Test Clocks (Abo über mehrere Monate) und
|
||||||
|
SEPA-Testkonten, darunter eines, das eine Rückbuchung auslöst.
|
||||||
|
- Entwicklung und CI in einer eigenen Stripe-Sandbox mit eingeschränktem
|
||||||
|
API-Schlüssel (`rk_`), nicht im Livekonto.
|
||||||
|
|
||||||
|
Ist-Stand Livekonto „Wabenhain“ (gelesen am 2026-10-16, nichts geändert):
|
||||||
|
|
||||||
|
- Zahlarten-Konfiguration „Default“: Karte, Apple Pay, SEPA-Lastschrift,
|
||||||
|
PayPal, Klarna, Link und weitere sind aktiv. Google Pay ist nicht verfügbar
|
||||||
|
und muss im Dashboard unter Zahlungsmethoden geprüft werden. Checkout zeigt
|
||||||
|
im Abo-Modus nur Zahlarten, die wiederkehrende Zahlungen unterstützen.
|
||||||
|
- Ein Webhook-Endpunkt `https://wabenhain.com/api/checkout/webhook` mit den
|
||||||
|
sechs bestehenden Ereignissen. Die Abo-Ereignisse fehlen und kommen mit
|
||||||
|
Teilprojekt 4 dazu.
|
||||||
|
- Kein Kundenportal konfiguriert.
|
||||||
|
- Die Stripe-Anbindung in Claude erreicht nur den Livemodus. Änderungen am
|
||||||
|
Livekonto erfolgen erst nach Abnahme von Teilprojekt 4 und mit Freigabe.
|
||||||
|
|
||||||
|
### Lieferaufträge
|
||||||
|
|
||||||
|
Ein täglicher Lauf im CronJob `wabenhain-certificates` prüft fällige
|
||||||
|
Lieferungen. Ist der Anspruch erfüllt und Bestand reserviert, legt er einen
|
||||||
|
Lieferauftrag als normale Bestellung an (Status `BEZAHLT`, Betrag 0 €, mit
|
||||||
|
Verweis auf das Abo). Er erscheint in der bestehenden Pack- und DHL-Liste. Der
|
||||||
|
Inhaber packt und versendet wie bei jeder Bestellung.
|
||||||
|
|
||||||
|
### Honigbestand und Kontingent
|
||||||
|
|
||||||
|
Kein Abo darf ohne Honig bleiben, weil der Shop zu viele Gläser einzeln
|
||||||
|
verkauft hat:
|
||||||
|
|
||||||
|
- Kontingent: Jedes Abo-Angebot hat eine Höchstzahl aktiver Abos, im Admin
|
||||||
|
pflegbar. Ist sie erreicht, zeigt die Verkaufsseite „Derzeit ausgebucht“
|
||||||
|
mit der bestehenden Benachrichtigung, sobald wieder Plätze frei sind.
|
||||||
|
- Reservierung, vierteljährlich: Für jedes aktive Abo sind die Gläser der
|
||||||
|
nächsten fälligen Lieferung im Bestand des verknüpften Honigprodukts
|
||||||
|
reserviert. Der Shop verkauft einzeln nur den freien Bestand: Bestand minus
|
||||||
|
Abo-Reservierung minus offene Bestellungen.
|
||||||
|
- Reservierung, Ernte-Variante: Der Inhaber trägt je Erntefenster die
|
||||||
|
geplanten Abo-Gläser ein. Sobald die Abfüllung im Bestand ist, wird dieser
|
||||||
|
Anteil reserviert, bevor er in den Freiverkauf geht.
|
||||||
|
- Deckungsanzeige im Admin-Dashboard: „Bestand deckt den Abo-Bedarf der
|
||||||
|
nächsten 2 Lieferungen.“ Unter einer Lieferung erscheint eine Warnung in der
|
||||||
|
Aufmerksamkeitsliste.
|
||||||
|
- Reicht die Deckung für neue Abos nicht, stoppt der Shop neue Abschlüsse
|
||||||
|
automatisch, bis der Inhaber nachlegt.
|
||||||
|
|
||||||
|
### Kündigung
|
||||||
|
|
||||||
|
- Kündigungsbutton nach § 312k BGB: „Verträge hier kündigen“. Im Konto mit
|
||||||
|
einem Klick auf die Bestätigungsseite, zusätzlich öffentlich erreichbar
|
||||||
|
unter `/vertraege-kuendigen` mit Name, E-Mail und Abo-Nummer, nach dem
|
||||||
|
Muster der bestehenden Route `vertrag-widerrufen`. Rechtlich prüfen lassen.
|
||||||
|
- Die Kündigung wirkt zum Ende des bezahlten Monats (`cancel_at_period_end`).
|
||||||
|
Eine Bestätigungsmail geht sofort hinaus.
|
||||||
|
- Wird die Kündigung wirksam:
|
||||||
|
1. Die Urkunde bekommt automatisch eine neue Revision mit „vom … bis …“.
|
||||||
|
Ersteller „System“, Grund „Abo beendet“. Sie bleibt im Konto
|
||||||
|
herunterladbar.
|
||||||
|
2. Die Abo-Stufe und ihre Medaille verschwinden.
|
||||||
|
3. Die Patenschafts-Medaille und alle Downloads bleiben.
|
||||||
|
4. Offene Reservierungen werden freigegeben.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Ein Layout, mehrere Ausgaben
|
||||||
|
|
||||||
|
Vorschau, 3D-Textur und finale PDF müssen identisch umbrechen. Deshalb gibt es
|
||||||
|
genau eine Layout-Berechnung auf dem Server:
|
||||||
|
|
||||||
|
```
|
||||||
|
Personalisierung + Textbausteine + Vorlagenversion
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
composeCertificateText() → aufgelöste Textblöcke (reine Funktion)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
layoutCertificate() → positionierte Elemente: Zeilen mit x/y,
|
||||||
|
Schriftgröße, Schrift, Pfade (reine Funktion,
|
||||||
|
misst mit fontkit an denselben TTF-Dateien)
|
||||||
|
│
|
||||||
|
├──► renderCertificatePdf() pdfkit, Text als Text
|
||||||
|
├──► renderCertificatePreview() SVG, Glyphen als Pfade, sharp → WebP,
|
||||||
|
│ Wasserzeichen, Bildschirmauflösung
|
||||||
|
└──► renderCertificateTexture() wie Vorschau, ohne Wasserzeichen,
|
||||||
|
2048 px, nur für Besitzer
|
||||||
|
```
|
||||||
|
|
||||||
|
Bild-Ausgaben setzen Glyphen als Pfade aus denselben Schriftdateien an
|
||||||
|
dieselben Positionen. Sie hängen nicht von Browser-Schriften ab. Ein Test
|
||||||
|
vergleicht die Zeilen aller Ausgaben.
|
||||||
|
|
||||||
|
Module unter `src/lib/certificate/`:
|
||||||
|
|
||||||
|
| Modul | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| `schema.ts` | zod-Schema der Personalisierung, gemeinsam für Client und Server |
|
||||||
|
| `text.ts` | Bausteine je Sprache, Anlass, eigene/verschenkte Patenschaft, Laufzeitform |
|
||||||
|
| `layout.ts` | Messen, Umbrechen, Schriftstufen, Layoutvarianten |
|
||||||
|
| `templates/` | Vorlagen, je Version eine Datei, alte Versionen bleiben erhalten |
|
||||||
|
| `render-pdf.ts` | pdfkit-Ausgabe |
|
||||||
|
| `render-image.ts` | SVG-Ausgabe, Vorschau und Textur |
|
||||||
|
| `storage.ts` | Ablage der PDFs |
|
||||||
|
| `issue.ts` | Erzeugung nach Zahlung, idempotent, mit Wiederholung |
|
||||||
|
|
||||||
|
Weitere Module: `src/lib/medals/` (Medaillen-SVG, Stufenberechnung),
|
||||||
|
`src/lib/sponsorship-subscription/` (Abo, Lieferanspruch, Reservierung),
|
||||||
|
`src/lib/sponsorship-transfer.ts`.
|
||||||
|
|
||||||
|
### Vorlagenversion
|
||||||
|
|
||||||
|
Jede Vorlage hat eine feste Kennung, etwa `wabe-classic@1`. Eine
|
||||||
|
Designänderung erzeugt eine neue Version, die alte bleibt im Code. Neue
|
||||||
|
Patenschaften nutzen die aktuelle Version. Bestehende behalten ihre Version
|
||||||
|
und ihre gespeicherte PDF. Systemrevisionen (Abo-Ende) nutzen die Version der
|
||||||
|
vorherigen Revision. Medaillen sind ebenso versioniert.
|
||||||
|
|
||||||
|
### Datenmodell (Prisma, beide Provider)
|
||||||
|
|
||||||
|
- `SponsorshipOffer`: Art (`EINMALIG`, `ABO`), Name, Beschreibung,
|
||||||
|
Leistungen, Preis (einmalig) oder Monatspreis, aktiv, Reihenfolge, physisch,
|
||||||
|
verknüpftes Honigprodukt, Gläser je Lieferung, Lieferzyklus
|
||||||
|
(`VIERTELJAEHRLICH`, `ERNTE`), Kontingent, Stripe-Preis-ID,
|
||||||
|
Steueraufteilung, Medaillenstufe
|
||||||
|
- `Sponsorship`: Besitzerkonto, Angebot, Bezug auf `OrderItem` (einmalig)
|
||||||
|
oder `SponsorshipSubscription` (Abo), jeweils eindeutig als
|
||||||
|
Idempotenzschlüssel, Patenschaftsnummer, Start, Ende (leer bei aktivem Abo),
|
||||||
|
Status, aktuelle Revision, Zustellfelder `pdfAttempts`, `nextAttemptAt`,
|
||||||
|
`lastError`, `mailSentAt`, `printedAt`
|
||||||
|
- `SponsorshipRevision`: unveränderlich: Personalisierung (JSON), aufgelöster
|
||||||
|
Text, Vorlagenversion, PDF-Pfad, SHA-256, Ersteller (System, Admin),
|
||||||
|
Grund, Zeitpunkt
|
||||||
|
- `SponsorshipCounter`: fortlaufende Nummer je Jahr
|
||||||
|
- `SponsorshipSubscription`: Konto, Angebot, `stripeSubscriptionId`
|
||||||
|
(eindeutig), Status (`AKTIV`, `IM_VERZUG`, `GEKUENDIGT`, `BEENDET`),
|
||||||
|
Beginn, Kündigung wirksam zum, Ende
|
||||||
|
- `SubscriptionPayment`: Abo, `stripeInvoiceId` (eindeutig), bezahlt am,
|
||||||
|
Betrag, Zahlart, zählt für Lieferung ab (Datum, bei SEPA-Neukunden nach
|
||||||
|
der Frist), zurückgebucht am, Wabenhain-Rechnung
|
||||||
|
- `SubscriptionDelivery`: Abo, fällig am, Gläser, Status, erzeugte Bestellung
|
||||||
|
- `HarvestWindow`: Zeitraum, geplante Abo-Gläser je Angebot
|
||||||
|
- `SponsorshipTransfer`: Patenschaft, Token-Hash, gültig bis, erstellt von,
|
||||||
|
eingelöst von, eingelöst am, widerrufen am
|
||||||
|
- `LoyaltyLevel`: Art (`STAMMKUNDE`, `ABO`), Schwelle, Name, Medaillendesign;
|
||||||
|
VIP als Stufe ohne Zahl
|
||||||
|
- `Customer.stripeCustomerId`, `Customer.sepaBlockedAt` (gesetzt nach einer
|
||||||
|
Rückbuchung)
|
||||||
|
|
||||||
|
Statuswerte der Patenschaft: `AUSSTEHEND`, `FEHLER`, `BEREIT`, `GEDRUCKT`.
|
||||||
|
Aktiv, abgelaufen und gekündigt werden aus Daten berechnet.
|
||||||
|
|
||||||
|
### Warenkorb und Checkout (Einmal-Patenschaft)
|
||||||
|
|
||||||
|
- Patenschaft als Position im bestehenden Warenkorb, Menge immer 1 je
|
||||||
|
Personalisierung.
|
||||||
|
- Die Personalisierung reist mit der Position. Der Checkout prüft sie erneut
|
||||||
|
mit demselben Schema und denselben Layoutregeln und verlangt ein
|
||||||
|
angemeldetes Konto.
|
||||||
|
- Vor dem Hinzufügen bestätigt der Kunde: „Ich habe die Vorschau und die
|
||||||
|
Schreibweise geprüft.“
|
||||||
|
- Beim Anlegen der Bestellung wird die Personalisierung mit der
|
||||||
|
Vorlagenversion an der `OrderItem` gespeichert. Genau dieser Stand wird nach
|
||||||
|
der Zahlung zur ersten Revision.
|
||||||
|
- Reine Digitalbestellungen haben keine Versandkosten. Ob dafür ein neuer
|
||||||
|
`FulfillmentMethod`-Wert `DIGITAL` nötig ist, entscheidet der
|
||||||
|
Umsetzungsplan nach Lesen von Checkout, Rechnung und DHL-Logik.
|
||||||
|
- Physische Pakete laufen über die bestehenden Versandstaffeln je Kategorie
|
||||||
|
mit eigener Staffel für Patenschaftspakete.
|
||||||
|
- Der Honig eines Pakets bucht den Bestand des verknüpften Produkts über die
|
||||||
|
vorhandene Bestandslogik (einschließlich ApiCore-Abgleich), unter
|
||||||
|
Berücksichtigung der Abo-Reservierung.
|
||||||
|
|
||||||
|
### Steuer
|
||||||
|
|
||||||
|
Pakete und Abos enthalten Leistungen mit unterschiedlichen Steuersätzen
|
||||||
|
(Urkunde und Leistung, Honig). Das Angebot speichert deshalb eine Aufteilung:
|
||||||
|
Honiganteil in Cent zum Honig-Satz, Rest zum Regelsatz. Die Rechnung zeigt
|
||||||
|
beide Steuerzeilen über die vorhandene Gruppierung in `lib/vat.ts`. Die
|
||||||
|
Methode muss die Steuerberatung bestätigen. Bei Kleinunternehmerregelung
|
||||||
|
entfällt die Aufteilung wie heute.
|
||||||
|
|
||||||
|
### Ablauf nach der Zahlung (Einmal-Patenschaft)
|
||||||
|
|
||||||
|
1. Der Stripe-Webhook markiert die Bestellung wie heute über den atomaren
|
||||||
|
Claim als bezahlt. Nur dieser Lauf legt die `Sponsorship`-Zeilen an.
|
||||||
|
Ein Duplikat scheitert am eindeutigen `orderItemId` und bricht still ab.
|
||||||
|
2. Danach versucht der Webhook die Erzeugung direkt: Text, Layout, PDF,
|
||||||
|
Ablage, Hash, erste Revision.
|
||||||
|
3. Die Bestellbestätigung verlinkt auf `/konto/patenschaft/<id>`. Der
|
||||||
|
Mailversand wird über `updateMany … where mailSentAt = null` beansprucht,
|
||||||
|
damit doppelte Webhooks keine zweite Mail erzeugen. Erst danach ist der
|
||||||
|
Status `BEREIT`.
|
||||||
|
4. Scheitert die Erzeugung, antwortet der Webhook trotzdem 200 (die Zahlung
|
||||||
|
ist erfolgt). Die Patenschaft bleibt `AUSSTEHEND`. Die Mail sagt: „Deine
|
||||||
|
Urkunde wird gerade erstellt und erscheint in Kürze in deinem Konto.“
|
||||||
|
5. Der CronJob `wabenhain-certificates` (alle 10 Minuten,
|
||||||
|
`concurrencyPolicy: Forbid`, wie die Jobs in `k8s/07-backup-cronjob.yaml`)
|
||||||
|
holt offene Erzeugungen und Mails nach, mit wachsendem Abstand, höchstens
|
||||||
|
8 Versuche, danach `FEHLER` und Hinweis in der Aufmerksamkeitsliste.
|
||||||
|
|
||||||
|
Die finale Urkunde entsteht nie vor bestätigter Zahlung. SEPA-Zahlungen
|
||||||
|
erzeugen sie erst bei `async_payment_succeeded`. Beim Abo entsteht sie mit der
|
||||||
|
ersten bezahlten Rechnung.
|
||||||
|
|
||||||
|
### Ablage und Zugriff
|
||||||
|
|
||||||
|
- PDFs liegen in `CERTIFICATE_DIR`, nicht unter `UPLOAD_DIR`. Die
|
||||||
|
Startprüfung aus `lib/invoice-storage.ts` wird übernommen. Das Verzeichnis
|
||||||
|
kommt auf das gesicherte Volume und in den Sicherungs-CronJob.
|
||||||
|
- Alle Downloads und die 3D-Textur liefern Routen aus, die Anmeldung und
|
||||||
|
Besitz prüfen. Fremde IDs ergeben 404.
|
||||||
|
- Downloads liefern immer die gespeicherte PDF der aktuellen Revision.
|
||||||
|
- Kein QR-Code auf der Urkunde: Er brächte kaum Nutzen und würde die
|
||||||
|
Gestaltung stören.
|
||||||
|
|
||||||
|
### Vorschau
|
||||||
|
|
||||||
|
- `POST /api/patenschaft/vorschau`: prüft die Personalisierung, gibt ein WebP
|
||||||
|
mit Wasserzeichen „Unverbindliche Vorschau“ in Bildschirmauflösung zurück.
|
||||||
|
Rate-Limit je Besucher. Keine Anmeldung nötig.
|
||||||
|
- Der Client fordert sie etwa 400 ms nach der letzten Eingabe an und zeigt bis
|
||||||
|
dahin die letzte gültige Vorschau.
|
||||||
|
|
||||||
|
## Adminbereich
|
||||||
|
|
||||||
|
- Angebote: Einmal- und Abo-Angebote bearbeiten, Kontingent, Erntefenster,
|
||||||
|
Medaillenstufe.
|
||||||
|
- Patenschaften: Liste mit Filtern (ausstehend, Fehler, bereit, zu drucken,
|
||||||
|
gedruckt, Art, Angebot). Detail mit Vorschau, Revisionen, „Druck-PDF
|
||||||
|
öffnen“, „Als gedruckt markieren“, „Erneut erzeugen“, „Mail erneut senden“,
|
||||||
|
Übertragungshistorie.
|
||||||
|
- Abos: Liste mit Status, bezahlten Monaten, nächster Lieferung; Deckung des
|
||||||
|
Honigbedarfs.
|
||||||
|
- Treuestufen: Schwellen und Namen pflegen.
|
||||||
|
- Korrektur: Felder im Kundenformular ändern, Vorschau mit derselben
|
||||||
|
Layoutlogik, Pflichtfeld Änderungsgrund, neue Revision mit neuer PDF, alte
|
||||||
|
bleibt abrufbar, Eintrag über `logAdminAction`, optional Mail an den Kunden.
|
||||||
|
Standardmäßig mit der Vorlagenversion der bisherigen Revision.
|
||||||
|
- Druckablauf: Physische Bestellungen erscheinen nach Zahlung unter „zu
|
||||||
|
drucken“. PDF öffnen, auf festem Papier drucken, als gedruckt markieren,
|
||||||
|
wie gewohnt über DHL versenden.
|
||||||
|
|
||||||
|
## Verkaufsseite
|
||||||
|
|
||||||
|
Route `/patenschaft`, zweisprachig, im Design der Website.
|
||||||
|
|
||||||
|
Im Mittelpunkt:
|
||||||
|
|
||||||
|
- einer Biene symbolisch einen eigenen Namen geben
|
||||||
|
- ein persönlich gestaltetes Geschenk
|
||||||
|
- die regionale Imkerei Wabenhain unterstützen
|
||||||
|
- je nach Angebot eigenen Wabenhain-Honig, einmalig oder regelmäßig
|
||||||
|
|
||||||
|
Aufbau:
|
||||||
|
|
||||||
|
1. Kopfbereich mit großer, echter Urkundenvorschau
|
||||||
|
2. Umschalter „Einmalig“ / „Im Abo“ mit den Angeboten, Preis, Leistungen,
|
||||||
|
Versandkosten beziehungsweise „Versand inklusive“ und den Lieferregeln
|
||||||
|
3. Personalisierung in Schritten: Angebot, für wen und Anlass, Namen, Widmung
|
||||||
|
und Startdatum, Vorschau prüfen
|
||||||
|
4. Ausschnitt aus dem Kontobereich (3D-Urkunde und Medaille als Video oder
|
||||||
|
Bildfolge) mit dem Hinweis, dass der Kauf ein Kundenkonto braucht
|
||||||
|
5. Hinweis zur symbolischen Namensgebung, gut sichtbar
|
||||||
|
6. FAQ
|
||||||
|
|
||||||
|
Smartphone: Vorschau über dem jeweiligen Schritt, ein Tipp vergrößert sie.
|
||||||
|
Eingabefelder mindestens 16 px Schrift, Bedienelemente mindestens 44 px.
|
||||||
|
|
||||||
|
FAQ-Themen: symbolische Namensgebung, Laufzeit (einmalig zwölf Monate, Abo
|
||||||
|
monatlich kündbar), wann der Honig im Abo kommt, warum ein Konto nötig ist,
|
||||||
|
Übertragung an Beschenkte, Versandkosten, Tippfehler (Kontakt, Korrektur
|
||||||
|
durch Wabenhain), was nach Ablauf oder Kündigung bleibt. Keine FAQ verspricht
|
||||||
|
Besuche, Berichte, Fotos oder Umweltwirkungen.
|
||||||
|
|
||||||
|
## Prüfung
|
||||||
|
|
||||||
|
### Layout-Testmatrix
|
||||||
|
|
||||||
|
Automatisierte Tests rendern echte PDFs und prüfen die Layoutdaten (keine
|
||||||
|
Überlappung, alles in der Sicherheitszone, Schrift nie unter Mindestgröße,
|
||||||
|
genau eine Seite) für:
|
||||||
|
|
||||||
|
- sehr kurze Namen („Li“, „Bo“)
|
||||||
|
- maximal lange Namen in beiden Feldern
|
||||||
|
- Doppelnamen („Marie-Luise Schmidt-Habermann“)
|
||||||
|
- Umlaute und Sonderzeichen („Jürgen Weiß“, „Zoë“, „Ñúñez“, „O’Brien“)
|
||||||
|
- mehrere Schenkende („Anna, Ben, Carla und Familie Özdemir“)
|
||||||
|
- ohne Widmung und mit maximal langer Widmung samt allen Zeilenumbrüchen
|
||||||
|
- jeden Anlass, eigene und verschenkte Patenschaft, Deutsch und Englisch
|
||||||
|
- alle drei Laufzeitformen (einmalig, Abo aktiv, Abo beendet)
|
||||||
|
- Vorschau, Textur und PDF ergeben dieselben Zeilen
|
||||||
|
|
||||||
|
Die Matrix legt die endgültigen Eingabelimits fest.
|
||||||
|
|
||||||
|
### Visuelle Kontrolle
|
||||||
|
|
||||||
|
PDFs werden mit 300 dpi gerastert und angesehen: ganze Seite und Ausschnitte
|
||||||
|
in hoher Vergrößerung. Probedruck auf einem Bürodrucker. Medaillen als PNG in
|
||||||
|
Originalgröße. 3D-Ansicht auf Desktop, 390 px mobil, mit
|
||||||
|
`prefers-reduced-motion` und ohne WebGL.
|
||||||
|
|
||||||
|
### Ablauftests
|
||||||
|
|
||||||
|
- doppelter Webhook: eine Patenschaft, eine Mail
|
||||||
|
- PDF-Erzeugung scheitert: Webhook 200, CronJob holt nach
|
||||||
|
- Gastkauf einer Patenschaft wird serverseitig abgewiesen
|
||||||
|
- fremde Patenschaft, fremder Download, fremde Textur: 404
|
||||||
|
- Korrektur: neue Revision, alte unverändert, Audit-Eintrag
|
||||||
|
- neue Vorlagenversion: alte Urkunden unverändert
|
||||||
|
- Übertragung: Link läuft ab, wird widerrufen, wird genau einmal eingelöst
|
||||||
|
- Abo: Lieferung erst nach drei bezahlten Monaten; Kündigung nach zwei
|
||||||
|
Monaten erzeugt keine Lieferung; Zahlungsausfall stoppt Lieferungen;
|
||||||
|
Kündigung erzeugt Von-bis-Revision und entfernt die Abo-Stufe
|
||||||
|
- Bestand: Einzelverkauf kann reservierte Abo-Gläser nicht kaufen;
|
||||||
|
Kontingent voll stoppt neue Abos
|
||||||
|
- doppelte Stripe-Abo-Ereignisse: eine Rechnung, eine Zahlung
|
||||||
|
- SEPA: erste Zahlung zählt erst nach der Neukunden-Frist; Rückbuchung
|
||||||
|
stoppt Lieferungen, setzt das Abo in Verzug, sperrt SEPA für das Konto;
|
||||||
|
ohne Ausgleich endet das Abo nach 14 Tagen
|
||||||
|
- Treuestufen: Erstattung senkt die Stammkunden-Stufe
|
||||||
|
- E2E (Playwright): Einmalkauf mit Stripe-Testkarte bis zur 3D-Ansicht im
|
||||||
|
Konto; Abo-Abschluss mit Stripe-Testuhr (Test Clock) über drei Monate
|
||||||
|
|
||||||
|
Die Repo-Skripte `lint`, `typecheck`, `test` und `build` laufen grün.
|
||||||
|
|
||||||
|
## Abnahme
|
||||||
|
|
||||||
|
Je Teilprojekt gilt: fertig erst nach den passenden Tests, der visuellen
|
||||||
|
Kontrolle und einer Vorführung. Gesamtabnahme:
|
||||||
|
|
||||||
|
1. Verkaufsseite und Personalisierung auf Desktop und 390 px mobil
|
||||||
|
2. die drei Designvarianten und die gewählte Gestaltung
|
||||||
|
3. mehrere echte PDF-Ausgaben mit unterschiedlichen Beispieldaten
|
||||||
|
4. automatischer Ablauf vom Kauf bis zur 3D-Ansicht und zum Download
|
||||||
|
5. Medaillen aller Arten und Stufen
|
||||||
|
6. Übertragung von Konto zu Konto
|
||||||
|
7. Abo über drei Monate mit Test Clock bis zur Lieferung, dann Kündigung
|
||||||
|
8. Druck- und Korrekturablauf im Admin
|
||||||
|
9. offene geschäftliche Konfiguration (Liste unten)
|
||||||
|
|
||||||
|
## Geschäftliche Konfiguration vor Veröffentlichung
|
||||||
|
|
||||||
|
1. Preise kaufmännisch prüfen: Einmalpakete und Abo-Monatspreise mit Honig,
|
||||||
|
Papier, Verpackung, Grußkarte, Versand, Stripe-Gebühren, Arbeitszeit.
|
||||||
|
2. Gläser je Abo-Lieferung, Erntefenster und Kontingente festlegen.
|
||||||
|
3. Steueraufteilung mit der Steuerberatung abstimmen.
|
||||||
|
4. AGB, Abo-Bedingungen und Widerrufsbelehrung: personalisierte Ware
|
||||||
|
(§ 312g Abs. 2 Nr. 1 BGB), digitale Inhalte (§ 356 Abs. 5 BGB),
|
||||||
|
Abo-Laufzeit und Kündigung, Kündigungsbutton (§ 312k BGB), Lieferregel
|
||||||
|
„nach bezahlten Monaten“, Übertragung auf eigene Verantwortung. Rechtlich
|
||||||
|
prüfen lassen.
|
||||||
|
5. Datenschutzerklärung: Namen Dritter, Speicherdauer, Stripe Billing.
|
||||||
|
6. Stripe-Livemodus freigeben: Die Einrichtung (siehe „Stripe-Einrichtung“)
|
||||||
|
läuft erst im Testmodus. Den Livemodus stellt das Skript erst nach
|
||||||
|
ausdrücklicher Freigabe des Inhabers um. Neukunden-Frist für SEPA
|
||||||
|
bestätigen (Vorgabe 56 Tage).
|
||||||
|
7. Versandstaffel für Patenschaftspakete anlegen.
|
||||||
|
8. Logo als SVG oder PNG mit mindestens 300 dpi hochladen.
|
||||||
|
9. Material bevorraten: Urkundenpapier (200–250 g/m², matt, naturweiß),
|
||||||
|
Geschenkverpackung, Grußkarten. Probedruck freigeben.
|
||||||
|
10. Bestellschluss für Weihnachten festlegen.
|
||||||
|
11. Textbausteine und Medaillennamen sprachlich freigeben (de und en).
|
||||||
+37
-4
@@ -28,6 +28,8 @@ data:
|
|||||||
|
|
||||||
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
|
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
|
||||||
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
|
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
|
||||||
|
# Muss die Adresse des Postfachs aus SMTP_USER (Secret) sein — der Versand
|
||||||
|
# laeuft ueber Microsoft 365 mit OAuth 2.0, siehe README "E-Mail-Versand".
|
||||||
SMTP_FROM: "Wabenhain <[email protected]>"
|
SMTP_FROM: "Wabenhain <[email protected]>"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
@@ -42,8 +44,6 @@ data:
|
|||||||
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
|
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
|
||||||
DATABASE_PROVIDER: "postgresql"
|
DATABASE_PROVIDER: "postgresql"
|
||||||
|
|
||||||
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
|
||||||
# implizites TLS.
|
|
||||||
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
||||||
#
|
#
|
||||||
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
||||||
@@ -53,8 +53,6 @@ data:
|
|||||||
# welche. Entweder diesen Wert senken oder max_connections anheben.
|
# welche. Entweder diesen Wert senken oder max_connections anheben.
|
||||||
DATABASE_POOL_SIZE: "20"
|
DATABASE_POOL_SIZE: "20"
|
||||||
|
|
||||||
SMTP_PORT: "587"
|
|
||||||
|
|
||||||
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
||||||
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
|
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
|
||||||
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
|
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
|
||||||
@@ -71,6 +69,29 @@ data:
|
|||||||
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
||||||
INVOICE_DIR: "/app/invoices"
|
INVOICE_DIR: "/app/invoices"
|
||||||
|
|
||||||
|
# Urkunden-PDFs der Bienenpatenschaft (src/lib/sponsorship/storage.ts):
|
||||||
|
# /app/invoices/urkunden/2026/WH-P-2026-00042/r1.pdf
|
||||||
|
# Unterordner des Rechnungs-Volumes: ebenso privat (keine oeffentliche Route
|
||||||
|
# zeigt dorthin), ebenso taeglich gesichert (07-backup-cronjob.yaml sichert
|
||||||
|
# das ganze Volume), und kein zweites ReadWriteOnce-Volume am App-Pod. Die
|
||||||
|
# Pfade der Rechnungen beginnen mit dem Jahr, "urkunden" kollidiert nicht.
|
||||||
|
CERTIFICATE_DIR: "/app/invoices/urkunden"
|
||||||
|
|
||||||
|
# Hintergrundaufgaben im App-Prozess (src/lib/background-jobs.ts): Urkunden
|
||||||
|
# nachholen, Abo-Lieferungen anlegen, Verzug nach Rueckbuchung beenden.
|
||||||
|
# Laufen im App-Pod, weil nur er das Volume mit CERTIFICATE_DIR einhaengt.
|
||||||
|
# "off" nur fuer zusaetzliche Instanzen, die keine Aufgaben uebernehmen sollen.
|
||||||
|
BACKGROUND_JOBS: "on"
|
||||||
|
|
||||||
|
# Stripe-Kundenportal fuer "Zahlungsart aendern" im Abo (bpc_..., kein
|
||||||
|
# Geheimnis). Legt scripts/stripe-setup.ts --apply an und gibt die ID aus.
|
||||||
|
# Gesetzt am 2026-10-16 (Livekonto, nur "Zahlungsart aendern"). Leer: dann
|
||||||
|
# sucht die App die Konfiguration ueber ihre Metadaten
|
||||||
|
# (wabenhain=abo-zahlungsart). Gibt es keine, bietet das Konto
|
||||||
|
# "Zahlungsart aendern" nicht an — die Standardkonfiguration von Stripe wird
|
||||||
|
# bewusst nie benutzt, weil sie Kuendigung oder Tarifwechsel erlauben koennte.
|
||||||
|
STRIPE_PORTAL_CONFIGURATION_ID: "bpc_1URCIfEvHaDWQ1U0nD4jVWXc"
|
||||||
|
|
||||||
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
||||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||||
# 19 = voller Satz
|
# 19 = voller Satz
|
||||||
@@ -95,6 +116,12 @@ data:
|
|||||||
VAT_RATE_PERCENT_HONIG: "7"
|
VAT_RATE_PERCENT_HONIG: "7"
|
||||||
VAT_RATE_PERCENT_KERZEN: "19"
|
VAT_RATE_PERCENT_KERZEN: "19"
|
||||||
VAT_RATE_PERCENT_PROPOLIS: "19"
|
VAT_RATE_PERCENT_PROPOLIS: "19"
|
||||||
|
# Weitere Bienenprodukte (Kategorie SONSTIGES, z. B. Salben, Wachstuecher).
|
||||||
|
VAT_RATE_PERCENT_SONSTIGES: "19"
|
||||||
|
# Bienenpatenschaft (Urkunde, Namensgebung, Abo-Monatsbeitrag ohne
|
||||||
|
# Honiganteil): Leistung zum Regelsatz. Vor der Veroeffentlichung mit der
|
||||||
|
# Steuerberatung bestaetigen (Spec, "Geschaeftliche Konfiguration").
|
||||||
|
VAT_RATE_PERCENT_PATENSCHAFT: "19"
|
||||||
|
|
||||||
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
||||||
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
||||||
@@ -107,6 +134,12 @@ data:
|
|||||||
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
|
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
|
||||||
DHL_ENVIRONMENT: "sandbox"
|
DHL_ENVIRONMENT: "sandbox"
|
||||||
|
|
||||||
|
# Oeffentlicher Stripe-Schluessel (pk_live_...) fuer das eingebettete
|
||||||
|
# Zahlungsformular. Nicht geheim — er steht ohnehin im HTML der Kasse.
|
||||||
|
# Muss zum STRIPE_SECRET_KEY im Secret passen (beide live). Leer = der
|
||||||
|
# Checkout nimmt keine Bestellungen an.
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
|
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|||||||
+32
-17
@@ -68,13 +68,15 @@ stringData:
|
|||||||
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
|
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
|
||||||
STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
|
STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
|
||||||
|
|
||||||
# Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts):
|
# Mailversand ueber Microsoft 365 mit OAuth 2.0 (src/lib/mail.ts) — mit der
|
||||||
# keine Bestellbestaetigung, keine Versandmeldung, keine
|
# App-Registrierung der Entra-ID-SSO unten (ENTRA_TENANT_ID,
|
||||||
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
|
# ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), kein Passwort. Ohne diese Werte
|
||||||
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
|
# und SMTP_USER werden E-Mails nur ins Server-Log geschrieben: keine
|
||||||
SMTP_HOST: "CHANGE-ME"
|
# Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung.
|
||||||
|
# SMTP_USER ist das Postfach, mit dem gesendet wird (z. B.
|
||||||
|
# [email protected]) — dieselbe Adresse wie SMTP_FROM in
|
||||||
|
# 01-configmap.yaml. Einrichtung: README "E-Mail-Versand".
|
||||||
SMTP_USER: "CHANGE-ME"
|
SMTP_USER: "CHANGE-ME"
|
||||||
SMTP_PASSWORD: "CHANGE-ME"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
|
# EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
|
||||||
@@ -100,17 +102,23 @@ stringData:
|
|||||||
# ist dagegen oeffentlich und steht dort.
|
# ist dagegen oeffentlich und steht dort.
|
||||||
GOOGLE_PLACES_API_KEY: ""
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
|
||||||
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
|
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
|
||||||
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
|
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
|
||||||
# Konfigurationsfehler, statt still nichts zu tun.
|
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
|
||||||
#
|
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
|
||||||
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login —
|
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
|
||||||
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen
|
APICORE_API_KEY: ""
|
||||||
# echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read,
|
|
||||||
# extractions:read, bottling:read und optional apiaries:read. Die Adresse
|
# Secret des ApiCore-Webhooks "stock.changed" (ApiCore -> Automatisierung ->
|
||||||
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml.
|
# Webhooks, Ziel https://<shop>/api/apicore/webhook). Damit kommen
|
||||||
APICORE_SYNC_EMAIL: ""
|
# Entnahmen aus ApiCore sofort im Shop an; leer = nur der CronJob gleicht
|
||||||
APICORE_SYNC_PASSWORD: ""
|
# ab (alle 10 Minuten).
|
||||||
|
APICORE_WEBHOOK_SECRET: ""
|
||||||
|
|
||||||
|
# Rechnungsexport fuer ERPNext (GET /api/erp/rechnungen, nur lesend): ERPNext
|
||||||
|
# holt damit die Rechnungen samt PDF ab. Mindestens 32 Zeichen, derselbe
|
||||||
|
# Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus.
|
||||||
|
ERP_EXPORT_TOKEN: ""
|
||||||
|
|
||||||
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
||||||
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
||||||
@@ -121,9 +129,16 @@ stringData:
|
|||||||
DHL_GKP_USERNAME: ""
|
DHL_GKP_USERNAME: ""
|
||||||
DHL_GKP_PASSWORD: ""
|
DHL_GKP_PASSWORD: ""
|
||||||
DHL_ACCOUNT_NUMBER: ""
|
DHL_ACCOUNT_NUMBER: ""
|
||||||
|
# Optional: eigener Key fuer die DHL-Sendungsverfolgung auf
|
||||||
|
# /bestellung-verfolgen. Leer = DHL_API_KEY wird verwendet.
|
||||||
|
DHL_TRACKING_API_KEY: ""
|
||||||
|
|
||||||
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
|
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
|
||||||
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
|
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
|
||||||
|
# Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben, Berechtigung
|
||||||
|
# SMTP.SendAsApp) — ohne diese Werte also auch kein Mailversand. Das
|
||||||
|
# Client-Secret laeuft ab (hoechstens 24 Monate): Ablaufdatum im Kalender
|
||||||
|
# eintragen, sonst fallen Anmeldung UND Mails gleichzeitig aus.
|
||||||
# ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
|
# ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
|
||||||
# entsprechen und auf die echte Domain zeigen:
|
# entsprechen und auf die echte Domain zeigen:
|
||||||
# https://wabenhain.com/api/auth/entra/callback
|
# https://wabenhain.com/api/auth/entra/callback
|
||||||
|
|||||||
+2
-2
@@ -43,8 +43,8 @@ spec:
|
|||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: "0"
|
||||||
memory: 256Mi
|
memory: "0"
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
volumeClaimTemplates:
|
volumeClaimTemplates:
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ spec:
|
|||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: "0"
|
||||||
memory: 256Mi
|
memory: "0"
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
volumeClaimTemplates:
|
volumeClaimTemplates:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# image: unten mit der tatsaechlichen Registry/Tag aus der Gitea-Actions-
|
# image: unten auf den Build gepinnt, siehe Kommentar dort. Kein separater
|
||||||
# Pipeline ersetzen. Kein separater Migrations-Job mehr noetig: der
|
# Migrations-Job mehr noetig: der
|
||||||
# Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus,
|
# Container fuehrt "prisma migrate deploy" + "prisma db seed" selbst aus,
|
||||||
# BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) —
|
# BEVOR er den Server startet (siehe Dockerfile-CMD in der runner-Stage) —
|
||||||
# beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos
|
# beide Schritte sind idempotent, laufen also bei jedem Neustart gefahrlos
|
||||||
@@ -72,9 +72,26 @@ spec:
|
|||||||
# "Uploads lassen sich nicht speichern".
|
# "Uploads lassen sich nicht speichern".
|
||||||
securityContext:
|
securityContext:
|
||||||
fsGroup: 1001
|
fsGroup: 1001
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: git.encry.de/nick.rosenloecher/wabenhain:latest
|
# Fest auf einen Build gepinnt (Tag sha-<kurzer Commit> aus der
|
||||||
|
# Gitea-Pipeline), nie :latest: ein "rollout restart" holt damit
|
||||||
|
# kein ungeprueftes neues Image. Neuer Build:
|
||||||
|
# kubectl -n wabenhain set image deploy/wabenhain-app app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
|
||||||
|
# und hier nachziehen. Tag plus Digest: der Tag ist lesbar, der
|
||||||
|
# Digest macht das Image unveraenderlich (Tags lassen sich in der
|
||||||
|
# Registry ueberschreiben).
|
||||||
|
image: git.encry.de/nick.rosenloecher/wabenhain:sha-1d88bac@sha256:49dd869c339f476fdb51a79e803f2c3cf8fba308b846cddb314b37d1d0a76731
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
envFrom:
|
envFrom:
|
||||||
@@ -164,8 +181,8 @@ spec:
|
|||||||
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
|
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 500m
|
cpu: "0"
|
||||||
memory: 512Mi
|
memory: "0"
|
||||||
limits:
|
limits:
|
||||||
memory: 1Gi
|
memory: 1Gi
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+8
-1
@@ -72,7 +72,14 @@ metadata:
|
|||||||
# ports.web.redirectTo.port=websecure), sind diese Zeile UND die
|
# ports.web.redirectTo.port=websecure), sind diese Zeile UND die
|
||||||
# Middleware am Ende der Datei ueberfluessig. Der Namespace-Praefix im
|
# Middleware am Ende der Datei ueberfluessig. Der Namespace-Praefix im
|
||||||
# Wert ist Pflicht — Traefik loest den Namen sonst nicht auf.
|
# Wert ist Pflicht — Traefik loest den Namen sonst nicht auf.
|
||||||
traefik.ingress.kubernetes.io/router.middlewares: wabenhain-redirect-to-https@kubernetescrd
|
#
|
||||||
|
# "cloudflare-only" steht bewusst an erster Stelle: wabenhain.com laeuft
|
||||||
|
# ueber den Cloudflare-Proxy, direkte Zugriffe auf den Origin (an
|
||||||
|
# Cloudflare und damit an WAF und Rate-Limits vorbei) beantwortet Traefik
|
||||||
|
# mit 403. Erlaubt sind nur Cloudflare-IPs und private Netze. Die
|
||||||
|
# Middleware liegt nicht in diesem Repo, sondern in
|
||||||
|
# rke2-cluster/security/cloudflare-only-middlewares.yaml (Stand 2026-10-12).
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: wabenhain-cloudflare-only@kubernetescrd,wabenhain-redirect-to-https@kubernetescrd
|
||||||
spec:
|
spec:
|
||||||
# Muss zur IngressClass der Traefik-Installation passen. Das offizielle
|
# Muss zur IngressClass der Traefik-Installation passen. Das offizielle
|
||||||
# Helm-Chart legt sie als "traefik" an; mit "kubectl get ingressclass"
|
# Helm-Chart legt sie als "traefik" an; mit "kubectl get ingressclass"
|
||||||
|
|||||||
+95
-14
@@ -8,9 +8,9 @@
|
|||||||
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
|
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
|
||||||
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
|
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
|
||||||
#
|
#
|
||||||
# Diese Sicherung liegt bewusst NUR im Cluster. Sie schuetzt gegen
|
# Diese Sicherung liegt auf derselben Storage Box wie die Datenbank. Sie
|
||||||
# Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
|
# schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall
|
||||||
# Clusters oder Standorts. Fuer eine vollstaendige Absicherung die
|
# der Storage Box oder des Standorts. Fuer eine vollstaendige Absicherung die
|
||||||
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
|
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
|
||||||
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
|
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
|
||||||
# und Wiederherstellung").
|
# und Wiederherstellung").
|
||||||
@@ -20,6 +20,33 @@
|
|||||||
# pg_dump-Aufruf unten ersetzen durch:
|
# pg_dump-Aufruf unten ersetzen durch:
|
||||||
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
|
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
|
||||||
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
|
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
|
||||||
|
# Ziel ist die Storage Box wie bei den uebrigen Wabenhain-Volumes. Das PV
|
||||||
|
# zeigt auf den vorhandenen Ordner Customer/Wabenhain; der Unterordner
|
||||||
|
# db-backup entsteht ueber subPath beim ersten Lauf.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-db-backup
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 10Gi
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
persistentVolumeReclaimPolicy: Retain
|
||||||
|
storageClassName: ""
|
||||||
|
mountOptions: ["vers=3.0", "uid=70", "gid=70", "dir_mode=0700", "file_mode=0600", "noperm"]
|
||||||
|
csi:
|
||||||
|
driver: smb.csi.k8s.io
|
||||||
|
volumeHandle: wabenhain-db-backup
|
||||||
|
volumeAttributes:
|
||||||
|
source: //u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain
|
||||||
|
nodeStageSecretRef:
|
||||||
|
name: smb-credentials
|
||||||
|
namespace: wabenhain
|
||||||
|
claimRef:
|
||||||
|
namespace: wabenhain
|
||||||
|
name: wabenhain-db-backup
|
||||||
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
@@ -28,7 +55,8 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
# storageClassName: "dein-storage-class"
|
storageClassName: ""
|
||||||
|
volumeName: wabenhain-db-backup
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
|
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
|
||||||
@@ -129,13 +157,14 @@ spec:
|
|||||||
ls -lh /backup
|
ls -lh /backup
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: "0"
|
||||||
memory: 128Mi
|
memory: "0"
|
||||||
limits:
|
limits:
|
||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: backup
|
- name: backup
|
||||||
mountPath: /backup
|
mountPath: /backup
|
||||||
|
subPath: db-backup
|
||||||
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
|
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
|
||||||
- name: invoices
|
- name: invoices
|
||||||
mountPath: /invoices
|
mountPath: /invoices
|
||||||
@@ -209,17 +238,17 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
|
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
|
||||||
# 1001 nextjs) — dasselbe Image, derselbe Nutzer.
|
# 1001 nextjs) — beide Images legen denselben Nutzer an.
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
runAsGroup: 1001
|
runAsGroup: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: release-stale-orders
|
- name: release-stale-orders
|
||||||
# DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml.
|
# Werkzeug-Image (Dockerfile "AS tools") mit DEMSELBEN Tag wie
|
||||||
# Beide zusammen aktualisieren: der Lauf nutzt denselben
|
# das App-Image in 05-app-deployment.yaml. Beide zusammen
|
||||||
# Prisma-Client wie die App und muss deshalb zum selben
|
# aktualisieren: der Lauf nutzt denselben Prisma-Client wie die
|
||||||
# Datenbankschema passen.
|
# App und muss deshalb zum selben Datenbankschema passen.
|
||||||
image: git.encry.de/nick.rosenloecher/wabenhain:latest
|
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
|
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
|
||||||
# — die stehen bereits in denselben beiden Quellen wie fuer die
|
# — die stehen bereits in denselben beiden Quellen wie fuer die
|
||||||
@@ -242,9 +271,61 @@ spec:
|
|||||||
exec tsx scripts/release-stale-orders.ts
|
exec tsx scripts/release-stale-orders.ts
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: "0"
|
||||||
memory: 128Mi
|
memory: "0"
|
||||||
limits:
|
limits:
|
||||||
# Derselbe Wert wie beim App-Container: der Lauf laedt
|
# Derselbe Wert wie beim App-Container: der Lauf laedt
|
||||||
# denselben Prisma-Client samt Treiber.
|
# denselben Prisma-Client samt Treiber.
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
|
---
|
||||||
|
# Bestandsabgleich mit ApiCore (scripts/apicore-stock-sync.ts): sendet
|
||||||
|
# liegengebliebene Verkaeufe/Rueckbuchungen an ApiCore und gleicht alle
|
||||||
|
# verknuepften Produkte gegen den ApiCore-Restbestand ab. Sicherheitsnetz
|
||||||
|
# unter dem Sofort-Versand und dem Webhook "stock.changed" — scheitert der
|
||||||
|
# Lauf (ApiCore nicht erreichbar, Buchung abgelehnt), ist der Job rot.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: wabenhain-apicore-stock
|
||||||
|
namespace: wabenhain
|
||||||
|
spec:
|
||||||
|
schedule: "*/10 * * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 5
|
||||||
|
startingDeadlineSeconds: 300
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
# Ein Lauf darf den naechsten nicht blockieren.
|
||||||
|
activeDeadlineSeconds: 540
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
containers:
|
||||||
|
- name: apicore-stock
|
||||||
|
# Werkzeug-Image mit demselben Tag wie in 05-app-deployment.yaml.
|
||||||
|
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
# Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und
|
||||||
|
# APICORE_API_KEY — dieselben Quellen wie die App.
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: wabenhain-config
|
||||||
|
- secretRef:
|
||||||
|
name: wabenhain-secrets
|
||||||
|
command: ["/bin/sh", "-c"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
node scripts/prisma-provider.mjs
|
||||||
|
exec tsx scripts/apicore-stock-sync.ts
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "0"
|
||||||
|
memory: "0"
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
+62
-24
@@ -3,11 +3,18 @@
|
|||||||
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
|
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
|
||||||
anwendbar. Nummerierung = empfohlene Reihenfolge.
|
anwendbar. Nummerierung = empfohlene Reihenfolge.
|
||||||
|
|
||||||
|
Woher jeder Wert in `02-secret.yaml` und `01-configmap.yaml` kommt (Entra ID,
|
||||||
|
Microsoft 365, Stripe, optionale Dienste) und wie man danach prueft, dass
|
||||||
|
alles laeuft: [../docs/EINRICHTUNG.md](../docs/EINRICHTUNG.md).
|
||||||
|
|
||||||
## Einmalige Vorbereitung
|
## Einmalige Vorbereitung
|
||||||
|
|
||||||
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem
|
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` prueft bei jedem
|
||||||
Push auf `main` das Image `<registry>/<owner>/wabenhain:latest` (+
|
Push auf `main` lint, audit, typecheck und test und baut erst danach das
|
||||||
`:sha-<kurzhash>`, `:vX.Y.Z` bei Tags) in der Gitea-Container-Registry.
|
Image `<registry>/<owner>/wabenhain:latest` (+ `:sha-<kurzhash>`,
|
||||||
|
`:vX.Y.Z` bei Tags) in der Gitea-Container-Registry. Daneben entsteht
|
||||||
|
mit denselben Tags `wabenhain-tools` fuer die CronJobs in
|
||||||
|
`07-backup-cronjob.yaml`.
|
||||||
Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt
|
Ein einzelnes Image reicht fuer `05-app-deployment.yaml` — es traegt
|
||||||
sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet
|
sowohl die Next.js-App als auch die Prisma-CLI/tsx und migriert/seedet
|
||||||
die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates
|
die Datenbank beim Start selbst (siehe Punkt 2 unten), kein separates
|
||||||
@@ -49,8 +56,9 @@ anwendbar. Nummerierung = empfohlene Reihenfolge.
|
|||||||
fuer Produkt- und Erntenfotos.
|
fuer Produkt- und Erntenfotos.
|
||||||
|
|
||||||
6. `image:` in `05-app-deployment.yaml` zeigt bereits auf
|
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` und ist auf einen festen Build
|
||||||
(`:latest` passt fuer den jeweils neuesten `main`-Build). Die Domain steht
|
gepinnt (`:sha-<commit>@sha256:<digest>`), nie auf `:latest` — siehe
|
||||||
|
„Bei einem neuen Image“ unten. Die Domain steht
|
||||||
in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
|
in `06-ingress.yaml` (`host:` und `tls.hosts`) und in `01-configmap.yaml`
|
||||||
(`NEXT_PUBLIC_SITE_URL`) bereits als `wabenhain.com` — beide Stellen
|
(`NEXT_PUBLIC_SITE_URL`) bereits als `wabenhain.com` — beide Stellen
|
||||||
muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die
|
muessen uebereinstimmen, sonst zeigen Sitemap, Canonical-Tags und die
|
||||||
@@ -87,10 +95,25 @@ der erste Pod bereit ist.
|
|||||||
|
|
||||||
## Bei einem neuen Image (Redeploy)
|
## Bei einem neuen Image (Redeploy)
|
||||||
|
|
||||||
|
Das Deployment ist auf Tag **und** Digest gepinnt. Ein `rollout restart`
|
||||||
|
holt deshalb kein neues Image, sondern startet nur denselben Build neu (das
|
||||||
|
genuegt nach einer Aenderung an Secret oder ConfigMap). Einen neuen Build
|
||||||
|
rollt man so aus:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl rollout restart deployment/wabenhain-app -n wabenhain
|
# Digest des gewuenschten Builds aus der Gitea-Registry (Paket "wabenhain",
|
||||||
|
# Tag sha-<kurzer Commit>) uebernehmen:
|
||||||
|
kubectl -n wabenhain set image deploy/wabenhain-app \
|
||||||
|
app=git.encry.de/nick.rosenloecher/wabenhain:sha-<commit>@sha256:<digest>
|
||||||
|
kubectl -n wabenhain rollout status deploy/wabenhain-app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Danach dieselbe Zeile in `05-app-deployment.yaml` nachziehen und
|
||||||
|
committen, damit Repo und Cluster nicht auseinanderlaufen. Laufen die
|
||||||
|
CronJobs aus `07-backup-cronjob.yaml`, gehoert ihr `wabenhain-tools`-Image
|
||||||
|
auf denselben Build (sie nutzen denselben Prisma-Client und muessen zum
|
||||||
|
Datenbankschema passen).
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
@@ -110,13 +133,24 @@ Redeploy.
|
|||||||
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
|
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
|
||||||
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
|
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
|
||||||
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`. Die
|
||||||
`NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen.
|
`NetworkPolicy`s (`default-deny-ingress`, `allow-same-namespace`,
|
||||||
|
`allow-from-traefik`) und die Traefik-Middleware `cloudflare-only` liegen
|
||||||
|
nicht hier, sondern in den Cluster-Manifesten (Haertung vom 2026-10-12,
|
||||||
|
Ordner `rke2-cluster/`, nicht unter Git).
|
||||||
|
- Repo und Cluster weichen voneinander ab (Stand 2026-10-16): Im Cluster
|
||||||
|
sind `wabenhain-uploads` (5Gi) und `wabenhain-invoices` (50Gi)
|
||||||
|
`ReadWriteMany` auf SMB mit eigenen PersistentVolumes, hier stehen
|
||||||
|
`ReadWriteOnce`-Claims gegen die Default-StorageClass (5Gi/2Gi).
|
||||||
|
`STRIPE_PUBLISHABLE_KEY` liegt im Cluster im Secret, hier in der
|
||||||
|
ConfigMap (beides funktioniert, weil die App beide Quellen per `envFrom`
|
||||||
|
liest). Vor einem `kubectl apply` auf ein bestehendes Cluster die
|
||||||
|
Unterschiede pruefen.
|
||||||
- Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
|
- Die Postgres-/MySQL-StatefulSets sind Single-Instance **ohne
|
||||||
Replikation**. Eine taegliche Sicherung gibt es inzwischen
|
Replikation**. Eine taegliche Sicherung gibt es inzwischen
|
||||||
(`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
|
(`07-backup-cronjob.yaml`, siehe "Sicherung und Wiederherstellung"), sie
|
||||||
liegt aber im selben Cluster und ersetzt damit weder Replikation noch
|
liegt aber auf derselben Storage Box wie die Datenbank und ersetzt damit
|
||||||
eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
|
weder Replikation noch eine Auslagerung an einen anderen Ort. Faellt der Knoten aus, ist der
|
||||||
Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
|
Shop bis zur Wiederherstellung offline. Fuer produktiven Betrieb mit
|
||||||
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
|
hoeherem Anspruch einen Managed-Datenbank-Dienst oder einen dedizierten
|
||||||
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
|
DB-Operator (z.B. CloudNativePG) in Erwaegung ziehen — `DATABASE_URL` in
|
||||||
@@ -259,7 +293,8 @@ PersistentVolume:
|
|||||||
```yaml
|
```yaml
|
||||||
spec:
|
spec:
|
||||||
mountOptions:
|
mountOptions:
|
||||||
- vers=3.0
|
- vers=3.1.1
|
||||||
|
- seal # SMB-Verschluesselung, die Storage Box liegt im Internet
|
||||||
- uid=1001 # muss zum Containerbenutzer passen
|
- uid=1001 # muss zum Containerbenutzer passen
|
||||||
- gid=1001
|
- gid=1001
|
||||||
- dir_mode=0770
|
- dir_mode=0770
|
||||||
@@ -330,9 +365,14 @@ ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \
|
|||||||
|
|
||||||
## Sicherung und Wiederherstellung
|
## Sicherung und Wiederherstellung
|
||||||
|
|
||||||
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
|
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr (Zeitzone des
|
||||||
gzip-komprimierte `pg_dump`-Sicherung auf einem eigenen Volume ab und
|
Clusters, meist UTC) eine gzip-komprimierte `pg_dump`-Sicherung und ein
|
||||||
haelt sie 30 Tage vor. Ohne diese Sicherung liegen Bestellungen,
|
`rechnungen-<Zeitstempel>.tar.gz` der Rechnungsablage auf einem eigenen
|
||||||
|
Volume ab und haelt beides 30 Tage vor. Das Volume ist ein SMB-PV auf der
|
||||||
|
Storage Box (`//u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain`,
|
||||||
|
Unterordner `db-backup`). Im Cluster ist der Job seit 2026-10-10
|
||||||
|
angelegt; die letzten drei Laeufe waren erfolgreich, zuletzt am 2026-10-16
|
||||||
|
um 03:15 UTC. Ohne diese Sicherung liegen Bestellungen,
|
||||||
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
|
Rechnungen und Kundenkonten in einem einzelnen PersistentVolume ohne
|
||||||
jedes Netz — und Rechnungen unterliegen in Deutschland einer
|
jedes Netz — und Rechnungen unterliegen in Deutschland einer
|
||||||
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
|
zehnjaehrigen Aufbewahrungspflicht (§ 147 AO).
|
||||||
@@ -350,16 +390,14 @@ kubectl logs -n wabenhain -l job-name --tail=50 # Ausgabe des letzten Laufs
|
|||||||
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
|
kubectl create job --from=cronjob/wabenhain-db-backup manuell-$(date +%s) -n wabenhain
|
||||||
```
|
```
|
||||||
|
|
||||||
**Aus dem Cluster herausziehen.** Die Sicherung im Cluster schuetzt gegen
|
**Aus dem Cluster herausziehen.** Die Sicherung schuetzt gegen
|
||||||
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
|
Volume-Verlust und Bedienfehler, nicht gegen den Ausfall der Storage Box
|
||||||
Clusters oder Standorts. Deshalb regelmaessig (oder per eigenem Cron auf
|
oder des Standorts. Deshalb regelmaessig (oder per eigenem Cron auf einem
|
||||||
einem Rechner ausserhalb) kopieren:
|
Rechner ausserhalb) an einen zweiten Ort kopieren. Die Dateien liegen auf
|
||||||
|
der Storage Box unter `Server/Disks/Customer/Wabenhain/db-backup/` und
|
||||||
```bash
|
lassen sich dort direkt abholen (SMB, SFTP oder rsync der Storage Box). Ein
|
||||||
POD=$(kubectl get pod -n wabenhain -l job-name -o name | head -1)
|
`kubectl exec` in den Sicherungs-Pod geht nicht: der Pod ist nach dem Lauf
|
||||||
kubectl exec -n wabenhain "$POD" -- ls -1 /backup
|
beendet (`Completed`).
|
||||||
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
|
**Wiederherstellen.** Vorher die App anhalten, damit waehrend des
|
||||||
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
|
Einspielens niemand schreibt — `--clean --if-exists` im Dump loescht die
|
||||||
|
|||||||
@@ -25,6 +25,43 @@ const nextConfig: NextConfig = {
|
|||||||
// werden."
|
// werden."
|
||||||
serverExternalPackages: ["pdfkit", "ffmpeg-static"],
|
serverExternalPackages: ["pdfkit", "ffmpeg-static"],
|
||||||
|
|
||||||
|
images: {
|
||||||
|
// AVIF vor WebP. Next.js liefert das erste Format, das der Browser laut
|
||||||
|
// Accept-Header unterstuetzt; Browser ohne AVIF bekommen unveraendert
|
||||||
|
// WebP, ganz alte das Original.
|
||||||
|
//
|
||||||
|
// Am Hero-Bild der Startseite nachgemessen (1920x1280), mit den
|
||||||
|
// Einstellungen aus next/dist/server/image-optimizer.js — WebP q75 gegen
|
||||||
|
// AVIF q47 (Next.js skaliert die Qualitaet mit 50/80) bei effort 3:
|
||||||
|
// 640 px -43 %, 828 px -42 %, 1080 px -40 %, 1920 px -37 %.
|
||||||
|
//
|
||||||
|
// Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in
|
||||||
|
// derselben Messung gleichauf oder schneller als WebP (1920 px: 60 ms
|
||||||
|
// gegen 122 ms). Die frueher hier vermutete teure Erstkodierung gibt es
|
||||||
|
// in dieser Einstellung nicht.
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
|
||||||
|
// Ein Jahr statt der Next.js-Vorgabe von vier Wochen. Hochgeladene Bilder
|
||||||
|
// sind unveraenderlich: lib/uploads.ts legt jede Datei unter einem eigenen
|
||||||
|
// Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL.
|
||||||
|
// Ein kurzes TTL wuerde hier nur brauchbare Ergebnisse wegwerfen.
|
||||||
|
minimumCacheTTL: 60 * 60 * 24 * 365,
|
||||||
|
|
||||||
|
// Entspricht der Vorgabe von Next.js 16, die andere Werte bereits ablehnt
|
||||||
|
// (gegen die laufende Seite geprueft: ?q=42 antwortet mit 400) — aendert
|
||||||
|
// das Verhalten also nicht. Ausgeschrieben, damit eine spaetere
|
||||||
|
// Erweiterung der Liste eine bewusste Entscheidung ist: jeder zusaetzlich
|
||||||
|
// erlaubte Wert oeffnet einen eigenen Cache-Eintrag und eine eigene
|
||||||
|
// Kodierung je Bild, von aussen ueber die URL ausloesbar.
|
||||||
|
qualities: [75],
|
||||||
|
|
||||||
|
// Ebenfalls die Vorgabe, ausdruecklich festgehalten: ein SVG ist ein
|
||||||
|
// Dokument, das Skripte enthalten kann — same-origin ausgeliefert waere
|
||||||
|
// das ein XSS-Vektor. Diese Zeile soll verhindern, dass der Schalter
|
||||||
|
// spaeter beilaeufig umgelegt wird.
|
||||||
|
dangerouslyAllowSVG: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
|
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
|
||||||
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
|
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
|
||||||
// wiederholt mit der Cloud-Synchronisierung ("Failed to open database" /
|
// wiederholt mit der Cloud-Synchronisierung ("Failed to open database" /
|
||||||
@@ -35,6 +72,14 @@ const nextConfig: NextConfig = {
|
|||||||
turbopackFileSystemCacheForBuild: false,
|
turbopackFileSystemCacheForBuild: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Das Favicon ist im Admin austauschbar und wird von app/icon.tsx erzeugt;
|
||||||
|
// eine statische app/favicon.ico gibt es deshalb nicht mehr. Browser und
|
||||||
|
// Crawler, die ohne <link rel="icon"> direkt /favicon.ico abfragen,
|
||||||
|
// bekommen dasselbe Bild.
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/favicon.ico", destination: "/icon" }];
|
||||||
|
},
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
||||||
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
||||||
|
|||||||
+16
-6
@@ -7,6 +7,7 @@
|
|||||||
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
||||||
"start": "node scripts/generate-prisma-clients.mjs && next start",
|
"start": "node scripts/generate-prisma-clients.mjs && next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"typecheck": "node scripts/generate-prisma-clients.mjs && next typegen && tsc --noEmit",
|
||||||
"postinstall": "node scripts/generate-prisma-clients.mjs",
|
"postinstall": "node scripts/generate-prisma-clients.mjs",
|
||||||
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
||||||
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
||||||
@@ -19,27 +20,35 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"preflight": "tsx scripts/preflight.ts",
|
"preflight": "tsx scripts/preflight.ts",
|
||||||
"places:find": "tsx scripts/find-place-id.ts",
|
"places:find": "tsx scripts/find-place-id.ts",
|
||||||
"orders:release-stale": "tsx scripts/release-stale-orders.ts"
|
"orders:release-stale": "tsx scripts/release-stale-orders.ts",
|
||||||
|
"stock:apicore-sync": "tsx scripts/apicore-stock-sync.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-mariadb": "^7.10.0",
|
"@prisma/adapter-mariadb": "^7.10.0",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
|
"@react-three/drei": "10.0.6",
|
||||||
|
"@react-three/fiber": "9.1.2",
|
||||||
|
"@stripe/react-stripe-js": "^6.12.0",
|
||||||
|
"@stripe/stripe-js": "^9.17.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"fflate": "0.8.2",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"jose": "^6.2.8",
|
"jose": "^6.2.8",
|
||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.4",
|
||||||
"motion": "^13.0.0",
|
"motion": "^13.0.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.8",
|
||||||
"nodemailer": "^9.1.1",
|
"nodemailer": "^10.0.10",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
"prisma": "^7.10.0",
|
"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.4",
|
"sharp": "^0.35.5",
|
||||||
"stripe": "^22.4.0",
|
"stripe": "^22.4.0",
|
||||||
|
"three": "0.170.0",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -56,10 +65,11 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/three": "0.170.0",
|
||||||
"@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.5",
|
"eslint-config-next": "16.3.8",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4",
|
"tsx": "^4",
|
||||||
|
|||||||
Generated
+710
-198
File diff suppressed because it is too large.
Load diff
+7
-2
@@ -15,8 +15,13 @@ overrides:
|
|||||||
# lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine
|
# lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine
|
||||||
# eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in
|
# eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in
|
||||||
# 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. Seit 2026-10-14 >= 3.5.4
|
||||||
mariadb: '>=3.4.6'
|
# (neues High-Advisory fuer < 3.5.4, pnpm audit).
|
||||||
|
mariadb: '>=3.5.4'
|
||||||
|
# sharp < 0.35.5 und source-map-js < 1.2.2 (High-Advisories), beide
|
||||||
|
# transitiv ueber next; reine Patch-Spruenge.
|
||||||
|
sharp: '>=0.35.5'
|
||||||
|
source-map-js: '>=1.2.2'
|
||||||
# GHSA-rgwj-5xj2-c3m3 u.a.: mysql2 < 3.23.1. Kommt ausschliesslich ueber die
|
# GHSA-rgwj-5xj2-c3m3 u.a.: mysql2 < 3.23.1. Kommt ausschliesslich ueber die
|
||||||
# prisma-CLI herein (Migrationen/Generate), nicht ueber den Laufzeitpfad der
|
# prisma-CLI herein (Migrationen/Generate), nicht ueber den Laufzeitpfad der
|
||||||
# Anwendung — die nutzt bei DATABASE_PROVIDER=mysql den mariadb-Treiber
|
# Anwendung — die nutzt bei DATABASE_PROVIDER=mysql den mariadb-Treiber
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Archiv fuer Bestellungen, Retouren und Produkte (siehe lib/archive.ts).
|
||||||
|
--
|
||||||
|
-- archivedAt gesetzt = Eintrag erscheint im Admin-Tab "Archiv" statt in der
|
||||||
|
-- aktiven Liste. Die Automatik archiviert abgeschlossene Eintraege nach einem
|
||||||
|
-- Monat ohne Bearbeitung (Produkte: Bestand 0) und holt sie zurueck, sobald
|
||||||
|
-- sie wieder offen sind. archivedManually markiert Eintraege, die jemand
|
||||||
|
-- von Hand archiviert hat — die fasst die Automatik nicht an.
|
||||||
|
--
|
||||||
|
-- Nullable bzw. mit Default: bestehende Zeilen gelten als aktiv; der erste
|
||||||
|
-- Aufruf der jeweiligen Admin-Liste archiviert faellige Altbestaende.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "archivedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "return_requests" ADD COLUMN "archivedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ADD COLUMN "archivedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "orders_archivedAt_idx" ON "orders"("archivedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "return_requests_archivedAt_idx" ON "return_requests"("archivedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "products_archivedAt_idx" ON "products"("archivedAt");
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Elektronische Widerrufsfunktion (§ 356a BGB, Pflicht seit 19.06.2026)
|
||||||
|
-- und Nachweis der Newsletter-Einwilligung (Art. 7 Abs. 1 DSGVO).
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "withdrawal_declarations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"orderNumber" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"message" TEXT,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||||
|
"orderId" TEXT,
|
||||||
|
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"confirmationSentAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "withdrawal_declarations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "withdrawal_declarations_receivedAt_idx" ON "withdrawal_declarations"("receivedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "withdrawal_declarations" ADD CONSTRAINT "withdrawal_declarations_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
-- Nullable: fuer bestehende Abonnements ist der Zeitpunkt nicht bekannt.
|
||||||
|
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Bearbeitungsstand der Widerrufserklaerungen: null = offen. Die Zahl offener
|
||||||
|
-- Widerrufe steht im Admin-Menue, damit die 14-Tage-Erstattungsfrist
|
||||||
|
-- (§ 357 Abs. 1 BGB) nicht uebersehen wird.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "withdrawal_declarations" ADD COLUMN "processedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "withdrawal_declarations_processedAt_idx" ON "withdrawal_declarations"("processedAt");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Herkunftsschluessel fuer den ApiCore-Chargen-Sync: ID der Abfuellung
|
||||||
|
-- (BottlingBatch). Bisher wurde eine Charge nur ueber die Losnummer (code)
|
||||||
|
-- wiedererkannt — wird die Losnummer in ApiCore korrigiert, entstand beim
|
||||||
|
-- naechsten Lauf eine zweite Charge. Nullable: manuell angelegte Chargen
|
||||||
|
-- haben keine Herkunft in ApiCore, bestehende Zeilen bleiben gueltig. Schon
|
||||||
|
-- synchronisierte Chargen erhalten die ID beim naechsten Lauf ueber ihre
|
||||||
|
-- Losnummer.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "batches" ADD COLUMN "apicoreBottlingId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "batches_apicoreBottlingId_key" ON "batches"("apicoreBottlingId");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Abgelehnte Bewertungen werden nicht mehr geloescht, sondern markiert und
|
||||||
|
-- im Archiv unter "Abgelehnt" gefuehrt. null = nicht abgelehnt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reviews" ADD COLUMN "rejectedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Mehrere Ernten derselben Sorte im selben Jahr zulassen.
|
||||||
|
--
|
||||||
|
-- Bisher erzwang "harvests_year_type_key" genau eine Ernte je (Jahr, Sorte).
|
||||||
|
-- Eine Imkerei schleudert dieselbe Sorte aber oft mehrmals im Jahr; der
|
||||||
|
-- ApiCore-Sync scheiterte daran mit P2002. Eindeutig ist ab jetzt nur noch
|
||||||
|
-- der Slug (oeffentliche Adresse /ernten/<slug>).
|
||||||
|
--
|
||||||
|
-- Der Sync erkannte importierte Ernten bisher am Slug "apicore-<ID>". Diese
|
||||||
|
-- ID wandert in eine eigene Spalte, damit der Slug lesbar werden darf.
|
||||||
|
-- Keine Zeile wird geloescht; die Aenderung ist rein additiv bis auf den
|
||||||
|
-- entfernten Unique-Index.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "harvests" ADD COLUMN "apicoreHarvestId" TEXT;
|
||||||
|
|
||||||
|
-- Backfill: bisherige Sync-Ernten behalten ihre ApiCore-Zuordnung.
|
||||||
|
UPDATE "harvests"
|
||||||
|
SET "apicoreHarvestId" = substring("slug" from 9)
|
||||||
|
WHERE "slug" LIKE 'apicore-%';
|
||||||
|
|
||||||
|
-- Von Hand angelegte Ernten bekamen ihren Slug bisher ohne Umlaut-Behandlung
|
||||||
|
-- ("Frühtracht" 2026 -> "fr-htracht-2026"). Bis jetzt war das unsichtbar;
|
||||||
|
-- ab jetzt ist der Slug die oeffentliche Adresse. Genau diese Form wird auf
|
||||||
|
-- die Schreibweise von lib/slugify.ts gebracht ("fruehtracht-2026") — nur,
|
||||||
|
-- wenn sie sich unterscheidet und die neue Adresse noch frei ist. Sonst
|
||||||
|
-- bleibt der alte Slug: er funktioniert, ist nur weniger schoen.
|
||||||
|
WITH renamed AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
trim(both '-' from regexp_replace(
|
||||||
|
replace(replace(replace(replace(lower("type"), 'ä', 'ae'), 'ö', 'oe'), 'ü', 'ue'), 'ß', 'ss'),
|
||||||
|
'[^a-z0-9]+', '-', 'g'
|
||||||
|
)) || '-' || "year" AS "newSlug"
|
||||||
|
FROM "harvests"
|
||||||
|
WHERE "apicoreHarvestId" IS NULL
|
||||||
|
AND "slug" = regexp_replace(lower("type"), '[^a-z0-9]+', '-', 'g') || '-' || "year"
|
||||||
|
)
|
||||||
|
UPDATE "harvests" h
|
||||||
|
SET "slug" = r."newSlug"
|
||||||
|
FROM renamed r
|
||||||
|
WHERE h."id" = r."id"
|
||||||
|
AND r."newSlug" <> h."slug"
|
||||||
|
AND r."newSlug" <> ('-' || h."year")
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "harvests" o WHERE o."slug" = r."newSlug")
|
||||||
|
-- z.B. "Frühtracht" und "Fruehtracht" im selben Jahr: beide ergaeben
|
||||||
|
-- denselben Slug, der Unique-Index liesse die Migration scheitern.
|
||||||
|
AND (SELECT count(*) FROM renamed r2 WHERE r2."newSlug" = r."newSlug") = 1;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_apicoreHarvestId_key" ON "harvests"("apicoreHarvestId");
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "harvests_year_type_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "harvests_year_type_idx" ON "harvests"("year", "type");
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Bestandsabgleich mit ApiCore (ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
|
||||||
|
-- ApiCore fuehrt den Restbestand je Charge; Wabenhain meldet bezahlte
|
||||||
|
-- Verkaeufe und Rueckbuchungen ueber eine Warteschlange und gleicht seinen
|
||||||
|
-- Shop-Bestand daran ab. Rein additiv.
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ProductCategory" ADD VALUE 'SONSTIGES';
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ApiCoreStockEventKind" AS ENUM ('SALE', 'RETURN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ADD COLUMN "apicoreProductId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "order_items" ADD COLUMN "apicoreAllocations" JSONB;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "apicore_stock_events" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kind" "ApiCoreStockEventKind" NOT NULL,
|
||||||
|
"orderItemId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"apicoreBottlingId" TEXT,
|
||||||
|
"apicoreProductId" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"reversesEventId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"rejectedAt" TIMESTAMP(3),
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "apicore_stock_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "products_apicoreProductId_key" ON "products"("apicoreProductId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "apicore_stock_events_sentAt_rejectedAt_nextAttemptAt_idx" ON "apicore_stock_events"("sentAt", "rejectedAt", "nextAttemptAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "apicore_stock_events_orderItemId_idx" ON "apicore_stock_events"("orderItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "apicore_stock_events_productId_idx" ON "apicore_stock_events"("productId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_reversesEventId_fkey" FOREIGN KEY ("reversesEventId") REFERENCES "apicore_stock_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Genau ein ApiCore-Ziel je Buchung.
|
||||||
|
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_exactly_one_target"
|
||||||
|
CHECK (("apicoreBottlingId" IS NULL) <> ("apicoreProductId" IS NULL));
|
||||||
|
ALTER TABLE "apicore_stock_events" ADD CONSTRAINT "apicore_stock_events_quantity_positive"
|
||||||
|
CHECK ("quantity" > 0);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Stornorechnungen: Gegenbeleg mit eigenem Nummernkreis (ST-JJJJ-NNNN) statt
|
||||||
|
-- Loeschen oder Aendern einer Rechnung. Rein additiv.
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "cancellation_counters" (
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"lastNumber" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "cancellation_counters_pkey" PRIMARY KEY ("year")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "cancellation_invoices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"invoiceId" TEXT NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"pdfPath" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "cancellation_invoices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "cancellation_invoices_invoiceId_key" ON "cancellation_invoices"("invoiceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "cancellation_invoices_number_key" ON "cancellation_invoices"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "cancellation_invoices_issuedAt_idx" ON "cancellation_invoices"("issuedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "cancellation_invoices" ADD CONSTRAINT "cancellation_invoices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
-- Bienenpatenschaft: Angebote, Patenschaften mit Urkunden-Revisionen,
|
||||||
|
-- Uebertragungen, Abos (Stripe Billing) mit Zahlungen und Lieferungen,
|
||||||
|
-- Erntefenster und Treuestufen. Rein additiv; die beiden neuen Enum-Werte
|
||||||
|
-- (PATENSCHAFT, DIGITAL) werden in dieser Migration nicht benutzt, ALTER TYPE
|
||||||
|
-- ADD VALUE ist deshalb auch innerhalb der Migrationstransaktion zulaessig.
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SponsorshipOfferKind" AS ENUM ('EINMALIG', 'ABO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SponsorshipCycle" AS ENUM ('VIERTELJAEHRLICH', 'ERNTE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MedalTier" AS ENUM ('BRONZE', 'SILBER', 'GOLD', 'ABO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SponsorshipStatus" AS ENUM ('AUSSTEHEND', 'FEHLER', 'BEREIT', 'GEDRUCKT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubscriptionStatus" AS ENUM ('AUSSTEHEND', 'AKTIV', 'IM_VERZUG', 'GEKUENDIGT', 'BEENDET');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DeliveryStatus" AS ENUM ('ANGELEGT', 'ZURUECKGESTELLT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LoyaltyKind" AS ENUM ('STAMMKUNDE', 'ABO');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ProductCategory" ADD VALUE 'PATENSCHAFT';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "FulfillmentMethod" ADD VALUE 'DIGITAL';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "sepaBlockedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "stripeCustomerId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "order_items" ADD COLUMN "personalization" JSONB,
|
||||||
|
ADD COLUMN "sponsorshipOfferId" TEXT,
|
||||||
|
ADD COLUMN "sponsorshipRole" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorship_offers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"kind" "SponsorshipOfferKind" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"descriptionEn" TEXT,
|
||||||
|
"features" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"featuresEn" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isPhysical" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"productId" TEXT,
|
||||||
|
"honeyProductId" TEXT,
|
||||||
|
"honeyJars" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"honeyShareCentsPerJar" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"honeyShareCentsPerMonth" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"cycle" "SponsorshipCycle",
|
||||||
|
"maxActiveSubscriptions" INTEGER,
|
||||||
|
"stripeProductId" TEXT,
|
||||||
|
"stripePriceId" TEXT,
|
||||||
|
"stripePriceAmountCents" INTEGER,
|
||||||
|
"medalTier" "MedalTier" NOT NULL DEFAULT 'BRONZE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorship_offers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorships" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"offerId" TEXT NOT NULL,
|
||||||
|
"orderItemId" TEXT,
|
||||||
|
"subscriptionId" TEXT,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||||
|
"startDate" DATE NOT NULL,
|
||||||
|
"endDate" DATE,
|
||||||
|
"status" "SponsorshipStatus" NOT NULL DEFAULT 'AUSSTEHEND',
|
||||||
|
"currentRevision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"pdfAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"mailSentAt" TIMESTAMP(3),
|
||||||
|
"printedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorships_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorship_revisions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sponsorshipId" TEXT NOT NULL,
|
||||||
|
"revision" INTEGER NOT NULL,
|
||||||
|
"personalization" JSONB NOT NULL,
|
||||||
|
"term" JSONB NOT NULL,
|
||||||
|
"text" JSONB NOT NULL,
|
||||||
|
"templateVersion" TEXT NOT NULL,
|
||||||
|
"pdfPath" TEXT NOT NULL,
|
||||||
|
"pdfSha256" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdByLabel" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorship_revisions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorship_counters" (
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"lastNumber" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorship_counters_pkey" PRIMARY KEY ("year")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorship_transfers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sponsorshipId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"claimedById" TEXT,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorship_transfers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sponsorship_subscriptions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"offerId" TEXT NOT NULL,
|
||||||
|
"status" "SubscriptionStatus" NOT NULL DEFAULT 'AUSSTEHEND',
|
||||||
|
"personalization" JSONB NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||||
|
"stripeCheckoutSessionId" TEXT,
|
||||||
|
"stripeSubscriptionId" TEXT,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"currentPeriodEnd" TIMESTAMP(3),
|
||||||
|
"cancelAt" TIMESTAMP(3),
|
||||||
|
"endedAt" TIMESTAMP(3),
|
||||||
|
"delinquentSince" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sponsorship_subscriptions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "subscription_payments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT NOT NULL,
|
||||||
|
"stripeInvoiceId" TEXT NOT NULL,
|
||||||
|
"stripePaymentIntentId" TEXT,
|
||||||
|
"paymentMethodType" TEXT,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"paidAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"periodStart" TIMESTAMP(3) NOT NULL,
|
||||||
|
"periodEnd" TIMESTAMP(3) NOT NULL,
|
||||||
|
"countsFrom" TIMESTAMP(3) NOT NULL,
|
||||||
|
"reversedAt" TIMESTAMP(3),
|
||||||
|
"deliveryId" TEXT,
|
||||||
|
"orderId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "subscription_payments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "subscription_deliveries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subscriptionId" TEXT NOT NULL,
|
||||||
|
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"jars" INTEGER NOT NULL,
|
||||||
|
"status" "DeliveryStatus" NOT NULL,
|
||||||
|
"orderId" TEXT,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "subscription_deliveries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "harvest_windows" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"offerId" TEXT NOT NULL,
|
||||||
|
"startsOn" DATE NOT NULL,
|
||||||
|
"endsOn" DATE NOT NULL,
|
||||||
|
"plannedJars" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "harvest_windows_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "loyalty_levels" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kind" "LoyaltyKind" NOT NULL,
|
||||||
|
"threshold" INTEGER,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "loyalty_levels_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_offers_slug_key" ON "sponsorship_offers"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_offers_productId_key" ON "sponsorship_offers"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorship_offers_kind_isActive_sortOrder_idx" ON "sponsorship_offers"("kind", "isActive", "sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorship_offers_honeyProductId_idx" ON "sponsorship_offers"("honeyProductId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorships_number_key" ON "sponsorships"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorships_orderItemId_key" ON "sponsorships"("orderItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorships_subscriptionId_key" ON "sponsorships"("subscriptionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorships_ownerId_idx" ON "sponsorships"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorships_status_nextAttemptAt_idx" ON "sponsorships"("status", "nextAttemptAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorships_offerId_idx" ON "sponsorships"("offerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_revisions_sponsorshipId_revision_key" ON "sponsorship_revisions"("sponsorshipId", "revision");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_transfers_tokenHash_key" ON "sponsorship_transfers"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorship_transfers_sponsorshipId_idx" ON "sponsorship_transfers"("sponsorshipId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeCheckoutSessionId_key" ON "sponsorship_subscriptions"("stripeCheckoutSessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sponsorship_subscriptions_stripeSubscriptionId_key" ON "sponsorship_subscriptions"("stripeSubscriptionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorship_subscriptions_customerId_idx" ON "sponsorship_subscriptions"("customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sponsorship_subscriptions_offerId_status_idx" ON "sponsorship_subscriptions"("offerId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "subscription_payments_stripeInvoiceId_key" ON "subscription_payments"("stripeInvoiceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "subscription_payments_orderId_key" ON "subscription_payments"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "subscription_payments_subscriptionId_paidAt_idx" ON "subscription_payments"("subscriptionId", "paidAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "subscription_deliveries_orderId_key" ON "subscription_deliveries"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "subscription_deliveries_subscriptionId_dueAt_idx" ON "subscription_deliveries"("subscriptionId", "dueAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "harvest_windows_offerId_startsOn_idx" ON "harvest_windows"("offerId", "startsOn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "loyalty_levels_kind_threshold_key" ON "loyalty_levels"("kind", "threshold");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "customers_stripeCustomerId_key" ON "customers"("stripeCustomerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_offers" ADD CONSTRAINT "sponsorship_offers_honeyProductId_fkey" FOREIGN KEY ("honeyProductId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "order_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorships" ADD CONSTRAINT "sponsorships_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_revisions" ADD CONSTRAINT "sponsorship_revisions_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_transfers" ADD CONSTRAINT "sponsorship_transfers_sponsorshipId_fkey" FOREIGN KEY ("sponsorshipId") REFERENCES "sponsorships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sponsorship_subscriptions" ADD CONSTRAINT "sponsorship_subscriptions_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "subscription_deliveries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subscription_payments" ADD CONSTRAINT "subscription_payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "sponsorship_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "subscription_deliveries" ADD CONSTRAINT "subscription_deliveries_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "harvest_windows" ADD CONSTRAINT "harvest_windows_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "sponsorship_offers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Widerrufene Patenschaft: Einmalkauf storniert oder voll erstattet
|
||||||
|
-- (Stornorechnung ausgestellt). Rein additiv; der neue Wert wird in dieser
|
||||||
|
-- Migration nicht benutzt, ALTER TYPE ADD VALUE ist deshalb auch innerhalb
|
||||||
|
-- der Migrationstransaktion zulaessig.
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "SponsorshipStatus" ADD VALUE 'WIDERRUFEN';
|
||||||
+545
-2
@@ -76,6 +76,17 @@ model Customer {
|
|||||||
wishlistItems WishlistItem[]
|
wishlistItems WishlistItem[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
|
/// Stripe-Kunde fuer Abos (Stripe Billing). Erst beim ersten Abo-Abschluss
|
||||||
|
/// angelegt; Einmalkaeufe laufen weiter ohne Stripe-Kunde.
|
||||||
|
stripeCustomerId String? @unique
|
||||||
|
/// Gesetzt nach einer SEPA-Rueckbuchung: neue Abos dieses Kontos nur noch
|
||||||
|
/// mit Karte oder Wallet (siehe lib/sponsorship-subscription). Der Admin
|
||||||
|
/// kann die Sperre aufheben.
|
||||||
|
sepaBlockedAt DateTime?
|
||||||
|
|
||||||
|
sponsorships Sponsorship[]
|
||||||
|
subscriptions SponsorshipSubscription[]
|
||||||
|
|
||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +159,14 @@ model Address {
|
|||||||
model Harvest {
|
model Harvest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
year Int
|
year Int
|
||||||
/// z.B. "Fruehtracht", "Sommertracht"
|
/// z.B. "Fruehtracht", "Sommertracht". Bewusst NICHT eindeutig je Jahr:
|
||||||
|
/// dieselbe Sorte wird oft mehrmals im Jahr geerntet.
|
||||||
type String
|
type String
|
||||||
|
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
/// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs
|
||||||
|
/// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten.
|
||||||
|
apicoreHarvestId String? @unique
|
||||||
harvestDate DateTime
|
harvestDate DateTime
|
||||||
location String @default("Altenmuenster")
|
location String @default("Altenmuenster")
|
||||||
description String
|
description String
|
||||||
@@ -170,7 +186,8 @@ model Harvest {
|
|||||||
batches Batch[]
|
batches Batch[]
|
||||||
products Product[]
|
products Product[]
|
||||||
|
|
||||||
@@unique([year, type])
|
/// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
|
||||||
|
@@index([year, type])
|
||||||
@@map("harvests")
|
@@map("harvests")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +195,10 @@ model Batch {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// z.B. FH-26-01
|
/// z.B. FH-26-01
|
||||||
code String @unique
|
code String @unique
|
||||||
|
/// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
|
||||||
|
/// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
|
||||||
|
/// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
|
||||||
|
apicoreBottlingId String? @unique
|
||||||
harvestId String
|
harvestId String
|
||||||
fillDate DateTime
|
fillDate DateTime
|
||||||
bestBeforeDate DateTime
|
bestBeforeDate DateTime
|
||||||
@@ -202,6 +223,11 @@ enum ProductCategory {
|
|||||||
HONIG
|
HONIG
|
||||||
KERZEN
|
KERZEN
|
||||||
PROPOLIS
|
PROPOLIS
|
||||||
|
SONSTIGES
|
||||||
|
/// Traegerprodukt einer Einmal-Patenschaft (siehe SponsorshipOffer). Nicht
|
||||||
|
/// im Shop gelistet; es haelt Preis, Steuersatz und Rechnungsposition, damit
|
||||||
|
/// Warenkorb, Checkout und Rechnung keinen Sonderweg brauchen.
|
||||||
|
PATENSCHAFT
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
@@ -225,6 +251,12 @@ model Product {
|
|||||||
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
|
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
|
||||||
/// richten.
|
/// richten.
|
||||||
apicoreBottlingId String? @unique
|
apicoreBottlingId String? @unique
|
||||||
|
/// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis-
|
||||||
|
/// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und
|
||||||
|
/// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt
|
||||||
|
/// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt;
|
||||||
|
/// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock).
|
||||||
|
apicoreProductId String? @unique
|
||||||
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
|
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
|
||||||
harvestId String?
|
harvestId String?
|
||||||
batchId String?
|
batchId String?
|
||||||
@@ -255,6 +287,13 @@ model Product {
|
|||||||
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
|
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
|
||||||
images Json @default("[]")
|
images Json @default("[]")
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
|
||||||
|
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
|
||||||
|
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
|
||||||
|
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
|
||||||
|
/// rueckgaengig machen darf.
|
||||||
|
archivedAt DateTime?
|
||||||
|
archivedManually Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -263,9 +302,16 @@ model Product {
|
|||||||
inventory Inventory?
|
inventory Inventory?
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
|
apicoreStockEvents ApiCoreStockEvent[]
|
||||||
wishlistItems WishlistItem[]
|
wishlistItems WishlistItem[]
|
||||||
stockNotifications StockNotification[]
|
stockNotifications StockNotification[]
|
||||||
|
|
||||||
|
/// Einmal-Patenschaft, deren Traegerprodukt dieses Produkt ist.
|
||||||
|
sponsorshipOffer SponsorshipOffer? @relation("SponsorshipOfferProduct")
|
||||||
|
/// Patenschafts-Angebote, deren Honig aus diesem Produkt kommt.
|
||||||
|
sponsorshipHoneyFor SponsorshipOffer[] @relation("SponsorshipOfferHoney")
|
||||||
|
|
||||||
|
@@index([archivedAt])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +360,10 @@ model Review {
|
|||||||
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
||||||
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
||||||
isApproved Boolean @default(false)
|
isApproved Boolean @default(false)
|
||||||
|
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
|
||||||
|
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
|
||||||
|
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
|
||||||
|
rejectedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -354,6 +404,9 @@ enum OrderStatus {
|
|||||||
enum FulfillmentMethod {
|
enum FulfillmentMethod {
|
||||||
VERSAND
|
VERSAND
|
||||||
ABHOLUNG
|
ABHOLUNG
|
||||||
|
/// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung):
|
||||||
|
/// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt.
|
||||||
|
DIGITAL
|
||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
@@ -412,6 +465,14 @@ model Order {
|
|||||||
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
|
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
|
||||||
giftMessage String?
|
giftMessage String?
|
||||||
|
|
||||||
|
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
|
||||||
|
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
|
||||||
|
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
|
||||||
|
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
|
||||||
|
/// rueckgaengig machen darf.
|
||||||
|
archivedAt DateTime?
|
||||||
|
archivedManually Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -423,6 +484,11 @@ model Order {
|
|||||||
pickup Pickup?
|
pickup Pickup?
|
||||||
invoice Invoice?
|
invoice Invoice?
|
||||||
returnRequest ReturnRequest?
|
returnRequest ReturnRequest?
|
||||||
|
withdrawalDeclarations WithdrawalDeclaration[]
|
||||||
|
/// Gesetzt, wenn diese Bestellung eine bezahlte Abo-Monatsrechnung abbildet.
|
||||||
|
subscriptionPayment SubscriptionPayment?
|
||||||
|
/// Gesetzt, wenn diese Bestellung eine Abo-Honiglieferung ist (0 €).
|
||||||
|
subscriptionDelivery SubscriptionDelivery?
|
||||||
|
|
||||||
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
|
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
|
||||||
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
|
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
|
||||||
@@ -434,6 +500,8 @@ model Order {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
|
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
|
||||||
@@index([discountCodeId])
|
@@index([discountCodeId])
|
||||||
|
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
|
||||||
|
@@index([archivedAt])
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,11 +519,19 @@ model ReturnRequest {
|
|||||||
orderId String @unique
|
orderId String @unique
|
||||||
reason String
|
reason String
|
||||||
status ReturnStatus @default(ANGEFRAGT)
|
status ReturnStatus @default(ANGEFRAGT)
|
||||||
|
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
|
||||||
|
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
|
||||||
|
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
|
||||||
|
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
|
||||||
|
/// rueckgaengig machen darf.
|
||||||
|
archivedAt DateTime?
|
||||||
|
archivedManually Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([archivedAt])
|
||||||
@@map("return_requests")
|
@@map("return_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,9 +560,29 @@ model OrderItem {
|
|||||||
/// feststellbar ist, welcher Satz gegolten haette.
|
/// feststellbar ist, welcher Satz gegolten haette.
|
||||||
vatRatePercent Int?
|
vatRatePercent Int?
|
||||||
|
|
||||||
|
/// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie
|
||||||
|
/// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer
|
||||||
|
/// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht
|
||||||
|
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
|
||||||
|
apicoreAllocations Json?
|
||||||
|
|
||||||
|
/// Personalisierung einer Patenschaft, wie sie beim Kauf bestaetigt wurde
|
||||||
|
/// (lib/certificate/schema.ts), samt Vorlagenversion. Nur an der
|
||||||
|
/// Hauptposition (sponsorshipRole "MAIN"); daraus entsteht nach der Zahlung
|
||||||
|
/// die erste Revision der Urkunde.
|
||||||
|
personalization Json?
|
||||||
|
/// Angebot, zu dem diese Position gehoert — an Haupt- UND Honigposition,
|
||||||
|
/// damit Warenkorb, Rechnung und Admin sie als ein Paket zeigen koennen.
|
||||||
|
sponsorshipOfferId String?
|
||||||
|
/// "MAIN" (Patenschaft, Regelsteuersatz) oder "HONEY" (Honiganteil des
|
||||||
|
/// Pakets zum Honig-Satz). Null bei gewoehnlichen Positionen.
|
||||||
|
sponsorshipRole String?
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
sponsorship Sponsorship?
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||||
|
apicoreStockEvents ApiCoreStockEvent[]
|
||||||
|
|
||||||
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
|
||||||
/// Rechnung) ueber die order.items-Relation abgefragt.
|
/// Rechnung) ueber die order.items-Relation abgefragt.
|
||||||
@@ -510,6 +606,55 @@ model OrderItem {
|
|||||||
@@map("order_items")
|
@@map("order_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bestandsabgleich mit ApiCore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum ApiCoreStockEventKind {
|
||||||
|
SALE
|
||||||
|
RETURN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock).
|
||||||
|
///
|
||||||
|
/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung
|
||||||
|
/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per
|
||||||
|
/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade
|
||||||
|
/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht
|
||||||
|
/// jede id nur einmal, Wiederholungen sind also gefahrlos.
|
||||||
|
model ApiCoreStockEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kind ApiCoreStockEventKind
|
||||||
|
orderItemId String
|
||||||
|
/// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt.
|
||||||
|
productId String
|
||||||
|
/// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber
|
||||||
|
/// dessen Herstellungschargen).
|
||||||
|
apicoreBottlingId String?
|
||||||
|
apicoreProductId String?
|
||||||
|
quantity Int
|
||||||
|
/// Bei RETURN: der Verkauf, der zurueckgebucht wird.
|
||||||
|
reversesEventId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// Von ApiCore bestaetigt (neu gebucht oder schon bekannt).
|
||||||
|
sentAt DateTime?
|
||||||
|
/// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es.
|
||||||
|
rejectedAt DateTime?
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastError String?
|
||||||
|
nextAttemptAt DateTime @default(now())
|
||||||
|
|
||||||
|
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict)
|
||||||
|
reversedBy ApiCoreStockEvent[] @relation("StockEventReversal")
|
||||||
|
|
||||||
|
@@index([sentAt, rejectedAt, nextAttemptAt])
|
||||||
|
@@index([orderItemId])
|
||||||
|
@@index([productId])
|
||||||
|
@@map("apicore_stock_events")
|
||||||
|
}
|
||||||
|
|
||||||
enum PaymentStatus {
|
enum PaymentStatus {
|
||||||
PENDING
|
PENDING
|
||||||
SUCCEEDED
|
SUCCEEDED
|
||||||
@@ -606,10 +751,46 @@ model Invoice {
|
|||||||
pdfPath String?
|
pdfPath String?
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
cancellation CancellationInvoice?
|
||||||
|
|
||||||
@@map("invoices")
|
@@map("invoices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN),
|
||||||
|
/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie
|
||||||
|
/// InvoiceCounter.
|
||||||
|
model CancellationCounter {
|
||||||
|
year Int @id
|
||||||
|
lastNumber Int @default(0)
|
||||||
|
|
||||||
|
@@map("cancellation_counters")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird
|
||||||
|
/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung
|
||||||
|
/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit
|
||||||
|
/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts).
|
||||||
|
/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als
|
||||||
|
/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf
|
||||||
|
/// "Credit Note Issued".
|
||||||
|
model CancellationInvoice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
invoiceId String @unique
|
||||||
|
number String @unique
|
||||||
|
issuedAt DateTime @default(now())
|
||||||
|
/// Betrag der aufgehobenen Rechnung (brutto, positiv).
|
||||||
|
totalCents Int
|
||||||
|
/// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe).
|
||||||
|
reason String
|
||||||
|
/// Wie Invoice.pdfPath, relativ zu INVOICE_DIR.
|
||||||
|
pdfPath String?
|
||||||
|
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([issuedAt])
|
||||||
|
@@map("cancellation_invoices")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Marketing / Kontakt
|
// Marketing / Kontakt
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -623,6 +804,9 @@ model NewsletterSubscriber {
|
|||||||
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
|
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
|
||||||
confirmationToken String? @unique
|
confirmationToken String? @unique
|
||||||
subscribedAt DateTime @default(now())
|
subscribedAt DateTime @default(now())
|
||||||
|
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
|
||||||
|
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
|
||||||
|
confirmedAt DateTime?
|
||||||
unsubscribedAt DateTime?
|
unsubscribedAt DateTime?
|
||||||
|
|
||||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||||
@@ -779,3 +963,362 @@ model BusinessInquiry {
|
|||||||
|
|
||||||
@@map("business_inquiries")
|
@@map("business_inquiries")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
|
||||||
|
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
|
||||||
|
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
|
||||||
|
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
|
||||||
|
/// automatische Zuordnung, wenn beides passt.
|
||||||
|
model WithdrawalDeclaration {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
|
||||||
|
orderNumber String
|
||||||
|
email String
|
||||||
|
message String?
|
||||||
|
locale String @default("de")
|
||||||
|
orderId String?
|
||||||
|
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
|
||||||
|
receivedAt DateTime @default(now())
|
||||||
|
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
|
||||||
|
confirmationSentAt DateTime?
|
||||||
|
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
|
||||||
|
processedAt DateTime?
|
||||||
|
|
||||||
|
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([receivedAt])
|
||||||
|
@@index([processedAt])
|
||||||
|
@@map("withdrawal_declarations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bienenpatenschaft (docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum SponsorshipOfferKind {
|
||||||
|
EINMALIG
|
||||||
|
ABO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SponsorshipCycle {
|
||||||
|
/// Lieferanspruch nach jeweils drei bezahlten Monaten.
|
||||||
|
VIERTELJAEHRLICH
|
||||||
|
/// Lieferung in den Erntefenstern, frueheste nach sechs bezahlten Monaten
|
||||||
|
/// seit der letzten Lieferung.
|
||||||
|
ERNTE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Praegung der Patenschafts-Medaille (lib/medals).
|
||||||
|
enum MedalTier {
|
||||||
|
BRONZE
|
||||||
|
SILBER
|
||||||
|
GOLD
|
||||||
|
ABO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SponsorshipStatus {
|
||||||
|
/// Bezahlt, PDF noch nicht erzeugt oder Mail noch nicht versendet.
|
||||||
|
AUSSTEHEND
|
||||||
|
/// Alle automatischen Versuche aufgebraucht — braucht den Admin.
|
||||||
|
FEHLER
|
||||||
|
/// PDF liegt vor, Mail ist raus.
|
||||||
|
BEREIT
|
||||||
|
/// Nur physische Pakete: Urkunde gedruckt.
|
||||||
|
GEDRUCKT
|
||||||
|
/// Einmalkauf storniert oder voll erstattet (Stornorechnung): keine
|
||||||
|
/// Downloads, keine Uebertragung, keine Ausstellung mehr.
|
||||||
|
WIDERRUFEN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
/// Checkout gestartet, bei Stripe noch nicht abgeschlossen.
|
||||||
|
AUSSTEHEND
|
||||||
|
AKTIV
|
||||||
|
/// Zahlung offen oder zurueckgebucht — keine Lieferungen, Stufe ruht.
|
||||||
|
IM_VERZUG
|
||||||
|
/// Gekuendigt, laeuft bis zum Ende des bezahlten Monats.
|
||||||
|
GEKUENDIGT
|
||||||
|
BEENDET
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeliveryStatus {
|
||||||
|
/// Bestellung fuer den Versand angelegt.
|
||||||
|
ANGELEGT
|
||||||
|
/// Faellig, aber ohne Bestand oder Anspruch zurueckgestellt.
|
||||||
|
ZURUECKGESTELLT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoyaltyKind {
|
||||||
|
STAMMKUNDE
|
||||||
|
ABO
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein im Admin pflegbares Patenschafts-Angebot (Einmalpaket oder Abo).
|
||||||
|
model SponsorshipOffer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
kind SponsorshipOfferKind
|
||||||
|
name String
|
||||||
|
nameEn String?
|
||||||
|
description String @db.Text
|
||||||
|
descriptionEn String? @db.Text
|
||||||
|
/// Leistungsliste als JSON-Array von Strings (lib/json-array.ts).
|
||||||
|
features Json @default("[]")
|
||||||
|
featuresEn Json @default("[]")
|
||||||
|
/// EINMALIG: Bruttopreis des ganzen Pakets. ABO: Monatspreis brutto.
|
||||||
|
priceCents Int
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
/// Mit gedruckter Urkunde, Honig, Grusskarte und Versand.
|
||||||
|
isPhysical Boolean @default(false)
|
||||||
|
/// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb,
|
||||||
|
/// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil.
|
||||||
|
productId String? @unique
|
||||||
|
/// UNGENUTZT: Pakete und Abos bekommen ihren Honig automatisch (nie cremig,
|
||||||
|
/// neueste Ernte, genug Bestand — lib/sponsorship/honey-selection.ts). Die
|
||||||
|
/// Spalte wird weder gelesen noch geschrieben und bleibt nur stehen, damit
|
||||||
|
/// die laufende Produktion keine Migration braucht; Altwerte sind bedeutungslos.
|
||||||
|
honeyProductId String?
|
||||||
|
/// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung.
|
||||||
|
honeyJars Int @default(0)
|
||||||
|
/// EINMALIG: Honiganteil des Paketpreises je Glas (zum Honig-Steuersatz).
|
||||||
|
honeyShareCentsPerJar Int @default(0)
|
||||||
|
/// ABO: Honiganteil des Monatspreises (zum Honig-Steuersatz).
|
||||||
|
honeyShareCentsPerMonth Int @default(0)
|
||||||
|
/// Nur ABO.
|
||||||
|
cycle SponsorshipCycle?
|
||||||
|
/// Nur ABO: Hoechstzahl aktiver Abos (Kontingent); null = unbegrenzt.
|
||||||
|
maxActiveSubscriptions Int?
|
||||||
|
/// Nur ABO: Stripe-Produkt und der Preis, der priceCents abbildet. Bei einer
|
||||||
|
/// Preisaenderung entsteht ein neuer Stripe-Preis; laufende Abos behalten
|
||||||
|
/// ihren alten.
|
||||||
|
stripeProductId String?
|
||||||
|
stripePriceId String?
|
||||||
|
stripePriceAmountCents Int?
|
||||||
|
medalTier MedalTier @default(BRONZE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
product Product? @relation("SponsorshipOfferProduct", fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
honeyProduct Product? @relation("SponsorshipOfferHoney", fields: [honeyProductId], references: [id], onDelete: SetNull)
|
||||||
|
sponsorships Sponsorship[]
|
||||||
|
subscriptions SponsorshipSubscription[]
|
||||||
|
harvestWindows HarvestWindow[]
|
||||||
|
|
||||||
|
@@index([kind, isActive, sortOrder])
|
||||||
|
@@index([honeyProductId])
|
||||||
|
@@map("sponsorship_offers")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine gekaufte Patenschaft (eine Biene, ein Name, eine Urkunde).
|
||||||
|
model Sponsorship {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// WH-P-JJJJ-NNNNN, fortlaufend ueber SponsorshipCounter.
|
||||||
|
number String @unique
|
||||||
|
ownerId String
|
||||||
|
offerId String
|
||||||
|
/// Einmalkauf: die Hauptposition der Bestellung. Eindeutig — das ist der
|
||||||
|
/// Idempotenzschluessel gegen doppelt zugestellte Webhooks.
|
||||||
|
orderItemId String? @unique
|
||||||
|
/// Abo: das Abo, aus dem die Patenschaft entstand.
|
||||||
|
subscriptionId String? @unique
|
||||||
|
locale String @default("de")
|
||||||
|
startDate DateTime @db.Date
|
||||||
|
/// Leer bei laufendem Abo.
|
||||||
|
endDate DateTime? @db.Date
|
||||||
|
status SponsorshipStatus @default(AUSSTEHEND)
|
||||||
|
/// Nummer der aktuellen Revision; 0 = noch keine Urkunde erzeugt.
|
||||||
|
currentRevision Int @default(0)
|
||||||
|
pdfAttempts Int @default(0)
|
||||||
|
nextAttemptAt DateTime?
|
||||||
|
lastError String? @db.Text
|
||||||
|
mailSentAt DateTime?
|
||||||
|
printedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
owner Customer @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
|
||||||
|
orderItem OrderItem? @relation(fields: [orderItemId], references: [id], onDelete: SetNull)
|
||||||
|
subscription SponsorshipSubscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||||
|
revisions SponsorshipRevision[]
|
||||||
|
transfers SponsorshipTransfer[]
|
||||||
|
|
||||||
|
@@index([ownerId])
|
||||||
|
@@index([status, nextAttemptAt])
|
||||||
|
@@index([offerId])
|
||||||
|
@@map("sponsorships")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unveraenderlicher Stand einer Urkunde. Korrekturen und das Abo-Ende
|
||||||
|
/// erzeugen neue Revisionen; alte bleiben samt PDF erhalten.
|
||||||
|
model SponsorshipRevision {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sponsorshipId String
|
||||||
|
revision Int
|
||||||
|
/// Personalisierung wie bestaetigt (lib/certificate/schema.ts).
|
||||||
|
personalization Json
|
||||||
|
/// Laufzeitform (FIXED/OPEN/ENDED) mit Daten.
|
||||||
|
term Json
|
||||||
|
/// Aufgeloeste Textbausteine — Beleg dessen, was auf der Urkunde steht.
|
||||||
|
text Json
|
||||||
|
templateVersion String
|
||||||
|
/// Relativ zu CERTIFICATE_DIR.
|
||||||
|
pdfPath String
|
||||||
|
pdfSha256 String
|
||||||
|
/// Admin-Konto bei Korrekturen, null bei System-Revisionen.
|
||||||
|
createdById String?
|
||||||
|
/// Lesbarer Ersteller ("System" oder die Admin-Adresse) — bleibt lesbar,
|
||||||
|
/// auch wenn das Admin-Konto spaeter geloescht wird.
|
||||||
|
createdByLabel String
|
||||||
|
reason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([sponsorshipId, revision])
|
||||||
|
@@map("sponsorship_revisions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jahreszaehler der Patenschaftsnummern, Muster wie InvoiceCounter.
|
||||||
|
model SponsorshipCounter {
|
||||||
|
year Int @id
|
||||||
|
lastNumber Int @default(0)
|
||||||
|
|
||||||
|
@@map("sponsorship_counters")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Befristeter, einmal nutzbarer Link zur Uebertragung einer
|
||||||
|
/// Einmal-Patenschaft an ein anderes Konto. Gespeichert wird nur der Hash.
|
||||||
|
model SponsorshipTransfer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sponsorshipId String
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdById String
|
||||||
|
claimedById String?
|
||||||
|
claimedAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([sponsorshipId])
|
||||||
|
@@map("sponsorship_transfers")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abo-Patenschaft ueber Stripe Billing.
|
||||||
|
model SponsorshipSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
offerId String
|
||||||
|
status SubscriptionStatus @default(AUSSTEHEND)
|
||||||
|
/// Personalisierung beim Abschluss bestaetigt; daraus entsteht mit der
|
||||||
|
/// ersten bezahlten Rechnung die Patenschaft.
|
||||||
|
personalization Json
|
||||||
|
locale String @default("de")
|
||||||
|
stripeCheckoutSessionId String? @unique
|
||||||
|
stripeSubscriptionId String? @unique
|
||||||
|
startedAt DateTime?
|
||||||
|
currentPeriodEnd DateTime?
|
||||||
|
/// Gesetzt nach Kuendigung: Ende des bezahlten Zeitraums.
|
||||||
|
cancelAt DateTime?
|
||||||
|
endedAt DateTime?
|
||||||
|
/// Beginn des Verzugs (fehlgeschlagene Zahlung oder Rueckbuchung).
|
||||||
|
delinquentSince DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict)
|
||||||
|
payments SubscriptionPayment[]
|
||||||
|
deliveries SubscriptionDelivery[]
|
||||||
|
sponsorship Sponsorship?
|
||||||
|
|
||||||
|
@@index([customerId])
|
||||||
|
@@index([offerId, status])
|
||||||
|
@@map("sponsorship_subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine bezahlte Abo-Monatsrechnung von Stripe.
|
||||||
|
model SubscriptionPayment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subscriptionId String
|
||||||
|
/// Idempotenzschluessel gegen doppelt zugestellte invoice.paid-Ereignisse.
|
||||||
|
stripeInvoiceId String @unique
|
||||||
|
stripePaymentIntentId String?
|
||||||
|
paymentMethodType String?
|
||||||
|
amountCents Int
|
||||||
|
paidAt DateTime
|
||||||
|
periodStart DateTime
|
||||||
|
periodEnd DateTime
|
||||||
|
/// Ab wann diese Zahlung fuer den Lieferanspruch zaehlt (SEPA-Neukunden-
|
||||||
|
/// Frist, siehe lib/sponsorship-subscription/entitlement.ts).
|
||||||
|
countsFrom DateTime
|
||||||
|
/// Zurueckgebucht — zaehlt nie mehr fuer eine Lieferung.
|
||||||
|
reversedAt DateTime?
|
||||||
|
/// Lieferung, die diese Zahlung verbraucht hat.
|
||||||
|
deliveryId String?
|
||||||
|
/// Wabenhain-Bestellung samt Rechnung zu dieser Zahlung.
|
||||||
|
orderId String? @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
|
delivery SubscriptionDelivery? @relation(fields: [deliveryId], references: [id], onDelete: SetNull)
|
||||||
|
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([subscriptionId, paidAt])
|
||||||
|
@@map("subscription_payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Abo-Honiglieferung (0-€-Bestellung mit Honigpositionen).
|
||||||
|
model SubscriptionDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subscriptionId String
|
||||||
|
dueAt DateTime
|
||||||
|
jars Int
|
||||||
|
status DeliveryStatus
|
||||||
|
orderId String? @unique
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
|
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||||
|
payments SubscriptionPayment[]
|
||||||
|
|
||||||
|
@@index([subscriptionId, dueAt])
|
||||||
|
@@map("subscription_deliveries")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erntefenster eines Abo-Angebots mit Zyklus ERNTE.
|
||||||
|
model HarvestWindow {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
offerId String
|
||||||
|
startsOn DateTime @db.Date
|
||||||
|
endsOn DateTime @db.Date
|
||||||
|
/// Fuer Abos geplante Glaeser — werden reserviert, bevor sie in den
|
||||||
|
/// Freiverkauf gehen.
|
||||||
|
plannedJars Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([offerId, startsOn])
|
||||||
|
@@map("harvest_windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Treuestufe (Stammkunde nach bezahlten Bestellungen, Abo nach bezahlten
|
||||||
|
/// Monaten). threshold null = VIP (nur ABO, nach der hoechsten Zahl).
|
||||||
|
model LoyaltyLevel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kind LoyaltyKind
|
||||||
|
threshold Int?
|
||||||
|
name String
|
||||||
|
nameEn String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([kind, threshold])
|
||||||
|
@@map("loyalty_levels")
|
||||||
|
}
|
||||||
+115
@@ -83,6 +83,121 @@ async function main() {
|
|||||||
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
|
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await seedSponsorshipDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startangebote der Bienenpatenschaft und Treuestufen — nur wenn es noch
|
||||||
|
* keine gibt, und die Angebote INAKTIV: dieser Seed laeuft bei jedem
|
||||||
|
* Containerstart auch in Produktion, und Preise, Honig und Rechtstexte muss
|
||||||
|
* der Inhaber vor der Veroeffentlichung pruefen (Spec, "Geschaeftliche
|
||||||
|
* Konfiguration"). Erst das Aktivieren im Admin macht /patenschaft sichtbar.
|
||||||
|
*/
|
||||||
|
async function seedSponsorshipDefaults(): Promise<void> {
|
||||||
|
if ((await prisma.sponsorshipOffer.count()) === 0) {
|
||||||
|
const packages = [
|
||||||
|
{
|
||||||
|
slug: "bienenfreund",
|
||||||
|
name: "Bienenfreund",
|
||||||
|
nameEn: "Bee Friend",
|
||||||
|
priceCents: 1900,
|
||||||
|
isPhysical: false,
|
||||||
|
honeyJars: 0,
|
||||||
|
medalTier: "BRONZE" as const,
|
||||||
|
description: "Symbolische Namensgebung mit individuell gestalteter Urkunde als PDF.",
|
||||||
|
descriptionEn: "Symbolic naming with an individually designed certificate as a PDF.",
|
||||||
|
features: ["Symbolische Namensgebung", "Individuell gestaltete PDF-Urkunde", "Medaille und 3D-Ansicht im Kundenkonto", "Sicherer Download"],
|
||||||
|
featuresEn: ["Symbolic naming", "Individually designed PDF certificate", "Medal and 3D view in your account", "Secure download"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "honigfreund",
|
||||||
|
name: "Honigfreund",
|
||||||
|
nameEn: "Honey Friend",
|
||||||
|
priceCents: 3900,
|
||||||
|
isPhysical: true,
|
||||||
|
honeyJars: 1,
|
||||||
|
medalTier: "SILBER" as const,
|
||||||
|
description: "Digitale Leistungen plus gedruckte Urkunde, ein Glas Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||||||
|
descriptionEn: "Digital items plus a printed certificate, a jar of Wabenhain honey, a greeting card and gift wrapping.",
|
||||||
|
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "1 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||||||
|
featuresEn: ["All digital items", "High-quality printed certificate", "1 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "wabenfreund",
|
||||||
|
name: "Wabenfreund",
|
||||||
|
nameEn: "Comb Friend",
|
||||||
|
priceCents: 5900,
|
||||||
|
isPhysical: true,
|
||||||
|
honeyJars: 3,
|
||||||
|
medalTier: "GOLD" as const,
|
||||||
|
description: "Digitale Leistungen plus gedruckte Urkunde, drei Gläser Wabenhain-Honig, Grußkarte und Geschenkverpackung.",
|
||||||
|
descriptionEn: "Digital items plus a printed certificate, three jars of Wabenhain honey, a greeting card and gift wrapping.",
|
||||||
|
features: ["Alle digitalen Leistungen", "Hochwertig gedruckte Urkunde", "3 × 500 g Wabenhain-Honig", "Grußkarte und Geschenkverpackung"],
|
||||||
|
featuresEn: ["All digital items", "High-quality printed certificate", "3 × 500 g Wabenhain honey", "Greeting card and gift wrapping"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, pkg] of packages.entries()) {
|
||||||
|
// Honiganteil als Vorschlag (7 € je Glas) — der Inhaber prueft ihn mit
|
||||||
|
// der Steuerberatung, bevor er das Angebot aktiviert.
|
||||||
|
const honeyShareCentsPerJar = pkg.honeyJars > 0 ? 700 : 0;
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: {
|
||||||
|
slug: `patenschaft-${pkg.slug}`,
|
||||||
|
name: `Bienenpatenschaft ${pkg.name}`,
|
||||||
|
category: "PATENSCHAFT",
|
||||||
|
description: pkg.description,
|
||||||
|
weightGrams: 0,
|
||||||
|
priceCents: pkg.priceCents - pkg.honeyJars * honeyShareCentsPerJar,
|
||||||
|
pricePerKgCents: 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.sponsorshipOffer.create({
|
||||||
|
data: {
|
||||||
|
slug: pkg.slug,
|
||||||
|
kind: "EINMALIG",
|
||||||
|
name: pkg.name,
|
||||||
|
nameEn: pkg.nameEn,
|
||||||
|
description: pkg.description,
|
||||||
|
descriptionEn: pkg.descriptionEn,
|
||||||
|
features: pkg.features,
|
||||||
|
featuresEn: pkg.featuresEn,
|
||||||
|
priceCents: pkg.priceCents,
|
||||||
|
isActive: false,
|
||||||
|
sortOrder: index,
|
||||||
|
isPhysical: pkg.isPhysical,
|
||||||
|
productId: product.id,
|
||||||
|
honeyJars: pkg.honeyJars,
|
||||||
|
honeyShareCentsPerJar,
|
||||||
|
medalTier: pkg.medalTier,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("Bienenpatenschaft: drei Startangebote angelegt (inaktiv, bitte im Admin pruefen und aktivieren).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await prisma.loyaltyLevel.count()) === 0) {
|
||||||
|
const regular = [
|
||||||
|
{ threshold: 2, name: "Blütenbesuch", nameEn: "Blossom Visitor" },
|
||||||
|
{ threshold: 5, name: "Wabenbau", nameEn: "Comb Builder" },
|
||||||
|
{ threshold: 10, name: "Honigschatz", nameEn: "Honey Treasure" },
|
||||||
|
{ threshold: 25, name: "Goldwabe", nameEn: "Golden Comb" },
|
||||||
|
];
|
||||||
|
const subscription = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
threshold: i + 1,
|
||||||
|
name: `Stufe ${i + 1}`,
|
||||||
|
nameEn: `Level ${i + 1}`,
|
||||||
|
}));
|
||||||
|
await prisma.loyaltyLevel.createMany({
|
||||||
|
data: [
|
||||||
|
...regular.map((level) => ({ kind: "STAMMKUNDE" as const, ...level })),
|
||||||
|
...subscription.map((level) => ({ kind: "ABO" as const, ...level })),
|
||||||
|
{ kind: "ABO" as const, threshold: null, name: "VIP", nameEn: "VIP" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log("Treuestufen angelegt (Stammkunde 2/5/10/25, Abo 1–24 und VIP).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Bestandsabgleich mit ApiCore als Sicherheitsnetz unter den Sofort-Wegen.
|
||||||
|
*
|
||||||
|
* Normalerweise geht eine Buchung direkt nach der Zahlung/Stornierung an
|
||||||
|
* ApiCore, und eine Entnahme in ApiCore kommt per Webhook sofort an. Dieser
|
||||||
|
* Lauf faengt alles ab, was dabei liegen blieb: ApiCore war nicht erreichbar,
|
||||||
|
* ein Webhook ging verloren, der Server wurde mitten im Senden neu gestartet.
|
||||||
|
*
|
||||||
|
* 1. offene Buchungen senden (mit Wartezeiten 1, 5, 15, 60 Minuten),
|
||||||
|
* 2. ALLE verknuepften Produkte gegen ApiCore abgleichen.
|
||||||
|
*
|
||||||
|
* Aufruf: pnpm stock:apicore-sync
|
||||||
|
* Im Cluster laeuft er alle 10 Minuten als CronJob (k8s/07-backup-cronjob.yaml).
|
||||||
|
*
|
||||||
|
* Exit-Code 1, wenn ApiCore nicht erreichbar war oder Buchungen abgelehnt
|
||||||
|
* hat: dann soll der Job als fehlgeschlagen sichtbar sein, nicht nur in
|
||||||
|
* einer Logzeile verschwinden.
|
||||||
|
*/
|
||||||
|
import { prisma } from "../src/lib/prisma";
|
||||||
|
import { deliverPendingStockEvents } from "../src/lib/apicore-stock/delivery";
|
||||||
|
import { reconcileApiCoreStock } from "../src/lib/apicore-stock/reconcile";
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const delivered = await deliverPendingStockEvents();
|
||||||
|
console.log(
|
||||||
|
`[apicore-stock] Buchungen: ${delivered.sent} bestaetigt, ${delivered.rejected} abgelehnt, ${delivered.failed} nicht zugestellt${delivered.error ? ` (${delivered.error})` : ""}`
|
||||||
|
);
|
||||||
|
const reconciled = await reconcileApiCoreStock();
|
||||||
|
console.log(
|
||||||
|
`[apicore-stock] Abgleich: ${reconciled.updated} Produkte aktualisiert, ${reconciled.missing} in ApiCore nicht gefunden${reconciled.error ? ` (${reconciled.error})` : ""}`
|
||||||
|
);
|
||||||
|
const failed = delivered.failed > 0 || delivered.rejected > 0 || Boolean(reconciled.error);
|
||||||
|
return failed ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async (code) => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(code);
|
||||||
|
})
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
console.error("[apicore-stock] Lauf abgebrochen:", error);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
|
||||||
|
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
|
||||||
|
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
|
||||||
|
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Runner-Isolation verletzt: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable() {
|
||||||
|
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
|
||||||
|
# gelegentlich erst beim zweiten Versuch.
|
||||||
|
resolve() {
|
||||||
|
local address=""
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
|
||||||
|
if [ -n "$address" ]; then echo "$address"; return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Kein Zugang zum Docker-Daemon über den Socket.
|
||||||
|
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
|
||||||
|
for socket in /var/run/docker.sock /run/docker.sock; do
|
||||||
|
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
|
||||||
|
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
|
||||||
|
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
|
||||||
|
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
|
||||||
|
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
|
||||||
|
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
|
||||||
|
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
|
||||||
|
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
|
||||||
|
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
|
||||||
|
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
|
||||||
|
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
|
||||||
|
host=${target%:*}
|
||||||
|
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
|
||||||
|
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
|
||||||
|
|
||||||
|
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
|
||||||
+15
-2
@@ -133,11 +133,24 @@ mustWhenSet(
|
|||||||
(raw) => !raw.startsWith("sk_test"),
|
(raw) => !raw.startsWith("sk_test"),
|
||||||
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
|
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
|
||||||
);
|
);
|
||||||
|
must(
|
||||||
|
"STRIPE_PUBLISHABLE_KEY gesetzt",
|
||||||
|
isSet("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
|
||||||
|
);
|
||||||
|
mustWhenSet(
|
||||||
|
"STRIPE_PUBLISHABLE_KEY",
|
||||||
|
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
|
||||||
|
(raw) => raw.startsWith("pk_live_"),
|
||||||
|
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
|
||||||
|
);
|
||||||
|
|
||||||
// --- E-Mail ----------------------------------------------------------------
|
// --- E-Mail ----------------------------------------------------------------
|
||||||
|
// Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App-
|
||||||
|
// Registrierung (siehe src/lib/mail.ts).
|
||||||
must(
|
must(
|
||||||
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
|
"Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)",
|
||||||
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
|
isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"),
|
||||||
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
|
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
|
||||||
);
|
);
|
||||||
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
|
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Rendert Beispiel-Urkunden fuer die Sichtpruefung (Spec "Visuelle
|
||||||
|
* Kontrolle"): je Vorlage und Datensatz ein PNG mit 300 dpi (2480 px breit)
|
||||||
|
* und die Druck-PDF, dazu eine Vorschau mit Wasserzeichen.
|
||||||
|
*
|
||||||
|
* Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=a@1,b@1] [--samples=standard,lang]
|
||||||
|
*
|
||||||
|
* Schreibt nur in den angegebenen Ordner; keine Datenbank, keine Uploads.
|
||||||
|
*/
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { layoutCertificate } from "../src/lib/certificate/layout";
|
||||||
|
import { renderCertificateImage } from "../src/lib/certificate/render-image";
|
||||||
|
import { certificatePdfTitle, renderCertificatePdf } from "../src/lib/certificate/render-pdf";
|
||||||
|
import { fixedTermFor, type CertificateTerm, type Personalization } from "../src/lib/certificate/schema";
|
||||||
|
import { listTemplateVersions } from "../src/lib/certificate/templates";
|
||||||
|
import { composeCertificateText } from "../src/lib/certificate/text";
|
||||||
|
import type { Locale } from "../src/i18n/config";
|
||||||
|
|
||||||
|
interface Sample {
|
||||||
|
personalization: Personalization;
|
||||||
|
locale: Locale;
|
||||||
|
term: CertificateTerm;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const START = "2026-12-24";
|
||||||
|
const NUMBER = "WH-P-2026-00042";
|
||||||
|
|
||||||
|
function sample(personalization: Personalization, extra: Partial<Omit<Sample, "personalization">> = {}): Sample {
|
||||||
|
return { personalization, locale: "de", term: fixedTermFor(personalization.startDate), number: NUMBER, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DEDICATION =
|
||||||
|
"Für die Frau, die jeden Garten zum Summen bringt.\nMöge diese kleine Biene dich an all die sonnigen Tage erinnern, die wir gemeinsam zwischen Lavendel und Linden verbracht haben.\n\nIn Liebe und Dankbarkeit,\nAnna, Ben und Carla";
|
||||||
|
|
||||||
|
const SAMPLES: Record<string, Sample> = {
|
||||||
|
// Identische Beispieldaten fuer den Vergleich der drei Varianten.
|
||||||
|
standard: sample({
|
||||||
|
beeName: "Summsi",
|
||||||
|
recipientName: "Marie-Luise Schmidt",
|
||||||
|
giverNames: "Anna und Ben",
|
||||||
|
dedication: "Für die beste Gärtnerin der Welt –\nmögen deine Blüten immer summen.",
|
||||||
|
occasion: "WEIHNACHTEN",
|
||||||
|
startDate: START,
|
||||||
|
}),
|
||||||
|
kurz: sample({ beeName: "Bo", recipientName: "Li", occasion: "SELBST", startDate: START }),
|
||||||
|
lang: sample({
|
||||||
|
beeName: "Hildegard-Wilhelmine",
|
||||||
|
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden",
|
||||||
|
giverNames: "Anna, Ben, Carla und Familie Özdemir aus dem Nachbarhaus",
|
||||||
|
dedication: MAX_DEDICATION,
|
||||||
|
occasion: "GEBURTSTAG",
|
||||||
|
startDate: START,
|
||||||
|
}),
|
||||||
|
// An den Eingabelimits (CERTIFICATE_LIMITS): 23/24, 57/60, 80/80, 277/280 Zeichen, 5 Umbrueche.
|
||||||
|
maximal: sample(
|
||||||
|
{
|
||||||
|
beeName: "Wilhelmine-Mümmelwunder",
|
||||||
|
recipientName: "Marie-Luise Schmidt-Habermann von Hohenlinden-Württemberg",
|
||||||
|
giverNames: "Anna, Ben, Carla, Dominik, Emilia, Friedrich und die ganze Familie Özdemir-Lange",
|
||||||
|
dedication: [
|
||||||
|
"Möge diese kleine Biene dich an sonnige Tage im Garten erinnern,",
|
||||||
|
"an Lindenblüten, Lavendel und an den Duft von frischem Honigbrot,",
|
||||||
|
"an lange Abende auf der Bank vor dem Haus und an alle Gespräche.",
|
||||||
|
"Danke für alles, was du für uns getan hast, jeden einzelnen Tag!",
|
||||||
|
"Alles Liebe",
|
||||||
|
"Anna",
|
||||||
|
].join("\n"),
|
||||||
|
occasion: "DANKESCHOEN",
|
||||||
|
startDate: START,
|
||||||
|
},
|
||||||
|
{ locale: "de" }
|
||||||
|
),
|
||||||
|
sonderzeichen: sample(
|
||||||
|
{
|
||||||
|
beeName: "Zoë",
|
||||||
|
recipientName: "Jürgen Weiß & Ñúñez O’Brien",
|
||||||
|
giverNames: "Anna, Ben, Carla und Familie Özdemir",
|
||||||
|
occasion: "DANKESCHOEN",
|
||||||
|
startDate: START,
|
||||||
|
},
|
||||||
|
{ term: { kind: "OPEN", start: START } }
|
||||||
|
),
|
||||||
|
englisch: sample(
|
||||||
|
{
|
||||||
|
beeName: "Honeybell",
|
||||||
|
recipientName: "Charlotte Ashworth-Blake",
|
||||||
|
giverNames: "Oliver and Grace",
|
||||||
|
dedication: "For the kindest gardener we know —\nmay your days always hum with sunshine.",
|
||||||
|
occasion: "GEBURTSTAG",
|
||||||
|
startDate: START,
|
||||||
|
},
|
||||||
|
{ locale: "en" }
|
||||||
|
),
|
||||||
|
beendet: sample(
|
||||||
|
{ beeName: "Lindenblüte", recipientName: "Familie Wagner", occasion: "GESCHENK", startDate: START },
|
||||||
|
{ term: { kind: "ENDED", start: START, end: "2027-05-31" } }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseList(flag: string): string[] | null {
|
||||||
|
const arg = process.argv.find((value) => value.startsWith(`--${flag}=`));
|
||||||
|
return arg ? arg.slice(flag.length + 3).split(",").filter(Boolean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const outDir = process.argv[2];
|
||||||
|
if (!outDir || outDir.startsWith("--")) {
|
||||||
|
throw new Error("Aufruf: pnpm tsx scripts/render-certificate-samples.ts <ausgabeordner> [--templates=…] [--samples=…]");
|
||||||
|
}
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const templates = parseList("templates") ?? listTemplateVersions();
|
||||||
|
const samples = parseList("samples") ?? Object.keys(SAMPLES);
|
||||||
|
|
||||||
|
for (const version of templates) {
|
||||||
|
for (const name of samples) {
|
||||||
|
const data = SAMPLES[name];
|
||||||
|
if (!data) throw new Error(`Unbekannter Datensatz "${name}".`);
|
||||||
|
const text = composeCertificateText(data);
|
||||||
|
const layout = layoutCertificate(text, version);
|
||||||
|
const base = path.join(outDir, `${version.replace("@", "-v")}_${name}`);
|
||||||
|
const [png, pdf] = await Promise.all([
|
||||||
|
renderCertificateImage(layout, { widthPx: 2480, format: "png" }),
|
||||||
|
renderCertificatePdf(layout, { title: certificatePdfTitle(text), creationDate: new Date(`${START}T00:00:00Z`) }),
|
||||||
|
]);
|
||||||
|
await writeFile(`${base}.png`, png);
|
||||||
|
await writeFile(`${base}.pdf`, pdf);
|
||||||
|
if (name === "standard") {
|
||||||
|
const preview = await renderCertificateImage(layout, { widthPx: 1240, format: "png", watermark: data.locale });
|
||||||
|
await writeFile(`${base}_vorschau.png`, preview);
|
||||||
|
}
|
||||||
|
process.stdout.write(`${base}.png / .pdf\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Rendert Beispiel-Medaillen aller Arten und Stufen als PNG (und SVG) zur
|
||||||
|
* Sichtpruefung. Aufruf: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]
|
||||||
|
*/
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { medalReliefSvg, renderMedalPng, renderMedalSvg, type MedalInput } from "../src/lib/medals";
|
||||||
|
|
||||||
|
const SAMPLES: Array<[string, MedalInput]> = [
|
||||||
|
["patenschaft-bronze", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Summsi", year: 2026, number: "WH-P-2026-00042" }],
|
||||||
|
["patenschaft-silber", { kind: "PATENSCHAFT", tier: "SILBER", beeName: "Zoë", year: 2026, number: "WH-P-2026-00043" }],
|
||||||
|
["patenschaft-gold", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Ñúñez", year: 2026, number: "WH-P-2026-00044" }],
|
||||||
|
["patenschaft-abo", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Hildegard", year: 2026, number: "WH-P-2026-00045" }],
|
||||||
|
["patenschaft-lang-24", { kind: "PATENSCHAFT", tier: "BRONZE", beeName: "Wilhelmine Augusta Maxi", year: 2026, number: "WH-P-2026-00046" }],
|
||||||
|
["patenschaft-lang-ohne-umbruch", { kind: "PATENSCHAFT", tier: "GOLD", beeName: "Bienenköniginwunderbarer", year: 2026, number: "WH-P-2026-00047" }],
|
||||||
|
["patenschaft-abo-lang", { kind: "PATENSCHAFT", tier: "ABO", beeName: "Wilhelmine-Augusta Maxi", year: 2026, number: "WH-P-2026-00048" }],
|
||||||
|
["stammkunde-1", { kind: "STAMMKUNDE", level: 1, name: "Blütenbesuch" }],
|
||||||
|
["stammkunde-2", { kind: "STAMMKUNDE", level: 2, name: "Wabenbau" }],
|
||||||
|
["stammkunde-3", { kind: "STAMMKUNDE", level: 3, name: "Honigschatz" }],
|
||||||
|
["stammkunde-4", { kind: "STAMMKUNDE", level: 4, name: "Goldwabe" }],
|
||||||
|
["abo-1", { kind: "ABO", months: 1 }],
|
||||||
|
["abo-12", { kind: "ABO", months: 12 }],
|
||||||
|
["abo-24", { kind: "ABO", months: 24 }],
|
||||||
|
["vip", { kind: "VIP" }],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const target = process.argv[2];
|
||||||
|
if (!target) throw new Error("Zielordner fehlt: pnpm tsx scripts/render-medal-samples.ts <Zielordner> [Groesse]");
|
||||||
|
const size = Number(process.argv[3] ?? 1024);
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
for (const [name, input] of SAMPLES) {
|
||||||
|
await writeFile(path.join(target, `${name}.png`), await renderMedalPng(input, size));
|
||||||
|
await writeFile(path.join(target, `${name}.svg`), renderMedalSvg(input));
|
||||||
|
if (name === "patenschaft-gold") await writeFile(path.join(target, `${name}-relief.svg`), medalReliefSvg(input));
|
||||||
|
}
|
||||||
|
process.stdout.write(`${SAMPLES.length} Medaillen in ${target}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Ersteinrichtung der Bienenpatenschaft in einer bestehenden Installation.
|
||||||
|
*
|
||||||
|
* Wiederholbar: legt nur an, was fehlt, und aendert nie ein Angebot, das der
|
||||||
|
* Inhaber schon bearbeitet hat (Felder werden nur gesetzt, wenn sie leer sind).
|
||||||
|
*
|
||||||
|
* - legt die beiden Abo-Angebote INAKTIV an (Testpreise, im Admin pruefen),
|
||||||
|
* - legt fuer jedes Abo-Angebot Stripe-Produkt, Monatspreis und
|
||||||
|
* Traegerprodukt an (syncOfferStripePrice).
|
||||||
|
*
|
||||||
|
* Einen Honig verknuepft das Skript nicht mehr (frueher --honey=<slug>):
|
||||||
|
* Pakete und Abos bekommen ihren Honig automatisch — nie cremig, neueste
|
||||||
|
* Ernte, genug Bestand (lib/sponsorship/honey-selection.ts). Die Spalte
|
||||||
|
* honeyProductId bleibt ungenutzt im Schema; der Probelauf zeigt zur
|
||||||
|
* Kontrolle, welchen Honig die Regel gerade waehlen wuerde.
|
||||||
|
*
|
||||||
|
* Aufruf (Werkzeug-Image, Umgebung wie die App):
|
||||||
|
* tsx scripts/sponsorship-bootstrap.ts (Probelauf)
|
||||||
|
* tsx scripts/sponsorship-bootstrap.ts --apply (ausfuehren)
|
||||||
|
*/
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
|
||||||
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||||
|
import { honeyLabel, pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||||
|
|
||||||
|
interface AboTemplate {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
nameEn: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEn: string;
|
||||||
|
features: string[];
|
||||||
|
featuresEn: string[];
|
||||||
|
priceCents: number;
|
||||||
|
honeyJars: number;
|
||||||
|
honeyShareCentsPerMonth: number;
|
||||||
|
cycle: "VIERTELJAEHRLICH" | "ERNTE";
|
||||||
|
maxActiveSubscriptions: number;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Namen: die Verkaufsseite zeigt sie unter "Im Abo", Stripe und die
|
||||||
|
// Rechnung als "Bienenpatenschaft-Abo <Name>".
|
||||||
|
// Testpreise — vor der Aktivierung kaufmaennisch pruefen (Spec, "Geschaeftliche
|
||||||
|
// Konfiguration"). Honiganteil je Monat = Glaeser je Lieferung × 7 € ÷ Monate
|
||||||
|
// je Lieferung, zum Honig-Steuersatz.
|
||||||
|
const ABO_TEMPLATES: AboTemplate[] = [
|
||||||
|
{
|
||||||
|
slug: "abo-vierteljaehrlich",
|
||||||
|
name: "Vierteljährlich",
|
||||||
|
nameEn: "Quarterly",
|
||||||
|
description: "Monatlich zahlen, alle drei bezahlten Monate zwei Gläser Wabenhain-Honig. Versand inklusive, monatlich kündbar.",
|
||||||
|
descriptionEn: "Pay monthly, two jars of Wabenhain honey after every three paid months. Shipping included, cancel monthly.",
|
||||||
|
features: ["Symbolische Namensgebung mit Urkunde", "2 × 500 g Honig nach jeweils 3 bezahlten Monaten", "Versand inklusive", "Monatlich kündbar"],
|
||||||
|
featuresEn: ["Symbolic naming with certificate", "2 × 500 g honey after every 3 paid months", "Shipping included", "Cancel monthly"],
|
||||||
|
priceCents: 1290,
|
||||||
|
honeyJars: 2,
|
||||||
|
honeyShareCentsPerMonth: 467,
|
||||||
|
cycle: "VIERTELJAEHRLICH",
|
||||||
|
maxActiveSubscriptions: 20,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "abo-ernte",
|
||||||
|
name: "Nach der Ernte",
|
||||||
|
nameEn: "After the harvest",
|
||||||
|
description: "Monatlich zahlen, Honig frisch nach der Ernte: drei Gläser, sobald seit der letzten Lieferung sechs Monate bezahlt sind. Versand inklusive, monatlich kündbar.",
|
||||||
|
descriptionEn: "Pay monthly, honey fresh after the harvest: three jars once six months have been paid since the last delivery. Shipping included, cancel monthly.",
|
||||||
|
features: ["Symbolische Namensgebung mit Urkunde", "3 × 500 g Honig im Erntefenster", "Versand inklusive", "Monatlich kündbar"],
|
||||||
|
featuresEn: ["Symbolic naming with certificate", "3 × 500 g honey in the harvest window", "Shipping included", "Cancel monthly"],
|
||||||
|
priceCents: 990,
|
||||||
|
honeyJars: 3,
|
||||||
|
honeyShareCentsPerMonth: 350,
|
||||||
|
cycle: "ERNTE",
|
||||||
|
maxActiveSubscriptions: 10,
|
||||||
|
sortOrder: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
const honey = pickHoneyProduct(await loadHoneyCandidates(), 1);
|
||||||
|
console.log(
|
||||||
|
`${apply ? "AUSFUEHREN" : "PROBELAUF"} — Honig automatisch, derzeit: ${honey ? honeyLabel(honey) : "kein nicht-cremiger Honig mit freiem Bestand"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const template of ABO_TEMPLATES) {
|
||||||
|
const existing = await prisma.sponsorshipOffer.findUnique({ where: { slug: template.slug }, select: { id: true, stripePriceId: true } });
|
||||||
|
if (existing) {
|
||||||
|
console.log(` Abo ${template.slug}: vorhanden${existing.stripePriceId ? "" : ", Stripe-Preis fehlt"}`);
|
||||||
|
if (apply && !existing.stripePriceId) await syncOfferStripePrice(existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` Abo ${template.slug}: anlegen (inaktiv, ${(template.priceCents / 100).toFixed(2)} €/Monat) + Stripe-Preis`);
|
||||||
|
if (!apply) continue;
|
||||||
|
const created = await prisma.sponsorshipOffer.create({
|
||||||
|
data: {
|
||||||
|
...template,
|
||||||
|
kind: "ABO",
|
||||||
|
isActive: false,
|
||||||
|
isPhysical: true,
|
||||||
|
medalTier: "ABO",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await syncOfferStripePrice(created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offers = await prisma.sponsorshipOffer.findMany({
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { slug: true, kind: true, isActive: true, priceCents: true, honeyJars: true, stripePriceId: true, productId: true },
|
||||||
|
});
|
||||||
|
console.table(offers);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Stripe-Einrichtung fuer das Abo der Bienenpatenschaft (Spec
|
||||||
|
* "Stripe-Einrichtung", docs/EINRICHTUNG.md).
|
||||||
|
*
|
||||||
|
* Wiederholbar und ohne Geheimnisse im Code: liest STRIPE_SECRET_KEY aus der
|
||||||
|
* Umgebung (.env), vergleicht den Ist-Stand mit dem Soll und zeigt, was es
|
||||||
|
* aendern wuerde. Ohne --apply wird NICHTS geschrieben.
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/stripe-setup.ts # Probelauf (Standard)
|
||||||
|
* pnpm tsx scripts/stripe-setup.ts --apply # Aenderungen schreiben
|
||||||
|
* pnpm tsx scripts/stripe-setup.ts --base-url=https://wabenhain.com
|
||||||
|
*
|
||||||
|
* Gegen ein Livekonto (sk_live_/rk_live_) schreibt es nur mit dem
|
||||||
|
* zusaetzlichen Schalter --live-ok — erst nach ausdruecklicher Freigabe des
|
||||||
|
* Inhabers.
|
||||||
|
*
|
||||||
|
* Was es einrichtet:
|
||||||
|
* 1. Webhook-Endpunkt <BASE_URL>/api/checkout/webhook mit allen Ereignissen
|
||||||
|
* (die bestehenden sechs plus die Abo-Ereignisse). Bestehende Ereignisse
|
||||||
|
* bleiben, es wird nur ergaenzt.
|
||||||
|
* 2. Kundenportal-Konfiguration, die NUR das Aendern der Zahlungsart erlaubt
|
||||||
|
* (keine Kuendigung, kein Tarifwechsel, keine Kundendaten, keine
|
||||||
|
* Stripe-Rechnungen). Die App findet sie ueber ihre Metadaten.
|
||||||
|
* Was nur im Dashboard geht, gibt es am Ende als Checkliste aus.
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
const REQUIRED_EVENTS = [
|
||||||
|
// bestehend (Einmalbestellungen)
|
||||||
|
"checkout.session.completed",
|
||||||
|
"checkout.session.async_payment_succeeded",
|
||||||
|
"checkout.session.async_payment_failed",
|
||||||
|
"checkout.session.expired",
|
||||||
|
"charge.refunded",
|
||||||
|
"charge.dispute.created",
|
||||||
|
// Abo
|
||||||
|
"customer.subscription.created",
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
"invoice.paid",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
"charge.dispute.closed",
|
||||||
|
] as const satisfies readonly Stripe.WebhookEndpointCreateParams.EnabledEvent[];
|
||||||
|
|
||||||
|
/** Muss zu PORTAL_METADATA_VALUE in src/lib/sponsorship-subscription/cancellation.ts passen. */
|
||||||
|
const PORTAL_METADATA = { wabenhain: "abo-zahlungsart" } as const;
|
||||||
|
|
||||||
|
const PORTAL_FEATURES = {
|
||||||
|
payment_method_update: { enabled: true },
|
||||||
|
subscription_cancel: { enabled: false },
|
||||||
|
subscription_update: { enabled: false },
|
||||||
|
customer_update: { enabled: false },
|
||||||
|
invoice_history: { enabled: false },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
apply: boolean;
|
||||||
|
liveOk: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(argv: string[]): Options {
|
||||||
|
const baseArg = argv.find((arg) => arg.startsWith("--base-url="))?.slice("--base-url=".length);
|
||||||
|
const baseUrl = (baseArg || process.env.NEXT_PUBLIC_SITE_URL || "https://wabenhain.com").replace(/\/+$/, "");
|
||||||
|
return { apply: argv.includes("--apply"), liveOk: argv.includes("--live-ok"), baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(line: string): void {
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebhook(stripe: Stripe, options: Options): Promise<void> {
|
||||||
|
const url = `${options.baseUrl}/api/checkout/webhook`;
|
||||||
|
const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
|
||||||
|
const existing = endpoints.data.find((endpoint) => endpoint.url === url);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
log(`Webhook: kein Endpunkt fuer ${url} — ${options.apply ? "wird angelegt" : "wuerde angelegt"} mit ${REQUIRED_EVENTS.length} Ereignissen.`);
|
||||||
|
if (!options.apply) return;
|
||||||
|
const created = await stripe.webhookEndpoints.create({
|
||||||
|
url,
|
||||||
|
enabled_events: [...REQUIRED_EVENTS],
|
||||||
|
description: "Wabenhain Shop (Bestellungen und Abos)",
|
||||||
|
});
|
||||||
|
log(` angelegt: ${created.id}. Das Signatur-Geheimnis steht NUR in dieser Antwort und im Dashboard:`);
|
||||||
|
log(" im Dashboard unter Entwickler → Webhooks → Endpunkt → Signaturgeheimnis anzeigen, als STRIPE_WEBHOOK_SECRET setzen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = new Set<string>(existing.enabled_events);
|
||||||
|
if (enabled.has("*")) {
|
||||||
|
log(`Webhook ${existing.id}: empfaengt alle Ereignisse ("*") — nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const missing = REQUIRED_EVENTS.filter((event) => !enabled.has(event));
|
||||||
|
if (missing.length === 0) {
|
||||||
|
log(`Webhook ${existing.id}: alle ${REQUIRED_EVENTS.length} Ereignisse abonniert — nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Webhook ${existing.id}: ${missing.length} Ereignisse fehlen — ${options.apply ? "werden ergaenzt" : "wuerden ergaenzt"}:`);
|
||||||
|
for (const event of missing) log(` + ${event}`);
|
||||||
|
if (existing.status !== "enabled") log(` Hinweis: Endpunkt ist "${existing.status}".`);
|
||||||
|
if (!options.apply) return;
|
||||||
|
await stripe.webhookEndpoints.update(existing.id, {
|
||||||
|
enabled_events: [...existing.enabled_events, ...missing] as Stripe.WebhookEndpointUpdateParams.EnabledEvent[],
|
||||||
|
});
|
||||||
|
log(" ergaenzt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function portalDeviations(config: Stripe.BillingPortal.Configuration): string[] {
|
||||||
|
const features = config.features;
|
||||||
|
const actual: Record<keyof typeof PORTAL_FEATURES, boolean> = {
|
||||||
|
payment_method_update: features.payment_method_update.enabled,
|
||||||
|
subscription_cancel: features.subscription_cancel.enabled,
|
||||||
|
subscription_update: features.subscription_update.enabled,
|
||||||
|
customer_update: features.customer_update.enabled,
|
||||||
|
invoice_history: features.invoice_history.enabled,
|
||||||
|
};
|
||||||
|
return (Object.keys(PORTAL_FEATURES) as (keyof typeof PORTAL_FEATURES)[])
|
||||||
|
.filter((key) => actual[key] !== PORTAL_FEATURES[key].enabled)
|
||||||
|
.map((key) => `${key}: ${actual[key]} → ${PORTAL_FEATURES[key].enabled}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePortal(stripe: Stripe, options: Options): Promise<void> {
|
||||||
|
const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 100 });
|
||||||
|
const existing = configs.data.find((config) => config.metadata?.wabenhain === PORTAL_METADATA.wabenhain);
|
||||||
|
const defaultReturnUrl = `${options.baseUrl}/konto/patenschaft`;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
log(`Kundenportal: keine Wabenhain-Konfiguration — ${options.apply ? "wird angelegt" : "wuerde angelegt"} (nur Zahlungsart aendern).`);
|
||||||
|
if (!options.apply) return;
|
||||||
|
const created = await stripe.billingPortal.configurations.create({
|
||||||
|
name: "Wabenhain Abo – nur Zahlungsart",
|
||||||
|
features: PORTAL_FEATURES,
|
||||||
|
default_return_url: defaultReturnUrl,
|
||||||
|
metadata: PORTAL_METADATA,
|
||||||
|
});
|
||||||
|
log(` angelegt: ${created.id} (die App findet sie ueber die Metadaten; optional STRIPE_PORTAL_CONFIGURATION_ID=${created.id}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviations = portalDeviations(existing);
|
||||||
|
if (deviations.length === 0) {
|
||||||
|
log(`Kundenportal ${existing.id}: erlaubt nur das Aendern der Zahlungsart — nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Kundenportal ${existing.id}: weicht ab — ${options.apply ? "wird korrigiert" : "wuerde korrigiert"}:`);
|
||||||
|
for (const deviation of deviations) log(` ~ ${deviation}`);
|
||||||
|
if (!options.apply) return;
|
||||||
|
await stripe.billingPortal.configurations.update(existing.id, { features: PORTAL_FEATURES, default_return_url: defaultReturnUrl });
|
||||||
|
log(" korrigiert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDashboardChecklist(): void {
|
||||||
|
log("");
|
||||||
|
log("Nur im Stripe-Dashboard einstellbar (siehe docs/EINRICHTUNG.md):");
|
||||||
|
log(" [ ] Billing → Einstellungen → Abos und E-Mails → Fehlgeschlagene Zahlungen: Smart Retries an.");
|
||||||
|
log(" [ ] … nach dem letzten erfolglosen Versuch: Abo kuendigen (nicht 'als unbezahlt markieren').");
|
||||||
|
log(" [ ] … Kunden-E-Mails: Rechnungen/Belege fuer erfolgreiche Zahlungen, bevorstehende Verlaengerungen und");
|
||||||
|
log(" fehlgeschlagene Zahlungen AUS — Wabenhain verschickt eigene Rechnungen und Mails.");
|
||||||
|
log(" [ ] Einstellungen → Zahlungsmethoden: Karte, Apple Pay, Google Pay, SEPA-Lastschrift aktiv (Google Pay pruefen).");
|
||||||
|
log(" [ ] Apple Pay / Google Pay im eingebetteten Checkout: Domain wabenhain.com unter Zahlungsmethoden-Domains registrieren.");
|
||||||
|
log(" [ ] Stripe Tax nur mit aktiver Registrierung einschalten — sonst bleibt es aus (Preise sind Bruttopreise).");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const options = parseOptions(process.argv.slice(2));
|
||||||
|
const key = process.env.STRIPE_SECRET_KEY?.trim();
|
||||||
|
if (!key) throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt.");
|
||||||
|
const live = /^(sk|rk)_live_/.test(key);
|
||||||
|
if (live && options.apply && !options.liveOk) {
|
||||||
|
throw new Error("Livekonto: --apply schreibt hier nur zusammen mit --live-ok (nach Freigabe des Inhabers).");
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Stripe-Einrichtung (${live ? "LIVEMODUS" : "Testmodus"}, ${options.apply ? "SCHREIBT" : "Probelauf, schreibt nichts"})`);
|
||||||
|
log(`Basis-URL: ${options.baseUrl}`);
|
||||||
|
log("");
|
||||||
|
const stripe = new Stripe(key);
|
||||||
|
await ensureWebhook(stripe, options);
|
||||||
|
await ensurePortal(stripe, options);
|
||||||
|
printDashboardChecklist();
|
||||||
|
if (!options.apply) log("\nProbelauf beendet. Mit --apply schreiben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`Fehler: ${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -14,3 +14,9 @@ export * from "./actions/discount-codes";
|
|||||||
export * from "./actions/content";
|
export * from "./actions/content";
|
||||||
export * from "./actions/apicore-sync";
|
export * from "./actions/apicore-sync";
|
||||||
export * from "./actions/dhl";
|
export * from "./actions/dhl";
|
||||||
|
export * from "./actions/archive";
|
||||||
|
export * from "./actions/sponsorship";
|
||||||
|
export * from "./actions/sponsorship-offers";
|
||||||
|
export * from "./actions/sponsorship-harvest";
|
||||||
|
export * from "./actions/sponsorship-subscriptions";
|
||||||
|
export * from "./actions/sponsorship-loyalty";
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import type { CustomerRole } from "@/generated/prisma/client";
|
import type { CustomerRole } from "@/generated/prisma/client";
|
||||||
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fail(path: string, message: string): never {
|
export { fail, succeed } from "@/lib/flash";
|
||||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||||
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -5,26 +5,33 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
getApiCoreConnection,
|
||||||
fetchApiCoreHarvests,
|
fetchApiCoreShopData,
|
||||||
fetchApiCoreProducts,
|
|
||||||
fetchApiCoreBottlingBatches,
|
|
||||||
isSellableStatus,
|
isSellableStatus,
|
||||||
|
isManufactured,
|
||||||
ApiCoreConfigError,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreShopData,
|
||||||
type ApiCoreHarvestWithApiaryName,
|
type ApiCoreHarvest,
|
||||||
type ApiCoreProduct,
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
} from "@/lib/apicore-client";
|
} from "@/lib/apicore-client";
|
||||||
|
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
|
||||||
|
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
|
||||||
import {
|
import {
|
||||||
slugForApiCoreSource,
|
hasLegacyApiCoreSlug,
|
||||||
buildHarvestDescription,
|
buildHarvestDescription,
|
||||||
buildProductDescription,
|
buildProductDescription,
|
||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
productSlugCandidates,
|
productSlugCandidates,
|
||||||
|
slugifyProductName,
|
||||||
|
harvestFactChanges,
|
||||||
|
batchFactChanges,
|
||||||
} from "@/lib/apicore-mapping";
|
} from "@/lib/apicore-mapping";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
import { deliverPendingStockEvents } from "@/lib/apicore-stock/delivery";
|
||||||
|
import { reconcileApiCoreStock } from "@/lib/apicore-stock/reconcile";
|
||||||
|
import type { ProductCategory } from "@/generated/prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
|
* Uebernimmt Stammdaten aus ApiCore (separates Imkerei-Verwaltungssystem) in
|
||||||
@@ -36,23 +43,33 @@ import { requireAdmin } from "./_shared";
|
|||||||
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
* bewusst gesetzt hat — importiert wird in einen Entwurfszustand, den erst
|
||||||
* ein Mensch freigibt.
|
* ein Mensch freigibt.
|
||||||
*
|
*
|
||||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
* Zustaendigkeit pro Feld (siehe apicore-mapping.ts, "Zustaendigkeit pro
|
||||||
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
|
* Feld"): Imkerei-Fakten gehoeren ApiCore und werden bei jedem Lauf
|
||||||
* synchronisierte Ernten bleiben unberuehrt.
|
* nachgezogen, alles Verkaufsbezogene gehoert Wabenhain und wird nach dem
|
||||||
|
* Anlegen nie mehr angefasst.
|
||||||
|
*
|
||||||
|
* - Ernten: Datum und Jahr kommen immer aus ApiCore, auch bei
|
||||||
|
* veroeffentlichten Ernten. Sorte, Standort und Text sind nur eine
|
||||||
|
* Vorbelegung: ein unveroeffentlichter, noch von niemandem bearbeiteter
|
||||||
|
* Entwurf wird damit aufgefrischt, sonst bleiben sie, wie der Admin sie
|
||||||
|
* gesetzt hat.
|
||||||
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||||
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
* Beschreibung und Geschmacksprofil ueberschreiben, die nur in Wabenhain
|
||||||
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
* gepflegt werden und die es in ApiCore gar nicht gibt.
|
||||||
* - Chargen: werden NUR neu angelegt, nie aktualisiert. Anders als Harvest
|
* - Chargen: Losnummer, Abfuell- und MHD-Datum sowie die abgefuellte
|
||||||
* und Product hat Batch kein Feld, an dem eine erfolgte Admin-Bearbeitung
|
* Glasanzahl kommen immer aus ApiCore. remainingGlasses ist Wabenhains
|
||||||
* erkennbar waere. Ein Update wuerde insbesondere remainingGlasses
|
* eigener, durch echte Verkaeufe veraenderter Bestand und wird nie gesetzt;
|
||||||
* ueberschreiben — Wabenhains EIGENEN, durch echte Verkaeufe veraenderten
|
* liegt er nach einer Korrektur ueber der Abfuellmenge, meldet der Lauf das.
|
||||||
* Lagerbestand.
|
* Wiedererkannt wird eine Charge an der ApiCore-ID der Abfuellung, nicht an
|
||||||
|
* der Losnummer — die kann in ApiCore korrigiert werden.
|
||||||
*
|
*
|
||||||
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||||
* "apicore-<ApiCore-ID>" als Harvest.slug bzw. Product.slug, und die
|
* die ApiCore-ID der Ernte als Harvest.apicoreHarvestId, und die ApiCore-ID
|
||||||
* ApiCore-Losnummer als Batch.code.
|
* der Abfuellung als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
||||||
|
* Mehrere Ernten derselben Sorte im selben Jahr sind ausdruecklich erlaubt;
|
||||||
|
* sie unterscheiden sich im Slug am Erntedatum (lib/harvest-slug.ts).
|
||||||
*
|
*
|
||||||
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
||||||
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
||||||
@@ -68,6 +85,46 @@ interface SyncCounts {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HarvestSyncCounts extends SyncCounts {
|
||||||
|
/**
|
||||||
|
* Entwuerfe, die ein Mensch bearbeitet hat: ihre Shop-Felder blieben
|
||||||
|
* unveraendert (Fakten wie das Datum werden trotzdem nachgezogen).
|
||||||
|
*/
|
||||||
|
protectedDrafts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchSyncCounts extends SyncCounts {
|
||||||
|
/** Chargen, deren Shop-Bestand ueber der (korrigierten) Abfuellmenge liegt. */
|
||||||
|
stockConflicts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||||
|
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||||
|
* damit nicht mit.
|
||||||
|
*/
|
||||||
|
async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
|
||||||
|
const entries = await prisma.auditLog.count({
|
||||||
|
where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } },
|
||||||
|
});
|
||||||
|
return entries > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ernten aus der Zeit, als der Slug "apicore-<ID>" der Sync-Schluessel war,
|
||||||
|
* bekommen eine lesbare Adresse. Gefahrlos auch fuer veroeffentlichte
|
||||||
|
* Ernten: oeffentlich verlinkt war bis dahin /ernten/<jahr>/<sorte>, nie der
|
||||||
|
* Slug. Gibt zurueck, ob umbenannt wurde.
|
||||||
|
*/
|
||||||
|
async function replaceLegacySlug(harvest: { id: string; slug: string; type: string; harvestDate: Date }): Promise<boolean> {
|
||||||
|
if (!hasLegacyApiCoreSlug(harvest.slug)) return false;
|
||||||
|
await writeWithFreeSlug(harvestSlugCandidates(harvest.type, harvest.harvestDate), (slug) =>
|
||||||
|
prisma.harvest.update({ where: { id: harvest.id }, data: { slug } }),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
||||||
* siehe syncProducts().
|
* siehe syncProducts().
|
||||||
@@ -95,11 +152,10 @@ function describeError(error: unknown): string {
|
|||||||
// Ernten
|
// Ernten
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
|
async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
|
||||||
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const slug = slugForApiCoreSource(source.id);
|
|
||||||
const date = new Date(source.date);
|
const date = new Date(source.date);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
@@ -109,9 +165,20 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Sy
|
|||||||
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
const existing = await prisma.harvest.findUnique({ where: { apicoreHarvestId: source.id } });
|
||||||
if (existing?.isPublished) {
|
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||||
counts.skipped++;
|
if (existing && shopOwned) {
|
||||||
|
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||||
|
const facts = harvestFactChanges(existing, date);
|
||||||
|
if (!existing.isPublished) counts.protectedDrafts++;
|
||||||
|
const renamed = await replaceLegacySlug(existing);
|
||||||
|
if (Object.keys(facts).length === 0) {
|
||||||
|
if (renamed) counts.updated++;
|
||||||
|
else counts.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.harvest.update({ where: { id: existing.id }, data: facts });
|
||||||
|
counts.updated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,17 +191,23 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Sy
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.harvest.update({ where: { slug }, data });
|
await prisma.harvest.update({ where: { id: existing.id }, data });
|
||||||
|
await replaceLegacySlug({ ...existing, ...data });
|
||||||
counts.updated++;
|
counts.updated++;
|
||||||
} else {
|
} else {
|
||||||
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
// Der Slug ist die oeffentliche Adresse und wird aus Sorte und Datum
|
||||||
|
// gebildet; mehrere Ernten derselben Sorte im selben Jahr bekommen
|
||||||
|
// ueber das Erntedatum je eine eigene (lib/harvest-slug.ts).
|
||||||
|
await writeWithFreeSlug(harvestSlugCandidates(type, date), (slug) =>
|
||||||
|
prisma.harvest.create({ data: { ...data, slug, apicoreHarvestId: source.id, isPublished: false } }),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
counts.created++;
|
counts.created++;
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
// Bewusst nicht fatal fuer den gesamten Sync-Lauf: die uebrigen Ernten
|
||||||
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
// werden weiter verarbeitet, der Fehler steht im Server-Log.
|
||||||
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
|
|
||||||
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
|
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,10 +306,13 @@ async function syncProducts(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batch = await prisma.batch.findUnique({
|
const found = await findBatchForBottling(bottling.id, bottling.lotNumber);
|
||||||
where: { code: bottling.lotNumber },
|
const batch = found
|
||||||
|
? await prisma.batch.findUnique({
|
||||||
|
where: { id: found.id },
|
||||||
include: { harvest: { select: { id: true, year: true } } },
|
include: { harvest: { select: { id: true, year: true } } },
|
||||||
});
|
})
|
||||||
|
: null;
|
||||||
if (!batch) {
|
if (!batch) {
|
||||||
counts.pending++;
|
counts.pending++;
|
||||||
continue;
|
continue;
|
||||||
@@ -292,6 +368,73 @@ async function syncProducts(
|
|||||||
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
|
* niemandem etwas, und der Fall bedeutet ohnehin, dass hier jemand
|
||||||
* hinschauen muss.
|
* hinschauen muss.
|
||||||
*/
|
*/
|
||||||
|
const SHOP_CATEGORIES: readonly ProductCategory[] = ["HONIG", "KERZEN", "PROPOLIS", "SONSTIGES"];
|
||||||
|
|
||||||
|
function shopCategoryOf(article: ApiCoreProduct): ProductCategory {
|
||||||
|
const category = article.shopCategory as ProductCategory | undefined;
|
||||||
|
return category && SHOP_CATEGORIES.includes(category) && category !== "HONIG" ? category : "SONSTIGES";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hergestellte Waren (Kerzen, Propolis-Tinkturen, Salben, ...): EIN
|
||||||
|
* Shop-Produkt je ApiCore-Artikel, nicht je Charge wie beim Honig. Kundinnen
|
||||||
|
* kaufen "Bienenwachskerze klein", nicht eine bestimmte Charge — welche Charge
|
||||||
|
* verkauft wird, entscheidet ApiCore (aelteste zuerst) und meldet es zurueck.
|
||||||
|
*
|
||||||
|
* Gleiche Regeln wie bei Honig-Entwuerfen: deaktiviert, ohne Preis, nie
|
||||||
|
* aktualisiert. Ein schon in Wabenhain gepflegtes Produkt verknuepft man
|
||||||
|
* im Admin ("ApiCore-Artikel verknuepfen") statt es neu anlegen zu lassen.
|
||||||
|
*/
|
||||||
|
async function syncManufacturedProducts(articles: ApiCoreProduct[]): Promise<ProductSyncCounts> {
|
||||||
|
const counts: ProductSyncCounts = { created: 0, skipped: 0, failed: 0, pending: 0, discontinued: 0 };
|
||||||
|
for (const article of articles.filter(isManufactured)) {
|
||||||
|
try {
|
||||||
|
const linked = await prisma.product.findUnique({
|
||||||
|
where: { apicoreProductId: article.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (linked) {
|
||||||
|
counts.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isSellableStatus(article.status)) {
|
||||||
|
counts.discontinued++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = slugifyProductName(article.name) || "produkt";
|
||||||
|
const sku = slugifyProductName(article.sku ?? "");
|
||||||
|
const slug = await firstFreeSlug(sku ? [base, `${base}-${sku}`] : [base]);
|
||||||
|
if (!slug) {
|
||||||
|
counts.failed++;
|
||||||
|
console.error(
|
||||||
|
`[apicore-sync] Für "${article.name}" ist keine freie Adresse frei — Produkt bitte von Hand anlegen und verknüpfen.`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.product.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
apicoreProductId: article.id,
|
||||||
|
name: article.name,
|
||||||
|
description: buildProductDescription(article),
|
||||||
|
weightGrams: deriveJarWeightGrams(null, article),
|
||||||
|
category: shopCategoryOf(article),
|
||||||
|
priceCents: 0,
|
||||||
|
pricePerKgCents: 0,
|
||||||
|
isActive: false,
|
||||||
|
// Der Bestand kommt im Abgleich direkt danach aus ApiCore.
|
||||||
|
inventory: { create: { quantity: 0 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
counts.created++;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
counts.failed++;
|
||||||
|
console.error(`[apicore-sync] Artikel "${article.name}" konnte nicht als Produkt importiert werden: ${describeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
||||||
for (const slug of candidates) {
|
for (const slug of candidates) {
|
||||||
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
const taken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
||||||
@@ -304,8 +447,21 @@ async function firstFreeSlug(candidates: string[]): Promise<string | null> {
|
|||||||
// Chargen
|
// Chargen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<SyncCounts> {
|
/**
|
||||||
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
* Die Wabenhain-Charge zu einer ApiCore-Abfuellung: zuerst ueber die
|
||||||
|
* gespeicherte ApiCore-ID, sonst — fuer Chargen aus der Zeit vor dieser
|
||||||
|
* Spalte — ueber die Losnummer, sofern die Charge noch keiner anderen
|
||||||
|
* Abfuellung gehoert.
|
||||||
|
*/
|
||||||
|
async function findBatchForBottling(bottlingId: string, lotNumber: string) {
|
||||||
|
const byId = await prisma.batch.findUnique({ where: { apicoreBottlingId: bottlingId } });
|
||||||
|
if (byId) return byId;
|
||||||
|
const byCode = await prisma.batch.findUnique({ where: { code: lotNumber } });
|
||||||
|
return byCode && byCode.apicoreBottlingId === null ? byCode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promise<BatchSyncCounts> {
|
||||||
|
const counts: BatchSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, stockConflicts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
if (!source.harvestSourceId) {
|
if (!source.harvestSourceId) {
|
||||||
@@ -322,13 +478,34 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
const existing = await findBatchForBottling(source.id, source.lotNumber);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const { changes, stockExceedsFilled } = batchFactChanges(existing, {
|
||||||
|
code: source.lotNumber,
|
||||||
|
fillDate,
|
||||||
|
bestBeforeDate,
|
||||||
|
glassCount: Math.max(Math.round(source.jarCount), 0),
|
||||||
|
});
|
||||||
|
const adopt = existing.apicoreBottlingId === null;
|
||||||
|
if (stockExceedsFilled) {
|
||||||
|
counts.stockConflicts++;
|
||||||
|
console.warn(
|
||||||
|
`[apicore-sync] Charge ${source.lotNumber}: Shop-Bestand (${existing.remainingGlasses}) liegt über der Abfüllmenge — bitte prüfen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.keys(changes).length === 0 && !adopt) {
|
||||||
counts.skipped++;
|
counts.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
await prisma.batch.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { ...changes, apicoreBottlingId: source.id },
|
||||||
|
});
|
||||||
|
counts.updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
|
where: { apicoreHarvestId: source.harvestSourceId },
|
||||||
});
|
});
|
||||||
if (!harvest) {
|
if (!harvest) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
@@ -342,6 +519,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
await prisma.batch.create({
|
await prisma.batch.create({
|
||||||
data: {
|
data: {
|
||||||
code: source.lotNumber,
|
code: source.lotNumber,
|
||||||
|
apicoreBottlingId: source.id,
|
||||||
harvestId: harvest.id,
|
harvestId: harvest.id,
|
||||||
fillDate,
|
fillDate,
|
||||||
bestBeforeDate,
|
bestBeforeDate,
|
||||||
@@ -370,11 +548,13 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
|
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
|
||||||
* Wert koennte Admins auf eine fremde Seite schicken.
|
* Wert koennte Admins auf eine fremde Seite schicken.
|
||||||
*/
|
*/
|
||||||
const RETURN_TARGETS = ["/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
|
// "/admin" ist der Normalfall (der eine Sync-Knopf sitzt auf dem Dashboard);
|
||||||
|
// die uebrigen bleiben erlaubt, damit alte Lesezeichen/Formulare nicht ins Leere laufen.
|
||||||
|
const RETURN_TARGETS = ["/admin", "/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
|
||||||
|
|
||||||
function returnTargetFrom(formData: FormData): string {
|
function returnTargetFrom(formData: FormData): string {
|
||||||
const requested = formData.get("returnTo");
|
const requested = formData.get("returnTo");
|
||||||
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin/ernten";
|
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -388,60 +568,51 @@ function returnTargetFrom(formData: FormData): string {
|
|||||||
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||||
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||||
*
|
*
|
||||||
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
* Alle Daten kommen aus EINEM Abruf (ApiCore GET /shop-sync) und werden erst
|
||||||
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
* danach verarbeitet: scheitert der Abruf, wird gar nichts geschrieben — es
|
||||||
*
|
* entsteht nie ein halber Stand aus neuen Ernten und alten Chargen.
|
||||||
* Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
|
|
||||||
* uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
|
|
||||||
* dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
|
|
||||||
*/
|
*/
|
||||||
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const returnTo = returnTargetFrom(formData);
|
const returnTo = returnTargetFrom(formData);
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
let source: ApiCoreShopData;
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
|
||||||
let articles: ApiCoreProduct[];
|
|
||||||
try {
|
try {
|
||||||
session = await openApiCoreSession();
|
source = await fetchApiCoreShopData(getApiCoreConnection());
|
||||||
[sourceHarvests, articles] = await Promise.all([
|
|
||||||
fetchApiCoreHarvests(session),
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Ernten
|
// 1. Ernten
|
||||||
const harvests = await syncHarvests(sourceHarvests);
|
const harvests = await syncHarvests(source.harvests);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
entityType: "Harvest",
|
entityType: "Harvest",
|
||||||
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
detail:
|
||||||
|
`${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` +
|
||||||
|
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
// 2. Chargen
|
||||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
const batches = await syncBatches(source.bottlings);
|
||||||
let batchLoadError: string | null = null;
|
|
||||||
try {
|
|
||||||
bottlings = await fetchApiCoreBottlingBatches(session);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
batchLoadError = describeError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const batches = batchLoadError
|
|
||||||
? { created: 0, updated: 0, skipped: 0, failed: 0 }
|
|
||||||
: await syncBatches(bottlings);
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batchLoadError
|
detail:
|
||||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
`${batches.created} neu, ${batches.updated} aktualisiert, ${batches.skipped} unverändert, ` +
|
||||||
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
|
`${batches.stockConflicts} Bestandskonflikte, ${batches.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
// 3. Produkte — Honig je Abfuellung ein Jahrgang, hergestellte Waren je Artikel.
|
||||||
const products = await syncProducts(articles, bottlings);
|
const honeyProducts = await syncProducts(source.products, source.bottlings);
|
||||||
|
const manufacturedProducts = await syncManufacturedProducts(source.products);
|
||||||
|
const products: ProductSyncCounts = {
|
||||||
|
created: honeyProducts.created + manufacturedProducts.created,
|
||||||
|
skipped: honeyProducts.skipped + manufacturedProducts.skipped,
|
||||||
|
failed: honeyProducts.failed + manufacturedProducts.failed,
|
||||||
|
pending: honeyProducts.pending,
|
||||||
|
discontinued: honeyProducts.discontinued + manufacturedProducts.discontinued,
|
||||||
|
};
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "product.sync_apicore",
|
action: "product.sync_apicore",
|
||||||
entityType: "Product",
|
entityType: "Product",
|
||||||
@@ -450,15 +621,33 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
|
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4. Bestand — offene Buchungen senden, dann alle verknuepften Produkte
|
||||||
|
// auf den ApiCore-Rest setzen. Scheitert das, bleibt der Sync trotzdem
|
||||||
|
// gueltig; der CronJob holt den Abgleich nach.
|
||||||
|
try {
|
||||||
|
const delivered = await deliverPendingStockEvents();
|
||||||
|
const reconciled = await reconcileApiCoreStock();
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "stock.sync_apicore",
|
||||||
|
entityType: "Inventory",
|
||||||
|
detail:
|
||||||
|
`${delivered.sent} Buchungen bestätigt, ${delivered.rejected} abgelehnt, ${delivered.failed} offen; ` +
|
||||||
|
`${reconciled.updated} Bestände abgeglichen, ${reconciled.missing} in ApiCore nicht gefunden`,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[apicore-sync] Bestandsabgleich fehlgeschlagen: ${describeError(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect(
|
redirect(
|
||||||
`${returnTo}?syncDone=1` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
`&hProtected=${harvests.protectedDrafts}` +
|
||||||
|
`&bNew=${batches.created}&bUpdated=${batches.updated}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||||
|
`&bConflicts=${batches.stockConflicts}` +
|
||||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}`
|
||||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import type { ArchiveKind } from "@/lib/archive";
|
||||||
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
|
const LIST_PATH: Record<ArchiveKind, string> = {
|
||||||
|
orders: "/admin/bestellungen",
|
||||||
|
returns: "/admin/retouren",
|
||||||
|
products: "/admin/produkte",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTITY_TYPE: Record<ArchiveKind, string> = {
|
||||||
|
orders: "Order",
|
||||||
|
returns: "ReturnRequest",
|
||||||
|
products: "Product",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isArchiveKind(value: string): value is ArchiveKind {
|
||||||
|
return value in LIST_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiviert einen Eintrag von Hand oder holt ihn zurueck.
|
||||||
|
*
|
||||||
|
* Archivieren setzt archivedManually, damit die Automatik den Eintrag nicht
|
||||||
|
* beim naechsten Seitenaufruf wieder in die aktive Liste holt.
|
||||||
|
* Wiederherstellen setzt die Markierung zurueck; da @updatedAt dabei die
|
||||||
|
* Bearbeitungszeit erneuert, bleibt der Eintrag einen weiteren Monat aktiv.
|
||||||
|
*/
|
||||||
|
export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const kind = str(formData, "kind");
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const archive = str(formData, "mode") === "archive";
|
||||||
|
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
|
||||||
|
|
||||||
|
const data = archive
|
||||||
|
? { archivedAt: new Date(), archivedManually: true }
|
||||||
|
: { archivedAt: null, archivedManually: false };
|
||||||
|
|
||||||
|
// updateMany statt update: eine nicht (mehr) vorhandene ID ist dann kein
|
||||||
|
// Fehler, sondern schlicht count 0 — z.B. nach dem Loeschen in einem
|
||||||
|
// zweiten Tab.
|
||||||
|
const where = { id };
|
||||||
|
const result =
|
||||||
|
kind === "orders"
|
||||||
|
? await prisma.order.updateMany({ where, data })
|
||||||
|
: kind === "returns"
|
||||||
|
? await prisma.returnRequest.updateMany({ where, data })
|
||||||
|
: await prisma.product.updateMany({ where, data });
|
||||||
|
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: archive ? "archive.manual_archive" : "archive.manual_restore",
|
||||||
|
entityType: ENTITY_TYPE[kind],
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH[kind]);
|
||||||
|
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
import { fail, succeed } from "@/lib/flash";
|
||||||
|
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||||
|
import { settingsPathForGroup } from "@/lib/settings-tabs";
|
||||||
|
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
|
||||||
|
|
||||||
|
const COMPANY_EMAIL_KEY = "company.email";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||||
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
|
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
|
||||||
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
|
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
|
||||||
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
|
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
|
||||||
* "en::{key}" (siehe admin/inhalte/page.tsx).
|
* "en::{key}" (siehe admin/einstellungen/content-form.tsx).
|
||||||
*/
|
*/
|
||||||
export async function updateSiteContentAction(formData: FormData): Promise<void> {
|
export async function updateSiteContentAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -48,7 +53,29 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
updates.push({ key: def.key, valueDe, valueEn: null });
|
// Die Firmen-E-Mail darf so eingetragen werden, wie sie gemeint ist —
|
||||||
|
// auch verschleiert ("info [at] …", "info [á] …"). Gespeichert wird der
|
||||||
|
// Eintrag unveraendert; die Website rechnet ihn in die echte Adresse um
|
||||||
|
// und zeigt sie geschuetzt an (lib/contact-email.ts). Abgelehnt wird nur,
|
||||||
|
// was sich in keine gueltige Adresse umrechnen laesst — sonst stuende im
|
||||||
|
// Impressum ein Kontaktweg, der ins Leere fuehrt.
|
||||||
|
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
|
||||||
|
return fail(
|
||||||
|
settingsPathForGroup(group),
|
||||||
|
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder
|
||||||
|
// eine echte Adresse, sonst verschwaenden die Kopien stillschweigend.
|
||||||
|
// Genau eine Adresse: "[email protected], [email protected]" waere fuer nodemailer eine Liste.
|
||||||
|
if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && !/^[^,;]+$/.test(normalizeContactEmail(valueDe) ?? "")) {
|
||||||
|
return fail(
|
||||||
|
settingsPathForGroup(group),
|
||||||
|
"„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. [email protected] eintragen oder das Feld leer lassen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trimmed = def.key === COMPANY_EMAIL_KEY || def.key === INVOICE_COPY_KEY;
|
||||||
|
updates.push({ key: def.key, valueDe: trimmed ? valueDe.trim() : valueDe, valueEn: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,5 +97,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
redirect("/admin/inhalte?saved=1");
|
return succeed(settingsPathForGroup(group), "Einstellungen gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -10,6 +9,9 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
|||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||||
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
import { certificateDirsOfCustomer, hasBlockingSubscription, removeCertificateDirs } from "@/lib/sponsorship/customer-deletion";
|
||||||
|
import { discardPendingCheckouts } from "@/lib/sponsorship-subscription/pending";
|
||||||
|
|
||||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -21,12 +23,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
role: str(formData, "role") || "KUNDE",
|
role: str(formData, "role") || "KUNDE",
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||||
@@ -43,7 +45,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createAndSendPasswordResetEmail(customer);
|
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "customer.create",
|
action: "customer.create",
|
||||||
entityType: "Customer",
|
entityType: "Customer",
|
||||||
@@ -52,7 +54,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
if (!mailSent) {
|
||||||
|
return fail(
|
||||||
|
"/admin/benutzer",
|
||||||
|
`Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -67,23 +75,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
|||||||
role: str(formData, "role") || "KUNDE",
|
role: str(formData, "role") || "KUNDE",
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === admin.id && parsed.data.role !== admin.role) {
|
if (id === admin.id && parsed.data.role !== admin.role) {
|
||||||
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await prisma.customer.findUnique({ where: { id } });
|
const target = await prisma.customer.findUnique({ where: { id } });
|
||||||
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
|
if (!target) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||||
|
|
||||||
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
||||||
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||||
if (emailOwner && emailOwner.id !== id) {
|
if (emailOwner && emailOwner.id !== id) {
|
||||||
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailChanged = parsed.data.email !== target.email;
|
const emailChanged = parsed.data.email !== target.email;
|
||||||
@@ -116,7 +124,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
return succeed("/admin/benutzer", "Änderungen gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -124,10 +132,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
if (id === admin.id) {
|
if (id === admin.id) {
|
||||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||||
}
|
}
|
||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||||
@@ -139,6 +147,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
|
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -156,6 +165,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
|||||||
detail: unblocked.email,
|
detail: unblocked.email,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
|
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||||
@@ -163,16 +173,27 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
if (id === admin.id) {
|
if (id === admin.id) {
|
||||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||||
}
|
}
|
||||||
if (await isLastAdmin(id)) {
|
if (await isLastAdmin(id)) {
|
||||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||||
|
}
|
||||||
|
// Laufendes Abo: erst kuendigen bzw. bei Stripe beenden — sonst zoege
|
||||||
|
// Stripe weiter ab, ohne dass es ein Konto gibt (lib/sponsorship/customer-deletion.ts).
|
||||||
|
if (await hasBlockingSubscription(id)) {
|
||||||
|
return fail(
|
||||||
|
"/admin/benutzer",
|
||||||
|
"Dieses Konto hat ein laufendes Patenschafts-Abo. Bitte das Abo zuerst kündigen und sein Ende abwarten (oder bei Stripe beenden) — erst danach lässt sich das Konto löschen."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const certificateDirs = await certificateDirsOfCustomer(id);
|
||||||
|
await discardPendingCheckouts(id);
|
||||||
const deleted = await prisma.customer.delete({ where: { id } });
|
const deleted = await prisma.customer.delete({ where: { id } });
|
||||||
|
await removeCertificateDirs(certificateDirs);
|
||||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||||
revalidatePath("/admin/benutzer");
|
revalidatePath("/admin/benutzer");
|
||||||
redirect("/admin/benutzer");
|
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||||
@@ -181,14 +202,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
|
|||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id } });
|
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||||
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||||
|
|
||||||
await createAndSendPasswordResetEmail(customer);
|
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "customer.password_reset_sent",
|
action: "customer.password_reset_sent",
|
||||||
entityType: "Customer",
|
entityType: "Customer",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
detail: customer.email,
|
detail: customer.email,
|
||||||
});
|
});
|
||||||
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
|
if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
|
||||||
|
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
||||||
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
||||||
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) {
|
if (!order) {
|
||||||
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.fulfillmentMethod !== "VERSAND") {
|
if (order.fulfillmentMethod !== "VERSAND") {
|
||||||
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||||
}
|
}
|
||||||
if (order.payment?.status !== "SUCCEEDED") {
|
if (order.payment?.status !== "SUCCEEDED") {
|
||||||
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||||
}
|
}
|
||||||
if (order.shipment?.trackingNumber) {
|
if (order.shipment?.trackingNumber) {
|
||||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
||||||
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
claimed = false;
|
claimed = false;
|
||||||
}
|
}
|
||||||
if (!claimed) {
|
if (!claimed) {
|
||||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
||||||
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (error instanceof DhlNotConfiguredError) {
|
if (error instanceof DhlNotConfiguredError) {
|
||||||
fail(detailPath, error.message);
|
return fail(detailPath, error.message);
|
||||||
}
|
}
|
||||||
if (error instanceof DhlApiError) {
|
if (error instanceof DhlApiError) {
|
||||||
fail(detailPath, error.message);
|
return fail(detailPath, error.message);
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
fail(
|
return fail(
|
||||||
detailPath,
|
detailPath,
|
||||||
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
||||||
);
|
);
|
||||||
@@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
revalidatePath(detailPath);
|
revalidatePath(detailPath);
|
||||||
|
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { discountCodeSchema } from "@/lib/validation";
|
import { discountCodeSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
function parseDiscountCodeForm(formData: FormData) {
|
function parseDiscountCodeForm(formData: FormData) {
|
||||||
const rawMax = str(formData, "maxUses");
|
const rawMax = str(formData, "maxUses");
|
||||||
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseDiscountCodeForm(formData);
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await prisma.discountCode.create({ data: parsed.data });
|
const code = await prisma.discountCode.create({ data: parsed.data });
|
||||||
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseDiscountCodeForm(formData);
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||||
if (existing && existing.id !== id) {
|
if (existing && existing.id !== id) {
|
||||||
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
||||||
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
try {
|
try {
|
||||||
deleted = await prisma.discountCode.delete({ where: { id } });
|
deleted = await prisma.discountCode.delete({ where: { id } });
|
||||||
} catch {
|
} catch {
|
||||||
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/gutscheine");
|
revalidatePath("/admin/gutscheine");
|
||||||
redirect("/admin/gutscheine");
|
return succeed("/admin/gutscheine", "Gutschein gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
|
||||||
|
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
||||||
@@ -26,25 +28,33 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
|||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const year = num(formData, "year");
|
const year = num(formData, "year");
|
||||||
const type = str(formData, "type");
|
const type = str(formData, "type");
|
||||||
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
const harvestDate = new Date(str(formData, "harvestDate"));
|
||||||
|
if (!type || !Number.isInteger(year) || year < 1 || Number.isNaN(harvestDate.getTime())) {
|
||||||
|
return fail("/admin/ernten", "Bitte Sorte, Jahr und ein gültiges Erntedatum angeben.");
|
||||||
|
}
|
||||||
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
||||||
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
||||||
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
||||||
// Einfuehrung von isPublished).
|
// Einfuehrung von isPublished).
|
||||||
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
||||||
|
|
||||||
const harvest = await prisma.harvest.create({
|
// Dieselbe Sorte darf mehrmals im Jahr geerntet werden; die zweite bekommt
|
||||||
|
// eine Adresse mit Erntedatum statt eines Fehlers (lib/harvest-slug.ts).
|
||||||
|
const harvest = await writeWithFreeSlug(harvestSlugCandidates(type, harvestDate), (slug) =>
|
||||||
|
prisma.harvest.create({
|
||||||
data: {
|
data: {
|
||||||
year,
|
year,
|
||||||
type,
|
type,
|
||||||
slug,
|
slug,
|
||||||
harvestDate: new Date(str(formData, "harvestDate")),
|
harvestDate,
|
||||||
location: str(formData, "location") || "Altenmünster",
|
location: str(formData, "location") || "Altenmünster",
|
||||||
description: str(formData, "description"),
|
description: str(formData, "description"),
|
||||||
images: imageSlots(formData),
|
images: imageSlots(formData),
|
||||||
isPublished,
|
isPublished,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.create",
|
action: "harvest.create",
|
||||||
@@ -55,7 +65,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||||
@@ -80,7 +90,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,10 +109,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { batches: true, products: true } } },
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
});
|
});
|
||||||
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
|
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||||
|
|
||||||
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/ernten",
|
"/admin/ernten",
|
||||||
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
||||||
);
|
);
|
||||||
@@ -119,7 +129,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Ernte gelöscht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,7 +158,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
|
|||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/ernten");
|
revalidatePath("/ernten");
|
||||||
redirect("/admin/ernten");
|
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBatchAction(formData: FormData): Promise<void> {
|
export async function createBatchAction(formData: FormData): Promise<void> {
|
||||||
@@ -169,7 +179,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBatchAction(formData: FormData): Promise<void> {
|
export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||||
@@ -190,7 +200,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,16 +220,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { products: true, orderItems: true } } },
|
include: { _count: { select: { products: true, orderItems: true } } },
|
||||||
});
|
});
|
||||||
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
|
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
|
||||||
|
|
||||||
if (batch._count.orderItems > 0) {
|
if (batch._count.orderItems > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/chargen",
|
"/admin/chargen",
|
||||||
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (batch._count.products > 0) {
|
if (batch._count.products > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/chargen",
|
"/admin/chargen",
|
||||||
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
||||||
);
|
);
|
||||||
@@ -230,5 +240,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
||||||
|
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
redirect("/admin/chargen");
|
return succeed("/admin/chargen", "Charge gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -3,17 +3,19 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str } from "./_shared";
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
import { isInquiryStatus } from "@/lib/inquiry-status";
|
||||||
|
|
||||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const status = str(formData, "status");
|
const status = str(formData, "status");
|
||||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
|
if (!isInquiryStatus(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
||||||
|
|
||||||
await prisma.businessInquiry.update({
|
await prisma.businessInquiry.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
@@ -22,6 +24,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
detail: status,
|
detail: status,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/anfragen");
|
revalidatePath("/admin/anfragen");
|
||||||
|
await setFlash("success", "Status der Anfrage gespeichert.");
|
||||||
}
|
}
|
||||||
+274
-15
@@ -6,11 +6,23 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { sendMail } from "@/lib/mail";
|
import { sendMail } from "@/lib/mail";
|
||||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||||
import { restockOrderItems } from "@/lib/inventory";
|
import {
|
||||||
|
InsufficientStockError,
|
||||||
|
needsConfirmToChangeStatus,
|
||||||
|
needsReserveOnReopen,
|
||||||
|
needsRestockOnCancel,
|
||||||
|
reserveOrderItems,
|
||||||
|
restockOrderItems,
|
||||||
|
} from "@/lib/inventory";
|
||||||
|
import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
|
||||||
import { getStripeClient } from "@/lib/stripe";
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||||
|
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||||
|
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||||
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||||
|
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
|
||||||
const ORDER_STATUSES = [
|
const ORDER_STATUSES = [
|
||||||
"NEU",
|
"NEU",
|
||||||
@@ -54,14 +66,15 @@ async function settleCancelledOrder(order: {
|
|||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
previousStatus: OrderStatus;
|
previousStatus: OrderStatus;
|
||||||
stripeCheckoutSessionId: string | null;
|
stripeCheckoutSessionId: string | null;
|
||||||
payment: { status: PaymentStatus; stripePaymentIntentId: string | null } | null;
|
payment: { status: PaymentStatus; stripePaymentIntentId: string | null; amountCents: number } | null;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
// Nur eine tatsaechlich eingezogene Zahlung wird erstattet. "FAILED" gab es
|
||||||
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
// nie zu erstatten, "REFUNDED" waere eine zweite Erstattung desselben
|
||||||
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
// Betrags, und "PENDING" ist noch gar nicht abgeschlossen.
|
||||||
if (order.payment?.status === "SUCCEEDED") {
|
// 0-€-Zahlungen (Abo-Honiglieferung, Provider "abo") haben nichts zu erstatten.
|
||||||
|
if (order.payment?.status === "SUCCEEDED" && order.payment.amountCents > 0) {
|
||||||
const paymentIntentId = order.payment.stripePaymentIntentId;
|
const paymentIntentId = order.payment.stripePaymentIntentId;
|
||||||
if (!paymentIntentId) {
|
if (!paymentIntentId) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -121,8 +134,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
const orderId = str(formData, "orderId");
|
const orderId = str(formData, "orderId");
|
||||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||||
|
// Gesetzt vom Bestaetigungsdialog der Statusauswahl, nur bei stornierten Bestellungen.
|
||||||
|
const confirmedReopen = formData.get("confirmReopen") === "1";
|
||||||
if (!ORDER_STATUSES.includes(status)) {
|
if (!ORDER_STATUSES.includes(status)) {
|
||||||
fail(detailPath, "Ungültiger Status.");
|
return fail(detailPath, "Ungültiger Status.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
@@ -135,12 +150,28 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
stripeCheckoutSessionId: true,
|
stripeCheckoutSessionId: true,
|
||||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
payment: { select: { status: true, stripePaymentIntentId: true, amountCents: true } },
|
||||||
items: { select: { productId: true, quantity: true } },
|
items: { select: { productId: true, quantity: true, productName: true, product: { select: { category: true } } } },
|
||||||
|
returnRequest: { select: { status: true } },
|
||||||
|
invoice: { select: { cancellation: { select: { number: true } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!previous) return null;
|
if (!previous) return null;
|
||||||
|
|
||||||
|
// Eine stornierte Bestellung wieder zu oeffnen verlangt die ausdrueckliche
|
||||||
|
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
|
||||||
|
// sie wird nichts veraendert.
|
||||||
|
const reopening = needsConfirmToChangeStatus(previous.status, status);
|
||||||
|
// Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet;
|
||||||
|
// ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue
|
||||||
|
// Bestellung anlegen.
|
||||||
|
if (reopening && previous.invoice?.cancellation) {
|
||||||
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const };
|
||||||
|
}
|
||||||
|
if (reopening && !confirmedReopen) {
|
||||||
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
|
||||||
|
}
|
||||||
|
|
||||||
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
|
// Nur wahr, wenn DIESER Aufruf die Bestellung tatsaechlich storniert hat.
|
||||||
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
|
// Steuert weiter unten Erstattung und Schliessen der Zahlungssitzung —
|
||||||
// beides darf bei einem zweiten Klick auf eine bereits stornierte
|
// beides darf bei einem zweiten Klick auf eine bereits stornierte
|
||||||
@@ -162,10 +193,45 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
if (claim.count === 1) {
|
if (claim.count === 1) {
|
||||||
cancelledNow = true;
|
cancelledNow = true;
|
||||||
|
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
|
||||||
await restockOrderItems(tx, previous.items);
|
await restockOrderItems(tx, previous.items);
|
||||||
}
|
}
|
||||||
|
// War die Bestellung bezahlt, steht ein Verkauf in ApiCore — der
|
||||||
|
// wird zurueckgebucht. Unbezahlte Bestellungen haben keinen, dann
|
||||||
|
// passiert hier nichts.
|
||||||
|
await recordOrderReturn(tx, orderId);
|
||||||
|
}
|
||||||
|
} else if (reopening) {
|
||||||
|
// Gegenbuchung zur Stornierung: die dabei zurueckgebuchte Ware wird
|
||||||
|
// wieder abgezogen — sonst buchte ein spaeteres erneutes Stornieren sie
|
||||||
|
// ein zweites Mal zurueck (+2 statt +1). Atomar: nur, wenn die
|
||||||
|
// Bestellung in diesem Moment noch storniert ist.
|
||||||
|
const reopened = await tx.order.updateMany({
|
||||||
|
where: { id: orderId, status: "STORNIERT" },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
if (reopened.count === 0) {
|
||||||
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const };
|
||||||
|
}
|
||||||
|
if (needsReserveOnReopen(previous.returnRequest?.status ?? null)) {
|
||||||
|
// Wirft InsufficientStockError, wenn die Ware inzwischen verkauft ist —
|
||||||
|
// die Transaktion nimmt dann auch den Statuswechsel zurueck.
|
||||||
|
await reserveOrderItems(tx, previous.items);
|
||||||
|
// Eine bezahlte Bestellung ist damit wieder verkauft.
|
||||||
|
if (previous.payment?.status === "SUCCEEDED") {
|
||||||
|
await recordOrderSale(tx, orderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
// Atomar gegen den aktuellen Zeilenstand: hat ein zweiter Tab die
|
||||||
|
// Bestellung zwischen Lesen und Schreiben storniert, bleibt sie das.
|
||||||
|
const moved = await tx.order.updateMany({
|
||||||
|
where: { id: orderId, status: { not: "STORNIERT" } },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
if (moved.count === 0) {
|
||||||
|
return { ...previous, shipment: null, cancelledNow: false, blocked: "changed" as const };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
|
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
|
||||||
@@ -192,13 +258,45 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...previous, shipment, cancelledNow };
|
return { ...previous, shipment, cancelledNow, blocked: false as const };
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
if (error instanceof InsufficientStockError) return { insufficientStock: error.productName };
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (result && !("insufficientStock" in result)) {
|
||||||
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
scheduleApiCoreStockSync(result.items.map((item) => item.productId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result && "insufficientStock" in result) {
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
`Die Bestellung kann nicht wieder geöffnet werden: für „${result.insufficientStock}“ ist nicht mehr genug Bestand da. Sie bleibt storniert.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||||
|
}
|
||||||
|
if (result.blocked === "needs-confirm") {
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
"Diese Bestellung ist storniert. Zum Wiederöffnen muss die Warnung bestätigt werden — der Status wurde nicht geändert."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.blocked === "changed") {
|
||||||
|
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
|
||||||
|
}
|
||||||
|
if (result.blocked === "cancellation-invoice") {
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
`Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||||
|
let mailSent: boolean | undefined;
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "order.status_change",
|
action: "order.status_change",
|
||||||
@@ -224,6 +322,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
||||||
payment: result.payment,
|
payment: result.payment,
|
||||||
});
|
});
|
||||||
|
// Hatte die Bestellung eine Rechnung, wird sie durch eine
|
||||||
|
// Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung
|
||||||
|
// oben scheiterte: verkauft ist dann trotzdem nichts mehr.
|
||||||
|
try {
|
||||||
|
await issueCancellationInvoice(orderId, "STORNIERUNG");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error);
|
||||||
|
stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden. Sie entsteht spätestens mit der Stripe-Erstattung; sonst bitte den Support informieren.");
|
||||||
|
}
|
||||||
|
|
||||||
if (stornoWarnings.length > 0) {
|
if (stornoWarnings.length > 0) {
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
@@ -236,7 +343,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
||||||
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
||||||
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
||||||
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +362,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
});
|
});
|
||||||
await sendMail({ to: result.email, subject, html, text });
|
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||||
}
|
}
|
||||||
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||||
const { subject, html, text } = await pickupReadyEmail({
|
const { subject, html, text } = await pickupReadyEmail({
|
||||||
@@ -263,11 +370,155 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
orderNumber: result.orderNumber,
|
orderNumber: result.orderNumber,
|
||||||
firstName: result.firstName,
|
firstName: result.firstName,
|
||||||
});
|
});
|
||||||
await sendMail({ to: result.email, subject, html, text });
|
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
|
const done = `Status von ${result.orderNumber} gespeichert.`;
|
||||||
|
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht eine Bestellung endgueltig — aber nur dort, wo nie ein
|
||||||
|
* Geschaeftsvorfall daraus geworden ist.
|
||||||
|
*
|
||||||
|
* WARUM NICHT JEDE BESTELLUNG:
|
||||||
|
* Eine bezahlte Bestellung und die Rechnung dazu sind Buchungsbelege. § 147
|
||||||
|
* AO und § 257 HGB verlangen, dass sie jahrelang vorgelegt werden koennen.
|
||||||
|
* Ein Loeschknopf darauf wuerde also nicht aufraeumen, sondern Unterlagen
|
||||||
|
* vernichten, die im Zweifel das Finanzamt sehen will — und zwar ohne
|
||||||
|
* Rueckgaengig. Wer eine bezahlte Bestellung loswerden will, storniert sie:
|
||||||
|
* das erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.
|
||||||
|
*
|
||||||
|
* Erlaubt ist das Loeschen deshalb nur, wenn ALLE drei Bedingungen gelten:
|
||||||
|
* - keine Zahlung erfasst (auch keine fehlgeschlagene — sie belegt einen
|
||||||
|
* Zahlungsversuch),
|
||||||
|
* - keine Rechnung erzeugt,
|
||||||
|
* - Status NEU oder STORNIERT.
|
||||||
|
* Das trifft genau auf abgebrochene Checkouts und Testbestellungen zu.
|
||||||
|
*
|
||||||
|
* LAGERBESTAND: bei NEU ist die Ware noch reserviert und wird beim Loeschen
|
||||||
|
* zurueckgebucht. Bei STORNIERT ist das bereits passiert (siehe
|
||||||
|
* updateOrderStatusAction) — dort noch einmal gutzuschreiben wuerde den
|
||||||
|
* Bestand erhoehen, ohne dass je ein Glas zurueckkam.
|
||||||
|
*
|
||||||
|
* ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
|
||||||
|
* Stripe-Sitzung haben. Wird die Bestellung geloescht, ohne sie zu
|
||||||
|
* schliessen, kann die Kundschaft Minuten spaeter noch bezahlen — der Webhook
|
||||||
|
* findet dann keine Bestellung mehr, und das Geld liegt bei Stripe, ohne dass
|
||||||
|
* im Shop irgendetwas davon zu sehen waere. Deshalb wird die Sitzung VOR dem
|
||||||
|
* Loeschen geschlossen, und wenn das nicht sicher gelingt, wird gar nicht
|
||||||
|
* geloescht.
|
||||||
|
*/
|
||||||
|
export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const orderId = str(formData, "orderId");
|
||||||
|
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
orderNumber: true,
|
||||||
|
totalCents: true,
|
||||||
|
currency: true,
|
||||||
|
email: true,
|
||||||
|
stripeCheckoutSessionId: true,
|
||||||
|
payment: { select: { status: true } },
|
||||||
|
invoice: { select: { invoiceNumber: true } },
|
||||||
|
items: { select: { productId: true, quantity: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) {
|
||||||
|
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = checkOrderDeletable({
|
||||||
|
status: order.status,
|
||||||
|
hasPayment: order.payment !== null,
|
||||||
|
hasInvoice: order.invoice !== null,
|
||||||
|
});
|
||||||
|
if (!verdict.deletable) {
|
||||||
|
return fail(detailPath, {
|
||||||
|
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
|
||||||
|
payment:
|
||||||
|
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
|
||||||
|
status: `Löschen ist nur bei Bestellungen im Status NEU oder STORNIERT möglich, diese steht auf ${order.status}. Storniere sie zuerst.`,
|
||||||
|
}[verdict.reason]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem Loeschen, nicht danach: nach dem Loeschen gaebe es keine
|
||||||
|
// Bestellung mehr, auf die sich eine noch eingehende Zahlung beziehen
|
||||||
|
// koennte.
|
||||||
|
if (order.status === "NEU" && order.stripeCheckoutSessionId) {
|
||||||
|
try {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||||
|
if (session.status === "open") {
|
||||||
|
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||||
|
} else if (session.status === "complete") {
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// redirect() aus fail() wirft intern eine Ausnahme, die Next.js
|
||||||
|
// auswertet — sie darf hier nicht als Stripe-Fehler behandelt und
|
||||||
|
// verschluckt werden.
|
||||||
|
if (error instanceof Error && error.message === "NEXT_REDIRECT") throw error;
|
||||||
|
console.error(
|
||||||
|
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.$transaction(async (tx) => {
|
||||||
|
// Die Bedingungen stehen bewusst noch einmal im DELETE selbst und nicht
|
||||||
|
// nur in den Pruefungen oben: zwischen Lesen und Loeschen koennte der
|
||||||
|
// Stripe-Webhook eine Zahlung eingetragen haben. Die WHERE-Klausel wird
|
||||||
|
// atomar gegen den aktuellen Zeilenstand ausgewertet, die Pruefung oben
|
||||||
|
// gegen einen Schnappschuss.
|
||||||
|
const claim = await tx.order.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
status: { in: ["NEU", "STORNIERT"] },
|
||||||
|
payment: { is: null },
|
||||||
|
invoice: { is: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (claim.count !== 1) return false;
|
||||||
|
if (needsRestockOnDelete(order.status)) {
|
||||||
|
await restockOrderItems(tx, order.items);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return fail(
|
||||||
|
detailPath,
|
||||||
|
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ausfuehrlicher als bei anderen Aktionen, und das mit Absicht: der
|
||||||
|
// Datensatz ist weg, dieser Eintrag ist die einzige verbleibende Spur.
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "order.delete",
|
||||||
|
entityType: "Order",
|
||||||
|
entityId: orderId,
|
||||||
|
detail: `${order.orderNumber} (Status ${order.status}, ${(order.totalCents / 100).toFixed(2)} ${order.currency}, ${order.email}, ${order.items.length} Position(en)) endgültig gelöscht`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bestellungen");
|
||||||
|
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,7 +539,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||||
});
|
});
|
||||||
if (!order) return;
|
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||||
|
|
||||||
await prisma.shipment.upsert({
|
await prisma.shipment.upsert({
|
||||||
where: { orderId },
|
where: { orderId },
|
||||||
@@ -303,6 +554,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mailSent: boolean | undefined;
|
||||||
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||||
const { subject, html, text } = await shipmentEmail({
|
const { subject, html, text } = await shipmentEmail({
|
||||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
@@ -312,9 +564,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
|||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
});
|
});
|
||||||
await sendMail({ to: order.email, subject, html, text });
|
mailSent = await sendMail({ to: order.email, subject, html, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/bestellungen");
|
revalidatePath("/admin/bestellungen");
|
||||||
revalidatePath(`/admin/bestellungen/${orderId}`);
|
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||||
|
if (mailSent === false) {
|
||||||
|
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
|
||||||
|
}
|
||||||
|
await setFlash(
|
||||||
|
"success",
|
||||||
|
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
|
||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
|
import { planInventoryEdit } from "@/lib/inventory";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
||||||
@@ -18,9 +21,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
|||||||
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
||||||
* Produkt bestellbar.
|
* Produkt bestellbar.
|
||||||
*/
|
*/
|
||||||
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
|
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
|
||||||
if (isActive && priceCents <= 0) {
|
if (isActive && priceCents <= 0) {
|
||||||
fail(
|
return fail(
|
||||||
redirectPath,
|
redirectPath,
|
||||||
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
||||||
);
|
);
|
||||||
@@ -29,7 +32,15 @@ function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCent
|
|||||||
|
|
||||||
export async function createProductAction(formData: FormData): Promise<void> {
|
export async function createProductAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
const parsedIdentity = adminProductIdentitySchema.safeParse({
|
||||||
|
name: str(formData, "name"),
|
||||||
|
slug: str(formData, "slug"),
|
||||||
|
category: str(formData, "category") || "HONIG",
|
||||||
|
});
|
||||||
|
if (!parsedIdentity.success) {
|
||||||
|
return fail("/admin/produkte/neu", parsedIdentity.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
}
|
||||||
|
const { slug, category } = parsedIdentity.data;
|
||||||
|
|
||||||
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
||||||
intensityMild: str(formData, "intensityMild"),
|
intensityMild: str(formData, "intensityMild"),
|
||||||
@@ -42,14 +53,13 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
vatRatePercent: str(formData, "vatRatePercent"),
|
vatRatePercent: str(formData, "vatRatePercent"),
|
||||||
});
|
});
|
||||||
if (!parsedNumbers.success) {
|
if (!parsedNumbers.success) {
|
||||||
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
const numbers = parsedNumbers.data;
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
const isActive = formData.get("isActive") === "on";
|
const isActive = formData.get("isActive") === "on";
|
||||||
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||||
|
|
||||||
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
|
||||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||||
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
|
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
|
||||||
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
|
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
|
||||||
@@ -64,7 +74,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
// hervorgeht, dass lediglich die Adresse belegt ist.
|
// hervorgeht, dass lediglich die Adresse belegt ist.
|
||||||
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
||||||
if (slugTaken) {
|
if (slugTaken) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/produkte/neu",
|
"/admin/produkte/neu",
|
||||||
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
||||||
);
|
);
|
||||||
@@ -101,7 +111,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,10 +141,10 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: { _count: { select: { orderItems: true } } },
|
include: { _count: { select: { orderItems: true } } },
|
||||||
});
|
});
|
||||||
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
|
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
if (product._count.orderItems > 0) {
|
if (product._count.orderItems > 0) {
|
||||||
fail(
|
return fail(
|
||||||
"/admin/produkte",
|
"/admin/produkte",
|
||||||
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
||||||
);
|
);
|
||||||
@@ -150,7 +160,36 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt den Bestand nur, wenn er noch dem Stand beim Oeffnen des
|
||||||
|
* Formulars entspricht. Die Bedingung steht im UPDATE selbst und wird damit
|
||||||
|
* atomar gegen den aktuellen Zeilenstand geprueft — ein vorheriges Lesen
|
||||||
|
* koennte eine gleichzeitige Bestellung nicht ausschliessen.
|
||||||
|
*
|
||||||
|
* @returns false, wenn sich der Bestand inzwischen geaendert hat.
|
||||||
|
*/
|
||||||
|
async function setInventoryIfUnchanged(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
productId: string,
|
||||||
|
plan: { expected: number; quantity: number }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const updated = await tx.inventory.updateMany({
|
||||||
|
where: { productId, quantity: plan.expected },
|
||||||
|
data: { quantity: plan.quantity },
|
||||||
|
});
|
||||||
|
if (updated.count === 1) return true;
|
||||||
|
// Kein Bestands-Datensatz gilt im Formular als 0 (siehe produkte/[id]).
|
||||||
|
// Nur dann anlegen; existiert der Datensatz mit anderem Wert, ueberspringt
|
||||||
|
// skipDuplicates das Anlegen und count bleibt 0 — also ein Konflikt.
|
||||||
|
if (plan.expected !== 0) return false;
|
||||||
|
const created = await tx.inventory.createMany({
|
||||||
|
data: [{ productId, quantity: plan.quantity }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
return created.count === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||||
@@ -168,19 +207,47 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
vatRatePercent: str(formData, "vatRatePercent"),
|
vatRatePercent: str(formData, "vatRatePercent"),
|
||||||
});
|
});
|
||||||
if (!parsedNumbers.success) {
|
if (!parsedNumbers.success) {
|
||||||
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
const numbers = parsedNumbers.data;
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
const isActive = formData.get("isActive") === "on";
|
const isActive = formData.get("isActive") === "on";
|
||||||
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||||
|
|
||||||
const previousInventory = await prisma.inventory.findUnique({
|
const current = await prisma.product.findUnique({
|
||||||
where: { productId: id },
|
where: { id },
|
||||||
select: { quantity: true },
|
select: { apicoreBottlingId: true, apicoreProductId: true },
|
||||||
});
|
});
|
||||||
|
if (!current) return fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
const product = await prisma.product.update({
|
// Verknuepfung mit einem ApiCore-Artikel (nur hergestellte Waren; Honig
|
||||||
|
// haengt fest an seiner Abfuellung). Feld fehlt = unveraendert lassen.
|
||||||
|
const requestedLink = formData.has("apicoreProductId") ? strOrNull(formData, "apicoreProductId") : undefined;
|
||||||
|
const apicoreProductId = current.apicoreBottlingId ? current.apicoreProductId : requestedLink;
|
||||||
|
const linkChanged = apicoreProductId !== undefined && apicoreProductId !== current.apicoreProductId;
|
||||||
|
const stockFromApiCore = Boolean(current.apicoreBottlingId || (apicoreProductId ?? current.apicoreProductId));
|
||||||
|
|
||||||
|
// Bei verknuepften Produkten gehoert der Bestand ApiCore: ein Wert aus dem
|
||||||
|
// Formular wird nie geschrieben, auch wenn jemand das gesperrte Feld per
|
||||||
|
// Entwicklerwerkzeug aendert.
|
||||||
|
const inventoryPlan = stockFromApiCore
|
||||||
|
? ({ kind: "keep" } as const)
|
||||||
|
: planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
|
||||||
|
if (inventoryPlan.kind === "reload") {
|
||||||
|
return fail(
|
||||||
|
`/admin/produkte/${id}`,
|
||||||
|
"Das Formular ist veraltet. Bitte die Seite neu laden und die Änderung erneut eintragen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestand und Produktdaten in einer Transaktion: passt der Bestand nicht
|
||||||
|
// mehr zum geladenen Stand, wird auch der Rest nicht gespeichert — sonst
|
||||||
|
// haette die Imkerei halb gespeicherte Aenderungen, ohne es zu merken.
|
||||||
|
const product = await prisma.$transaction(async (tx) => {
|
||||||
|
if (inventoryPlan.kind === "set" && !(await setInventoryIfUnchanged(tx, id, inventoryPlan))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return tx.product.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name: str(formData, "name"),
|
name: str(formData, "name"),
|
||||||
@@ -196,11 +263,36 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
vatRatePercent: numbers.vatRatePercent,
|
vatRatePercent: numbers.vatRatePercent,
|
||||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isActive,
|
isActive,
|
||||||
inventory: {
|
...(linkChanged ? { apicoreProductId } : {}),
|
||||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
|
||||||
|
return "link-taken" as const;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (product === "link-taken") {
|
||||||
|
return fail(
|
||||||
|
`/admin/produkte/${id}`,
|
||||||
|
"Dieser ApiCore-Artikel ist bereits mit einem anderen Produkt verknüpft. Nichts wurde gespeichert."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (linkChanged && apicoreProductId) {
|
||||||
|
// Bestand sofort aus ApiCore holen, nicht erst beim naechsten CronJob.
|
||||||
|
scheduleApiCoreStockSync([id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
|
||||||
|
return fail(
|
||||||
|
`/admin/produkte/${id}`,
|
||||||
|
`Der Bestand hat sich seit dem Öffnen geändert (jetzt ${current?.quantity ?? 0} statt ${
|
||||||
|
inventoryPlan.kind === "set" ? inventoryPlan.expected : 0
|
||||||
|
}), vermutlich durch eine Bestellung. Nichts wurde gespeichert — bitte die Seite neu laden und erneut eintragen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "product.update",
|
action: "product.update",
|
||||||
@@ -211,10 +303,11 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
|
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
|
||||||
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
|
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
|
||||||
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) {
|
// Bei "set" ist expected garantiert der Stand direkt vor dem Schreiben.
|
||||||
|
if (inventoryPlan.kind === "set" && inventoryPlan.expected === 0 && inventoryPlan.quantity > 0) {
|
||||||
await notifyStockAvailable(id);
|
await notifyStockAvailable(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect("/admin/produkte");
|
return succeed("/admin/produkte", "Produkt gespeichert.");
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str } from "./_shared";
|
import { requireAdmin, str } from "./_shared";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
|
||||||
export async function approveReviewAction(formData: FormData): Promise<void> {
|
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -11,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const review = await prisma.review.update({
|
const review = await prisma.review.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isApproved: true },
|
data: { isApproved: true, rejectedAt: null },
|
||||||
select: { product: { select: { slug: true, name: true } } },
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +25,34 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bewertungen");
|
revalidatePath("/admin/bewertungen");
|
||||||
revalidatePath(`/honig/${review.product.slug}`);
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lehnt eine Bewertung ab, statt sie zu loeschen: sie verschwindet aus dem
|
||||||
|
* Shop (falls sie dort war) und steht im Archiv unter "Abgelehnt", damit die
|
||||||
|
* Entscheidung nachvollziehbar bleibt und sich rueckgaengig machen laesst.
|
||||||
|
*/
|
||||||
|
export async function rejectReviewAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const review = await prisma.review.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isApproved: false, rejectedAt: new Date() },
|
||||||
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "review.reject",
|
||||||
|
entityType: "Review",
|
||||||
|
entityId: id,
|
||||||
|
detail: review.product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bewertungen");
|
||||||
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung abgelehnt — sie steht jetzt im Archiv.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||||
@@ -44,4 +73,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/bewertungen");
|
revalidatePath("/admin/bewertungen");
|
||||||
revalidatePath(`/honig/${review.product.slug}`);
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { shippingTierSchema } from "@/lib/validation";
|
import { shippingTierSchema } from "@/lib/validation";
|
||||||
import { requireAdmin, fail, str } from "./_shared";
|
import { requireAdmin, fail, str } from "./_shared";
|
||||||
|
import { succeed } from "@/lib/flash";
|
||||||
|
|
||||||
function parseShippingTierForm(formData: FormData) {
|
function parseShippingTierForm(formData: FormData) {
|
||||||
const rawMax = str(formData, "maxQuantity");
|
const rawMax = str(formData, "maxQuantity");
|
||||||
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseShippingTierForm(formData);
|
const parsed = parseShippingTierForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = await prisma.shippingTier.create({
|
const tier = await prisma.shippingTier.create({
|
||||||
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe angelegt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
|||||||
|
|
||||||
const parsed = parseShippingTierForm(formData);
|
const parsed = parseShippingTierForm(formData);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.shippingTier.update({
|
await prisma.shippingTier.update({
|
||||||
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe gespeichert.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||||
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
|
|||||||
revalidatePath("/admin/versand");
|
revalidatePath("/admin/versand");
|
||||||
revalidatePath("/warenkorb");
|
revalidatePath("/warenkorb");
|
||||||
revalidatePath("/versand-zahlung");
|
revalidatePath("/versand-zahlung");
|
||||||
redirect("/admin/versand");
|
return succeed("/admin/versand", "Versandstufe gelöscht.");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { isIsoDate } from "@/lib/certificate/schema";
|
||||||
|
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||||
|
|
||||||
|
const windowSchema = z
|
||||||
|
.object({
|
||||||
|
startsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Beginndatum angeben."),
|
||||||
|
endsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Enddatum angeben."),
|
||||||
|
plannedJars: z.coerce
|
||||||
|
.number({ error: "Geplante Gläser: bitte eine ganze Zahl." })
|
||||||
|
.int("Geplante Gläser: bitte eine ganze Zahl.")
|
||||||
|
.min(0, "Geplante Gläser: mindestens 0.")
|
||||||
|
.max(100_000, "Geplante Gläser: höchstens 100000."),
|
||||||
|
})
|
||||||
|
.refine((value) => value.endsOn >= value.startsOn, "Das Ende darf nicht vor dem Beginn liegen.");
|
||||||
|
|
||||||
|
function windowPath(offerId: string): string {
|
||||||
|
return `/admin/patenschaften/angebote/${offerId}/erntefenster`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(isoDate: string): Date {
|
||||||
|
return new Date(`${isoDate}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erntefenster gibt es nur fuer Abo-Angebote mit Zyklus ERNTE. */
|
||||||
|
async function loadHarvestOffer(offerId: string) {
|
||||||
|
const offer = await prisma.sponsorshipOffer.findUnique({ where: { id: offerId }, select: { id: true, name: true, kind: true, cycle: true } });
|
||||||
|
return offer && offer.kind === "ABO" && offer.cycle === "ERNTE" ? offer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fenster desselben Angebots duerfen sich nicht ueberschneiden: sonst waere
|
||||||
|
* unklar, in welchem Fenster eine Lieferung faellig wird, und die geplanten
|
||||||
|
* Glaeser wuerden doppelt reserviert.
|
||||||
|
*/
|
||||||
|
async function overlaps(offerId: string, startsOn: Date, endsOn: Date, exceptId?: string): Promise<boolean> {
|
||||||
|
const count = await prisma.harvestWindow.count({
|
||||||
|
where: {
|
||||||
|
offerId,
|
||||||
|
...(exceptId ? { id: { not: exceptId } } : {}),
|
||||||
|
startsOn: { lte: endsOn },
|
||||||
|
endsOn: { gte: startsOn },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindow(formData: FormData) {
|
||||||
|
return windowSchema.safeParse({
|
||||||
|
startsOn: str(formData, "startsOn"),
|
||||||
|
endsOn: str(formData, "endsOn"),
|
||||||
|
plannedJars: str(formData, "plannedJars") || "0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHarvestWindowAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const offer = await loadHarvestOffer(str(formData, "offerId"));
|
||||||
|
if (!offer) return fail("/admin/patenschaften/angebote", "Erntefenster gibt es nur für Abo-Angebote mit dem Zyklus „Nach der Ernte“.");
|
||||||
|
const back = windowPath(offer.id);
|
||||||
|
const parsed = parseWindow(formData);
|
||||||
|
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
const startsOn = toDate(parsed.data.startsOn);
|
||||||
|
const endsOn = toDate(parsed.data.endsOn);
|
||||||
|
if (await overlaps(offer.id, startsOn, endsOn)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
|
||||||
|
|
||||||
|
const window = await prisma.harvestWindow.create({
|
||||||
|
data: { offerId: offer.id, startsOn, endsOn, plannedJars: parsed.data.plannedJars },
|
||||||
|
});
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "harvest_window.create",
|
||||||
|
entityType: "HarvestWindow",
|
||||||
|
entityId: window.id,
|
||||||
|
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
|
||||||
|
});
|
||||||
|
revalidatePath(back);
|
||||||
|
return succeed(back, "Erntefenster angelegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHarvestWindowAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const existing = await prisma.harvestWindow.findUnique({ where: { id }, select: { offerId: true } });
|
||||||
|
const offer = existing ? await loadHarvestOffer(existing.offerId) : null;
|
||||||
|
if (!existing || !offer) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden.");
|
||||||
|
const back = windowPath(offer.id);
|
||||||
|
const parsed = parseWindow(formData);
|
||||||
|
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
const startsOn = toDate(parsed.data.startsOn);
|
||||||
|
const endsOn = toDate(parsed.data.endsOn);
|
||||||
|
if (await overlaps(offer.id, startsOn, endsOn, id)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
|
||||||
|
|
||||||
|
await prisma.harvestWindow.update({ where: { id }, data: { startsOn, endsOn, plannedJars: parsed.data.plannedJars } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "harvest_window.update",
|
||||||
|
entityType: "HarvestWindow",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
|
||||||
|
});
|
||||||
|
revalidatePath(back);
|
||||||
|
return succeed(back, "Erntefenster gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHarvestWindowAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const existing = await prisma.harvestWindow.findUnique({ where: { id }, include: { offer: { select: { name: true } } } });
|
||||||
|
if (!existing) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||||
|
const back = windowPath(existing.offerId);
|
||||||
|
await prisma.harvestWindow.deleteMany({ where: { id } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "harvest_window.delete",
|
||||||
|
entityType: "HarvestWindow",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${existing.offer.name}: ${existing.startsOn.toISOString().slice(0, 10)} – ${existing.endsOn.toISOString().slice(0, 10)}`,
|
||||||
|
});
|
||||||
|
revalidatePath(back);
|
||||||
|
return succeed(back, "Erntefenster gelöscht.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||||
|
import { isUniqueViolation } from "./sponsorship-shared";
|
||||||
|
|
||||||
|
const LOYALTY_PATH = "/admin/patenschaften/treuestufen";
|
||||||
|
|
||||||
|
const nameFields = {
|
||||||
|
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(60, "Name: höchstens 60 Zeichen."),
|
||||||
|
nameEn: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(60, "Name (Englisch): höchstens 60 Zeichen.")
|
||||||
|
.transform((value) => (value === "" ? null : value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const thresholdField = z.coerce
|
||||||
|
.number({ error: "Schwelle: bitte eine ganze Zahl." })
|
||||||
|
.int("Schwelle: bitte eine ganze Zahl.")
|
||||||
|
.min(1, "Schwelle: mindestens 1.")
|
||||||
|
.max(1000, "Schwelle: höchstens 1000.");
|
||||||
|
|
||||||
|
const levelSchema = z.object({ ...nameFields, threshold: thresholdField });
|
||||||
|
const newLevelSchema = levelSchema.extend({ kind: z.enum(["STAMMKUNDE", "ABO"], { error: "Unbekannte Stufenart." }) });
|
||||||
|
|
||||||
|
function readLevel(formData: FormData) {
|
||||||
|
return {
|
||||||
|
name: str(formData, "name"),
|
||||||
|
nameEn: str(formData, "nameEn"),
|
||||||
|
threshold: str(formData, "threshold"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DUPLICATE_THRESHOLD = "Für diese Schwelle gibt es bereits eine Stufe.";
|
||||||
|
|
||||||
|
export async function createLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const parsed = newLevelSchema.safeParse({ ...readLevel(formData), kind: str(formData, "kind") });
|
||||||
|
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
try {
|
||||||
|
const level = await prisma.loyaltyLevel.create({ data: parsed.data });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "loyalty_level.create",
|
||||||
|
entityType: "LoyaltyLevel",
|
||||||
|
entityId: level.id,
|
||||||
|
detail: `${level.kind} ab ${level.threshold}: ${level.name}`,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
revalidatePath(LOYALTY_PATH);
|
||||||
|
return succeed(LOYALTY_PATH, "Stufe angelegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert Namen und Schwelle einer Stufe. Die VIP-Stufe (Abo, ohne
|
||||||
|
* Schwelle) behaelt ihre fehlende Schwelle — sie folgt immer nach der
|
||||||
|
* hoechsten Zahl, nur ihr Name ist pflegbar.
|
||||||
|
*/
|
||||||
|
export async function updateLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
|
||||||
|
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden.");
|
||||||
|
const isVip = existing.threshold === null;
|
||||||
|
|
||||||
|
const parsed = isVip
|
||||||
|
? z.object(nameFields).safeParse(readLevel(formData))
|
||||||
|
: levelSchema.safeParse(readLevel(formData));
|
||||||
|
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.loyaltyLevel.update({ where: { id }, data: parsed.data });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const threshold = "threshold" in parsed.data ? parsed.data.threshold : null;
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "loyalty_level.update",
|
||||||
|
entityType: "LoyaltyLevel",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${existing.kind} ${existing.threshold ?? "VIP"} → ${threshold ?? "VIP"}: ${existing.name} → ${parsed.data.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath(LOYALTY_PATH);
|
||||||
|
return succeed(LOYALTY_PATH, "Stufe gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
|
||||||
|
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||||
|
if (existing.threshold === null) return fail(LOYALTY_PATH, "Die VIP-Stufe kann nicht gelöscht werden.");
|
||||||
|
|
||||||
|
await prisma.loyaltyLevel.deleteMany({ where: { id, threshold: { not: null } } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "loyalty_level.delete",
|
||||||
|
entityType: "LoyaltyLevel",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${existing.kind} ab ${existing.threshold}: ${existing.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath(LOYALTY_PATH);
|
||||||
|
return succeed(LOYALTY_PATH, "Stufe gelöscht.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
|
||||||
|
import { setFlash } from "@/lib/flash";
|
||||||
|
import { loadHoneyCandidates } from "@/lib/sponsorship/honey-candidates";
|
||||||
|
import { pickHoneyProduct } from "@/lib/sponsorship/honey-selection";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||||
|
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
|
||||||
|
|
||||||
|
const OFFERS_PATH = "/admin/patenschaften/angebote";
|
||||||
|
|
||||||
|
function carrierSlug(slug: string): string {
|
||||||
|
return `patenschaft-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traegerprodukt einer Einmal-Patenschaft (Kategorie PATENSCHAFT): haelt
|
||||||
|
* Preis, Steuersatz und Rechnungsposition, damit Warenkorb, Checkout und
|
||||||
|
* Rechnung keinen Sonderweg brauchen. Sein Preis ist Paketpreis minus
|
||||||
|
* Honiganteil — der Honig wird im Checkout als eigene Position zum
|
||||||
|
* Honig-Steuersatz angelegt.
|
||||||
|
*/
|
||||||
|
function carrierProductData(values: OfferFormValues) {
|
||||||
|
return {
|
||||||
|
name: `Bienenpatenschaft ${values.name}`,
|
||||||
|
slug: carrierSlug(values.slug),
|
||||||
|
category: "PATENSCHAFT" as const,
|
||||||
|
description: values.description,
|
||||||
|
weightGrams: 0,
|
||||||
|
priceCents: carrierPriceCents(values),
|
||||||
|
pricePerKgCents: 0,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* honeyProductId wird bewusst nicht geschrieben: der Honig wird automatisch
|
||||||
|
* gewaehlt (lib/sponsorship/honey-selection.ts). Die Spalte bleibt nur im
|
||||||
|
* Schema stehen, damit die laufende Produktion keine Migration braucht; ein
|
||||||
|
* dort noch stehender Altwert wird nirgends mehr gelesen.
|
||||||
|
*/
|
||||||
|
function offerData(values: OfferFormValues) {
|
||||||
|
return {
|
||||||
|
slug: values.slug,
|
||||||
|
name: values.name,
|
||||||
|
nameEn: values.nameEn,
|
||||||
|
description: values.description,
|
||||||
|
descriptionEn: values.descriptionEn,
|
||||||
|
features: values.features,
|
||||||
|
featuresEn: values.featuresEn,
|
||||||
|
priceCents: values.priceCents,
|
||||||
|
isActive: values.isActive,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
isPhysical: values.isPhysical,
|
||||||
|
honeyJars: values.honeyJars,
|
||||||
|
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
|
||||||
|
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
|
||||||
|
cycle: values.cycle,
|
||||||
|
maxActiveSubscriptions: values.maxActiveSubscriptions,
|
||||||
|
medalTier: values.medalTier,
|
||||||
|
} satisfies Prisma.SponsorshipOfferUncheckedUpdateInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefungen gegen die Datenbank: eindeutige Adressen. Den Honig prueft
|
||||||
|
* honeyWarning() — er sperrt das Aktivieren nicht mehr, weil er sich mit
|
||||||
|
* jeder Bestellung und jeder neuen Ernte aendert.
|
||||||
|
*/
|
||||||
|
async function findConflict(
|
||||||
|
values: OfferFormValues,
|
||||||
|
existing: { id: string; productId: string | null } | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
const slugOwner = await prisma.sponsorshipOffer.findUnique({ where: { slug: values.slug }, select: { id: true } });
|
||||||
|
if (slugOwner && slugOwner.id !== existing?.id) return `Die Adresse "${values.slug}" hat bereits ein anderes Angebot.`;
|
||||||
|
|
||||||
|
if (values.kind === "EINMALIG") {
|
||||||
|
const product = await prisma.product.findUnique({ where: { slug: carrierSlug(values.slug) }, select: { id: true } });
|
||||||
|
if (product && product.id !== existing?.productId) {
|
||||||
|
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditDetail(values: OfferFormValues): string {
|
||||||
|
const state = values.isActive ? "aktiv" : "inaktiv";
|
||||||
|
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktivierungsregel fuer Angebote mit Honig: es muss einen Honig geben, den
|
||||||
|
* die automatische Wahl nehmen darf (nicht cremig, genug freier Bestand fuer
|
||||||
|
* die Glaeser). Fehlt er, wird trotzdem gespeichert — das Angebot steht dann
|
||||||
|
* auf der Verkaufsseite als nicht bestellbar (isOfferAvailable), bis
|
||||||
|
* fluessiger Honig nachgelegt ist —, aber der Admin bekommt einen Hinweis,
|
||||||
|
* statt sich ueber ein "ausverkauftes" neues Angebot zu wundern.
|
||||||
|
*/
|
||||||
|
async function honeyWarning(values: OfferFormValues): Promise<string | null> {
|
||||||
|
if (!values.isActive || values.honeyJars <= 0) return null;
|
||||||
|
if (pickHoneyProduct(await loadHoneyCandidates(), values.honeyJars)) return null;
|
||||||
|
return "Hinweis: Derzeit gibt es keinen nicht-cremigen Honig mit genug freiem Bestand – bestellbar erst, wenn flüssiger Honig nachgelegt ist.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach dem Speichern: Stripe-Preis eines Abo-Angebots nachziehen, Honig pruefen und passend melden. */
|
||||||
|
async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
|
||||||
|
revalidatePath(OFFERS_PATH);
|
||||||
|
revalidatePath("/patenschaft");
|
||||||
|
const target = `${OFFERS_PATH}/${offerId}`;
|
||||||
|
if (values.kind === "ABO") {
|
||||||
|
const stripeError = await syncStripePriceSafely(offerId);
|
||||||
|
if (stripeError) {
|
||||||
|
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const warning = await honeyWarning(values);
|
||||||
|
if (warning) {
|
||||||
|
await setFlash("info", `${message} ${warning}`);
|
||||||
|
redirect(target);
|
||||||
|
}
|
||||||
|
return succeed(target, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSponsorshipOfferAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const back = `${OFFERS_PATH}/neu`;
|
||||||
|
const parsed = parseOfferForm(formData);
|
||||||
|
if (!parsed.ok) return fail(back, parsed.message);
|
||||||
|
const values = parsed.value;
|
||||||
|
|
||||||
|
const conflict = await findConflict(values, null);
|
||||||
|
if (conflict) return fail(back, conflict);
|
||||||
|
|
||||||
|
let offerId: string;
|
||||||
|
try {
|
||||||
|
offerId = await prisma.$transaction(async (tx) => {
|
||||||
|
const productId = values.kind === "EINMALIG" ? (await tx.product.create({ data: carrierProductData(values) })).id : null;
|
||||||
|
const offer = await tx.sponsorshipOffer.create({ data: { ...offerData(values), kind: values.kind, productId } });
|
||||||
|
return offer.id;
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship_offer.create",
|
||||||
|
entityType: "SponsorshipOffer",
|
||||||
|
entityId: offerId,
|
||||||
|
detail: auditDetail(values),
|
||||||
|
});
|
||||||
|
return finishSave(offerId, values, "Angebot angelegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSponsorshipOfferAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const existing = await prisma.sponsorshipOffer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, kind: true, productId: true, isActive: true, priceCents: true },
|
||||||
|
});
|
||||||
|
if (!existing) return fail(OFFERS_PATH, "Das Angebot wurde nicht gefunden.");
|
||||||
|
const back = `${OFFERS_PATH}/${id}`;
|
||||||
|
|
||||||
|
const parsed = parseOfferForm(formData, existing.kind);
|
||||||
|
if (!parsed.ok) return fail(back, parsed.message);
|
||||||
|
const values = parsed.value;
|
||||||
|
|
||||||
|
const conflict = await findConflict(values, existing);
|
||||||
|
if (conflict) return fail(back, conflict);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
let productId = existing.productId;
|
||||||
|
if (values.kind === "EINMALIG") {
|
||||||
|
// Fehlt das Traegerprodukt (Altbestand), wird es hier nachgeholt —
|
||||||
|
// ohne Produkt ist eine Einmal-Patenschaft nicht bestellbar.
|
||||||
|
productId = productId
|
||||||
|
? (await tx.product.update({ where: { id: productId }, data: carrierProductData(values) })).id
|
||||||
|
: (await tx.product.create({ data: carrierProductData(values) })).id;
|
||||||
|
}
|
||||||
|
await tx.sponsorshipOffer.update({ where: { id }, data: { ...offerData(values), productId } });
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = [
|
||||||
|
existing.isActive !== values.isActive ? (values.isActive ? "aktiviert" : "deaktiviert") : null,
|
||||||
|
existing.priceCents !== values.priceCents ? `Preis ${formatPrice(existing.priceCents)} → ${formatPrice(values.priceCents)}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship_offer.update",
|
||||||
|
entityType: "SponsorshipOffer",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${auditDetail(values)}${changes.length > 0 ? `; ${changes.join(", ")}` : ""}`,
|
||||||
|
});
|
||||||
|
return finishSave(id, values, "Angebot gespeichert.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Helfer der Patenschafts-Actions (bewusst ohne "use server":
|
||||||
|
* hier liegen keine aufrufbaren Endpunkte, nur Bausteine).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Prisma-Fehler P2002: eindeutiger Wert (Slug, Schwelle) bereits vergeben, etwa durch einen parallelen Speichervorgang. */
|
||||||
|
export function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt Stripe-Produkt und -Preis eines Abo-Angebots an bzw. zieht sie nach.
|
||||||
|
* Scheitert das (Stripe nicht erreichbar, kein Schluessel), bleibt das
|
||||||
|
* Angebot gespeichert — der Admin bekommt eine klare Meldung und kann erneut
|
||||||
|
* speichern; syncOfferStripePrice ist idempotent. Gibt die Fehlermeldung
|
||||||
|
* zurueck oder null.
|
||||||
|
*/
|
||||||
|
export async function syncStripePriceSafely(offerId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
await syncOfferStripePrice(offerId);
|
||||||
|
return null;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
console.error(`[patenschaft] Stripe-Preis fuer Angebot ${offerId} nicht synchronisiert: ${message}`);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ganze Zahl aus einem Formularfeld, sonst null (leer, Komma, Buchstaben). */
|
||||||
|
export function intOrNull(value: string): number | null {
|
||||||
|
return /^\d+$/.test(value.trim()) ? Number(value.trim()) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { MAX_SEPA_HOLD_DAYS, SEPA_HOLD_DAYS_KEY, getSepaHoldDays } from "@/lib/sponsorship/settings";
|
||||||
|
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||||
|
import { intOrNull } from "./sponsorship-shared";
|
||||||
|
|
||||||
|
const SUBSCRIPTIONS_PATH = "/admin/patenschaften/abos";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hebt die SEPA-Sperre eines Kontos auf (gesetzt nach einer Rueckbuchung).
|
||||||
|
* Danach kann das Konto neue Abos wieder per Lastschrift abschliessen.
|
||||||
|
*/
|
||||||
|
export async function liftSepaBlockAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const customerId = str(formData, "customerId");
|
||||||
|
const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { email: true, sepaBlockedAt: true } });
|
||||||
|
if (!customer) return fail(SUBSCRIPTIONS_PATH, "Das Konto wurde nicht gefunden.");
|
||||||
|
if (!customer.sepaBlockedAt) return succeed(SUBSCRIPTIONS_PATH, "Für dieses Konto besteht keine SEPA-Sperre.");
|
||||||
|
|
||||||
|
await prisma.customer.update({ where: { id: customerId }, data: { sepaBlockedAt: null } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "customer.sepa_unblock",
|
||||||
|
entityType: "Customer",
|
||||||
|
entityId: customerId,
|
||||||
|
detail: `${customer.email} (gesperrt seit ${customer.sepaBlockedAt.toISOString().slice(0, 10)})`,
|
||||||
|
});
|
||||||
|
revalidatePath(SUBSCRIPTIONS_PATH);
|
||||||
|
return succeed(SUBSCRIPTIONS_PATH, `SEPA-Sperre für ${customer.email} aufgehoben.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSepaHoldDaysAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const days = intOrNull(str(formData, "sepaHoldDays"));
|
||||||
|
if (days === null || days > MAX_SEPA_HOLD_DAYS) {
|
||||||
|
return fail(SUBSCRIPTIONS_PATH, `Bitte eine ganze Zahl von 0 bis ${MAX_SEPA_HOLD_DAYS} Tagen angeben.`);
|
||||||
|
}
|
||||||
|
const previous = await getSepaHoldDays();
|
||||||
|
await prisma.siteContent.upsert({
|
||||||
|
where: { key: SEPA_HOLD_DAYS_KEY },
|
||||||
|
create: { key: SEPA_HOLD_DAYS_KEY, valueDe: String(days) },
|
||||||
|
update: { valueDe: String(days) },
|
||||||
|
});
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship.sepa_hold_days",
|
||||||
|
entityType: "SiteContent",
|
||||||
|
entityId: SEPA_HOLD_DAYS_KEY,
|
||||||
|
detail: `${previous} → ${days} Tage`,
|
||||||
|
});
|
||||||
|
revalidatePath(SUBSCRIPTIONS_PATH);
|
||||||
|
return succeed(SUBSCRIPTIONS_PATH, days === 0 ? "SEPA-Neukunden-Frist abgeschaltet." : `SEPA-Neukunden-Frist auf ${days} Tage gesetzt.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { MAIL_FAILED_HINT } from "@/lib/flash";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { defaultLocale, isLocale } from "@/i18n/config";
|
||||||
|
import { processSponsorship, resetSponsorshipForRetry, type ProcessResult } from "@/lib/sponsorship/issue";
|
||||||
|
import { sponsorshipReadyEmail } from "@/lib/sponsorship/emails";
|
||||||
|
import { reviseSponsorship, type ReviseSponsorshipError } from "@/lib/sponsorship/revise";
|
||||||
|
import { beeNameFromJson } from "@/app/admin/patenschaften/_lib/revision-data";
|
||||||
|
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||||
|
|
||||||
|
const LIST_PATH = "/admin/patenschaften";
|
||||||
|
|
||||||
|
function detailPath(id: string): string {
|
||||||
|
return `${LIST_PATH}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markSponsorshipPrintedAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
// Atomar an den Zustand gebunden: ein Doppelklick oder ein zweiter Tab
|
||||||
|
// setzt printedAt nicht ein zweites Mal neu.
|
||||||
|
const updated = await prisma.sponsorship.updateMany({
|
||||||
|
where: { id, status: "BEREIT", printedAt: null, offer: { isPhysical: true } },
|
||||||
|
data: { status: "GEDRUCKT", printedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) {
|
||||||
|
return fail(detailPath(id), "Nur bereite, noch nicht gedruckte Urkunden physischer Pakete lassen sich als gedruckt markieren.");
|
||||||
|
}
|
||||||
|
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship.mark_printed",
|
||||||
|
entityType: "Sponsorship",
|
||||||
|
entityId: id,
|
||||||
|
detail: sponsorship?.number,
|
||||||
|
});
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return succeed(detailPath(id), "Als gedruckt markiert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROCESS_MESSAGES: Record<ProcessResult, string> = {
|
||||||
|
ready: "Urkunde erzeugt und Mail versendet.",
|
||||||
|
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
|
||||||
|
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
|
||||||
|
missing: "Die Patenschaft wurde nicht gefunden.",
|
||||||
|
revoked: "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und wird nicht mehr ausgestellt.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Erneut erzeugen": bei FEHLER werden die Versuche zurueckgesetzt, bei
|
||||||
|
* AUSSTEHEND wird nur sofort ein Versuch gestartet, statt auf den CronJob zu
|
||||||
|
* warten. Beides laeuft ueber processSponsorship — Revision 1 entsteht dort
|
||||||
|
* nur, solange es noch keine gibt, eine Wiederholung erzeugt also nie eine
|
||||||
|
* zweite Urkunde.
|
||||||
|
*/
|
||||||
|
export async function retrySponsorshipAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true, status: true } });
|
||||||
|
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
|
||||||
|
if (sponsorship.status !== "FEHLER" && sponsorship.status !== "AUSSTEHEND") {
|
||||||
|
return fail(detailPath(id), "Die Urkunde ist bereits erzeugt.");
|
||||||
|
}
|
||||||
|
if (sponsorship.status === "FEHLER") await resetSponsorshipForRetry(id);
|
||||||
|
const result = await processSponsorship(id);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship.retry",
|
||||||
|
entityType: "Sponsorship",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${sponsorship.number}: ${sponsorship.status} → ${result}`,
|
||||||
|
});
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
if (result !== "ready") return fail(detailPath(id), PROCESS_MESSAGES[result]);
|
||||||
|
return succeed(detailPath(id), PROCESS_MESSAGES.ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Mail erneut senden" — nur der Versand, sonst nichts. Bewusst NICHT ueber
|
||||||
|
* processSponsorship: das bricht bei BEREIT/GEDRUCKT sofort ab, und den
|
||||||
|
* Status dafuer zurueckzusetzen wuerde einen gedruckten Stand verlieren.
|
||||||
|
* Revisionen werden hier weder gelesen noch angelegt, ausser zum Auslesen
|
||||||
|
* des aktuellen Bienennamens.
|
||||||
|
*/
|
||||||
|
export async function resendSponsorshipMailAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const sponsorship = await prisma.sponsorship.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
status: true,
|
||||||
|
locale: true,
|
||||||
|
currentRevision: true,
|
||||||
|
owner: { select: { email: true, firstName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
|
||||||
|
if ((sponsorship.status !== "BEREIT" && sponsorship.status !== "GEDRUCKT") || sponsorship.currentRevision === 0) {
|
||||||
|
return fail(detailPath(id), "Die Mail lässt sich erst erneut senden, wenn die Urkunde zugestellt wurde.");
|
||||||
|
}
|
||||||
|
const revision = await prisma.sponsorshipRevision.findUnique({
|
||||||
|
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
|
||||||
|
select: { personalization: true },
|
||||||
|
});
|
||||||
|
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
|
||||||
|
const { subject, html, text } = await sponsorshipReadyEmail({
|
||||||
|
locale,
|
||||||
|
firstName: sponsorship.owner.firstName,
|
||||||
|
beeName: beeNameFromJson(revision?.personalization) ?? "",
|
||||||
|
number: sponsorship.number,
|
||||||
|
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
|
||||||
|
});
|
||||||
|
const sent = await sendMail({ to: sponsorship.owner.email, subject, html, text });
|
||||||
|
if (!sent) return fail(detailPath(id), MAIL_FAILED_HINT);
|
||||||
|
|
||||||
|
await prisma.sponsorship.update({ where: { id }, data: { mailSentAt: new Date() } });
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "sponsorship.resend_mail",
|
||||||
|
entityType: "Sponsorship",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${sponsorship.number} an ${sponsorship.owner.email}`,
|
||||||
|
});
|
||||||
|
return succeed(detailPath(id), `Mail erneut an ${sponsorship.owner.email} gesendet.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
beeName: "Name der Patenbiene",
|
||||||
|
recipientName: "Name auf der Urkunde",
|
||||||
|
giverNames: "Schenkende",
|
||||||
|
dedication: "Widmung",
|
||||||
|
occasion: "Anlass",
|
||||||
|
startDate: "Startdatum",
|
||||||
|
layout: "Gesamtlayout",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEMA_MESSAGES: Record<string, string> = {
|
||||||
|
required: "ist ein Pflichtfeld",
|
||||||
|
too_short: "ist zu kurz",
|
||||||
|
too_long: "ist zu lang",
|
||||||
|
too_many_line_breaks: "hat zu viele Zeilenumbrüche",
|
||||||
|
no_letter: "braucht mindestens einen Buchstaben",
|
||||||
|
invalid_date: "ist kein gültiges Datum",
|
||||||
|
};
|
||||||
|
|
||||||
|
function revisionErrorMessage(error: ReviseSponsorshipError): string {
|
||||||
|
switch (error.code) {
|
||||||
|
case "not_found":
|
||||||
|
return "Die Patenschaft wurde nicht gefunden.";
|
||||||
|
case "no_revision":
|
||||||
|
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
|
||||||
|
case "revoked":
|
||||||
|
return "Die Patenschaft ist widerrufen (Bestellung storniert oder erstattet) und lässt sich nicht mehr korrigieren.";
|
||||||
|
case "reason":
|
||||||
|
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
|
||||||
|
case "unknown_template":
|
||||||
|
return "Unbekannte Vorlagenversion.";
|
||||||
|
case "unchanged":
|
||||||
|
return "Keine Änderung gegenüber der aktuellen Revision.";
|
||||||
|
case "start_date_out_of_range":
|
||||||
|
return `Das Startdatum muss zwischen ${error.min} und ${error.max} liegen.`;
|
||||||
|
case "invalid": {
|
||||||
|
const label = error.field ? FIELD_LABELS[error.field] : "Eingabe";
|
||||||
|
return `${label} ${SCHEMA_MESSAGES[error.message] ?? "ist ungültig"}.`;
|
||||||
|
}
|
||||||
|
case "glyphs":
|
||||||
|
return `${FIELD_LABELS[error.field] ?? "Text"}: diese Zeichen kann die Urkunde nicht drucken: ${error.characters.join(" ")}`;
|
||||||
|
case "fit":
|
||||||
|
return `${FIELD_LABELS[error.field] ?? "Text"} passt so nicht auf die Urkunde — bitte kürzen.`;
|
||||||
|
case "conflict":
|
||||||
|
return "Die Urkunde wurde gerade von jemand anderem korrigiert. Bitte die Seite neu laden und erneut prüfen.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviseSponsorshipAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const back = `${detailPath(id)}/korrigieren`;
|
||||||
|
const result = await reviseSponsorship({
|
||||||
|
sponsorshipId: id,
|
||||||
|
personalization: {
|
||||||
|
beeName: String(formData.get("beeName") ?? ""),
|
||||||
|
recipientName: String(formData.get("recipientName") ?? ""),
|
||||||
|
giverNames: String(formData.get("giverNames") ?? ""),
|
||||||
|
dedication: String(formData.get("dedication") ?? ""),
|
||||||
|
occasion: str(formData, "occasion"),
|
||||||
|
startDate: str(formData, "startDate"),
|
||||||
|
},
|
||||||
|
reason: String(formData.get("reason") ?? ""),
|
||||||
|
templateVersion: str(formData, "templateVersion") || null,
|
||||||
|
notifyCustomer: formData.get("notifyCustomer") === "on",
|
||||||
|
admin,
|
||||||
|
});
|
||||||
|
if (!result.ok) return fail(back, revisionErrorMessage(result.error));
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
const message = `Revision ${result.revision} gespeichert.`;
|
||||||
|
if (result.mailSent === false) return fail(detailPath(id), `${message} ${MAIL_FAILED_HINT}`);
|
||||||
|
return succeed(detailPath(id), result.mailSent ? `${message} Kunde benachrichtigt.` : message);
|
||||||
|
}
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
AdminBadge,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminSearchForm,
|
||||||
|
type AdminBadgeTone,
|
||||||
|
} from "@/components/admin-list";
|
||||||
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
||||||
|
import { isArchiveView } from "@/lib/archive";
|
||||||
|
import { inquiryViewWhere } from "@/lib/inquiry-status";
|
||||||
import { updateBusinessInquiryStatusAction } from "../actions";
|
import { updateBusinessInquiryStatusAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
const STATUS_TONES: Record<string, AdminBadgeTone> = {
|
||||||
|
NEU: "pending",
|
||||||
|
IN_BEARBEITUNG: "pending",
|
||||||
|
ABGESCHLOSSEN: "positive",
|
||||||
|
ABGELEHNT: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
|
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -15,11 +31,11 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
||||||
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
||||||
};
|
};
|
||||||
const { q } = await props.searchParams;
|
const { q, ansicht } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
|
||||||
const inquiries = await prisma.businessInquiry.findMany({
|
const filterWhere = query
|
||||||
where: query
|
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ companyName: insensitiveContains(query) },
|
{ companyName: insensitiveContains(query) },
|
||||||
@@ -27,54 +43,70 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
{ email: insensitiveContains(query) },
|
{ email: insensitiveContains(query) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: undefined,
|
: {};
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
|
const [inquiries, activeCount, archivedCount] = await Promise.all([
|
||||||
|
prisma.businessInquiry.findMany({
|
||||||
|
where: { ...filterWhere, ...inquiryViewWhere(isArchive) },
|
||||||
|
// Aktiv: zuletzt eingegangen oben. Archiv: zuletzt erledigt oben.
|
||||||
|
orderBy: isArchive ? { updatedAt: "desc" } : { createdAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
}),
|
||||||
|
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(false) } }),
|
||||||
|
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(true) } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1>
|
<AdminPageHeader title={t.adminInquiriesPage.title} />
|
||||||
|
|
||||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
<AdminArchiveTabs
|
||||||
<label className="text-sm">
|
basePath="/admin/anfragen"
|
||||||
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span>
|
isArchive={isArchive}
|
||||||
<input
|
counts={{ active: activeCount, archived: archivedCount }}
|
||||||
type="text"
|
keepParams={{ q: query }}
|
||||||
name="q"
|
labels={{
|
||||||
defaultValue={query}
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
||||||
|
active: t.adminArchive.tabActive,
|
||||||
|
archive: t.adminArchive.tabArchive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleInquiries}</p> : null}
|
||||||
|
|
||||||
|
<AdminSearchForm
|
||||||
|
query={query}
|
||||||
|
label={t.adminInquiriesPage.searchLabel}
|
||||||
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
submitLabel={t.adminInquiriesPage.filterButton}
|
||||||
|
resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
|
||||||
|
resetLabel={t.adminInquiriesPage.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
||||||
{t.adminInquiriesPage.filterButton}
|
|
||||||
</button>
|
|
||||||
{query ? (
|
|
||||||
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
|
|
||||||
{t.adminInquiriesPage.resetFilter}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{inquiries.map((inquiry) => (
|
{inquiries.map((inquiry) => (
|
||||||
<div key={inquiry.id} className="bg-background border border-ink/10 p-6">
|
<div key={inquiry.id} className="bg-background border border-ink/10 p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">{inquiry.companyName}</p>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="text-heading">{inquiry.companyName}</p>
|
||||||
|
<AdminBadge tone={STATUS_TONES[inquiry.status] ?? "neutral"} pulse={inquiry.status === "NEU"}>
|
||||||
|
{statusLabels[inquiry.status] ?? inquiry.status}
|
||||||
|
</AdminBadge>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-ink/70">
|
<p className="text-sm text-ink/70">
|
||||||
{inquiry.contactPerson} · {inquiry.email}
|
{inquiry.contactPerson} · {inquiry.email}
|
||||||
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
|
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p>
|
<p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(inquiry.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
|
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
|
||||||
<input type="hidden" name="id" value={inquiry.id} />
|
<input type="hidden" name="id" value={inquiry.id} />
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={inquiry.status}
|
defaultValue={inquiry.status}
|
||||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
aria-label={t.adminInquiriesPage.statusSelectLabel}
|
||||||
|
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{Object.entries(statusLabels).map(([value, label]) => (
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
@@ -82,7 +114,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" className="text-xs text-forest underline">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="border border-heading px-3 py-1.5 text-xs text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||||
|
>
|
||||||
{t.adminInquiriesPage.save}
|
{t.adminInquiriesPage.save}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -107,9 +142,18 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{inquiries.length === 0 ? (
|
{inquiries.length === 0 ? (
|
||||||
<p className="text-ink/65">
|
<AdminEmptyState
|
||||||
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
bordered
|
||||||
</p>
|
message={
|
||||||
|
query
|
||||||
|
? t.adminInquiriesPage.noResultsFiltered
|
||||||
|
: isArchive
|
||||||
|
? t.adminArchive.emptyArchive
|
||||||
|
: archivedCount > 0
|
||||||
|
? t.adminInquiriesPage.noOpenInquiries
|
||||||
|
: t.adminInquiriesPage.noInquiriesYet
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
@@ -16,8 +17,7 @@ export default async function AdminAuditLogPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1>
|
<AdminPageHeader title={t.adminAuditLogPage.title} intro={t.adminAuditLogPage.intro(RECENT_LIMIT)} />
|
||||||
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -33,9 +33,9 @@ export default async function AdminAuditLogPage() {
|
|||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||||
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
||||||
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
|
<td className="p-4 whitespace-nowrap font-mono text-xs text-heading">{entry.action}</td>
|
||||||
<td className="p-4 whitespace-nowrap text-ink/70">
|
<td className="p-4 whitespace-nowrap text-ink/70">
|
||||||
{entry.entityType}
|
{entry.entityType}
|
||||||
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
|
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
|
||||||
@@ -45,7 +45,7 @@ export default async function AdminAuditLogPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null}
|
{entries.length === 0 ? <AdminEmptyState message={t.adminAuditLogPage.noEntriesYet} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { AdminField, AdminFieldset, AdminFormPanel, AdminSelect } from "@/components/admin-form";
|
||||||
|
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
|
||||||
import {
|
import {
|
||||||
updateCustomerAction,
|
updateCustomerAction,
|
||||||
blockCustomerAction,
|
blockCustomerAction,
|
||||||
@@ -16,8 +19,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
|
|
||||||
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
|
const tu = t.adminUserEditPage;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
|
|
||||||
const [admin, customer] = await Promise.all([
|
const [admin, customer] = await Promise.all([
|
||||||
getCurrentAdmin(),
|
getCurrentAdmin(),
|
||||||
@@ -29,66 +32,56 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
|||||||
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-2xl">
|
||||||
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} />
|
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
|
||||||
<h1 className="text-2xl text-forest mb-2">
|
<h1 className="text-2xl text-heading mb-2">
|
||||||
{customer.firstName} {customer.lastName}
|
{customer.firstName} {customer.lastName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
|
<p className="text-sm text-ink/65 mb-8">{tu.createdOn(formatDate(customer.createdAt))}</p>
|
||||||
|
|
||||||
{error ? (
|
<AdminFormPanel action={updateCustomerAction} actions={<Button type="submit">{tu.save}</Button>}>
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminFieldset legend={tu.sectionProfile} last>
|
||||||
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required />
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required />
|
<AdminField name="firstName" label={tu.firstNameLabel} defaultValue={customer.firstName} required />
|
||||||
|
<AdminField name="lastName" label={tu.lastNameLabel} defaultValue={customer.lastName} required />
|
||||||
</div>
|
</div>
|
||||||
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required />
|
<AdminField name="email" label={tu.emailLabel} type="email" defaultValue={customer.email} required />
|
||||||
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} />
|
<AdminField name="phone" label={tu.phoneLabel} type="tel" defaultValue={customer.phone ?? ""} />
|
||||||
<label className="block text-sm">
|
<AdminSelect
|
||||||
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
|
|
||||||
<select
|
|
||||||
name="role"
|
name="role"
|
||||||
|
label={tu.roleLabel}
|
||||||
defaultValue={customer.role}
|
defaultValue={customer.role}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
hint={isSelf ? tu.cannotChangeOwnRole : undefined}
|
||||||
>
|
>
|
||||||
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option>
|
<option value="KUNDE">{tu.roleCustomer}</option>
|
||||||
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option>
|
<option value="ADMIN">{tu.roleAdmin}</option>
|
||||||
</select>
|
</AdminSelect>
|
||||||
{isSelf ? (
|
</AdminFieldset>
|
||||||
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span>
|
</AdminFormPanel>
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
<section className="mt-8 space-y-6 border border-ink/10 bg-background p-6 sm:p-8">
|
||||||
{t.adminUserEditPage.save}
|
<h2 className="font-serif text-xl text-heading">{tu.securityHeading}</h2>
|
||||||
</button>
|
<dl className="divide-y divide-ink/10 text-sm">
|
||||||
</form>
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<dt className="text-ink/70">{tu.twoFactorLabel}</dt>
|
||||||
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
|
|
||||||
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
|
|
||||||
<dl className="text-sm space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
|
|
||||||
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
|
|
||||||
<dd>
|
<dd>
|
||||||
{customer.blockedAt ? (
|
{customer.twoFactorEnabled ? (
|
||||||
<span className="text-red-700">
|
<AdminBadge tone="positive">{tu.twoFactorActive}</AdminBadge>
|
||||||
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
|
|
||||||
</span>
|
|
||||||
) : isTemporarilyLocked ? (
|
|
||||||
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-forest">{t.adminUserEditPage.statusActive}</span>
|
<AdminBadge tone="neutral">{tu.twoFactorInactive}</AdminBadge>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<dt className="text-ink/70">{tu.statusLabel}</dt>
|
||||||
|
<dd className="text-right">
|
||||||
|
{customer.blockedAt ? (
|
||||||
|
<AdminBadge tone="danger">{tu.blockedSince(formatDate(customer.blockedAt))}</AdminBadge>
|
||||||
|
) : isTemporarilyLocked ? (
|
||||||
|
<AdminBadge tone="pending">{tu.temporarilyLockedFailedAttempts}</AdminBadge>
|
||||||
|
) : (
|
||||||
|
<AdminBadge tone="positive">{tu.statusActive}</AdminBadge>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,67 +89,39 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
|||||||
|
|
||||||
<form action={sendCustomerPasswordResetAction}>
|
<form action={sendCustomerPasswordResetAction}>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm">
|
<Button type="submit" variant="secondary" className="px-5 py-2.5">
|
||||||
{t.adminUserEditPage.sendPasswordReset}
|
{tu.sendPasswordReset}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Sperren/Loeschen des eigenen Kontos ist serverseitig ohnehin
|
||||||
|
verboten — hier gar nicht erst angeboten. */}
|
||||||
{!isSelf ? (
|
{!isSelf ? (
|
||||||
<div className="flex items-center gap-4 pt-2 border-t border-ink/10">
|
<div className="border-t border-ink/10 pt-6">
|
||||||
|
<h3 className="mb-3 text-xs tracking-wide-caps text-ink/65">{tu.sectionDanger}</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-6 text-sm">
|
||||||
{customer.blockedAt ? (
|
{customer.blockedAt ? (
|
||||||
<form action={unblockCustomerAction}>
|
<form action={unblockCustomerAction}>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-forest underline text-sm">
|
<AdminActionButton>{tu.unblock}</AdminActionButton>
|
||||||
{t.adminUserEditPage.unblock}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form action={blockCustomerAction}>
|
<form action={blockCustomerAction}>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-ink/70 underline text-sm">
|
<AdminActionButton tone="muted">{tu.block}</AdminActionButton>
|
||||||
{t.adminUserEditPage.block}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
<ConfirmForm
|
<ConfirmForm
|
||||||
action={deleteCustomerAction}
|
action={deleteCustomerAction}
|
||||||
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
confirmMessage={tu.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-red-700 underline text-sm">
|
<AdminActionButton tone="danger">{tu.deleteAccount}</AdminActionButton>
|
||||||
{t.adminUserEditPage.deleteAccount}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
defaultValue,
|
|
||||||
required = false,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
required={required}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +1,33 @@
|
|||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { AdminField, AdminFormPanel, AdminSelect } from "@/components/admin-form";
|
||||||
import { createCustomerAction } from "../../actions";
|
import { createCustomerAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
|
export default async function AdminBenutzerNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
const tu = t.adminUserNewPage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} />
|
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
|
||||||
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
|
<h1 className="text-2xl text-heading mb-2">{tu.title}</h1>
|
||||||
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
|
<p className="text-sm text-ink/70 mb-8">{tu.intro}</p>
|
||||||
|
|
||||||
{error ? (
|
<AdminFormPanel action={createCustomerAction} actions={<Button type="submit">{tu.createAndSend}</Button>}>
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
<div className="space-y-4">
|
||||||
) : null}
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
|
<AdminField name="firstName" label={tu.firstNameLabel} required />
|
||||||
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<AdminField name="lastName" label={tu.lastNameLabel} required />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
|
|
||||||
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
|
|
||||||
</div>
|
</div>
|
||||||
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required />
|
<AdminField name="email" label={tu.emailLabel} type="email" required />
|
||||||
<label className="block text-sm">
|
<AdminSelect name="role" label={tu.roleLabel} defaultValue="KUNDE">
|
||||||
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span>
|
<option value="KUNDE">{tu.roleCustomer}</option>
|
||||||
<select
|
<option value="ADMIN">{tu.roleAdmin}</option>
|
||||||
name="role"
|
</AdminSelect>
|
||||||
defaultValue="KUNDE"
|
</div>
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
</AdminFormPanel>
|
||||||
>
|
|
||||||
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
|
|
||||||
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminUserNewPage.createAndSend}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
required = false,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
required={required}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
AdminActionButton,
|
||||||
|
AdminActionLink,
|
||||||
|
AdminBadge,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminRowActions,
|
||||||
|
AdminSearchForm,
|
||||||
|
} from "@/components/admin-list";
|
||||||
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
|
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -12,7 +20,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
||||||
const { error, message, q } = await props.searchParams;
|
const { q } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
const [admin, customers] = await Promise.all([
|
const [admin, customers] = await Promise.all([
|
||||||
@@ -32,43 +40,21 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const newUser = { href: "/admin/benutzer/neu", label: t.adminUsersPage.newUser };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
|
||||||
<div className="flex items-center justify-between mb-8">
|
<AdminPageHeader title={t.adminUsersPage.title} action={newUser} />
|
||||||
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
|
|
||||||
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
|
||||||
{t.adminUsersPage.newUser}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
<AdminSearchForm
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
query={query}
|
||||||
) : null}
|
label={t.adminUsersPage.searchLabel}
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
|
||||||
<label className="text-sm">
|
|
||||||
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
defaultValue={query}
|
|
||||||
placeholder={t.adminUsersPage.searchPlaceholder}
|
placeholder={t.adminUsersPage.searchPlaceholder}
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
submitLabel={t.adminUsersPage.filterButton}
|
||||||
|
resetHref="/admin/benutzer"
|
||||||
|
resetLabel={t.adminUsersPage.resetFilter}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
||||||
{t.adminUsersPage.filterButton}
|
|
||||||
</button>
|
|
||||||
{query ? (
|
|
||||||
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
|
|
||||||
{t.adminUsersPage.resetFilter}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -90,42 +76,48 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest">
|
<td className="p-4 text-heading">
|
||||||
{customer.firstName} {customer.lastName}
|
{customer.firstName} {customer.lastName}
|
||||||
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{customer.email}</td>
|
<td className="p-4">{customer.email}</td>
|
||||||
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{customer.blockedAt ? (
|
{customer.role === "ADMIN" ? (
|
||||||
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span>
|
<AdminBadge tone="neutral">{roleLabels.ADMIN}</AdminBadge>
|
||||||
) : isTemporarilyLocked ? (
|
|
||||||
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-forest">{t.adminUsersPage.statusActive}</span>
|
(roleLabels[customer.role] ?? customer.role)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
|
|
||||||
<td className="p-4">{formatDate(customer.createdAt)}</td>
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
{customer.blockedAt ? (
|
||||||
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline">
|
<AdminBadge tone="danger">{t.adminUsersPage.statusBlocked}</AdminBadge>
|
||||||
{t.adminUsersPage.edit}
|
) : isTemporarilyLocked ? (
|
||||||
</Link>
|
<AdminBadge tone="pending">{t.adminUsersPage.statusTemporarilyLocked}</AdminBadge>
|
||||||
|
) : (
|
||||||
|
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{customer.twoFactorEnabled ? (
|
||||||
|
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink/55">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(customer.createdAt)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionLink href={`/admin/benutzer/${customer.id}`}>{t.adminUsersPage.edit}</AdminActionLink>
|
||||||
{!isSelf ? (
|
{!isSelf ? (
|
||||||
customer.blockedAt ? (
|
customer.blockedAt ? (
|
||||||
<form action={unblockCustomerAction}>
|
<form action={unblockCustomerAction}>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-forest underline">
|
<AdminActionButton>{t.adminUsersPage.unblock}</AdminActionButton>
|
||||||
{t.adminUsersPage.unblock}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form action={blockCustomerAction}>
|
<form action={blockCustomerAction}>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-ink/70 underline">
|
<AdminActionButton tone="muted">{t.adminUsersPage.block}</AdminActionButton>
|
||||||
{t.adminUsersPage.block}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -135,12 +127,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
|||||||
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={customer.id} />
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
<button type="submit" className="text-red-700 underline">
|
<AdminActionButton tone="danger">{t.adminUsersPage.delete}</AdminActionButton>
|
||||||
{t.adminUsersPage.delete}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</AdminRowActions>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -148,9 +138,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{customers.length === 0 ? (
|
{customers.length === 0 ? (
|
||||||
<p className="p-6 text-ink/65">
|
<AdminEmptyState
|
||||||
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
message={query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
||||||
</p>
|
action={query ? undefined : newUser}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,66 +3,108 @@ import Link from "next/link";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions";
|
import {
|
||||||
|
updateOrderStatusAction,
|
||||||
|
updateShipmentTrackingAction,
|
||||||
|
createDhlLabelAction,
|
||||||
|
deleteOrderAction,
|
||||||
|
} from "../../actions";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { OrderStatusForm } from "@/components/order-status-form";
|
||||||
|
import { checkOrderDeletable } from "@/lib/order-delete";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { isDhlConfigured } from "@/lib/dhl";
|
import { isDhlConfigured } from "@/lib/dhl";
|
||||||
|
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||||
|
|
||||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error, message } = await props.searchParams;
|
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
items: {
|
||||||
|
include: {
|
||||||
|
product: { select: { slug: true } },
|
||||||
|
batch: { select: { code: true } },
|
||||||
|
sponsorship: { select: { number: true, status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
payment: true,
|
payment: true,
|
||||||
shipment: true,
|
shipment: true,
|
||||||
pickup: true,
|
pickup: true,
|
||||||
invoice: true,
|
invoice: { include: { cancellation: { select: { number: true } } } },
|
||||||
customer: { select: { email: true, firstName: true, lastName: true } },
|
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||||
discountCode: { select: { code: true } },
|
discountCode: { select: { code: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) notFound();
|
if (!order) notFound();
|
||||||
const dhlConfigured = isDhlConfigured();
|
const dhlConfigured = isDhlConfigured();
|
||||||
|
// Storno/Erstattung widerruft Patenschaften (lib/sponsorship/revoke.ts);
|
||||||
|
// ein Wiederoeffnen stellt sie bewusst nicht von selbst wieder her.
|
||||||
|
const revokedNumbers = order.items.flatMap((item) => (item.sponsorship?.status === "WIDERRUFEN" ? [item.sponsorship.number] : []));
|
||||||
|
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
|
||||||
|
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
|
||||||
|
// auseinanderlaufen koennen.
|
||||||
|
const isDeletable = checkOrderDeletable({
|
||||||
|
status: order.status,
|
||||||
|
hasPayment: order.payment !== null,
|
||||||
|
hasInvoice: order.invoice !== null,
|
||||||
|
}).deletable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-4xl">
|
||||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
{message ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl text-heading">{order.orderNumber}</h1>
|
||||||
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||||
|
</div>
|
||||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
<OrderStatusForm
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
action={updateOrderStatusAction}
|
||||||
<select
|
orderId={order.id}
|
||||||
name="status"
|
currentStatus={order.status}
|
||||||
defaultValue={order.status}
|
labels={statusLabels}
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
saveLabel={t.adminOrderDetailPage.saveStatus}
|
||||||
>
|
statusAriaLabel={t.adminOrdersPage.statusLabel}
|
||||||
{Object.entries(statusLabels).map(([value, label]) => (
|
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
|
||||||
<option key={value} value={value}>
|
/>
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
||||||
{t.adminOrderDetailPage.saveStatus}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
|
||||||
|
<div className="mb-8 border border-ink/10 bg-background px-6 py-5">
|
||||||
|
<OrderStatusTimeline
|
||||||
|
status={order.status}
|
||||||
|
fulfillmentMethod={order.fulfillmentMethod}
|
||||||
|
labels={{
|
||||||
|
ordered: t.orderStatusTimeline.ordered,
|
||||||
|
paid: t.orderStatusTimeline.paid,
|
||||||
|
preparing: t.orderStatusTimeline.preparing,
|
||||||
|
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||||
|
shipped: t.orderStatusTimeline.shipped,
|
||||||
|
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||||
|
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||||
|
cancelled: t.orderStatusTimeline.cancelled,
|
||||||
|
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revokedNumbers.length > 0 ? (
|
||||||
|
<p role="note" className="mb-8 border border-ink/20 bg-ink/[0.03] px-6 py-4 text-sm text-ink/80">
|
||||||
|
{getSponsorshipAdminStrings(locale).order.revokedNote(revokedNumbers.join(", "))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{order.giftMessage ? (
|
{order.giftMessage ? (
|
||||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||||
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||||
@@ -129,7 +171,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
) : dhlConfigured ? (
|
) : dhlConfigured ? (
|
||||||
<form action={createDhlLabelAction}>
|
<form action={createDhlLabelAction}>
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
<button type="submit" className="bg-honey-dark text-cream px-4 py-2 text-sm">
|
<button type="submit" className="bg-honey-deep text-cream px-4 py-2 text-sm">
|
||||||
{t.adminOrderDetailPage.dhlCreateButton}
|
{t.adminOrderDetailPage.dhlCreateButton}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -161,9 +203,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
|
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
<Button type="submit" className="px-5 py-2.5">
|
||||||
{t.adminOrderDetailPage.saveTracking}
|
{t.adminOrderDetailPage.saveTracking}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -183,10 +225,10 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
|
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
|
||||||
{item.productName}
|
{item.productName}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
{item.weightGrams > 0 ? <span className="text-ink/65">({item.weightGrams} g)</span> : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||||
<td className="py-2 text-right">{item.quantity}</td>
|
<td className="py-2 text-right">{item.quantity}</td>
|
||||||
@@ -217,7 +259,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
|
<div className="flex justify-between text-heading pt-1 border-t border-ink/10">
|
||||||
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
||||||
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,16 +292,58 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
<span>{order.invoice.invoiceNumber}</span>
|
<span>{order.invoice.invoiceNumber}</span>
|
||||||
<a
|
<a
|
||||||
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
||||||
className="text-forest underline text-sm"
|
className="link-draw text-sm text-heading"
|
||||||
>
|
>
|
||||||
{t.adminOrderDetailPage.downloadPdf}
|
{t.adminOrderDetailPage.downloadPdf}
|
||||||
</a>
|
</a>
|
||||||
|
{order.invoice.cancellation && (
|
||||||
|
<>
|
||||||
|
<span className="mt-3">
|
||||||
|
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
|
||||||
|
className="link-draw text-sm text-heading"
|
||||||
|
>
|
||||||
|
{t.adminOrderDetailPage.downloadPdf}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dieselben drei Bedingungen prueft deleteOrderAction noch einmal
|
||||||
|
serverseitig. Das hier ist reine Anzeige: ein Knopf, den man nicht
|
||||||
|
sieht, ist keine Sicherheitsgrenze (siehe requireAdmin in
|
||||||
|
actions/_shared.ts). Steht am Seitenende, damit man nicht aus
|
||||||
|
Versehen daraufklickt, waehrend man oben den Status setzt. */}
|
||||||
|
<div className="mt-10 border border-red-200 bg-red-50/40 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-red-700 mb-3">
|
||||||
|
{t.adminOrderDetailPage.deleteHeading}
|
||||||
|
</h2>
|
||||||
|
{isDeletable ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-ink/70 leading-relaxed mb-4">
|
||||||
|
{t.adminOrderDetailPage.deleteHint}
|
||||||
|
</p>
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteOrderAction}
|
||||||
|
confirmMessage={t.adminOrderDetailPage.deleteConfirm(order.orderNumber)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
<button type="submit" className="bg-red-700 text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminOrderDetailPage.deleteButton}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink/70 leading-relaxed">{t.adminOrderDetailPage.deleteBlocked}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,27 @@ import { BackLink } from "@/components/back-link";
|
|||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
import { OrderStatusBadge } from "@/components/account-ui";
|
import { OrderStatusBadge } from "@/components/account-ui";
|
||||||
import { updateOrderStatusAction } from "../actions";
|
import { updateOrderStatusAction } from "../actions";
|
||||||
|
import { OrderStatusForm } from "@/components/order-status-form";
|
||||||
import type { OrderStatus } from "@/generated/prisma/client";
|
import type { OrderStatus } from "@/generated/prisma/client";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { isArchiveView } from "@/lib/archive";
|
||||||
|
import { ensureAutoArchived } from "@/lib/archive-runner";
|
||||||
|
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
|
||||||
|
import { AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
|
||||||
|
|
||||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const statusLabels = t.orderStatusBadge;
|
const statusLabels = t.orderStatusBadge;
|
||||||
const { q, status, error } = await props.searchParams;
|
const { q, status, ansicht } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||||
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
|
||||||
const orders = await prisma.order.findMany({
|
await ensureAutoArchived("orders");
|
||||||
where: {
|
|
||||||
|
const filterWhere = {
|
||||||
...(statusFilter ? { status: statusFilter } : {}),
|
...(statusFilter ? { status: statusFilter } : {}),
|
||||||
...(query
|
...(query
|
||||||
? {
|
? {
|
||||||
@@ -28,34 +35,50 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const [orders, activeCount, archivedCount] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
include: { items: true },
|
include: { items: true },
|
||||||
});
|
}),
|
||||||
|
prisma.order.count({ where: { ...filterWhere, archivedAt: null } }),
|
||||||
|
prisma.order.count({ where: { ...filterWhere, archivedAt: { not: null } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
|
<AdminPageHeader title={t.adminOrdersPage.title} />
|
||||||
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
|
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
|
||||||
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
||||||
aus wie ein folgenloser Klick. */}
|
aus wie ein folgenloser Klick. */}
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
<AdminArchiveTabs
|
||||||
<label className="text-sm">
|
basePath="/admin/bestellungen"
|
||||||
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
|
isArchive={isArchive}
|
||||||
<input
|
counts={{ active: activeCount, archived: archivedCount }}
|
||||||
type="text"
|
keepParams={{ q: query, status: statusFilter ?? "" }}
|
||||||
name="q"
|
labels={{
|
||||||
defaultValue={query}
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
||||||
placeholder={t.adminOrdersPage.searchPlaceholder}
|
active: t.adminArchive.tabActive,
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
archive: t.adminArchive.tabArchive,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null}
|
||||||
|
|
||||||
|
<AdminSearchForm
|
||||||
|
query={query}
|
||||||
|
label={t.adminOrdersPage.searchLabel}
|
||||||
|
placeholder={t.adminOrdersPage.searchPlaceholder}
|
||||||
|
submitLabel={t.adminOrdersPage.filterButton}
|
||||||
|
resetHref={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
|
||||||
|
resetLabel={t.adminOrdersPage.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
|
isFiltered={Boolean(query || statusFilter)}
|
||||||
|
>
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
|
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -71,15 +94,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
</AdminSearchForm>
|
||||||
{t.adminOrdersPage.filterButton}
|
|
||||||
</button>
|
|
||||||
{query || statusFilter ? (
|
|
||||||
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline">
|
|
||||||
{t.adminOrdersPage.resetFilter}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -92,18 +107,26 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
|
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
|
||||||
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
|
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
|
||||||
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
||||||
|
<th className="p-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest">
|
<td className="p-4 text-heading whitespace-nowrap">
|
||||||
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw">
|
||||||
{order.orderNumber}
|
{order.orderNumber}
|
||||||
</Link>
|
</Link>
|
||||||
{order.giftMessage ? (
|
{order.giftMessage ? (
|
||||||
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
|
<span
|
||||||
🎁
|
className="ml-1.5 inline-flex align-[-2px] text-honey-dark"
|
||||||
|
title={t.adminOrdersPage.giftOrderTitle}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3.5" y="8.5" width="17" height="4" />
|
||||||
|
<path d="M5 12.5v8h14v-8M12 8.5v12M12 8.5c-1.5-3.5-5-4-5-1.5 0 1.5 2.5 1.5 5 1.5zm0 0c1.5-3.5 5-4 5-1.5 0 1.5-2.5 1.5-5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">{t.adminOrdersPage.giftOrderTitle}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
@@ -112,40 +135,53 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
<br />
|
<br />
|
||||||
<span className="text-ink/65 text-xs">{order.email}</span>
|
<span className="text-ink/65 text-xs">{order.email}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{formatDate(order.createdAt)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(order.createdAt)}</td>
|
||||||
<td className="p-4">{order.items.length}</td>
|
<td className="p-4 tabular-nums">{order.items.length}</td>
|
||||||
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||||
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
<OrderStatusForm
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
action={updateOrderStatusAction}
|
||||||
<select
|
orderId={order.id}
|
||||||
name="status"
|
currentStatus={order.status}
|
||||||
defaultValue={order.status}
|
labels={statusLabels}
|
||||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
saveLabel={t.adminOrdersPage.save}
|
||||||
>
|
statusAriaLabel={t.adminOrdersPage.statusLabel}
|
||||||
{Object.entries(statusLabels).map(([value, label]) => (
|
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
|
||||||
<option key={value} value={value}>
|
compact
|
||||||
{label}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="text-xs text-forest underline">
|
|
||||||
{t.adminOrdersPage.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-4 whitespace-nowrap">
|
||||||
|
<AdminArchiveToggle
|
||||||
|
kind="orders"
|
||||||
|
id={order.id}
|
||||||
|
archivedAt={order.archivedAt}
|
||||||
|
archivedManually={order.archivedManually}
|
||||||
|
labels={{
|
||||||
|
archive: t.adminArchive.archive,
|
||||||
|
restore: t.adminArchive.restore,
|
||||||
|
archivedOn: t.adminArchive.archivedOn,
|
||||||
|
manualHint: t.adminArchive.manualHint,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{orders.length === 0 ? (
|
{orders.length === 0 ? (
|
||||||
<p className="p-6 text-ink/65">
|
<AdminEmptyState
|
||||||
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet}
|
message={
|
||||||
</p>
|
query || statusFilter
|
||||||
|
? t.adminOrdersPage.noResultsFiltered
|
||||||
|
: isArchive
|
||||||
|
? t.adminArchive.emptyArchive
|
||||||
|
: t.adminOrdersPage.noOrdersYet
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +1,121 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { StarRating } from "@/components/star-rating";
|
||||||
|
import {
|
||||||
|
AdminActionButton,
|
||||||
|
AdminBadge,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminRowActions,
|
||||||
|
AdminSearchForm,
|
||||||
|
} from "@/components/admin-list";
|
||||||
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { approveReviewAction, deleteReviewAction } from "../actions";
|
import { isArchiveView } from "@/lib/archive";
|
||||||
|
import { approvedReviewWhere, pendingReviewWhere, rejectedReviewWhere } from "@/lib/review-status";
|
||||||
|
import { approveReviewAction, deleteReviewAction, rejectReviewAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
const MAX_ROWS = 100;
|
||||||
const t = getDictionary(await getLocale());
|
const BASE_PATH = "/admin/bewertungen";
|
||||||
const { q } = await props.searchParams;
|
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
|
||||||
|
|
||||||
const reviews = await prisma.review.findMany({
|
const REVIEW_INCLUDE = {
|
||||||
where: query
|
product: { select: { name: true, slug: true } },
|
||||||
? {
|
customer: { select: { firstName: true, lastName: true } },
|
||||||
|
} satisfies Prisma.ReviewInclude;
|
||||||
|
|
||||||
|
type AdminReview = Prisma.ReviewGetPayload<{ include: typeof REVIEW_INCLUDE }>;
|
||||||
|
type ReviewTexts = Dictionary["adminReviewsPage"];
|
||||||
|
|
||||||
|
function searchWhere(query: string): Prisma.ReviewWhereInput {
|
||||||
|
if (!query) return {};
|
||||||
|
return {
|
||||||
OR: [
|
OR: [
|
||||||
{ product: { name: insensitiveContains(query) } },
|
{ product: { name: insensitiveContains(query) } },
|
||||||
{ customer: { firstName: insensitiveContains(query) } },
|
{ customer: { firstName: insensitiveContains(query) } },
|
||||||
{ customer: { lastName: insensitiveContains(query) } },
|
{ customer: { lastName: insensitiveContains(query) } },
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
: undefined,
|
}
|
||||||
include: {
|
|
||||||
product: { select: { name: true, slug: true } },
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
||||||
customer: { select: { firstName: true, lastName: true } },
|
const t = getDictionary(await getLocale());
|
||||||
},
|
const s = t.adminReviewsPage;
|
||||||
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
|
const { q, ansicht } = await props.searchParams;
|
||||||
take: 100,
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
});
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
const filter = searchWhere(query);
|
||||||
|
|
||||||
|
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
|
||||||
|
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
|
||||||
|
|
||||||
|
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
|
||||||
|
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
|
||||||
|
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
|
||||||
|
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
|
||||||
|
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
|
||||||
|
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
|
||||||
|
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
<BackLink href="/admin" label={s.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1>
|
<AdminPageHeader title={s.title} />
|
||||||
|
|
||||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
<AdminArchiveTabs
|
||||||
<label className="text-sm">
|
basePath={BASE_PATH}
|
||||||
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span>
|
isArchive={isArchive}
|
||||||
<input
|
counts={{ active: pendingCount, archived: decidedCount }}
|
||||||
type="text"
|
keepParams={{ q: query }}
|
||||||
name="q"
|
labels={{
|
||||||
defaultValue={query}
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
||||||
placeholder={t.adminReviewsPage.searchPlaceholder}
|
active: t.adminArchive.tabActive,
|
||||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
archive: t.adminArchive.tabArchive,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReviews}</p> : null}
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
||||||
{t.adminReviewsPage.filterButton}
|
|
||||||
</button>
|
|
||||||
{query ? (
|
|
||||||
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
|
|
||||||
{t.adminReviewsPage.resetFilter}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
|
<AdminSearchForm
|
||||||
|
query={query}
|
||||||
|
label={s.searchLabel}
|
||||||
|
placeholder={s.searchPlaceholder}
|
||||||
|
submitLabel={s.filterButton}
|
||||||
|
resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
|
||||||
|
resetLabel={s.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isArchive ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
|
||||||
|
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PendingTable reviews={pending} query={query} s={s} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) {
|
||||||
|
return (
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
|
<th className="p-4">{s.colProduct}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
|
<th className="p-4">{s.colCustomer}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colRating}</th>
|
<th className="p-4">{s.colRating}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colComment}</th>
|
<th className="p-4">{s.colComment}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colDate}</th>
|
<th className="p-4">{s.colDate}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
|
<th className="p-4">{s.colStatus}</th>
|
||||||
<th className="p-4" />
|
<th className="p-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -74,57 +123,124 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
|||||||
{reviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<Link href={`/honig/${review.product.slug}`} className="text-forest underline">
|
<ProductLink review={review} />
|
||||||
{review.product.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{review.customer.firstName} {review.customer.lastName}
|
{review.customer.firstName} {review.customer.lastName}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-honey-dark whitespace-nowrap">
|
<td className="p-4 whitespace-nowrap">
|
||||||
{"★".repeat(review.rating)}
|
<Stars rating={review.rating} />
|
||||||
{"☆".repeat(5 - review.rating)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
||||||
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{review.isApproved ? (
|
<AdminBadge tone="pending" pulse>
|
||||||
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span>
|
{s.statusPending}
|
||||||
) : (
|
</AdminBadge>
|
||||||
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
<ReviewActions review={review} decision="pending" s={s} />
|
||||||
{!review.isApproved ? (
|
|
||||||
<form action={approveReviewAction}>
|
|
||||||
<input type="hidden" name="id" value={review.id} />
|
|
||||||
<button type="submit" className="text-forest underline">
|
|
||||||
{t.adminReviewsPage.approve}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<ConfirmForm
|
|
||||||
action={deleteReviewAction}
|
|
||||||
confirmMessage={t.adminReviewsPage.deleteConfirm}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={review.id} />
|
|
||||||
<button type="submit" className="text-red-700 underline">
|
|
||||||
{t.adminReviewsPage.delete}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{reviews.length === 0 ? (
|
{reviews.length === 0 ? (
|
||||||
<p className="p-6 text-ink/65">
|
<AdminEmptyState message={query ? s.noResultsFiltered : s.noPendingReviews} />
|
||||||
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecidedColumn({
|
||||||
|
title,
|
||||||
|
reviews,
|
||||||
|
decision,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
reviews: AdminReview[];
|
||||||
|
decision: "approved" | "rejected";
|
||||||
|
s: ReviewTexts;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section aria-label={title} className="bg-background border border-ink/10">
|
||||||
|
<h2 className="flex items-center justify-between gap-3 border-b border-ink/10 px-5 py-3 text-heading">
|
||||||
|
{title}
|
||||||
|
<AdminBadge tone={decision === "approved" ? "positive" : "neutral"}>{reviews.length}</AdminBadge>
|
||||||
|
</h2>
|
||||||
|
{reviews.length === 0 ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-ink/60">{s.emptyColumn}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-ink/10">
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<li key={review.id} className="space-y-2 px-5 py-4 text-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<ProductLink review={review} />
|
||||||
|
<Stars rating={review.rating} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-ink/70">
|
||||||
|
{review.customer.firstName} {review.customer.lastName} ·{" "}
|
||||||
|
<span className="tabular-nums">{formatDate(review.createdAt)}</span>
|
||||||
|
</p>
|
||||||
|
{review.comment ? <p className="text-ink/80">{review.comment}</p> : null}
|
||||||
|
{decision === "rejected" && review.rejectedAt ? (
|
||||||
|
<p className="text-xs text-ink/55">{s.rejectedOn(formatDate(review.rejectedAt))}</p>
|
||||||
|
) : null}
|
||||||
|
<ReviewActions review={review} decision={decision} s={s} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductLink({ review }: { review: AdminReview }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
|
||||||
|
{review.product.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stars({ rating }: { rating: number }) {
|
||||||
|
return (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
<StarRating value={rating} />
|
||||||
|
<span className="sr-only">{rating} / 5</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freischalten und Ablehnen jeweils nur dort, wo es den Stand aendert; Loeschen immer. */
|
||||||
|
function ReviewActions({
|
||||||
|
review,
|
||||||
|
decision,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
review: AdminReview;
|
||||||
|
decision: "pending" | "approved" | "rejected";
|
||||||
|
s: ReviewTexts;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
{decision !== "approved" ? (
|
||||||
|
<form action={approveReviewAction}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton>{s.approve}</AdminActionButton>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{decision !== "rejected" ? (
|
||||||
|
<ConfirmForm action={rejectReviewAction} confirmMessage={s.rejectConfirm}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton>{s.reject}</AdminActionButton>
|
||||||
|
</ConfirmForm>
|
||||||
|
) : null}
|
||||||
|
<ConfirmForm action={deleteReviewAction} confirmMessage={s.deleteConfirm}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton tone="danger">{s.delete}</AdminActionButton>
|
||||||
|
</ConfirmForm>
|
||||||
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,96 +2,79 @@ import { notFound } from "next/navigation";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
import { asStringArray } from "@/lib/json-array";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
import { AdminImageField } from "@/components/admin-image-field";
|
import { AdminImageField } from "@/components/admin-image-field";
|
||||||
|
import { AdminField, AdminFieldset, AdminFormPanel } from "@/components/admin-form";
|
||||||
import { updateBatchAction } from "../../actions";
|
import { updateBatchAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
|
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
|
const tb = t.adminBatchEditPage;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const rawBatch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
|
const rawBatch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
|
||||||
if (!rawBatch) notFound();
|
if (!rawBatch) notFound();
|
||||||
const batch = { ...rawBatch, images: asStringArray(rawBatch.images) };
|
const batch = { ...rawBatch, images: asStringArray(rawBatch.images) };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-2xl">
|
||||||
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} />
|
<BackLink href="/admin/chargen" label={tb.backToBatches} />
|
||||||
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1>
|
<h1 className="text-2xl text-heading mb-2">{tb.title}</h1>
|
||||||
<p className="text-sm text-ink/65 mb-8">
|
<p className="text-sm text-ink/65 mb-8">
|
||||||
{batch.code} · {batch.harvest.type} {batch.harvest.year}
|
{batch.code} · {batch.harvest.type} {batch.harvest.year}
|
||||||
</p>
|
</p>
|
||||||
<form action={updateBatchAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
|
||||||
|
<AdminFormPanel action={updateBatchAction} actions={<Button type="submit">{tb.save}</Button>}>
|
||||||
<input type="hidden" name="id" value={batch.id} />
|
<input type="hidden" name="id" value={batch.id} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminFieldset legend={tb.sectionDetails}>
|
||||||
<label className="block text-sm">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span>
|
<AdminField
|
||||||
<input
|
|
||||||
name="fillDate"
|
name="fillDate"
|
||||||
|
label={tb.fillDateLabel}
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
|
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
|
||||||
required
|
required
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<AdminField
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminBatchEditPage.bestBeforeDateLabel}</span>
|
|
||||||
<input
|
|
||||||
name="bestBeforeDate"
|
name="bestBeforeDate"
|
||||||
|
label={tb.bestBeforeDateLabel}
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
|
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
|
||||||
required
|
required
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<label className="block text-sm">
|
<AdminField
|
||||||
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
|
|
||||||
<input
|
|
||||||
name="glassCount"
|
name="glassCount"
|
||||||
|
label={tb.glassCountLabel}
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={batch.glassCount}
|
min={0}
|
||||||
|
defaultValue={String(batch.glassCount)}
|
||||||
required
|
required
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<AdminField
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminBatchEditPage.remainingGlassesLabel}</span>
|
|
||||||
<input
|
|
||||||
name="remainingGlasses"
|
name="remainingGlasses"
|
||||||
|
label={tb.remainingGlassesLabel}
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={batch.remainingGlasses}
|
min={0}
|
||||||
|
defaultValue={String(batch.remainingGlasses)}
|
||||||
required
|
required
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</AdminFieldset>
|
||||||
|
|
||||||
{/* Zwei einzeln setzbare Slots statt einer Liste: die Position
|
{/* Zwei einzeln setzbare Slots statt einer Liste: die Position
|
||||||
entscheidet, welche Kachel auf /charge/[code] gefuellt wird.
|
entscheidet, welche Kachel auf /charge/[code] gefuellt wird.
|
||||||
Beide tragen denselben Feldnamen "images" und werden in dieser
|
Beide tragen denselben Feldnamen "images" und werden in dieser
|
||||||
Reihenfolge uebermittelt (siehe imageSlots() in
|
Reihenfolge uebermittelt (siehe imageSlots() in
|
||||||
admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf
|
admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf
|
||||||
das entsprechende Erntebild zurueck. */}
|
das entsprechende Erntebild zurueck. */}
|
||||||
<fieldset className="border border-ink/10 p-4">
|
<AdminFieldset legend={tb.imagesLabel} last>
|
||||||
<legend className="px-1 text-sm text-ink/70">{t.adminBatchEditPage.imagesLabel}</legend>
|
<AdminImageField name="images" label={tb.image1Label} initialValue={batch.images[0] ?? ""} />
|
||||||
<div className="space-y-4">
|
<AdminImageField name="images" label={tb.image2Label} initialValue={batch.images[1] ?? ""} />
|
||||||
<AdminImageField
|
</AdminFieldset>
|
||||||
name="images"
|
</AdminFormPanel>
|
||||||
label={t.adminBatchEditPage.image1Label}
|
|
||||||
initialValue={batch.images[0] ?? ""}
|
|
||||||
/>
|
|
||||||
<AdminImageField
|
|
||||||
name="images"
|
|
||||||
label={t.adminBatchEditPage.image2Label}
|
|
||||||
initialValue={batch.images[1] ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminBatchEditPage.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { AdminField, AdminSelect } from "@/components/admin-form";
|
||||||
|
import {
|
||||||
|
AdminActionButton,
|
||||||
|
AdminActionLink,
|
||||||
|
AdminActionNote,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminRowActions,
|
||||||
|
} from "@/components/admin-list";
|
||||||
import { createBatchAction, deleteBatchAction } from "../actions";
|
import { createBatchAction, deleteBatchAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) {
|
export default async function AdminBatchesPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const [batches, harvests] = await Promise.all([
|
const [batches, harvests] = await Promise.all([
|
||||||
prisma.batch.findMany({
|
prisma.batch.findMany({
|
||||||
orderBy: { fillDate: "desc" },
|
orderBy: { fillDate: "desc" },
|
||||||
@@ -21,10 +29,7 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
<AdminPageHeader title={t.adminBatchesPage.title} />
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
@@ -41,88 +46,74 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
|||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{batches.map((batch) => (
|
{batches.map((batch) => (
|
||||||
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest">{batch.code}</td>
|
<td className="p-4 text-heading">{batch.code}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{batch.harvest.type} {batch.harvest.year}
|
{batch.harvest.type} {batch.harvest.year}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{formatDate(batch.fillDate)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.fillDate)}</td>
|
||||||
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.bestBeforeDate)}</td>
|
||||||
<td className="p-4">{batch.glassCount}</td>
|
<td className="p-4 tabular-nums">{batch.glassCount}</td>
|
||||||
<td className="p-4">{batch.remainingGlasses}</td>
|
<td className={`p-4 tabular-nums ${batch.remainingGlasses === 0 ? "text-ink/55" : ""}`}>
|
||||||
<td className="p-4 whitespace-nowrap">
|
{batch.remainingGlasses}
|
||||||
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline mr-4">
|
</td>
|
||||||
{t.adminBatchesPage.edit}
|
<td className="p-4">
|
||||||
</Link>
|
<AdminRowActions>
|
||||||
|
<AdminActionLink href={`/admin/chargen/${batch.id}`}>{t.adminBatchesPage.edit}</AdminActionLink>
|
||||||
{batch._count.products === 0 && batch._count.orderItems === 0 ? (
|
{batch._count.products === 0 && batch._count.orderItems === 0 ? (
|
||||||
<ConfirmForm
|
<ConfirmForm
|
||||||
action={deleteBatchAction}
|
action={deleteBatchAction}
|
||||||
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
|
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
|
||||||
className="inline"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={batch.id} />
|
<input type="hidden" name="id" value={batch.id} />
|
||||||
<button type="submit" className="text-red-700 underline">
|
<AdminActionButton tone="danger">{t.adminBatchesPage.delete}</AdminActionButton>
|
||||||
{t.adminBatchesPage.delete}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
) : (
|
) : (
|
||||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||||
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span>
|
<AdminActionNote>{t.adminBatchesPage.notDeletable}</AdminActionNote>
|
||||||
)}
|
)}
|
||||||
|
</AdminRowActions>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null}
|
{batches.length === 0 ? <AdminEmptyState message={t.adminBatchesPage.noBatchesYet} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
<h2 className="font-serif text-xl text-heading mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||||
<form action={createBatchAction} className="space-y-4">
|
<form action={createBatchAction} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<label className="block text-sm">
|
<AdminField
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
|
|
||||||
<input
|
|
||||||
name="code"
|
name="code"
|
||||||
required
|
label={t.adminBatchesPage.codeLabel}
|
||||||
placeholder={t.adminBatchesPage.codePlaceholder}
|
placeholder={t.adminBatchesPage.codePlaceholder}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
<AdminSelect name="harvestId" label={t.adminBatchesPage.harvestLabel}>
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
|
|
||||||
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
|
||||||
{harvests.map((h) => (
|
{harvests.map((h) => (
|
||||||
<option key={h.id} value={h.id}>
|
<option key={h.id} value={h.id}>
|
||||||
{h.type} {h.year}
|
{h.type} {h.year}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</AdminSelect>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<label className="block text-sm">
|
<AdminField name="fillDate" label={t.adminBatchesPage.fillDateLabel} type="date" required />
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span>
|
<AdminField name="bestBeforeDate" label={t.adminBatchesPage.bestBeforeDateLabel} type="date" required />
|
||||||
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
|
||||||
</label>
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
|
|
||||||
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<label className="block text-sm">
|
<AdminField name="glassCount" label={t.adminBatchesPage.glassCountLabel} type="number" required />
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span>
|
<AdminField
|
||||||
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
name="remainingGlasses"
|
||||||
</label>
|
label={t.adminBatchesPage.remainingGlassesLabel}
|
||||||
<label className="block text-sm">
|
type="number"
|
||||||
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span>
|
required
|
||||||
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
/>
|
||||||
</label>
|
</div>
|
||||||
|
<div className="flex justify-end border-t border-ink/10 pt-4">
|
||||||
|
<Button type="submit">{t.adminBatchesPage.create}</Button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminBatchesPage.create}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { SETTINGS_PATH } from "@/lib/settings-tabs";
|
||||||
|
|
||||||
|
/** Der E-Mail-Test liegt jetzt in Admin → Einstellungen → System & Tests. */
|
||||||
|
export default function AdminEmailTestRedirect(): never {
|
||||||
|
redirect(`${SETTINGS_PATH}?reiter=system`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { AdminImageField } from "@/components/admin-image-field";
|
||||||
|
import { AdminVideoField } from "@/components/admin-video-field";
|
||||||
|
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||||
|
import { updateSiteContentAction } from "../actions";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
import type { Locale } from "@/i18n/config";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||||
|
text: 1,
|
||||||
|
textarea: 3,
|
||||||
|
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
|
||||||
|
// nur vorhanden, damit dieses Record vollstaendig bleibt.
|
||||||
|
select: 0,
|
||||||
|
"list-simple": 4,
|
||||||
|
"list-titled": 5,
|
||||||
|
"list-numbered": 6,
|
||||||
|
"richtext-sections": 14,
|
||||||
|
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
|
||||||
|
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
|
||||||
|
// weiter vollstaendig ist.
|
||||||
|
image: 0,
|
||||||
|
video: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
|
||||||
|
|
||||||
|
export async function loadCurrentValues(): Promise<CurrentValues> {
|
||||||
|
const rows = await prisma.siteContent.findMany();
|
||||||
|
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bild- und Video-Felder — auf jedem Reiter vor den Textfeldern (fieldsForGroup). */
|
||||||
|
function isImageOrVideoField(def: ContentFieldDef): boolean {
|
||||||
|
return def.type === "image" || def.type === "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldEditor({
|
||||||
|
def,
|
||||||
|
row,
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
def: ContentFieldDef;
|
||||||
|
row: { valueDe: string; valueEn: string | null } | undefined;
|
||||||
|
locale: Locale;
|
||||||
|
t: Dictionary;
|
||||||
|
}) {
|
||||||
|
const rows = ROWS_BY_TYPE[def.type];
|
||||||
|
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||||
|
|
||||||
|
if (!def.localized) {
|
||||||
|
if (def.type === "image") {
|
||||||
|
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
|
||||||
|
}
|
||||||
|
if (def.type === "video") {
|
||||||
|
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
|
||||||
|
}
|
||||||
|
if (def.type === "select") {
|
||||||
|
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
|
||||||
|
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
|
||||||
|
// darstellen als die Wirklichkeit.
|
||||||
|
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<select
|
||||||
|
name={def.key}
|
||||||
|
defaultValue={current}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||||
|
>
|
||||||
|
{(def.options ?? []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{locale === "de" ? option.labelDe : option.labelEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
{def.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
name={def.key}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={def.key}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTextarea = def.type !== "text";
|
||||||
|
return (
|
||||||
|
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
|
||||||
|
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
|
||||||
|
{isTextarea ? (
|
||||||
|
<textarea
|
||||||
|
name={`de::${def.key}`}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`de::${def.key}`}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
|
||||||
|
{isTextarea ? (
|
||||||
|
<textarea
|
||||||
|
name={`en::${def.key}`}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueEn ?? ""}
|
||||||
|
placeholder={def.getDefault("en")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`en::${def.key}`}
|
||||||
|
defaultValue={row?.valueEn ?? ""}
|
||||||
|
placeholder={def.getDefault("en")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein <form> je Inhaltsgruppe auf ihrem Reiter (admin/einstellungen/page.tsx).
|
||||||
|
* updateSiteContentAction liest pro Absenden nur die in DIESEM Formular
|
||||||
|
* vorhandenen Registry-Felder (siehe admin/actions/content.ts) und springt
|
||||||
|
* danach ueber das mitgeschickte group="..." auf denselben Reiter zurueck. */
|
||||||
|
export function GroupForm({
|
||||||
|
groupHeading,
|
||||||
|
fields,
|
||||||
|
current,
|
||||||
|
groupKeyValue,
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
groupHeading: string;
|
||||||
|
fields: ContentFieldDef[];
|
||||||
|
current: CurrentValues;
|
||||||
|
groupKeyValue: string;
|
||||||
|
locale: Locale;
|
||||||
|
t: Dictionary;
|
||||||
|
}) {
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updateSiteContentAction}
|
||||||
|
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="group" value={groupKeyValue} />
|
||||||
|
<h3 className="text-lg text-heading">{groupHeading}</h3>
|
||||||
|
|
||||||
|
{fields.map((def) => (
|
||||||
|
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button type="submit" className="px-5 py-2.5">
|
||||||
|
{t.adminContentPage.save}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Felder einer Inhaltsgruppe, Bilder und Video zuerst — so steht auf jedem Reiter das Visuelle oben. */
|
||||||
|
export function fieldsForGroup(group: ContentFieldDef["group"]): ContentFieldDef[] {
|
||||||
|
const fields = CONTENT_REGISTRY.filter((f) => f.group === group);
|
||||||
|
return [...fields.filter(isImageOrVideoField), ...fields.filter((f) => !isImageOrVideoField(f))];
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminBadge } from "@/components/admin-list";
|
||||||
|
import type { ContentFieldDef } from "@/lib/content-registry";
|
||||||
|
import { readIntegrationStatus } from "@/lib/integration-status";
|
||||||
|
import { TEXT_PLACEHOLDERS } from "@/lib/text-placeholders";
|
||||||
|
import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
import type { Locale } from "@/i18n/config";
|
||||||
|
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
|
||||||
|
import { SmtpTestPanel } from "./smtp-test-panel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Einstellungen des Shops an einem Ort, nach Bereichen in Reiter
|
||||||
|
* gegliedert (lib/settings-tabs.ts). Die Reiter sind echte Links
|
||||||
|
* (?reiter=…): direkt aufrufbar, als Lesezeichen speicherbar und ohne
|
||||||
|
* JavaScript bedienbar. Frueher getrennt: /admin/inhalte und
|
||||||
|
* /admin/e-mail-test — beide leiten hierher um.
|
||||||
|
*/
|
||||||
|
export default async function AdminEinstellungenPage(props: PageProps<"/admin/einstellungen">) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
|
const s = t.adminContentPage;
|
||||||
|
const { reiter } = await props.searchParams;
|
||||||
|
const activeId = parseSettingsTab(reiter);
|
||||||
|
const active = SETTINGS_TABS.find((tab) => tab.id === activeId) ?? SETTINGS_TABS[0];
|
||||||
|
|
||||||
|
const tabLabels: Record<SettingsTabId, string> = {
|
||||||
|
firma: s.tabCompany,
|
||||||
|
marke: s.tabBrand,
|
||||||
|
startseite: s.tabHome,
|
||||||
|
seiten: s.tabPages,
|
||||||
|
social: s.tabSocial,
|
||||||
|
recht: s.tabLegal,
|
||||||
|
system: s.tabSystem,
|
||||||
|
};
|
||||||
|
const groupHeadings: Record<ContentFieldDef["group"], string> = {
|
||||||
|
company: s.groupCompany,
|
||||||
|
shipping: s.groupShipping,
|
||||||
|
brand: s.groupBrand,
|
||||||
|
home: s.groupHome,
|
||||||
|
pages: s.groupPages,
|
||||||
|
social: s.groupSocial,
|
||||||
|
legal: s.groupLegal,
|
||||||
|
};
|
||||||
|
const current = active.groups.length > 0 ? await loadCurrentValues() : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={s.backToDashboard} />
|
||||||
|
<h1 className="mb-6 text-2xl text-heading">{s.title}</h1>
|
||||||
|
|
||||||
|
<nav aria-label={s.tabsLabel} className="-mx-1 mb-8 overflow-x-auto border-b border-ink/10">
|
||||||
|
<ul className="flex min-w-max gap-1 px-1">
|
||||||
|
{SETTINGS_TABS.map((tab) => {
|
||||||
|
const isActive = tab.id === active.id;
|
||||||
|
return (
|
||||||
|
<li key={tab.id}>
|
||||||
|
<Link
|
||||||
|
href={`${SETTINGS_PATH}?reiter=${tab.id}`}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={`-mb-px block border-b-2 px-4 py-3 text-sm transition-colors ${
|
||||||
|
isActive ? "border-honey text-heading" : "border-transparent text-ink/65 hover:border-ink/20 hover:text-heading"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tabLabels[tab.id]}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{active.id === "system" ? (
|
||||||
|
<SystemTab t={t} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
|
||||||
|
<PlaceholderHint t={t} locale={locale} />
|
||||||
|
{current
|
||||||
|
? active.groups.map((group) => (
|
||||||
|
<GroupForm
|
||||||
|
key={group}
|
||||||
|
groupHeading={groupHeadings[group]}
|
||||||
|
groupKeyValue={group}
|
||||||
|
fields={fieldsForGroup(group)}
|
||||||
|
current={current}
|
||||||
|
locale={locale}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersicht der Textbausteine (lib/text-placeholders.ts) ueber den
|
||||||
|
* Formularen: welche es gibt und was sie einsetzen. Der Baustein ist per
|
||||||
|
* select-all markierbar, damit er sich mit einem Klick kopieren laesst.
|
||||||
|
*/
|
||||||
|
function PlaceholderHint({ t, locale }: { t: Dictionary; locale: Locale }) {
|
||||||
|
const s = t.adminContentPage;
|
||||||
|
return (
|
||||||
|
<aside className="mb-10 max-w-2xl border-l-[3px] border-honey bg-surface px-5 py-4 text-sm">
|
||||||
|
<h2 className="text-heading">{s.placeholdersHeading}</h2>
|
||||||
|
<p className="mt-1 text-ink/65">{s.placeholdersIntro}</p>
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{TEXT_PLACEHOLDERS.map((placeholder) => (
|
||||||
|
<li key={placeholder.token} className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<code className="select-all rounded bg-background px-1.5 py-0.5 font-mono text-heading">{placeholder.token}</code>
|
||||||
|
<span className="text-ink/70">{locale === "de" ? placeholder.labelDe : placeholder.labelEn}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemTab({ t }: { t: Dictionary }) {
|
||||||
|
const s = t.adminContentPage;
|
||||||
|
const integrations = readIntegrationStatus();
|
||||||
|
const warnings = { "stripe-test-mode": s.warningStripeTest, "stripe-mixed-mode": s.warningStripeMixed } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
<p className="max-w-2xl text-sm text-ink/65">{s.systemIntro}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="settings-integrations">
|
||||||
|
<h2 id="settings-integrations" className="text-xl text-heading">
|
||||||
|
{s.integrationsHeading}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{s.integrationsIntro}</p>
|
||||||
|
<ul className="max-w-3xl divide-y divide-ink/10 border-y border-ink/10">
|
||||||
|
{integrations.map((integration) => (
|
||||||
|
<li key={integration.key} className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-heading">{s.integrationNames[integration.key]}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink/60">{integration.required ? s.statusRequired : s.statusOptional}</p>
|
||||||
|
{integration.warning ? (
|
||||||
|
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{warnings[integration.warning]}</p>
|
||||||
|
) : null}
|
||||||
|
{!integration.configured ? (
|
||||||
|
<p className="mt-2 break-words font-mono text-xs text-ink/70">{s.statusMissing(integration.missing.join(", "))}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<AdminBadge tone={integration.configured ? (integration.warning ? "danger" : "positive") : integration.required ? "danger" : "neutral"}>
|
||||||
|
{integration.configured ? s.statusConfigured : s.statusNotConfigured}
|
||||||
|
</AdminBadge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="settings-email-test">
|
||||||
|
<h2 id="settings-email-test" className="text-xl text-heading">
|
||||||
|
{s.emailTestHeading}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{t.adminEmailTestPage.intro}</p>
|
||||||
|
<SmtpTestPanel />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/app/admin/actions";
|
||||||
|
import { testSmtpConnection, type SmtpTestResult } from "@/lib/mail";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const TESTS_PER_WINDOW = 5;
|
||||||
|
const WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export type SmtpTestState = (SmtpTestResult & { to: string }) | { rateLimited: true } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-Mail immer an die Adresse des angemeldeten Admins — nie an eine frei
|
||||||
|
* eingegebene, sonst waere die Aktion ein Werkzeug fuer Versand an Dritte.
|
||||||
|
* Das Limit schuetzt das Postfach vor Sperrungen durch Microsoft bei
|
||||||
|
* wiederholten Fehlanmeldungen.
|
||||||
|
*/
|
||||||
|
export async function runSmtpTestAction(): Promise<SmtpTestState> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!rateLimit(`smtp-test:${admin.id}`, TESTS_PER_WINDOW, WINDOW_MS)) return { rateLimited: true };
|
||||||
|
|
||||||
|
const result = await testSmtpConnection(admin.email);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "mail.smtp_test",
|
||||||
|
entityType: "Mail",
|
||||||
|
detail: result.ok ? "erfolgreich" : result.configured ? `fehlgeschlagen: ${result.diagnosis.cause}` : "nicht eingerichtet",
|
||||||
|
});
|
||||||
|
return { ...result, to: admin.email };
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { runSmtpTestAction, type SmtpTestState } from "./smtp-test-actions";
|
||||||
|
|
||||||
|
export function SmtpTestPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const s = t.adminEmailTestPage;
|
||||||
|
const [state, formAction, isPending] = useActionState<SmtpTestState>(runSmtpTestAction, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<form action={formAction}>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? s.running : s.button}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div aria-live="polite" className="mt-8">
|
||||||
|
{state && "rateLimited" in state ? (
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">Zu viele Tests. Bitte warte ein paar Minuten.</p>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && state.ok ? (
|
||||||
|
<p className="border border-heading/30 bg-forest/5 px-5 py-4 text-sm text-heading">{s.success(state.to)}</p>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && !state.ok && !state.configured ? (
|
||||||
|
<div className="space-y-2 border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<p>{s.notConfigured}</p>
|
||||||
|
<p className="font-mono text-xs">
|
||||||
|
{s.missingLabel}: {state.missing.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && !state.ok && state.configured ? (
|
||||||
|
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
|
||||||
|
<p className="font-serif text-xl text-red-700 dark:text-red-300">{s.failureHeading}</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.causeLabel}</p>
|
||||||
|
<p className="mt-1 leading-relaxed text-ink/85">{state.diagnosis.hint}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.detailLabel}</p>
|
||||||
|
<p className="mt-1 break-words font-mono text-xs text-ink/80">{state.diagnosis.detail || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.configLabel}</p>
|
||||||
|
<p className="mt-1 break-words font-mono text-xs text-ink/80">
|
||||||
|
{s.authModeOAuth} · Server {state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{state && "network" in state ? (
|
||||||
|
<div className="mt-6 text-sm">
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.networkHeading}</p>
|
||||||
|
<ul className="mt-2 space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ label: s.networkSmtp, check: state.network.smtp },
|
||||||
|
{ label: s.networkEntra, check: state.network.entra },
|
||||||
|
].map(({ label, check }) => (
|
||||||
|
<li key={label} className="flex flex-wrap items-baseline gap-x-3">
|
||||||
|
<span className="text-ink/80">{label}:</span>
|
||||||
|
<span className={check.ok ? "text-heading" : "font-medium text-red-700 dark:text-red-300"}>
|
||||||
|
{check.ok ? s.networkOk : s.networkFailed}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-ink/60">{check.detail}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,23 @@ import { notFound } from "next/navigation";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
import { asStringArray } from "@/lib/json-array";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
import { AdminImageField } from "@/components/admin-image-field";
|
import { AdminImageField } from "@/components/admin-image-field";
|
||||||
|
import {
|
||||||
|
AdminField,
|
||||||
|
AdminFieldset,
|
||||||
|
AdminFormPanel,
|
||||||
|
AdminHint,
|
||||||
|
AdminSwitch,
|
||||||
|
AdminTextarea,
|
||||||
|
} from "@/components/admin-form";
|
||||||
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
|
const th = t.adminHarvestEditPage;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
|
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
|
||||||
if (!rawHarvest) notFound();
|
if (!rawHarvest) notFound();
|
||||||
@@ -17,102 +27,63 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
|||||||
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-2xl">
|
||||||
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
|
<BackLink href="/admin/ernten" label={th.backToHarvests} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
|
<h1 className="text-2xl text-heading mb-8">{th.title}</h1>
|
||||||
|
|
||||||
{harvest.isPublished ? null : (
|
{harvest.isPublished ? null : (
|
||||||
<div className="mb-6 bg-honey/10 border border-honey/30 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
|
<div className="mb-6 flex flex-col justify-between gap-3 border border-honey/40 bg-honey/5 px-4 py-3 sm:flex-row sm:items-center">
|
||||||
<p className="text-sm text-honey-dark">{t.adminHarvestEditPage.draftNotice}</p>
|
<p className="text-sm text-honey-dark">{th.draftNotice}</p>
|
||||||
<form action={toggleHarvestPublishedAction}>
|
<form action={toggleHarvestPublishedAction}>
|
||||||
<input type="hidden" name="id" value={harvest.id} />
|
<input type="hidden" name="id" value={harvest.id} />
|
||||||
<input type="hidden" name="isPublished" value="on" />
|
<input type="hidden" name="isPublished" value="on" />
|
||||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm whitespace-nowrap">
|
<Button type="submit" className="whitespace-nowrap px-5 py-2.5">
|
||||||
{t.adminHarvestEditPage.publishNow}
|
{th.publishNow}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<AdminFormPanel action={updateHarvestAction} actions={<Button type="submit">{th.save}</Button>}>
|
||||||
<input type="hidden" name="id" value={harvest.id} />
|
<input type="hidden" name="id" value={harvest.id} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminFieldset legend={th.sectionDetails}>
|
||||||
<label className="block text-sm">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span>
|
<AdminField name="type" label={th.typeLabel} defaultValue={harvest.type} required />
|
||||||
<input
|
<AdminField name="year" label={th.yearLabel} type="number" defaultValue={String(harvest.year)} required />
|
||||||
name="type"
|
|
||||||
defaultValue={harvest.type}
|
|
||||||
required
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminHarvestEditPage.yearLabel}</span>
|
|
||||||
<input
|
|
||||||
name="year"
|
|
||||||
type="number"
|
|
||||||
defaultValue={harvest.year}
|
|
||||||
required
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-sm">
|
<div className="grid items-end gap-4 sm:grid-cols-2">
|
||||||
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span>
|
<AdminField
|
||||||
<input
|
|
||||||
name="harvestDate"
|
name="harvestDate"
|
||||||
|
label={th.harvestDateLabel}
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={harvestDateValue}
|
defaultValue={harvestDateValue}
|
||||||
required
|
required
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<AdminField name="location" label={th.locationLabel} defaultValue={harvest.location} />
|
||||||
<label className="block text-sm">
|
</div>
|
||||||
<span className="text-ink/70">{t.adminHarvestEditPage.locationLabel}</span>
|
<AdminTextarea
|
||||||
<input
|
|
||||||
name="location"
|
|
||||||
defaultValue={harvest.location}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{t.adminHarvestEditPage.descriptionLabel}</span>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
name="description"
|
||||||
|
label={th.descriptionLabel}
|
||||||
defaultValue={harvest.description}
|
defaultValue={harvest.description}
|
||||||
required
|
|
||||||
rows={4}
|
rows={4}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</AdminFieldset>
|
||||||
|
|
||||||
{/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt
|
{/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt
|
||||||
einer Liste, damit sich das zweite Bild auch ohne erstes setzen
|
einer Liste, damit sich das zweite Bild auch ohne erstes setzen
|
||||||
laesst. Slot 1 erscheint zusaetzlich auf /ernten und der
|
laesst. Slot 1 erscheint zusaetzlich auf /ernten und der
|
||||||
Ernten-Detailseite. */}
|
Ernten-Detailseite. */}
|
||||||
<fieldset className="border border-ink/10 p-4">
|
<AdminFieldset legend={th.imagesLabel}>
|
||||||
<legend className="px-1 text-sm text-ink/70">{t.adminHarvestEditPage.imagesLabel}</legend>
|
<AdminImageField name="images" label={th.image1Label} initialValue={harvest.images[0] ?? ""} />
|
||||||
<div className="space-y-4">
|
<AdminImageField name="images" label={th.image2Label} initialValue={harvest.images[1] ?? ""} />
|
||||||
<AdminImageField
|
</AdminFieldset>
|
||||||
name="images"
|
|
||||||
label={t.adminHarvestEditPage.image1Label}
|
<AdminFieldset legend={th.sectionVisibility} last>
|
||||||
initialValue={harvest.images[0] ?? ""}
|
<AdminSwitch name="isPublished" label={th.isPublishedLabel} defaultChecked={harvest.isPublished} />
|
||||||
/>
|
<AdminHint>{th.isPublishedHint}</AdminHint>
|
||||||
<AdminImageField
|
</AdminFieldset>
|
||||||
name="images"
|
</AdminFormPanel>
|
||||||
label={t.adminHarvestEditPage.image2Label}
|
|
||||||
initialValue={harvest.images[1] ?? ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
|
||||||
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
|
||||||
{t.adminHarvestEditPage.isPublishedLabel}
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-ink/70">{t.adminHarvestEditPage.isPublishedHint}</p>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminHarvestEditPage.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { AdminField, AdminSwitch, AdminTextarea } from "@/components/admin-form";
|
||||||
|
import {
|
||||||
|
AdminActionButton,
|
||||||
|
AdminActionLink,
|
||||||
|
AdminActionNote,
|
||||||
|
AdminBadge,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminRowActions,
|
||||||
|
} from "@/components/admin-list";
|
||||||
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
|
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
|
||||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
export default async function AdminHarvestsPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const error = searchParams.error;
|
|
||||||
const harvests = await prisma.harvest.findMany({
|
const harvests = await prisma.harvest.findMany({
|
||||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||||
include: { _count: { select: { batches: true, products: true } } },
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
@@ -20,12 +27,8 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
<AdminPageHeader title={t.adminHarvestsPage.title} />
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
<ApiCoreSyncResult searchParams={searchParams} />
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -44,116 +47,70 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{harvests.map((harvest) => (
|
{harvests.map((harvest) => (
|
||||||
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest">{harvest.type}</td>
|
<td className="p-4 text-heading">{harvest.type}</td>
|
||||||
<td className="p-4">{harvest.year}</td>
|
<td className="p-4 tabular-nums">{harvest.year}</td>
|
||||||
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
|
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(harvest.harvestDate)}</td>
|
||||||
<td className="p-4">{harvest.location}</td>
|
<td className="p-4">{harvest.location}</td>
|
||||||
<td className="p-4">{harvest._count.batches}</td>
|
<td className="p-4 tabular-nums">{harvest._count.batches}</td>
|
||||||
<td className="p-4">{harvest._count.products}</td>
|
<td className="p-4 tabular-nums">{harvest._count.products}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<span
|
{harvest.isPublished ? (
|
||||||
className={
|
<AdminBadge tone="positive">{t.adminHarvestsPage.statusPublished}</AdminBadge>
|
||||||
harvest.isPublished
|
) : (
|
||||||
? "text-forest bg-forest/10 px-2 py-1 text-xs"
|
<AdminBadge tone="pending">{t.adminHarvestsPage.statusDraft}</AdminBadge>
|
||||||
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs"
|
)}
|
||||||
}
|
|
||||||
>
|
|
||||||
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 whitespace-nowrap">
|
<td className="p-4">
|
||||||
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4">
|
<AdminRowActions>
|
||||||
{t.adminHarvestsPage.edit}
|
<AdminActionLink href={`/admin/ernten/${harvest.id}`}>{t.adminHarvestsPage.edit}</AdminActionLink>
|
||||||
</Link>
|
<form action={toggleHarvestPublishedAction}>
|
||||||
<form action={toggleHarvestPublishedAction} className="inline">
|
|
||||||
<input type="hidden" name="id" value={harvest.id} />
|
<input type="hidden" name="id" value={harvest.id} />
|
||||||
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
|
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
|
||||||
<button type="submit" className="text-forest underline mr-4">
|
<AdminActionButton tone={harvest.isPublished ? "muted" : "default"}>
|
||||||
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
|
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
|
||||||
</button>
|
</AdminActionButton>
|
||||||
</form>
|
</form>
|
||||||
{harvest._count.batches === 0 && harvest._count.products === 0 ? (
|
{harvest._count.batches === 0 && harvest._count.products === 0 ? (
|
||||||
<ConfirmForm
|
<ConfirmForm
|
||||||
action={deleteHarvestAction}
|
action={deleteHarvestAction}
|
||||||
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
|
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
|
||||||
className="inline"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={harvest.id} />
|
<input type="hidden" name="id" value={harvest.id} />
|
||||||
<button type="submit" className="text-red-700 underline">
|
<AdminActionButton tone="danger">{t.adminHarvestsPage.delete}</AdminActionButton>
|
||||||
{t.adminHarvestsPage.delete}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
) : (
|
) : (
|
||||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||||
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
|
<AdminActionNote>{t.adminHarvestsPage.notDeletable}</AdminActionNote>
|
||||||
)}
|
)}
|
||||||
|
</AdminRowActions>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
|
{harvests.length === 0 ? <AdminEmptyState message={t.adminHarvestsPage.noHarvestsYet} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 grid md:grid-cols-2 gap-6">
|
<div className="mt-10 max-w-xl">
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
<h2 className="font-serif text-xl text-heading mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||||
<form action={createHarvestAction} className="space-y-4">
|
<form action={createHarvestAction} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
<AdminField name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||||
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
<AdminField name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||||
|
</div>
|
||||||
|
<AdminField name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||||
|
<AdminField name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||||
|
<AdminTextarea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||||
|
<AdminSwitch name="isPublished" label={t.adminHarvestsPage.isPublishedLabel} defaultChecked />
|
||||||
|
<div className="flex justify-end border-t border-ink/10 pt-4">
|
||||||
|
<Button type="submit">{t.adminHarvestsPage.create}</Button>
|
||||||
</div>
|
</div>
|
||||||
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
|
||||||
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
|
||||||
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
|
||||||
<input type="checkbox" name="isPublished" defaultChecked />
|
|
||||||
{t.adminHarvestsPage.isPublishedLabel}
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminHarvestsPage.create}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ApiCoreSyncPanel returnTo="/admin/ernten" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
defaultValue,
|
|
||||||
required = false,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
required={required}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,13 +21,13 @@ export default function AdminError({
|
|||||||
return (
|
return (
|
||||||
<div className="py-20 text-center">
|
<div className="py-20 text-center">
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||||
<h1 className="text-2xl text-forest">{t.errorPage.title}</h1>
|
<h1 className="text-2xl text-heading">{t.errorPage.title}</h1>
|
||||||
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
|
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
|
||||||
<div className="mt-6 flex items-center justify-center gap-4">
|
<div className="mt-6 flex items-center justify-center gap-4">
|
||||||
<Button type="button" onClick={() => reset()}>
|
<Button type="button" onClick={() => reset()}>
|
||||||
{t.errorPage.retry}
|
{t.errorPage.retry}
|
||||||
</Button>
|
</Button>
|
||||||
<Link href="/admin" className="text-sm text-forest underline">
|
<Link href="/admin" className="text-sm text-heading underline">
|
||||||
{t.adminVouchersPage.backToDashboard}
|
{t.adminVouchersPage.backToDashboard}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import {
|
||||||
|
AdminField,
|
||||||
|
AdminFieldset,
|
||||||
|
AdminFormPanel,
|
||||||
|
AdminSelect,
|
||||||
|
AdminSwitch,
|
||||||
|
} from "@/components/admin-form";
|
||||||
import { updateDiscountCodeAction } from "../../actions";
|
import { updateDiscountCodeAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -12,112 +20,79 @@ function toDateInputValue(date: Date | null): string {
|
|||||||
|
|
||||||
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
|
const tv = t.adminVoucherEditPage;
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const code = await prisma.discountCode.findUnique({ where: { id } });
|
const code = await prisma.discountCode.findUnique({ where: { id } });
|
||||||
if (!code) notFound();
|
if (!code) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-2xl">
|
||||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
|
<h1 className="text-2xl text-heading mb-2">{tv.title}</h1>
|
||||||
|
<p className="text-sm text-ink/65 mb-8">{tv.redeemedSoFar(code.usedCount, code.maxUses)}</p>
|
||||||
|
|
||||||
{error ? (
|
<AdminFormPanel action={updateDiscountCodeAction} actions={<Button type="submit">{tv.save}</Button>}>
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
|
||||||
<input type="hidden" name="id" value={code.id} />
|
<input type="hidden" name="id" value={code.id} />
|
||||||
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required />
|
<AdminFieldset legend={tv.sectionDiscount}>
|
||||||
|
<AdminField name="code" label={tv.codeLabel} defaultValue={code.code} required />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<label className="block text-sm">
|
<AdminSelect name="type" label={tv.typeLabel} defaultValue={code.type}>
|
||||||
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span>
|
<option value="PERCENT">{tv.typePercent}</option>
|
||||||
<select
|
<option value="FIXED">{tv.typeFixed}</option>
|
||||||
name="type"
|
</AdminSelect>
|
||||||
defaultValue={code.type}
|
<AdminField
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
name="value"
|
||||||
>
|
label={tv.valueLabel}
|
||||||
<option value="PERCENT">{t.adminVoucherEditPage.typePercent}</option>
|
|
||||||
<option value="FIXED">{t.adminVoucherEditPage.typeFixed}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<Field name="value" label={t.adminVoucherEditPage.valueLabel} type="number" defaultValue={String(code.value)} required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
name="minOrderCents"
|
|
||||||
label={t.adminVoucherEditPage.minOrderLabel}
|
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={String(code.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AdminFieldset>
|
||||||
|
|
||||||
|
<AdminFieldset legend={tv.sectionConditions}>
|
||||||
|
<AdminField
|
||||||
|
name="minOrderCents"
|
||||||
|
label={tv.minOrderLabel}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
defaultValue={String(code.minOrderCents)}
|
defaultValue={String(code.minOrderCents)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AdminField
|
||||||
name="minQuantity"
|
name="minQuantity"
|
||||||
label={t.adminVoucherEditPage.minQuantityLabel}
|
label={tv.minQuantityLabel}
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
|
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AdminField
|
||||||
name="maxUses"
|
name="maxUses"
|
||||||
label={t.adminVoucherEditPage.maxUsesLabel}
|
label={tv.maxUsesLabel}
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
|
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
|
||||||
/>
|
/>
|
||||||
|
</AdminFieldset>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminFieldset legend={tv.sectionValidity} last>
|
||||||
<Field
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
|
<AdminField
|
||||||
name="validFrom"
|
name="validFrom"
|
||||||
label={t.adminVoucherEditPage.validFromLabel}
|
label={tv.validFromLabel}
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={toDateInputValue(code.validFrom)}
|
defaultValue={toDateInputValue(code.validFrom)}
|
||||||
/>
|
/>
|
||||||
<Field
|
<AdminField
|
||||||
name="validUntil"
|
name="validUntil"
|
||||||
label={t.adminVoucherEditPage.validUntilLabel}
|
label={tv.validUntilLabel}
|
||||||
type="date"
|
type="date"
|
||||||
defaultValue={toDateInputValue(code.validUntil)}
|
defaultValue={toDateInputValue(code.validUntil)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked={code.isActive} />
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
</AdminFieldset>
|
||||||
<input type="checkbox" name="isActive" defaultChecked={code.isActive} />
|
</AdminFormPanel>
|
||||||
{t.adminVoucherEditPage.activeLabel}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p className="text-xs text-ink/65">{t.adminVoucherEditPage.redeemedSoFar(code.usedCount, code.maxUses)}</p>
|
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminVoucherEditPage.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
defaultValue,
|
|
||||||
required = false,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
required={required}
|
|
||||||
min={0}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +1,51 @@
|
|||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import {
|
||||||
|
AdminField,
|
||||||
|
AdminFieldset,
|
||||||
|
AdminFormPanel,
|
||||||
|
AdminSelect,
|
||||||
|
AdminSwitch,
|
||||||
|
} from "@/components/admin-form";
|
||||||
import { createDiscountCodeAction } from "../../actions";
|
import { createDiscountCodeAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) {
|
export default async function AdminGutscheinNewPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
const tv = t.adminVoucherNewPage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-2xl">
|
||||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
|
<h1 className="text-2xl text-heading mb-8">{tv.title}</h1>
|
||||||
|
|
||||||
{error ? (
|
<AdminFormPanel action={createDiscountCodeAction} actions={<Button type="submit">{tv.create}</Button>}>
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
<AdminFieldset legend={tv.sectionDiscount}>
|
||||||
) : null}
|
<AdminField name="code" label={tv.codeLabel} placeholder={tv.codePlaceholder} required />
|
||||||
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<AdminSelect name="type" label={tv.typeLabel} defaultValue="PERCENT">
|
||||||
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
<option value="PERCENT">{tv.typePercent}</option>
|
||||||
|
<option value="FIXED">{tv.typeFixed}</option>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</AdminSelect>
|
||||||
<label className="block text-sm">
|
<AdminField name="value" label={tv.valueLabel} type="number" min={0} placeholder="10" required />
|
||||||
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span>
|
|
||||||
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
|
||||||
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option>
|
|
||||||
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
|
|
||||||
</div>
|
</div>
|
||||||
|
</AdminFieldset>
|
||||||
|
|
||||||
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" />
|
<AdminFieldset legend={tv.sectionConditions}>
|
||||||
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" />
|
<AdminField name="minOrderCents" label={tv.minOrderLabel} type="number" min={0} placeholder="0" />
|
||||||
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" />
|
<AdminField name="minQuantity" label={tv.minQuantityLabel} type="number" min={0} placeholder="3" />
|
||||||
|
<AdminField name="maxUses" label={tv.maxUsesLabel} type="number" min={0} />
|
||||||
|
</AdminFieldset>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminFieldset legend={tv.sectionValidity} last>
|
||||||
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" />
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" />
|
<AdminField name="validFrom" label={tv.validFromLabel} type="date" />
|
||||||
|
<AdminField name="validUntil" label={tv.validUntilLabel} type="date" />
|
||||||
</div>
|
</div>
|
||||||
|
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked />
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
</AdminFieldset>
|
||||||
<input type="checkbox" name="isActive" defaultChecked />
|
</AdminFormPanel>
|
||||||
{t.adminVoucherNewPage.activeLabel}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminVoucherNewPage.create}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
placeholder,
|
|
||||||
required = false,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type={type}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
min={0}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,29 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
AdminActionButton,
|
||||||
|
AdminActionLink,
|
||||||
|
AdminBadge,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminRowActions,
|
||||||
|
} from "@/components/admin-list";
|
||||||
import { deleteDiscountCodeAction } from "../actions";
|
import { deleteDiscountCodeAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
|
export default async function AdminGutscheinePage() {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { error } = await props.searchParams;
|
|
||||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
const now = new Date();
|
||||||
|
const newVoucher = { href: "/admin/gutscheine/neu", label: t.adminVouchersPage.newVoucher };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
|
||||||
<div className="flex items-center justify-between mb-8">
|
<AdminPageHeader title={t.adminVouchersPage.title} action={newVoucher} />
|
||||||
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
|
|
||||||
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
|
||||||
{t.adminVouchersPage.newVoucher}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -43,13 +42,13 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
|
|||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{codes.map((code) => (
|
{codes.map((code) => (
|
||||||
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest font-mono">{code.code}</td>
|
<td className="p-4 text-heading font-mono">{code.code}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
|
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
|
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
|
||||||
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
|
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
|
||||||
<td className="p-4">
|
<td className="p-4 tabular-nums">
|
||||||
{code.usedCount}
|
{code.usedCount}
|
||||||
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
|
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
|
||||||
</td>
|
</td>
|
||||||
@@ -58,33 +57,31 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
|
|||||||
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
|
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{code.isActive ? (
|
{!code.isActive ? (
|
||||||
<span className="text-forest">{t.adminVouchersPage.statusActive}</span>
|
<AdminBadge tone="neutral">{t.adminVouchersPage.statusInactive}</AdminBadge>
|
||||||
|
) : code.validUntil && code.validUntil < now ? (
|
||||||
|
<AdminBadge tone="pending">{t.adminVouchersPage.statusExpired}</AdminBadge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span>
|
<AdminBadge tone="positive">{t.adminVouchersPage.statusActive}</AdminBadge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
<AdminRowActions>
|
||||||
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline">
|
<AdminActionLink href={`/admin/gutscheine/${code.id}`}>{t.adminVouchersPage.edit}</AdminActionLink>
|
||||||
{t.adminVouchersPage.edit}
|
|
||||||
</Link>
|
|
||||||
<ConfirmForm
|
<ConfirmForm
|
||||||
action={deleteDiscountCodeAction}
|
action={deleteDiscountCodeAction}
|
||||||
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
|
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={code.id} />
|
<input type="hidden" name="id" value={code.id} />
|
||||||
<button type="submit" className="text-red-700 underline">
|
<AdminActionButton tone="danger">{t.adminVouchersPage.delete}</AdminActionButton>
|
||||||
{t.adminVouchersPage.delete}
|
|
||||||
</button>
|
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
</div>
|
</AdminRowActions>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null}
|
{codes.length === 0 ? <AdminEmptyState message={t.adminVouchersPage.noVouchersYet} action={newVoucher} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,260 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { redirect } from "next/navigation";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { SETTINGS_PATH } from "@/lib/settings-tabs";
|
||||||
import { AdminImageField } from "@/components/admin-image-field";
|
|
||||||
import { AdminVideoField } from "@/components/admin-video-field";
|
|
||||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
|
||||||
import { updateSiteContentAction } from "../actions";
|
|
||||||
import { getLocale } from "@/i18n/locale";
|
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
|
||||||
import type { Locale } from "@/i18n/config";
|
|
||||||
|
|
||||||
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
/** Frueher "Inhalte" — jetzt Teil von Admin → Einstellungen. Alte Lesezeichen fuehren weiter. */
|
||||||
text: 1,
|
export default function AdminInhalteRedirect(): never {
|
||||||
textarea: 3,
|
redirect(SETTINGS_PATH);
|
||||||
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
|
|
||||||
// nur vorhanden, damit dieses Record vollstaendig bleibt.
|
|
||||||
select: 0,
|
|
||||||
"list-simple": 4,
|
|
||||||
"list-titled": 5,
|
|
||||||
"list-numbered": 6,
|
|
||||||
"richtext-sections": 14,
|
|
||||||
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
|
|
||||||
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
|
|
||||||
// weiter vollstaendig ist.
|
|
||||||
image: 0,
|
|
||||||
video: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
|
|
||||||
|
|
||||||
async function loadCurrentValues(): Promise<CurrentValues> {
|
|
||||||
const rows = await prisma.siteContent.findMany();
|
|
||||||
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bild- und Video-Felder gehoeren zum "Bilder"-Bereich, alles andere
|
|
||||||
* (Text/Listen/Rechtstexte) zum "Text"-Bereich — siehe AdminInhaltePage. */
|
|
||||||
function isImageOrVideoField(def: ContentFieldDef): boolean {
|
|
||||||
return def.type === "image" || def.type === "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldEditor({
|
|
||||||
def,
|
|
||||||
row,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
def: ContentFieldDef;
|
|
||||||
row: { valueDe: string; valueEn: string | null } | undefined;
|
|
||||||
locale: Locale;
|
|
||||||
t: Dictionary;
|
|
||||||
}) {
|
|
||||||
const rows = ROWS_BY_TYPE[def.type];
|
|
||||||
const label = locale === "de" ? def.labelDe : def.labelEn;
|
|
||||||
|
|
||||||
if (!def.localized) {
|
|
||||||
if (def.type === "image") {
|
|
||||||
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
|
|
||||||
}
|
|
||||||
if (def.type === "video") {
|
|
||||||
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
|
|
||||||
}
|
|
||||||
if (def.type === "select") {
|
|
||||||
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
|
|
||||||
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
|
|
||||||
// darstellen als die Wirklichkeit.
|
|
||||||
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
<select
|
|
||||||
name={def.key}
|
|
||||||
defaultValue={current}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
|
||||||
>
|
|
||||||
{(def.options ?? []).map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{locale === "de" ? option.labelDe : option.labelEn}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<label className="block text-sm">
|
|
||||||
<span className="text-ink/70">{label}</span>
|
|
||||||
{def.type === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
name={def.key}
|
|
||||||
rows={rows}
|
|
||||||
defaultValue={row?.valueDe ?? ""}
|
|
||||||
placeholder={def.getDefault("de")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name={def.key}
|
|
||||||
defaultValue={row?.valueDe ?? ""}
|
|
||||||
placeholder={def.getDefault("de")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTextarea = def.type !== "text";
|
|
||||||
return (
|
|
||||||
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
|
|
||||||
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
|
||||||
<label className="block text-xs">
|
|
||||||
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
|
|
||||||
{isTextarea ? (
|
|
||||||
<textarea
|
|
||||||
name={`de::${def.key}`}
|
|
||||||
rows={rows}
|
|
||||||
defaultValue={row?.valueDe ?? ""}
|
|
||||||
placeholder={def.getDefault("de")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name={`de::${def.key}`}
|
|
||||||
defaultValue={row?.valueDe ?? ""}
|
|
||||||
placeholder={def.getDefault("de")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<label className="block text-xs">
|
|
||||||
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
|
|
||||||
{isTextarea ? (
|
|
||||||
<textarea
|
|
||||||
name={`en::${def.key}`}
|
|
||||||
rows={rows}
|
|
||||||
defaultValue={row?.valueEn ?? ""}
|
|
||||||
placeholder={def.getDefault("en")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name={`en::${def.key}`}
|
|
||||||
defaultValue={row?.valueEn ?? ""}
|
|
||||||
placeholder={def.getDefault("en")}
|
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein <form> pro Gruppe UND Bereich (Bilder/Text) — siehe AdminInhaltePage.
|
|
||||||
* Zwei Formulare koennen dieselbe group="..." fuers Audit-Log/Reload
|
|
||||||
* mitschicken, das ist unproblematisch: updateSiteContentAction liest pro
|
|
||||||
* Absenden nur die tatsaechlich in DIESEM Formular vorhandenen Registry-
|
|
||||||
* Felder (siehe admin/actions/content.ts), Felder aus dem jeweils anderen
|
|
||||||
* Formular fehlen im FormData schlicht und werden uebersprungen. */
|
|
||||||
function GroupForm({
|
|
||||||
groupHeading,
|
|
||||||
fields,
|
|
||||||
current,
|
|
||||||
groupKeyValue,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
groupHeading: string;
|
|
||||||
fields: ContentFieldDef[];
|
|
||||||
current: CurrentValues;
|
|
||||||
groupKeyValue: string;
|
|
||||||
locale: Locale;
|
|
||||||
t: Dictionary;
|
|
||||||
}) {
|
|
||||||
if (fields.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
action={updateSiteContentAction}
|
|
||||||
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="group" value={groupKeyValue} />
|
|
||||||
<h3 className="text-lg text-forest">{groupHeading}</h3>
|
|
||||||
|
|
||||||
{fields.map((def) => (
|
|
||||||
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminContentPage.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
|
|
||||||
const locale = await getLocale();
|
|
||||||
const t = getDictionary(locale);
|
|
||||||
const { saved } = await props.searchParams;
|
|
||||||
const current = await loadCurrentValues();
|
|
||||||
|
|
||||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
|
||||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
|
||||||
{ key: "shipping", heading: t.adminContentPage.groupShipping },
|
|
||||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
|
||||||
{ key: "pages", heading: t.adminContentPage.groupPages },
|
|
||||||
{ key: "social", heading: t.adminContentPage.groupSocial },
|
|
||||||
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
|
|
||||||
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
|
||||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
|
||||||
|
|
||||||
{saved ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminContentPage.saved}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="mb-12">
|
|
||||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
|
|
||||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<GroupForm
|
|
||||||
key={`images-${group.key}`}
|
|
||||||
groupHeading={group.heading}
|
|
||||||
groupKeyValue={group.key}
|
|
||||||
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && isImageOrVideoField(f))}
|
|
||||||
current={current}
|
|
||||||
locale={locale}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionText}</h2>
|
|
||||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionTextIntro}</p>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<GroupForm
|
|
||||||
key={`text-${group.key}`}
|
|
||||||
groupHeading={group.heading}
|
|
||||||
groupKeyValue={group.key}
|
|
||||||
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && !isImageOrVideoField(f))}
|
|
||||||
current={current}
|
|
||||||
locale={locale}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { noticeUrl } from "@/lib/notice";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { logoutAction } from "@/app/konto/actions";
|
import { logoutAction } from "@/app/konto/actions";
|
||||||
import { AdminShell } from "@/components/admin-shell";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getBrandAssets } from "@/lib/brand";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { THEME_COOKIE, parseThemeChoice } from "@/lib/theme";
|
||||||
|
|
||||||
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
||||||
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
||||||
@@ -30,7 +36,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
||||||
const admin = await getCurrentAdmin();
|
const admin = await getCurrentAdmin();
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an."));
|
redirect(noticeUrl("admin-login"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
||||||
@@ -38,15 +44,42 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
||||||
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
||||||
if (!admin.twoFactorEnabled) {
|
if (!admin.twoFactorEnabled) {
|
||||||
redirect(
|
redirect(noticeUrl("admin-2fa"));
|
||||||
"/konto/zwei-faktor?error=" +
|
|
||||||
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
|
||||||
|
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
|
||||||
|
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
|
||||||
|
// offene Widerrufe, unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
|
||||||
|
const brand = await getBrandAssets();
|
||||||
|
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
|
||||||
|
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
|
||||||
|
prisma.order.count({
|
||||||
|
// DIGITAL (nur Patenschaften, Abo-Monatsrechnungen): nichts zu packen.
|
||||||
|
where: {
|
||||||
|
status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] },
|
||||||
|
archivedAt: null,
|
||||||
|
fulfillmentMethod: { not: "DIGITAL" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
|
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
||||||
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||||
|
logoSrc={brand.logoInverted}
|
||||||
|
themeChoice={themeChoice}
|
||||||
|
badges={{
|
||||||
|
"/admin/bestellungen": openOrders,
|
||||||
|
"/admin/retouren": openReturns,
|
||||||
|
"/admin/widerrufe": openWithdrawals,
|
||||||
|
"/admin/bewertungen": pendingReviews,
|
||||||
|
"/admin/anfragen": newInquiries,
|
||||||
|
}}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Loaded 100 of 598 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user