Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe1a01550 | ||
|
|
33580b3290 | ||
|
|
6135c1f9c0 | ||
|
|
92ac826baa | ||
|
|
8f0f06fd13 | ||
|
|
dc13bd071b |
No files matched your search
@@ -167,13 +167,24 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
|
|||||||
|
|
||||||
## Google-Rezensionen
|
## Google-Rezensionen
|
||||||
|
|
||||||
Die „Bewertungen"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig:
|
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
|
||||||
|
|
||||||
1. In der [Google Cloud Console](https://console.cloud.google.com/) ein Projekt anlegen (oder ein bestehendes verwenden) und darin die **„Places API"** aktivieren.
|
**Einrichtung:** Google-Cloud-Projekt anlegen, darin die **Places API** aktivieren, einen API-Schlüssel erzeugen und ihn auf diese eine API einschränken. Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher nicht; sinnvoll ist eine IP-Beschränkung auf die ausgehende Adresse des Clusters. Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||||
2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als `GOOGLE_PLACES_API_KEY` in `.env` eintragen. Empfehlenswert: den Key auf die Places API einschränken.
|
|
||||||
3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id) ermitteln und als `GOOGLE_PLACE_ID` eintragen.
|
|
||||||
|
|
||||||
Die Rezensionen werden serverseitig eine Stunde gecacht (`src/lib/google-reviews.ts`), um API-Kosten gering zu halten.
|
**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
|
||||||
|
|
||||||
|
| Meldung im Log | Bedeutung |
|
||||||
|
|----------------|-----------|
|
||||||
|
| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert |
|
||||||
|
| `INVALID_REQUEST` | Parameter falsch |
|
||||||
|
| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag |
|
||||||
|
| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt |
|
||||||
|
|
||||||
|
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
|
||||||
|
|
||||||
|
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) werden übersprungen, sie wären sonst leere Sprechblasen.
|
||||||
|
|
||||||
|
> **Legacy-Hinweis:** Genutzt wird der klassische `place/details`-Endpunkt. Google führt ihn als *Legacy* und stellt neue Projekte auf die Places API (New) um (`places.googleapis.com/v1`). Meldet das Log dauerhaft `REQUEST_DENIED`, obwohl Schlüssel und Abrechnung stimmen, ist das der wahrscheinliche Grund — dann ist die Umstellung auf die neue API nötig.
|
||||||
|
|
||||||
## E-Mail-Versand
|
## E-Mail-Versand
|
||||||
|
|
||||||
@@ -183,7 +194,20 @@ Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Host
|
|||||||
|
|
||||||
## CAPTCHA (Cloudflare Turnstile)
|
## CAPTCHA (Cloudflare Turnstile)
|
||||||
|
|
||||||
Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar:
|
**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
|
||||||
|
|
||||||
|
| Formular | Missbrauch, der damit abgewehrt wird |
|
||||||
|
|----------|--------------------------------------|
|
||||||
|
| Login | Durchprobieren erbeuteter Zugangsdaten |
|
||||||
|
| Zwei-Faktor-Code | Durchprobieren des sechsstelligen Codes |
|
||||||
|
| Passwort vergessen / zurücksetzen | Mailversand an fremde Adressen, Abfragen welche Adressen registriert sind |
|
||||||
|
| Registrierung | Massenhaft angelegte Konten |
|
||||||
|
| Checkout | Blockieren von Lagerbestand, Kartentests über die Zahlungsstrecke |
|
||||||
|
| Newsletter | Eintragen fremder Adressen |
|
||||||
|
| Firmenanfrage | Spam über das Kontaktformular |
|
||||||
|
| Verfügbarkeits-Benachrichtigung | Mailversand an fremde Adressen |
|
||||||
|
|
||||||
|
Nicht geschützt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile wäre nach dem ersten Aufruf verbraucht. Sie ist stattdessen rate-limitiert und verlangt Bestellnummer **und** zugehörige E-Mail-Adresse.
|
||||||
|
|
||||||
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
|
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
|
||||||
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
|
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
|
||||||
@@ -194,11 +218,15 @@ Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudfl
|
|||||||
|
|
||||||
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
|
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
|
||||||
|
|
||||||
| ApiCore | Wabenhain | Knopf |
|
**Ein Knopf für alles:** „Alles synchronisieren" steht sowohl unter `/admin/ernten` als auch unter `/admin/produkte` und löst denselben Vorgang aus — es geht danach nur auf die Seite zurück, von der er kam.
|
||||||
|---------|-----------|-------|
|
|
||||||
| `Harvest` (Ernte) | `Harvest` | `/admin/ernten` → „ApiCore-Synchronisierung" |
|
| Schritt | ApiCore | Wabenhain |
|
||||||
| `BottlingBatch` (Abfüllung) | `Batch` (Charge) | derselbe Knopf, läuft direkt nach den Ernten |
|
|---------|---------|-----------|
|
||||||
| `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) | `/admin/produkte` → „ApiCore-Synchronisierung" |
|
| 1 | `Harvest` (Ernte) | `Harvest` |
|
||||||
|
| 2 | `BottlingBatch` (Abfüllung) | `Batch` (Charge) |
|
||||||
|
| 3 | `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) |
|
||||||
|
|
||||||
|
Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine Charge braucht ihre Ernte als Pflicht-Fremdschlüssel, ein Produkt entsteht je Abfüllung und braucht die zugehörige Charge. Genau deshalb steckt sie im Ablauf und nicht im Kopf der bedienenden Person — getrennt ausgelöst musste man sie kennen und zweimal klicken, sonst meldete der erste Produkt-Lauf reihenweise fehlende Chargen. Nebenbei werden die Abfüllungen nur noch einmal von ApiCore geladen statt zweimal.
|
||||||
|
|
||||||
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
|
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
|
||||||
|
|
||||||
@@ -211,7 +239,7 @@ ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener D
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
**Reihenfolge:** die Charge muss schon da sein, sonst gäbe es nichts, woran der Jahrgang hängt. Erst „Ernten" synchronisieren, dann „Produkte" — fehlende Chargen meldet der Lauf als „wartet auf den Ernten-Sync", nicht als Fehler.
|
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** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -288,6 +288,46 @@ Kubernetes-Version), PVC und PV löschen und neu anlegen. Bei
|
|||||||
`persistentVolumeReclaimPolicy: Retain` bleiben die Daten auf der Freigabe
|
`persistentVolumeReclaimPolicy: Retain` bleiben die Daten auf der Freigabe
|
||||||
erhalten.
|
erhalten.
|
||||||
|
|
||||||
|
## Video-Upload bricht ab (`ECONNRESET`)
|
||||||
|
|
||||||
|
Symptom im Pod-Log:
|
||||||
|
|
||||||
|
```
|
||||||
|
[upload-video] Verbindung abgebrochen nach 41943040 von angekündigten 78643200 Bytes.
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist kein Fehler der Verarbeitung — die Datei ist gar nicht erst
|
||||||
|
vollständig angekommen. Die Zahlen im Log entscheiden, wo zu suchen ist:
|
||||||
|
|
||||||
|
- **Immer bei ungefähr derselben BYTEZAHL** → eine Größengrenze auf dem Weg.
|
||||||
|
Traefik begrenzt von sich aus nichts; prüfen, ob eine `buffering`-Middleware
|
||||||
|
mit `maxRequestBodyBytes` am Ingress hängt.
|
||||||
|
- **Immer nach ungefähr derselben ZEIT** → eine Zeitgrenze. Davon liegen
|
||||||
|
mehrere hintereinander:
|
||||||
|
|
||||||
|
| Grenze | Vorgabe | Wo einstellbar |
|
||||||
|
|--------|---------|----------------|
|
||||||
|
| Node `requestTimeout` | **300 s** | nicht konfigurierbar — harte Obergrenze für die Dauer eines Uploads |
|
||||||
|
| Traefik `respondingTimeouts.readTimeout` | je nach Version | `entryPoints.websecure.transport.respondingTimeouts` in der Traefik-Konfiguration |
|
||||||
|
| Browser bzw. Netz dazwischen | — | — |
|
||||||
|
|
||||||
|
Die 300 Sekunden von Node sind die eigentliche Obergrenze: Was in dieser Zeit
|
||||||
|
nicht durch die Leitung passt, lässt sich nicht hochladen — unabhängig davon,
|
||||||
|
dass `MAX_UPLOAD_VIDEO_BYTES` 100 MB erlaubt. Beispiel: 80 MB über eine
|
||||||
|
Upload-Leitung mit 5 Mbit/s brauchen rund 130 Sekunden und gehen durch; bei
|
||||||
|
2 Mbit/s sind es über 5 Minuten, und der Upload stirbt zuverlässig.
|
||||||
|
|
||||||
|
Praktisch heißt das: **das Hero-Video vor dem Hochladen verkleinern.** Der
|
||||||
|
Server rechnet es ohnehin auf höchstens 1920 px Breite und CRF 26 um (siehe
|
||||||
|
`src/lib/video-transcode.ts`) — was darüber hinausgeht, wird nur übertragen,
|
||||||
|
um anschließend weggerechnet zu werden. Ein vorab auf diese Größe gebrachtes
|
||||||
|
Video lädt um ein Vielfaches schneller hoch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i original.mp4 -an -vf "scale='min(1920,iw)':-2" \
|
||||||
|
-c:v libx264 -preset veryfast -crf 26 -movflags +faststart hero.mp4
|
||||||
|
```
|
||||||
|
|
||||||
## 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 eine
|
||||||
|
|||||||
+17
-2
@@ -20,6 +20,7 @@
|
|||||||
* als ungeprueft gemeldet statt geraten.
|
* als ungeprueft gemeldet statt geraten.
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { prisma } from "../src/lib/prisma";
|
||||||
import { getContentValue } from "../src/lib/content";
|
import { getContentValue } from "../src/lib/content";
|
||||||
import { locales } from "../src/i18n/config";
|
import { locales } from "../src/i18n/config";
|
||||||
@@ -130,14 +131,28 @@ should(
|
|||||||
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
|
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Rechnungsablage -------------------------------------------------------
|
||||||
|
// Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert
|
||||||
|
// (app/uploads/[...path]). Liegt die Rechnungsablage darin, sind fremde
|
||||||
|
// Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar, der den
|
||||||
|
// Pfad erraet — und im Betrieb faellt das nicht auf, weil weiterhin alles
|
||||||
|
// funktioniert.
|
||||||
|
const invoiceDir = path.resolve(value("INVOICE_DIR") || "./invoices");
|
||||||
|
const uploadDir = path.resolve(value("UPLOAD_DIR") || "./uploads");
|
||||||
|
must(
|
||||||
|
"INVOICE_DIR liegt ausserhalb von UPLOAD_DIR",
|
||||||
|
invoiceDir !== uploadDir && !invoiceDir.startsWith(uploadDir + path.sep),
|
||||||
|
`Rechnungen (${invoiceDir}) duerfen nicht unterhalb des oeffentlich ausgelieferten Upload-Verzeichnisses (${uploadDir}) liegen. Eigenes Verzeichnis waehlen, siehe .env.example.`
|
||||||
|
);
|
||||||
|
|
||||||
// --- Datenbank -------------------------------------------------------------
|
// --- Datenbank -------------------------------------------------------------
|
||||||
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
||||||
|
|
||||||
// --- Missbrauchsschutz -----------------------------------------------------
|
// --- Missbrauchsschutz -----------------------------------------------------
|
||||||
should(
|
must(
|
||||||
"Cloudflare Turnstile konfiguriert",
|
"Cloudflare Turnstile konfiguriert",
|
||||||
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
|
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
|
||||||
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
|
"Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret."
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
|
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
|
||||||
|
|||||||
@@ -359,53 +359,60 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht
|
|
||||||
* fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen
|
|
||||||
* Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
|
|
||||||
* verschwinden.
|
|
||||||
*
|
|
||||||
* Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit
|
|
||||||
* je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und
|
|
||||||
* Ernte angelegt (siehe syncProducts).
|
|
||||||
*/
|
|
||||||
async function loadAndSyncBatches(
|
|
||||||
session: ApiCoreSession
|
|
||||||
): Promise<{ counts: SyncCounts; loadError: string | null }> {
|
|
||||||
let sources: ApiCoreBottlingBatchWithHarvestId[];
|
|
||||||
try {
|
|
||||||
sources = await fetchApiCoreBottlingBatches(session);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return {
|
|
||||||
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
|
|
||||||
loadError: describeError(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { counts: await syncBatches(sources), loadError: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server Actions
|
// Server Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen
|
* Ziele, zu denen nach dem Sync zurueckgesprungen werden darf.
|
||||||
* Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte
|
*
|
||||||
* Ernte als Pflicht-Fremdschluessel schon bereitsteht.
|
* Feste Liste statt des uebergebenen Werts: "returnTo" kommt aus einem
|
||||||
|
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
|
||||||
|
* Wert koennte Admins auf eine fremde Seite schicken.
|
||||||
*/
|
*/
|
||||||
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
const RETURN_TARGETS = ["/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
|
||||||
|
|
||||||
|
function returnTargetFrom(formData: FormData): string {
|
||||||
|
const requested = formData.get("returnTo");
|
||||||
|
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin/ernten";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIN Knopf fuer alles: zieht Ernten, daraus die Chargen und daraus die
|
||||||
|
* Produkte — in genau dieser Reihenfolge, weil jede Stufe auf der
|
||||||
|
* vorherigen aufbaut.
|
||||||
|
*
|
||||||
|
* Eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, und ein
|
||||||
|
* Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer
|
||||||
|
* das getrennt ausloest, muss die Reihenfolge kennen und zweimal klicken;
|
||||||
|
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||||
|
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||||
|
*
|
||||||
|
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
||||||
|
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
||||||
|
*
|
||||||
|
* 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> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
|
const returnTo = returnTargetFrom(formData);
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
let session: ApiCoreSession;
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||||
|
let articles: ApiCoreProduct[];
|
||||||
try {
|
try {
|
||||||
session = await openApiCoreSession();
|
session = await openApiCoreSession();
|
||||||
sourceHarvests = await fetchApiCoreHarvests(session);
|
[sourceHarvests, articles] = await Promise.all([
|
||||||
|
fetchApiCoreHarvests(session),
|
||||||
|
fetchApiCoreProducts(session),
|
||||||
|
]);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Ernten
|
||||||
const harvests = await syncHarvests(sourceHarvests);
|
const harvests = await syncHarvests(sourceHarvests);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
@@ -413,64 +420,45 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|||||||
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batches = await loadAndSyncBatches(session);
|
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||||
|
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
||||||
|
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: batches.loadError
|
detail: batchLoadError
|
||||||
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
|
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
||||||
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`,
|
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
||||||
|
const products = await syncProducts(articles, bottlings);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "product.sync_apicore",
|
||||||
|
entityType: "Product",
|
||||||
|
detail:
|
||||||
|
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
|
||||||
|
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect(
|
redirect(
|
||||||
`/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
(batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "")
|
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||||
);
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
}
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||||
|
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||||
/**
|
|
||||||
* Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an,
|
|
||||||
* also einen eigenen Jahrgang (siehe syncProducts).
|
|
||||||
*
|
|
||||||
* Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die
|
|
||||||
* Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier
|
|
||||||
* NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre
|
|
||||||
* Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das
|
|
||||||
* als "wartet auf den Ernten-Sync" statt als Fehler.
|
|
||||||
*/
|
|
||||||
export async function syncProductsFromApiCoreAction(): Promise<void> {
|
|
||||||
const admin = await requireAdmin();
|
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
|
||||||
let articles: ApiCoreProduct[];
|
|
||||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
|
||||||
try {
|
|
||||||
session = await openApiCoreSession();
|
|
||||||
[articles, bottlings] = await Promise.all([
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
fetchApiCoreBottlingBatches(session),
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const products = await syncProducts(articles, bottlings);
|
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
|
||||||
action: "product.sync_apicore",
|
|
||||||
entityType: "Product",
|
|
||||||
detail:
|
|
||||||
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
|
|
||||||
`${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
|
||||||
redirect(
|
|
||||||
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
|
|
||||||
`&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,55 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
redirect("/admin/produkte");
|
redirect("/admin/produkte");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht ein Produkt endgueltig.
|
||||||
|
*
|
||||||
|
* Nur moeglich, solange es nie bestellt wurde. OrderItem verweist mit
|
||||||
|
* onDelete: Restrict auf das Produkt (prisma/schema.prisma), und das ist
|
||||||
|
* Absicht: Bestellpositionen und die daraus erzeugten Rechnungen sind Belege
|
||||||
|
* ueber einen vergangenen Vorgang. Verschwaende das Produkt darunter weg,
|
||||||
|
* verloere eine bereits gestellte Rechnung ihren Bezug — bei zehn Jahren
|
||||||
|
* Aufbewahrungspflicht (§ 147 AO) keine Option.
|
||||||
|
*
|
||||||
|
* Wer ein bereits verkauftes Produkt aus dem Shop nehmen will, schaltet es
|
||||||
|
* deshalb inaktiv, statt es zu loeschen. Genau das sagt die Meldung.
|
||||||
|
*
|
||||||
|
* Was mitgeht: Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
|
||||||
|
* Verfuegbarkeits-Benachrichtigungen haengen mit onDelete: Cascade am
|
||||||
|
* Produkt. Hochgeladene Bilder bleiben dagegen im Upload-Verzeichnis liegen
|
||||||
|
* — sie koennen auch anderswo eingebunden sein, und eine verwaiste Datei ist
|
||||||
|
* harmloser als ein totes Bild an anderer Stelle.
|
||||||
|
*/
|
||||||
|
export async function deleteProductAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { orderItems: true } } },
|
||||||
|
});
|
||||||
|
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||||
|
|
||||||
|
if (product._count.orderItems > 0) {
|
||||||
|
fail(
|
||||||
|
"/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.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.product.delete({ where: { id } });
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "product.delete",
|
||||||
|
entityType: "Product",
|
||||||
|
entityId: id,
|
||||||
|
detail: product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/produkte");
|
||||||
|
redirect("/admin/produkte");
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
|||||||
{t.adminBatchesPage.delete}
|
{t.adminBatchesPage.delete}
|
||||||
</button>
|
</button>
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
) : null}
|
) : (
|
||||||
|
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||||
|
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,19 +3,15 @@ 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 {
|
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
|
||||||
createHarvestAction,
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
toggleHarvestPublishedAction,
|
|
||||||
syncHarvestsFromApiCoreAction,
|
|
||||||
deleteHarvestAction,
|
|
||||||
} 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 AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError, error } =
|
const searchParams = await props.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 } } },
|
||||||
@@ -29,26 +25,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
{error ? (
|
{error ? (
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{synced !== undefined ? (
|
<ApiCoreSyncResult searchParams={searchParams} />
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{syncError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.syncError} ({syncError})
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{batchSynced !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{batchLoadError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.batchSyncError} ({batchLoadError})
|
|
||||||
</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">
|
||||||
@@ -106,7 +83,10 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
{t.adminHarvestsPage.delete}
|
{t.adminHarvestsPage.delete}
|
||||||
</button>
|
</button>
|
||||||
</ConfirmForm>
|
</ConfirmForm>
|
||||||
) : null}
|
) : (
|
||||||
|
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||||
|
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -136,15 +116,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<ApiCoreSyncPanel returnTo="/admin/ernten" />
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.syncHeading}</h2>
|
|
||||||
<p className="text-sm text-ink/65 mb-4">{t.adminHarvestsPage.syncIntro}</p>
|
|
||||||
<form action={syncHarvestsFromApiCoreAction}>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminHarvestsPage.syncButton}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,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 { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { syncProductsFromApiCoreAction } from "../actions";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { deleteProductAction } 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 AdminProductsPage(props: PageProps<"/admin/produkte">) {
|
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const products = await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
include: { harvest: true, batch: true, inventory: true },
|
include: {
|
||||||
|
harvest: true,
|
||||||
|
batch: true,
|
||||||
|
inventory: true,
|
||||||
|
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten.
|
||||||
|
_count: { select: { orderItems: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,28 +32,7 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{synced !== undefined ? (
|
<ApiCoreSyncResult searchParams={searchParams} />
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{/* "pending" ist kein Fehler, sondern eine Reihenfolge: die Charge des
|
|
||||||
Jahrgangs kommt ueber den Ernten-Sync. Deshalb als Hinweis, nicht rot. */}
|
|
||||||
{Number(pending ?? 0) > 0 ? (
|
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncPending(Number(pending))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{Number(discontinued ?? 0) > 0 ? (
|
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncDiscontinued(Number(discontinued))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{syncError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncError} ({syncError})
|
|
||||||
</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">
|
||||||
@@ -84,10 +71,29 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
|
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4 whitespace-nowrap">
|
||||||
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
|
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4">
|
||||||
{t.adminProductsPage.edit}
|
{t.adminProductsPage.edit}
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* Loeschen nur, solange nie bestellt: OrderItem verweist mit
|
||||||
|
onDelete: Restrict auf das Produkt, damit gestellte
|
||||||
|
Rechnungen ihren Bezug behalten (siehe
|
||||||
|
admin/actions/products.ts). Statt den Knopf wortlos
|
||||||
|
wegzulassen, steht dann der Grund da. */}
|
||||||
|
{product._count.orderItems === 0 ? (
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteProductAction}
|
||||||
|
confirmMessage={t.adminProductsPage.deleteConfirm(product.name)}
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={product.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline">
|
||||||
|
{t.adminProductsPage.delete}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink/50">{t.adminProductsPage.notDeletable}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -98,14 +104,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-3xl">
|
<div className="mt-10 max-w-3xl">
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminProductsPage.syncHeading}</h2>
|
<ApiCoreSyncPanel returnTo="/admin/produkte" />
|
||||||
<p className="text-sm text-ink/65 mb-4">{t.adminProductsPage.syncIntro}</p>
|
|
||||||
<form action={syncProductsFromApiCoreAction}>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminProductsPage.syncButton}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ export const runtime = "nodejs";
|
|||||||
*/
|
*/
|
||||||
const MAGIC_BYTES_LENGTH = 12;
|
const MAGIC_BYTES_LENGTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt, dass die Verbindung WAEHREND des Hochladens gekappt wurde — der
|
||||||
|
* Body kam also nie vollstaendig an.
|
||||||
|
*
|
||||||
|
* Das ist kein Fehler der Verarbeitung, sondern eine Aussage ueber die
|
||||||
|
* Strecke dazwischen: Browser geschlossen, Netz weg, oder eine Zeitgrenze auf
|
||||||
|
* dem Weg (Reverse-Proxy, oder Node selbst — dessen requestTimeout bricht
|
||||||
|
* einen Request nach fuenf Minuten ab, was bei einem grossen Video ueber eine
|
||||||
|
* langsame Leitung durchaus erreichbar ist).
|
||||||
|
*
|
||||||
|
* Ohne diese Unterscheidung landete der Fall in der allgemeinen Auffanglinie
|
||||||
|
* und wurde als "unerwarteter Fehler" geloggt. Genau das verdeckte die
|
||||||
|
* eigentliche Aussage: nicht die Verarbeitung ist gescheitert, die Datei ist
|
||||||
|
* gar nicht erst vollstaendig angekommen.
|
||||||
|
*/
|
||||||
|
function isClientAbort(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
const code = "code" in error ? error.code : undefined;
|
||||||
|
return code === "ECONNRESET" || code === "ECONNABORTED" || error.message === "aborted";
|
||||||
|
}
|
||||||
|
|
||||||
/** Signalisiert, dass der Datenstrom MAX_UPLOAD_VIDEO_BYTES ueberschritten hat. */
|
/** Signalisiert, dass der Datenstrom MAX_UPLOAD_VIDEO_BYTES ueberschritten hat. */
|
||||||
class UploadTooLargeError extends Error {
|
class UploadTooLargeError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -164,6 +185,25 @@ export async function POST(request: Request) {
|
|||||||
{ status: 413 }
|
{ status: 413 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isClientAbort(error)) {
|
||||||
|
// Mit Mengenangabe loggen: daran laesst sich unterscheiden, ob es
|
||||||
|
// jedes Mal an derselben Stelle abbricht (dann eine Groessengrenze
|
||||||
|
// auf dem Weg) oder nach ungefaehr derselben ZEIT (dann eine
|
||||||
|
// Zeitgrenze). Ohne diese Zahlen ist beides nicht auseinanderzuhalten.
|
||||||
|
console.warn(
|
||||||
|
`[upload-video] Verbindung abgebrochen nach ${receivedBytes} von angekündigten ${declaredLength || "?"} Bytes.`
|
||||||
|
);
|
||||||
|
// Die Antwort erreicht die Kundschaft meist nicht mehr, die
|
||||||
|
// Verbindung ist ja weg — sie ist fuer den Fall gedacht, dass nur
|
||||||
|
// eine Zwischenstation aufgegeben hat und der Browser noch zuhoert.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Die Verbindung wurde während des Uploads unterbrochen. Bitte erneut versuchen — bei großen Videos hilft es, sie vorher zu verkleinern.",
|
||||||
|
},
|
||||||
|
{ status: 408 }
|
||||||
|
);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +274,16 @@ export async function POST(request: Request) {
|
|||||||
// (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem
|
// (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem
|
||||||
// als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client
|
// als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client
|
||||||
// nicht auswertbar.
|
// nicht auswertbar.
|
||||||
|
// Ein Abbruch der Verbindung kann auch spaeter noch durchschlagen (etwa
|
||||||
|
// beim Schliessen des Streams). Auch hier als das benennen, was er ist,
|
||||||
|
// statt ihn als Fehler der Verarbeitung zu melden.
|
||||||
|
if (isClientAbort(error)) {
|
||||||
|
console.warn("[upload-video] Verbindung abgebrochen, bevor der Upload abgeschlossen war.");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Die Verbindung wurde während des Uploads unterbrochen. Bitte erneut versuchen." },
|
||||||
|
{ status: 408 }
|
||||||
|
);
|
||||||
|
}
|
||||||
console.error("Video-Upload: unerwarteter Fehler.", error);
|
console.error("Video-Upload: unerwarteter Fehler.", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Das Video konnte nicht verarbeitet werden." },
|
{ error: "Das Video konnte nicht verarbeitet werden." },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getProductsByIds } from "@/lib/data";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import { isTrustedOrigin } from "@/lib/csrf";
|
import { isTrustedOrigin } from "@/lib/csrf";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
import { getShippingCostCents } from "@/lib/shipping";
|
import { getShippingCostCents } from "@/lib/shipping";
|
||||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
@@ -55,6 +56,23 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|
||||||
|
// CAPTCHA vor allem Weiteren: eine Bestellung legt Datensaetze an,
|
||||||
|
// reserviert Lagerbestand und eroeffnet eine Stripe-Sitzung. Automatisiert
|
||||||
|
// abgeschickt liesse sich damit der Bestand blockieren und die
|
||||||
|
// Zahlungsstrecke fuer Kartentests missbrauchen. Das Token kommt hier aus
|
||||||
|
// dem JSON-Body, weil der Checkout kein Formular abschickt.
|
||||||
|
const turnstileToken =
|
||||||
|
body && typeof body === "object" && "turnstileToken" in body
|
||||||
|
? String((body as { turnstileToken?: unknown }).turnstileToken ?? "")
|
||||||
|
: "";
|
||||||
|
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte lade die Seite neu und versuche es erneut." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = checkoutSchema.safeParse(body);
|
const parsed = checkoutSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatPrice } from "@/lib/format";
|
|||||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||||
|
|
||||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function CheckoutForm({
|
|||||||
tiers,
|
tiers,
|
||||||
bulkDiscountTiers,
|
bulkDiscountTiers,
|
||||||
packstationSearchAvailable = false,
|
packstationSearchAvailable = false,
|
||||||
|
turnstileSiteKey,
|
||||||
}: {
|
}: {
|
||||||
initialValues?: CheckoutInitialValues;
|
initialValues?: CheckoutInitialValues;
|
||||||
tiers: ShippingTierLike[];
|
tiers: ShippingTierLike[];
|
||||||
@@ -51,6 +53,8 @@ export function CheckoutForm({
|
|||||||
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
|
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
|
||||||
*/
|
*/
|
||||||
packstationSearchAvailable?: boolean;
|
packstationSearchAvailable?: boolean;
|
||||||
|
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
|
||||||
|
turnstileSiteKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
@@ -59,6 +63,9 @@ export function CheckoutForm({
|
|||||||
const totalQuantity = cartItemCount(items);
|
const totalQuantity = cartItemCount(items);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
|
||||||
|
// Token-Rueckgabe statt eines versteckten Feldes.
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -160,6 +167,7 @@ export function CheckoutForm({
|
|||||||
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||||
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
||||||
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
||||||
|
turnstileToken: turnstileToken ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +464,11 @@ export function CheckoutForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{turnstileSiteKey ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default async function CheckoutPage() {
|
|||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) return <CheckoutForm
|
if (!customer) return <CheckoutForm
|
||||||
|
turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||||
@@ -51,6 +52,7 @@ export default async function CheckoutPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <CheckoutForm
|
return <CheckoutForm
|
||||||
|
turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { isProductWishlisted } from "@/lib/wishlist";
|
|||||||
import { getShippingTiers } from "@/lib/shipping";
|
import { getShippingTiers } from "@/lib/shipping";
|
||||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import type { WissenSlug } from "@/i18n/dictionary-type";
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||||
@@ -257,6 +258,11 @@ export default async function ProductDetailPage(
|
|||||||
{t.productPage.notifySubmit}
|
{t.productPage.notifySubmit}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
{notifyError ? (
|
{notifyError ? (
|
||||||
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { hasCustomerPurchased, getCustomerReview } from "@/lib/reviews";
|
|||||||
import { isProductWishlisted } from "@/lib/wishlist";
|
import { isProductWishlisted } from "@/lib/wishlist";
|
||||||
import { reviewSchema, stockNotificationSchema } from "@/lib/validation";
|
import { reviewSchema, stockNotificationSchema } from "@/lib/validation";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
const REVIEW_ATTEMPTS_PER_WINDOW = 10;
|
const REVIEW_ATTEMPTS_PER_WINDOW = 10;
|
||||||
@@ -119,6 +120,13 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
|||||||
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
|
||||||
|
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
||||||
|
// unseren Absender anzuschreiben.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = stockNotificationSchema.safeParse({
|
const parsed = stockNotificationSchema.safeParse({
|
||||||
productId: formData.get("productId"),
|
productId: formData.get("productId"),
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { loginSchema } from "@/lib/validation";
|
|||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import type { Customer } from "@/generated/prisma/client";
|
import type { Customer } from "@/generated/prisma/client";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
|
||||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
@@ -39,6 +40,12 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse({
|
const parsed = loginSchema.safeParse({
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
@@ -103,6 +110,12 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "").trim();
|
const code = String(formData.get("code") ?? "").trim();
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { sendMail } from "@/lib/mail";
|
|||||||
import { securityAlertEmail } from "@/lib/email-templates";
|
import { securityAlertEmail } from "@/lib/email-templates";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
|
||||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||||
@@ -28,6 +29,12 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
|
|||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||||
@@ -53,6 +60,12 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = resetPasswordSchema.safeParse({
|
const parsed = resetPasswordSchema.safeParse({
|
||||||
token: formData.get("token"),
|
token: formData.get("token"),
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
|||||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.account.login}</Button>
|
<Button type="submit">{t.account.login}</Button>
|
||||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
||||||
{t.account.forgotPassword}
|
{t.account.forgotPassword}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { requestPasswordResetAction } from "../actions";
|
import { requestPasswordResetAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -41,6 +42,9 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
|||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { PasswordField } from "@/components/password-field";
|
import { PasswordField } from "@/components/password-field";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { resetPasswordAction } from "../actions";
|
import { resetPasswordAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -43,6 +44,9 @@ export default async function PasswortZuruecksetzenPage(
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
showStrength
|
showStrength
|
||||||
/>
|
/>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||||
import { verifyTwoFactorAction } from "../actions";
|
import { verifyTwoFactorAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -44,6 +45,9 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
|
|||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { syncApiCoreAction } from "@/app/admin/actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der eine Sync-Knopf und die Anzeige seines Ergebnisses.
|
||||||
|
*
|
||||||
|
* Gemeinsam genutzt von /admin/ernten und /admin/produkte: es ist derselbe
|
||||||
|
* Vorgang, egal von welcher Seite er ausgeloest wird — Ernten, Chargen und
|
||||||
|
* Produkte laufen ohnehin immer zusammen. Zwei getrennte Bloecke in zwei
|
||||||
|
* Seiten waeren dieselbe Anzeige zweimal gepflegt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rohwerte aus der Adresszeile — Next liefert Query-Parameter so aus. */
|
||||||
|
export type SyncSearchParams = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
function count(value: string | string[] | undefined): number {
|
||||||
|
return Number(Array.isArray(value) ? value[0] : value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: string | string[] | undefined): string | undefined {
|
||||||
|
const first = Array.isArray(value) ? value[0] : value;
|
||||||
|
return first === undefined ? undefined : String(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis des letzten Laufs. Zeigt nichts an, solange gerade nicht
|
||||||
|
* synchronisiert wurde — die Seite soll nach einem spaeteren Aufruf nicht
|
||||||
|
* dauerhaft ein altes Ergebnis behaupten.
|
||||||
|
*/
|
||||||
|
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
||||||
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
|
const syncError = text(searchParams.syncError);
|
||||||
|
const batchLoadError = text(searchParams.batchLoadError);
|
||||||
|
const done = text(searchParams.syncDone) !== undefined;
|
||||||
|
|
||||||
|
if (!done && syncError === undefined) return null;
|
||||||
|
|
||||||
|
const pending = count(searchParams.pPending);
|
||||||
|
const discontinued = count(searchParams.pDiscontinued);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{syncError !== undefined ? (
|
||||||
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.error} ({syncError})
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{done ? (
|
||||||
|
<div className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3 space-y-1">
|
||||||
|
<p>
|
||||||
|
{t.resultHarvests(
|
||||||
|
count(searchParams.hNew),
|
||||||
|
count(searchParams.hUpdated),
|
||||||
|
count(searchParams.hSkipped),
|
||||||
|
count(searchParams.hFailed)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>{t.resultBatches(count(searchParams.bNew), count(searchParams.bSkipped), count(searchParams.bFailed))}</p>
|
||||||
|
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||||
|
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||||
|
{pending > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.pending(pending)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{discontinued > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.discontinued(discontinued)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{batchLoadError !== undefined ? (
|
||||||
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.batchLoadError} ({batchLoadError})
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Knopf selbst. "returnTo" bestimmt, auf welche Seite es danach
|
||||||
|
* zurueckgeht — die Action prueft den Wert gegen eine feste Liste, damit
|
||||||
|
* daraus keine offene Weiterleitung wird.
|
||||||
|
*/
|
||||||
|
export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||||
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-lg text-forest mb-4">{t.heading}</h2>
|
||||||
|
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||||
|
<form action={syncApiCoreAction}>
|
||||||
|
<input type="hidden" name="returnTo" value={returnTo} />
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.button}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -164,8 +164,13 @@ export async function Reviews() {
|
|||||||
// uebersetzt oder umformuliert — nur die eigenen Fallback-Testimonials
|
// uebersetzt oder umformuliert — nur die eigenen Fallback-Testimonials
|
||||||
// (ohne konfigurierte Google-Anbindung) existieren in beiden Sprachen.
|
// (ohne konfigurierte Google-Anbindung) existieren in beiden Sprachen.
|
||||||
const items = google && google.reviews.length > 0
|
const items = google && google.reviews.length > 0
|
||||||
? google.reviews.map((r) => ({ quote: r.text, author: r.author, rating: r.rating }))
|
? google.reviews.map((r) => ({
|
||||||
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5 }));
|
quote: r.text,
|
||||||
|
author: r.author,
|
||||||
|
rating: r.rating,
|
||||||
|
authorUrl: r.authorUrl,
|
||||||
|
}))
|
||||||
|
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5, authorUrl: null }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-24 bg-forest text-cream">
|
<section className="py-24 bg-forest text-cream">
|
||||||
@@ -192,7 +197,22 @@ export async function Reviews() {
|
|||||||
<p className="mt-4 text-cream/85 leading-relaxed italic">
|
<p className="mt-4 text-cream/85 leading-relaxed italic">
|
||||||
„{review.quote}“
|
„{review.quote}“
|
||||||
</p>
|
</p>
|
||||||
|
{/* Zuordnung zur Quelle: die Nutzungsbedingungen der Places API
|
||||||
|
verlangen, dass eine angezeigte Rezension zu Google
|
||||||
|
zurueckfuehrt. Bei den eigenen Beispiel-Stimmen gibt es
|
||||||
|
nichts zu verlinken — dort bleibt es beim reinen Namen. */}
|
||||||
|
{review.authorUrl ? (
|
||||||
|
<a
|
||||||
|
href={review.authorUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
className="mt-4 inline-block text-sm text-cream/70 underline decoration-cream/30 hover:decoration-cream"
|
||||||
|
>
|
||||||
|
{review.author}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
|
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
|
||||||
|
)}
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-21
@@ -811,6 +811,7 @@ export const de: Dictionary = {
|
|||||||
},
|
},
|
||||||
adminBatchesPage: {
|
adminBatchesPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
|
notDeletable: "in Verwendung",
|
||||||
title: "Chargen",
|
title: "Chargen",
|
||||||
colBatch: "Charge",
|
colBatch: "Charge",
|
||||||
colHarvest: "Ernte",
|
colHarvest: "Ernte",
|
||||||
@@ -877,6 +878,7 @@ export const de: Dictionary = {
|
|||||||
},
|
},
|
||||||
adminHarvestsPage: {
|
adminHarvestsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
|
notDeletable: "in Verwendung",
|
||||||
title: "Ernten",
|
title: "Ernten",
|
||||||
colType: "Typ",
|
colType: "Typ",
|
||||||
colYear: "Jahr",
|
colYear: "Jahr",
|
||||||
@@ -901,16 +903,6 @@ export const de: Dictionary = {
|
|||||||
descriptionLabel: "Beschreibung",
|
descriptionLabel: "Beschreibung",
|
||||||
isPublishedLabel: "Sofort veröffentlichen",
|
isPublishedLabel: "Sofort veröffentlichen",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
syncHeading: "ApiCore-Synchronisierung",
|
|
||||||
syncIntro:
|
|
||||||
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte. Die Produkte selbst kommen über den eigenen Sync-Knopf unter „Produkte“.",
|
|
||||||
syncButton: "Jetzt synchronisieren",
|
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
|
||||||
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
|
||||||
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
|
||||||
batchSyncResult: (created, skipped, failed) =>
|
|
||||||
`Chargen: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
|
||||||
batchSyncError: "Chargen konnten nicht von ApiCore geladen werden",
|
|
||||||
},
|
},
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: "Zurück zu Ernten",
|
backToHarvests: "Zurück zu Ernten",
|
||||||
@@ -929,8 +921,30 @@ export const de: Dictionary = {
|
|||||||
publishNow: "Jetzt veröffentlichen",
|
publishNow: "Jetzt veröffentlichen",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
},
|
},
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: "ApiCore-Synchronisierung",
|
||||||
|
intro:
|
||||||
|
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene Einträge werden nie überschrieben.",
|
||||||
|
button: "Alles synchronisieren",
|
||||||
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
|
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||||
|
resultBatches: (created, skipped, failed) =>
|
||||||
|
`Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
|
resultProducts: (created, skipped, failed) =>
|
||||||
|
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
|
pending: (count) =>
|
||||||
|
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
|
||||||
|
discontinued: (count) =>
|
||||||
|
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
||||||
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
|
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
||||||
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
|
delete: "Löschen",
|
||||||
|
deleteConfirm: (label) =>
|
||||||
|
`"${label}" wirklich löschen? Dies kann nicht rückgängig gemacht werden.`,
|
||||||
|
notDeletable: "bereits verkauft",
|
||||||
title: "Produkte",
|
title: "Produkte",
|
||||||
newProduct: "Neues Produkt",
|
newProduct: "Neues Produkt",
|
||||||
colProduct: "Produkt",
|
colProduct: "Produkt",
|
||||||
@@ -945,17 +959,6 @@ export const de: Dictionary = {
|
|||||||
statusInactive: "Inaktiv",
|
statusInactive: "Inaktiv",
|
||||||
edit: "Bearbeiten",
|
edit: "Bearbeiten",
|
||||||
noProductsYet: "Noch keine Produkte angelegt.",
|
noProductsYet: "Noch keine Produkte angelegt.",
|
||||||
syncHeading: "ApiCore-Synchronisierung",
|
|
||||||
syncIntro:
|
|
||||||
"Legt für jede Abfüllung aus ApiCore ein eigenes Produkt an — also einen Jahrgang. „Waldhonig 2026“ und „Waldhonig 2027“ sind damit getrennte Produkte mit eigenem Preis, eigenem Text und eigenen Bewertungen; die Adresse trägt die Jahreszahl. Importierte Produkte sind zunächst deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Erst wenn Preis, Gewicht und Beschreibung gepflegt sind, lässt sich ein Produkt aktiv schalten. Vorhandene Produkte werden nur übersprungen, nie überschrieben. Die zugehörige Charge muss schon vorhanden sein — also zuerst den Ernten-Sync ausführen.",
|
|
||||||
syncButton: "Jetzt synchronisieren",
|
|
||||||
syncResult: (created, skipped, failed) =>
|
|
||||||
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"} angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
|
||||||
syncDiscontinued: (count) =>
|
|
||||||
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene Jahrgänge bleiben unverändert im Verkauf, damit der Restbestand abverkauft werden kann.`,
|
|
||||||
syncPending: (count) =>
|
|
||||||
`${count} Abfüllung${count === 1 ? "" : "en"} wartet${count === 1 ? "" : "en"} noch auf die zugehörige Charge. Zuerst unter „Ernten“ synchronisieren, dann hier erneut.`,
|
|
||||||
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
|
||||||
draftHint: "Entwurf: kein Preis gepflegt",
|
draftHint: "Entwurf: kein Preis gepflegt",
|
||||||
},
|
},
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
+23
-21
@@ -811,6 +811,7 @@ export const en: Dictionary = {
|
|||||||
},
|
},
|
||||||
adminBatchesPage: {
|
adminBatchesPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
|
notDeletable: "in use",
|
||||||
title: "Batches",
|
title: "Batches",
|
||||||
colBatch: "Batch",
|
colBatch: "Batch",
|
||||||
colHarvest: "Harvest",
|
colHarvest: "Harvest",
|
||||||
@@ -877,6 +878,7 @@ export const en: Dictionary = {
|
|||||||
},
|
},
|
||||||
adminHarvestsPage: {
|
adminHarvestsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
|
notDeletable: "in use",
|
||||||
title: "Harvests",
|
title: "Harvests",
|
||||||
colType: "Type",
|
colType: "Type",
|
||||||
colYear: "Year",
|
colYear: "Year",
|
||||||
@@ -901,16 +903,6 @@ export const en: Dictionary = {
|
|||||||
descriptionLabel: "Description",
|
descriptionLabel: "Description",
|
||||||
isPublishedLabel: "Publish immediately",
|
isPublishedLabel: "Publish immediately",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
syncHeading: "ApiCore sync",
|
|
||||||
syncIntro:
|
|
||||||
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest. The products themselves have their own sync button under “Products”.",
|
|
||||||
syncButton: "Sync now",
|
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
|
||||||
syncError: "Sync with ApiCore failed.",
|
|
||||||
batchSyncResult: (created, skipped, failed) =>
|
|
||||||
`Batches: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
|
||||||
batchSyncError: "Batches could not be loaded from ApiCore",
|
|
||||||
},
|
},
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: "Back to harvests",
|
backToHarvests: "Back to harvests",
|
||||||
@@ -929,8 +921,29 @@ export const en: Dictionary = {
|
|||||||
publishNow: "Publish now",
|
publishNow: "Publish now",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
},
|
},
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: "ApiCore sync",
|
||||||
|
intro:
|
||||||
|
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released are never overwritten.",
|
||||||
|
button: "Sync everything",
|
||||||
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
|
resultBatches: (created, skipped, failed) =>
|
||||||
|
`Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
|
resultProducts: (created, skipped, failed) =>
|
||||||
|
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
|
pending: (count) =>
|
||||||
|
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
|
||||||
|
discontinued: (count) =>
|
||||||
|
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
||||||
|
error: "Sync with ApiCore failed.",
|
||||||
|
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
||||||
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
|
delete: "Delete",
|
||||||
|
deleteConfirm: (label) => `Really delete "${label}"? This cannot be undone.`,
|
||||||
|
notDeletable: "already sold",
|
||||||
title: "Products",
|
title: "Products",
|
||||||
newProduct: "New product",
|
newProduct: "New product",
|
||||||
colProduct: "Product",
|
colProduct: "Product",
|
||||||
@@ -945,17 +958,6 @@ export const en: Dictionary = {
|
|||||||
statusInactive: "Inactive",
|
statusInactive: "Inactive",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
noProductsYet: "No products created yet.",
|
noProductsYet: "No products created yet.",
|
||||||
syncHeading: "ApiCore sync",
|
|
||||||
syncIntro:
|
|
||||||
"Creates one product per bottling run from ApiCore — that is, per vintage. “Waldhonig 2026” and “Waldhonig 2027” are separate products with their own price, copy and reviews; the address carries the year. Imported products start out inactive and without a price, because ApiCore does not hold sales prices. A product can only be activated once price, weight and description have been filled in. Existing products are only ever skipped, never overwritten. The matching batch has to exist already — so run the harvest sync first.",
|
|
||||||
syncButton: "Sync now",
|
|
||||||
syncResult: (created, skipped, failed) =>
|
|
||||||
`Products: ${created} new vintage${created === 1 ? "" : "s"} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
|
||||||
syncDiscontinued: (count) =>
|
|
||||||
`${count} bottling run${count === 1 ? "" : "s"} belong${count === 1 ? "s" : ""} to an article ApiCore no longer carries, so no new vintage was created for ${count === 1 ? "it" : "them"}. Vintages already in the shop stay on sale so remaining stock can be sold through.`,
|
|
||||||
syncPending: (count) =>
|
|
||||||
`${count} bottling run${count === 1 ? " is" : "s are"} still waiting for the matching batch. Sync under “Harvests” first, then run this again.`,
|
|
||||||
syncError: "Sync with ApiCore failed.",
|
|
||||||
draftHint: "Draft: no price set",
|
draftHint: "Draft: no price set",
|
||||||
},
|
},
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
+19
-14
@@ -514,6 +514,7 @@ export interface Dictionary {
|
|||||||
};
|
};
|
||||||
adminBatchesPage: {
|
adminBatchesPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
|
notDeletable: string;
|
||||||
title: string;
|
title: string;
|
||||||
colBatch: string;
|
colBatch: string;
|
||||||
colHarvest: string;
|
colHarvest: string;
|
||||||
@@ -580,6 +581,7 @@ export interface Dictionary {
|
|||||||
};
|
};
|
||||||
adminHarvestsPage: {
|
adminHarvestsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
|
notDeletable: string;
|
||||||
title: string;
|
title: string;
|
||||||
colType: string;
|
colType: string;
|
||||||
colYear: string;
|
colYear: string;
|
||||||
@@ -604,15 +606,8 @@ export interface Dictionary {
|
|||||||
descriptionLabel: string;
|
descriptionLabel: string;
|
||||||
isPublishedLabel: string;
|
isPublishedLabel: string;
|
||||||
create: string;
|
create: string;
|
||||||
syncHeading: string;
|
|
||||||
syncIntro: string;
|
|
||||||
syncButton: string;
|
|
||||||
/** created, updated, skipped, failed */
|
/** created, updated, skipped, failed */
|
||||||
syncResult: (created: number, updated: number, skipped: number, failed: number) => string;
|
|
||||||
syncError: string;
|
|
||||||
/** created, skipped, failed */
|
/** created, skipped, failed */
|
||||||
batchSyncResult: (created: number, skipped: number, failed: number) => string;
|
|
||||||
batchSyncError: string;
|
|
||||||
};
|
};
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: string;
|
backToHarvests: string;
|
||||||
@@ -631,8 +626,25 @@ export interface Dictionary {
|
|||||||
publishNow: string;
|
publishNow: string;
|
||||||
save: string;
|
save: string;
|
||||||
};
|
};
|
||||||
|
/** Gemeinsam von /admin/ernten und /admin/produkte genutzt: EIN Sync-Knopf
|
||||||
|
* fuer Ernten, Chargen und Produkte. */
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
button: string;
|
||||||
|
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => string;
|
||||||
|
resultBatches: (created: number, skipped: number, failed: number) => string;
|
||||||
|
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||||
|
pending: (count: number) => string;
|
||||||
|
discontinued: (count: number) => string;
|
||||||
|
error: string;
|
||||||
|
batchLoadError: string;
|
||||||
|
};
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
|
delete: string;
|
||||||
|
deleteConfirm: (label: string) => string;
|
||||||
|
notDeletable: string;
|
||||||
title: string;
|
title: string;
|
||||||
newProduct: string;
|
newProduct: string;
|
||||||
colProduct: string;
|
colProduct: string;
|
||||||
@@ -647,13 +659,6 @@ export interface Dictionary {
|
|||||||
statusInactive: string;
|
statusInactive: string;
|
||||||
edit: string;
|
edit: string;
|
||||||
noProductsYet: string;
|
noProductsYet: string;
|
||||||
syncHeading: string;
|
|
||||||
syncIntro: string;
|
|
||||||
syncButton: string;
|
|
||||||
syncResult: (created: number, skipped: number, failed: number) => string;
|
|
||||||
syncDiscontinued: (count: number) => string;
|
|
||||||
syncPending: (count: number) => string;
|
|
||||||
syncError: string;
|
|
||||||
draftHint: string;
|
draftHint: string;
|
||||||
};
|
};
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
@@ -3,8 +3,18 @@ export interface GoogleReview {
|
|||||||
rating: number;
|
rating: number;
|
||||||
text: string;
|
text: string;
|
||||||
relativeTime: string;
|
relativeTime: string;
|
||||||
|
/**
|
||||||
|
* Link auf die Rezension bei Google. Die Nutzungsbedingungen der Places API
|
||||||
|
* verlangen, dass angezeigte Rezensionen der Quelle zugeordnet bleiben —
|
||||||
|
* ohne diesen Link steht ein fremdes Zitat unbelegt auf unserer Seite.
|
||||||
|
*/
|
||||||
|
authorUrl: string | null;
|
||||||
|
profilePhotoUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
|
||||||
|
const GOOGLE_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
interface PlacesDetailsResponse {
|
interface PlacesDetailsResponse {
|
||||||
result?: {
|
result?: {
|
||||||
rating?: number;
|
rating?: number;
|
||||||
@@ -12,11 +22,14 @@ interface PlacesDetailsResponse {
|
|||||||
reviews?: {
|
reviews?: {
|
||||||
author_name: string;
|
author_name: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
text: string;
|
text?: string;
|
||||||
relative_time_description: string;
|
relative_time_description: string;
|
||||||
|
author_url?: string;
|
||||||
|
profile_photo_url?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
status: string;
|
status: string;
|
||||||
|
error_message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +53,11 @@ export async function fetchGoogleReviews(): Promise<{
|
|||||||
|
|
||||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
||||||
url.searchParams.set("place_id", placeId);
|
url.searchParams.set("place_id", placeId);
|
||||||
url.searchParams.set("fields", "review,rating,user_ratings_total");
|
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST,
|
||||||
|
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
|
||||||
|
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so
|
||||||
|
// blieb der Tippfehler unbemerkt.
|
||||||
|
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
|
||||||
url.searchParams.set("language", "de");
|
url.searchParams.set("language", "de");
|
||||||
url.searchParams.set("reviews_sort", "newest");
|
url.searchParams.set("reviews_sort", "newest");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
@@ -48,17 +65,40 @@ export async function fetchGoogleReviews(): Promise<{
|
|||||||
try {
|
try {
|
||||||
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
|
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
|
||||||
// ohne die Rezensionen spuerbar veralten zu lassen.
|
// ohne die Rezensionen spuerbar veralten zu lassen.
|
||||||
const response = await fetch(url, { next: { revalidate: 3600 } });
|
const response = await fetch(url, {
|
||||||
if (!response.ok) return null;
|
next: { revalidate: 3600 },
|
||||||
|
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
||||||
|
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
||||||
|
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as PlacesDetailsResponse;
|
const data = (await response.json()) as PlacesDetailsResponse;
|
||||||
if (data.status !== "OK" || !data.result) return null;
|
if (data.status !== "OK" || !data.result) {
|
||||||
|
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel
|
||||||
|
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST"
|
||||||
|
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf
|
||||||
|
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
||||||
|
console.warn(
|
||||||
|
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const reviews: GoogleReview[] = (data.result.reviews ?? []).map((r) => ({
|
const reviews: GoogleReview[] = (data.result.reviews ?? [])
|
||||||
|
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
||||||
|
// Startseite waeren sie eine leere Sprechblase.
|
||||||
|
.filter((r) => r.text?.trim())
|
||||||
|
.map((r) => ({
|
||||||
author: r.author_name,
|
author: r.author_name,
|
||||||
rating: r.rating,
|
rating: r.rating,
|
||||||
text: r.text,
|
text: r.text!.trim(),
|
||||||
relativeTime: r.relative_time_description,
|
relativeTime: r.relative_time_description,
|
||||||
|
authorUrl: r.author_url ?? null,
|
||||||
|
profilePhotoUrl: r.profile_photo_url ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -66,9 +106,13 @@ export async function fetchGoogleReviews(): Promise<{
|
|||||||
rating: data.result.rating ?? null,
|
rating: data.result.rating ?? null,
|
||||||
totalCount: data.result.user_ratings_total ?? null,
|
totalCount: data.result.user_ratings_total ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
||||||
// stattdessen greift der statische Fallback.
|
// stattdessen greift der statische Fallback. Geloggt wird trotzdem, sonst
|
||||||
|
// ist eine dauerhaft kaputte Anbindung von aussen nicht zu erkennen.
|
||||||
|
console.warn(
|
||||||
|
`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
|
import { invoiceRelativePath, resolveInvoicePath, INVOICE_DIR } from "./invoice-storage";
|
||||||
|
|
||||||
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
|
function makeInput(overrides: Partial<Parameters<typeof invoiceRelativePath>[0]> = {}) {
|
||||||
@@ -58,6 +59,19 @@ describe("invoiceRelativePath", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Trennung von der oeffentlichen Upload-Ablage", () => {
|
||||||
|
test("die Rechnungsablage liegt nicht im oeffentlich ausgelieferten Upload-Verzeichnis", () => {
|
||||||
|
// Arrange: app/uploads/[...path] liefert JEDE Datei unterhalb von
|
||||||
|
// UPLOAD_DIR ohne Anmeldung aus.
|
||||||
|
const uploads = path.resolve(process.env.UPLOAD_DIR || "./uploads");
|
||||||
|
|
||||||
|
// Act & Assert: laege INVOICE_DIR darin, waeren fremde Rechnungen samt
|
||||||
|
// Anschrift und Bestellinhalt fuer jeden abrufbar, der den Pfad erraet.
|
||||||
|
expect(INVOICE_DIR).not.toBe(uploads);
|
||||||
|
expect(INVOICE_DIR.startsWith(uploads + path.sep)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveInvoicePath", () => {
|
describe("resolveInvoicePath", () => {
|
||||||
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
test("loest innerhalb des Ablageverzeichnisses auf", () => {
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ export const INVOICE_DIR = path.resolve(
|
|||||||
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
|
/*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass die Rechnungsablage NICHT innerhalb des oeffentlich
|
||||||
|
* ausgelieferten Upload-Verzeichnisses liegt.
|
||||||
|
*
|
||||||
|
* Die Trennung der beiden Verzeichnisse ist die einzige Sicherung dagegen,
|
||||||
|
* dass Rechnungen ueber app/uploads/[...path] abrufbar werden — jene Route
|
||||||
|
* liefert JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Bisher hing
|
||||||
|
* diese Trennung allein daran, dass jemand INVOICE_DIR richtig setzt. Ein
|
||||||
|
* INVOICE_DIR="/app/uploads/rechnungen" haette genuegt, damit fremde
|
||||||
|
* Rechnungen samt Anschrift und Bestellinhalt oeffentlich abrufbar sind — und
|
||||||
|
* es waere nicht aufgefallen, denn funktioniert haette weiterhin alles.
|
||||||
|
*
|
||||||
|
* Deshalb eine harte Pruefung beim Laden des Moduls statt eines blossen
|
||||||
|
* Hinweises in der Dokumentation: lieber startet die Anwendung nicht, als
|
||||||
|
* dass sie Belege oeffentlich ausliefert.
|
||||||
|
*/
|
||||||
|
function assertInvoicesAreNotPubliclyServed(): void {
|
||||||
|
const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
||||||
|
const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep;
|
||||||
|
if (INVOICE_DIR === uploads || INVOICE_DIR.startsWith(withSep)) {
|
||||||
|
throw new Error(
|
||||||
|
`INVOICE_DIR ("${INVOICE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` +
|
||||||
|
"Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert — Rechnungen waeren damit " +
|
||||||
|
"fuer jeden abrufbar, der den Pfad erraet. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertInvoicesAreNotPubliclyServed();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
|
* Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute
|
||||||
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
|
* ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben
|
||||||
|
|||||||
+14
-1
@@ -5,7 +5,7 @@ const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|||||||
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
|
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
|
||||||
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
|
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — CAPTCHA-Pruefung auf Registrierung/Newsletter/Firmenanfrage ist deaktiviert."
|
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — die CAPTCHA-Pruefung ist auf ALLEN oeffentlichen Formularen deaktiviert (Login, Zwei-Faktor, Passwort-Reset, Registrierung, Checkout, Newsletter, Firmenanfrage, Verfuegbarkeits-Benachrichtigung)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,3 +38,16 @@ export async function verifyTurnstileToken(token: string | null, remoteIp?: stri
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest das Turnstile-Token aus einem Formular und prueft es.
|
||||||
|
*
|
||||||
|
* Gemeinsame Stelle fuer alle Formulare, damit der Feldname nur einmal
|
||||||
|
* festgelegt ist: haette eine Seite ihn anders geschrieben, waere das Token
|
||||||
|
* leer angekommen — und weil ein leeres Token bei nicht konfiguriertem
|
||||||
|
* Secret durchgeht, waere der Schutz an genau dieser Stelle lautlos
|
||||||
|
* ausgefallen.
|
||||||
|
*/
|
||||||
|
export async function isTurnstileValid(formData: FormData, remoteIp?: string): Promise<boolean> {
|
||||||
|
return verifyTurnstileToken(String(formData.get("turnstileToken") ?? ""), remoteIp);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user