Compare commits
3
Commits
cbc81caa1a
...
29f59adc6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f59adc6b | ||
|
|
6639da130e | ||
|
|
9b10a9f0c5 |
No files matched your search
+5
-1
@@ -62,7 +62,11 @@ SITE_ADDRESS=
|
|||||||
|
|
||||||
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
||||||
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
||||||
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API".
|
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API (New)" — NICHT
|
||||||
|
# die gleichnamige alte API, die in neuen Projekten nicht mehr aktivierbar
|
||||||
|
# ist. Ausserdem noetig: Abrechnung am Projekt aktiv, und der Wert muss ein
|
||||||
|
# API-Schluessel sein (ein OAuth-Client funktioniert nicht). Keine
|
||||||
|
# Referrer-Beschraenkung setzen — der Abruf erfolgt serverseitig.
|
||||||
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
||||||
GOOGLE_PLACES_API_KEY=
|
GOOGLE_PLACES_API_KEY=
|
||||||
GOOGLE_PLACE_ID=
|
GOOGLE_PLACE_ID=
|
||||||
|
|||||||
@@ -209,22 +209,40 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**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).
|
**Verwendet wird die `Places API (New)`** (`places.googleapis.com/v1`), nicht der alte `place/details`-Endpunkt. Das ist keine Geschmacksfrage: Google lässt die alte „Places API" in seit März 2025 angelegten Cloud-Projekten gar nicht mehr aktivieren.
|
||||||
|
|
||||||
**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
|
**Einrichtung — alle vier Schritte sind nötig:**
|
||||||
|
|
||||||
|
1. **API aktivieren:** In der Cloud Console unter *APIs & Dienste → Bibliothek* nach `Places API (New)` suchen und aktivieren. Es gibt dort zwei fast gleich heißende Einträge — die alte „Places API" ist die falsche, und die „Maps JavaScript API" hilft hier ebenfalls nicht.
|
||||||
|
2. **Abrechnung aktivieren:** Ohne hinterlegtes Rechnungskonto antwortet auch die neue API mit einem Berechtigungsfehler. Das kostenlose Kontingent deckt diese Nutzung (ein Abruf pro Stunde) bei Weitem ab.
|
||||||
|
3. **API-Schlüssel erzeugen:** *APIs & Dienste → Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel*. Ein **OAuth-Client ist etwas anderes** und funktioniert hier nicht — er ist für Anmeldungen im Namen eines Nutzers gedacht, nicht für Serverabrufe.
|
||||||
|
4. **Werte setzen:** Schlüssel als `GOOGLE_PLACES_API_KEY` (Secret), Place-ID als `GOOGLE_PLACE_ID` (ConfigMap).
|
||||||
|
|
||||||
|
**Die Place-ID steht nicht in der Cloud Console.** Sie gehört zum Eintrag bei Google Maps, nicht zum Projekt. Mit gesetztem Schlüssel liefert sie dieses Kommando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm places:find "Wabenhain Altenmünster"
|
||||||
|
```
|
||||||
|
|
||||||
|
Es gibt Name, Anschrift und `GOOGLE_PLACE_ID=…` je Treffer aus — und ist zugleich die schnellste Probe auf die Einrichtung: kommt eine ID heraus, stimmen API, Abrechnung und Schlüssel. Andernfalls benennt es die Ursache. Ohne Schlüssel geht es auch über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||||
|
|
||||||
|
Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher **nicht** und würde den Abruf blockieren. Sinnvoll ist stattdessen eine API-Beschränkung auf „Places API (New)" und optional eine IP-Beschränkung auf die ausgehende Adresse des Clusters.
|
||||||
|
|
||||||
|
**Fehler werden 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 |
|
| Meldung im Log | Bedeutung |
|
||||||
|----------------|-----------|
|
|----------------|-----------|
|
||||||
| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert |
|
| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
|
||||||
| `INVALID_REQUEST` | Parameter falsch |
|
| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
|
||||||
| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag |
|
| `billing … not enabled` | Kein Rechnungskonto am Projekt |
|
||||||
| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt |
|
| `HTTP 404` | Die Place-ID passt zu keinem Eintrag |
|
||||||
|
| `API keys with referer restrictions …` | Der Schlüssel ist auf HTTP-Referrer beschränkt; für Serverabrufe untauglich |
|
||||||
|
|
||||||
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.
|
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.
|
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) oder ohne Namen werden übersprungen: die einen wären leere Sprechblasen, den anderen fehlte die verlangte Quellenangabe.
|
||||||
|
|
||||||
> **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.
|
> **Hinweis zur Sortierung:** Der alte Endpunkt kannte `reviews_sort=newest`. Die neue API bietet das nicht an und liefert bis zu fünf Rezensionen in Googles eigener Reihenfolge.
|
||||||
|
|
||||||
## E-Mail-Versand
|
## E-Mail-Versand
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ data:
|
|||||||
|
|
||||||
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
||||||
# implizites TLS.
|
# implizites TLS.
|
||||||
|
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
||||||
|
#
|
||||||
|
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
||||||
|
# in 05-app-deployment.yaml. Drei Instanzen mit je 20 belegen 60 der
|
||||||
|
# voreingestellt 100 Verbindungen von PostgreSQL — daneben brauchen der
|
||||||
|
# Aufraeum-CronJob, die Sicherung und gelegentlich eine Konsole noch
|
||||||
|
# welche. Entweder diesen Wert senken oder max_connections anheben.
|
||||||
|
DATABASE_POOL_SIZE: "20"
|
||||||
|
|
||||||
SMTP_PORT: "587"
|
SMTP_PORT: "587"
|
||||||
|
|
||||||
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"preflight": "tsx scripts/preflight.ts",
|
"preflight": "tsx scripts/preflight.ts",
|
||||||
|
"places:find": "tsx scripts/find-place-id.ts",
|
||||||
"orders:release-stale": "tsx scripts/release-stale-orders.ts"
|
"orders:release-stale": "tsx scripts/release-stale-orders.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Sucht die Place-ID eines Google-Maps-Eintrags.
|
||||||
|
*
|
||||||
|
* Die Place-ID steht NICHT in der Google Cloud Console — dort gibt es nur den
|
||||||
|
* API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss ueber die
|
||||||
|
* Places API nachgeschlagen werden. Genau das tut dieses Skript.
|
||||||
|
*
|
||||||
|
* Es ist zugleich der schnellste Weg, die Einrichtung zu pruefen: kommt hier
|
||||||
|
* eine ID heraus, sind API, Abrechnung und Schluessel richtig gesetzt und die
|
||||||
|
* Startseite kann die Rezensionen abrufen. Kommt stattdessen eine
|
||||||
|
* Fehlermeldung, benennt sie die Ursache — anders als die Meldung der alten
|
||||||
|
* API, die bei nicht aktivierter Schnittstelle behauptet, der Schluessel sei
|
||||||
|
* ungueltig.
|
||||||
|
*
|
||||||
|
* Aufruf:
|
||||||
|
* pnpm places:find "Wabenhain Altenmünster"
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const SEARCH_URL = "https://places.googleapis.com/v1/places:searchText";
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
places?: {
|
||||||
|
id?: string;
|
||||||
|
displayName?: { text?: string };
|
||||||
|
formattedAddress?: string;
|
||||||
|
}[];
|
||||||
|
error?: { message?: string; status?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(`\n✖ ${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const query = process.argv.slice(2).join(" ").trim();
|
||||||
|
if (!query) {
|
||||||
|
fail('Bitte einen Suchbegriff angeben, z.B.: pnpm places:find "Wabenhain Altenmünster"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.GOOGLE_PLACES_API_KEY?.trim();
|
||||||
|
if (!apiKey) {
|
||||||
|
fail(
|
||||||
|
"GOOGLE_PLACES_API_KEY ist nicht gesetzt. Den API-Schluessel aus der Google Cloud Console\n" +
|
||||||
|
" in die .env eintragen (Anmeldedaten → API-Schluessel, NICHT OAuth-Client)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(SEARCH_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Goog-Api-Key": apiKey,
|
||||||
|
// Nur die drei Felder, die unten ausgegeben werden — die API rechnet
|
||||||
|
// nach angeforderten Feldern ab.
|
||||||
|
"X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ textQuery: query, languageCode: "de" }),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json().catch(() => null)) as SearchResponse | null;
|
||||||
|
|
||||||
|
if (!response.ok || data?.error) {
|
||||||
|
const detail = data?.error?.message ?? `HTTP ${response.status}`;
|
||||||
|
fail(
|
||||||
|
`Google hat die Anfrage abgelehnt: ${detail}\n\n` +
|
||||||
|
" Die drei haeufigsten Ursachen, in dieser Reihenfolge:\n" +
|
||||||
|
' 1. "Places API (New)" ist im Projekt nicht aktiviert (die gleichnamige ALTE\n' +
|
||||||
|
" API reicht nicht — sie laesst sich in neuen Projekten gar nicht mehr\n" +
|
||||||
|
" aktivieren).\n" +
|
||||||
|
" 2. Am Projekt ist keine Abrechnung hinterlegt.\n" +
|
||||||
|
" 3. Der Wert ist ein OAuth-Client statt eines API-Schluessels, oder der\n" +
|
||||||
|
" Schluessel traegt eine Referrer-Beschraenkung — dieser Abruf laeuft\n" +
|
||||||
|
" serverseitig und hat keinen Referrer."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const places = data?.places ?? [];
|
||||||
|
if (places.length === 0) {
|
||||||
|
fail(`Kein Eintrag zu "${query}" gefunden. Genauer suchen, z.B. mit Ort oder Strasse.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTreffer für "${query}":\n`);
|
||||||
|
for (const place of places) {
|
||||||
|
console.log(` ${place.displayName?.text ?? "(ohne Namen)"}`);
|
||||||
|
console.log(` ${place.formattedAddress ?? "(ohne Adresse)"}`);
|
||||||
|
console.log(` GOOGLE_PLACE_ID=${place.id ?? "(ohne ID)"}\n`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"Den passenden Wert als GOOGLE_PLACE_ID eintragen — in der .env fuer lokal,\n" +
|
||||||
|
"in k8s/01-configmap.yaml fuer den Cluster.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
// Netzwerkfehler und Zeitueberschreitungen landen hier; alles Fachliche ist
|
||||||
|
// oben bereits mit einer deutlicheren Meldung abgefangen.
|
||||||
|
fail(`Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* ohne dass irgendwo ein Fehler sichtbar wurde.
|
* ohne dass irgendwo ein Fehler sichtbar wurde.
|
||||||
*/
|
*/
|
||||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
const sessionsCreate = vi.fn();
|
const sessionsCreate = vi.fn();
|
||||||
const sessionsExpire = vi.fn();
|
const sessionsExpire = vi.fn();
|
||||||
@@ -215,6 +216,58 @@ describe("Checkout-Route", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Wenn viele gleichzeitig bestellen", () => {
|
||||||
|
/**
|
||||||
|
* Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen
|
||||||
|
* Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf
|
||||||
|
* Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben
|
||||||
|
* Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der
|
||||||
|
* Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei
|
||||||
|
* der Kundschaft.
|
||||||
|
*/
|
||||||
|
function serializationConflict(): Error {
|
||||||
|
return new Prisma.PrismaClientKnownRequestError("could not serialize access", {
|
||||||
|
code: "P2034",
|
||||||
|
clientVersion: "7",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("wiederholt die Bestellanlage und kommt durch", async () => {
|
||||||
|
const runTransaction = async (arg: unknown) =>
|
||||||
|
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg;
|
||||||
|
db.$transaction
|
||||||
|
.mockRejectedValueOnce(serializationConflict())
|
||||||
|
.mockRejectedValueOnce(serializationConflict())
|
||||||
|
.mockImplementation(runTransaction);
|
||||||
|
|
||||||
|
const response = await post();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(db.$transaction).toHaveBeenCalledTimes(3);
|
||||||
|
expect(sessionsCreate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => {
|
||||||
|
db.$transaction.mockRejectedValue(serializationConflict());
|
||||||
|
|
||||||
|
const response = await post();
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(db.$transaction).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => {
|
||||||
|
// Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis
|
||||||
|
// vor — die Wiederholung wuerde die Antwort nur verzoegern.
|
||||||
|
db.inventory.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
const response = await post();
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(db.$transaction).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Wenn Stripe nicht mitspielt", () => {
|
describe("Wenn Stripe nicht mitspielt", () => {
|
||||||
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
|
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
|
||||||
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
|
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ class DiscountCodeExhaustedError extends Error {}
|
|||||||
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
|
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
|
||||||
const SERIALIZATION_CONFLICT_CODE = "P2034";
|
const SERIALIZATION_CONFLICT_CODE = "P2034";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie oft eine an einem Serialisierungskonflikt gescheiterte Bestellanlage
|
||||||
|
* wiederholt wird, bevor die Kundschaft einen Fehler sieht.
|
||||||
|
*
|
||||||
|
* Ein solcher Konflikt ist KEIN Fehler, sondern die vorgesehene Antwort der
|
||||||
|
* Datenbank auf zwei gleichzeitige Transaktionen, die sich widersprechen
|
||||||
|
* koennten — bei "Serializable" bricht sie eine davon ab, damit nicht beide
|
||||||
|
* auf veralteten Staenden weiterrechnen. Erwartet wird, dass der Aufrufer es
|
||||||
|
* einfach noch einmal versucht.
|
||||||
|
*
|
||||||
|
* Genau das fehlte: der Abbruch wurde direkt als 409 an die Kundschaft
|
||||||
|
* durchgereicht ("bitte in ein paar Sekunden erneut versuchen"). Bei wenigen
|
||||||
|
* Bestellungen faellt das nie auf, bei 100 gleichzeitigen schon — alle
|
||||||
|
* greifen auf dieselbe Lagerbestandszeile zu, Konflikte sind dann der
|
||||||
|
* Normalfall und nicht die Ausnahme. Ein Teil der Kundschaft bekaeme
|
||||||
|
* ausgerechnet im Moment der Kaufentscheidung eine Fehlermeldung.
|
||||||
|
*
|
||||||
|
* Vier Versuche, weil jeder die Transaktion vollstaendig neu ausfuehrt
|
||||||
|
* (inklusive frischer Bestandspruefung) und viermal hintereinander zu
|
||||||
|
* verlieren unwahrscheinlich ist. Bleibt es dabei, ist der Andrang so gross,
|
||||||
|
* dass "gleich nochmal" die ehrliche Antwort ist.
|
||||||
|
*/
|
||||||
|
const SERIALIZATION_MAX_ATTEMPTS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartezeit zwischen zwei Versuchen, mit Zufallsanteil.
|
||||||
|
*
|
||||||
|
* Ohne den Zufallsanteil starten alle gleichzeitig abgebrochenen Anfragen
|
||||||
|
* ihren naechsten Versuch im selben Moment und kollidieren erneut — dasselbe
|
||||||
|
* Problem, nur verschoben.
|
||||||
|
*/
|
||||||
|
function serializationBackoffMs(attempt: number): number {
|
||||||
|
const base = 25 * 2 ** (attempt - 1);
|
||||||
|
return base + Math.random() * base;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24
|
* Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24
|
||||||
* Stunden zu und nimmt ohne Angabe die 24 Stunden.
|
* Stunden zu und nimmt ohne Angabe die 24 Stunden.
|
||||||
@@ -49,6 +85,37 @@ const SERIALIZATION_CONFLICT_CODE = "P2034";
|
|||||||
*/
|
*/
|
||||||
const CHECKOUT_SESSION_TTL_MINUTES = 32;
|
const CHECKOUT_SESSION_TTL_MINUTES = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt die Bestellanlage aus und wiederholt sie, wenn die Datenbank sie
|
||||||
|
* wegen eines Serialisierungskonflikts abgebrochen hat.
|
||||||
|
*
|
||||||
|
* Wiederholt wird AUSSCHLIESSLICH dieser eine Fehlerfall. "Ausverkauft" und
|
||||||
|
* "Gutschein aufgebraucht" sind Sachentscheidungen — sie faenden beim zweiten
|
||||||
|
* Versuch dasselbe Ergebnis vor, und eine Wiederholung wuerde die Antwort nur
|
||||||
|
* verzoegern.
|
||||||
|
*/
|
||||||
|
async function withSerializationRetry<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const isConflict =
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === SERIALIZATION_CONFLICT_CODE;
|
||||||
|
if (!isConflict || attempt >= SERIALIZATION_MAX_ATTEMPTS) throw error;
|
||||||
|
|
||||||
|
// Auf "info" statt "error": das ist der vorgesehene Ablauf, kein
|
||||||
|
// Stoerfall. Sichtbar bleibt es trotzdem, weil eine Haeufung hier das
|
||||||
|
// erste Anzeichen dafuer waere, dass der Andrang die Datenbank an ihre
|
||||||
|
// Grenze bringt.
|
||||||
|
console.info(
|
||||||
|
`[checkout] Serialisierungskonflikt bei der Bestellanlage (Versuch ${attempt} von ${SERIALIZATION_MAX_ATTEMPTS}), wird wiederholt.`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, serializationBackoffMs(attempt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateOrderNumber(): string {
|
function generateOrderNumber(): string {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und
|
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und
|
||||||
@@ -263,13 +330,17 @@ export async function POST(request: Request) {
|
|||||||
// Leute abbrechen.
|
// Leute abbrechen.
|
||||||
const cancelToken = randomBytes(32).toString("hex");
|
const cancelToken = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
let order;
|
// Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
|
||||||
try {
|
// (quantity >= bestellt) verhindert Ueberverkauf auch bei gleichzeitigen
|
||||||
// Bestellanlage und Bestandsreservierung atomar: das bedingte
|
// Bestellungen — schlaegt eine Position fehl, wird die gesamte Transaktion
|
||||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
// (inkl. bereits reservierter Positionen) verworfen.
|
||||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
//
|
||||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
// Als eigene Funktion, damit sie bei einem Serialisierungskonflikt
|
||||||
order = await prisma.$transaction(
|
// vollstaendig wiederholt werden kann (siehe SERIALIZATION_MAX_ATTEMPTS).
|
||||||
|
// Jeder Versuch liest den Bestand frisch — eine Wiederholung kann also
|
||||||
|
// durchaus zu "ausverkauft" fuehren, und das ist dann auch richtig so.
|
||||||
|
const runOrderTransaction = () =>
|
||||||
|
prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
for (const item of orderItemsData) {
|
for (const item of orderItemsData) {
|
||||||
const reserved = await tx.inventory.updateMany({
|
const reserved = await tx.inventory.updateMany({
|
||||||
@@ -340,8 +411,23 @@ export async function POST(request: Request) {
|
|||||||
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
|
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
|
||||||
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
|
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
|
||||||
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
|
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
|
||||||
{ isolationLevel: "Serializable" }
|
{
|
||||||
|
isolationLevel: "Serializable",
|
||||||
|
// Prisma gibt einer Transaktion voreingestellt 2 Sekunden, um
|
||||||
|
// ueberhaupt eine Verbindung zu bekommen, und 5 Sekunden zum
|
||||||
|
// Ausfuehren. Bei starkem Andrang sind alle Verbindungen belegt und
|
||||||
|
// eine Bestellung scheitert dann nicht an der Sache, sondern am
|
||||||
|
// Warten — mitten in der Kaufentscheidung. Die Transaktion selbst
|
||||||
|
// bleibt kurz; die groesszuegigen Werte sind Puffer fuer die
|
||||||
|
// Warteschlange, keine Einladung, lange zu rechnen.
|
||||||
|
maxWait: 10_000,
|
||||||
|
timeout: 15_000,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let order;
|
||||||
|
try {
|
||||||
|
order = await withSerializationRetry(runOrderTransaction);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof OutOfStockError) {
|
if (error instanceof OutOfStockError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
+62
-1
@@ -1,6 +1,54 @@
|
|||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleichzeitig offene Datenbankverbindungen dieses Prozesses.
|
||||||
|
*
|
||||||
|
* Ohne Angabe nimmt node-postgres 10. Das reicht im Alltag mühelos, wird aber
|
||||||
|
* beim Andrang zur Obergrenze: eine Bestellung haelt ihre Verbindung ueber
|
||||||
|
* die gesamte Transaktion (Bestandsreservierung, Gutscheinpruefung,
|
||||||
|
* Bestellanlage), und Prisma gibt einer Transaktion nur begrenzt Zeit,
|
||||||
|
* ueberhaupt eine Verbindung zu bekommen (maxWait). Sind alle zehn belegt,
|
||||||
|
* scheitern weitere Checkouts nicht an der Sache, sondern am Warten.
|
||||||
|
*
|
||||||
|
* 20 ist bewusst moderat: PostgreSQL erlaubt in der Voreinstellung 100
|
||||||
|
* gleichzeitige Verbindungen, und daneben brauchen noch der Aufraeum-CronJob,
|
||||||
|
* die Sicherung und gelegentlich eine Konsole welche. Die Anwendung laeuft
|
||||||
|
* ausserdem mit einer einzigen Instanz (siehe k8s/05-app-deployment.yaml) —
|
||||||
|
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
|
||||||
|
* werden.
|
||||||
|
*/
|
||||||
|
const DEFAULT_POOL_SIZE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Wert gilt PRO INSTANZ. Wer die Anwendung auf mehrere Instanzen
|
||||||
|
* hochzieht, multipliziert damit auch die Verbindungen: drei Instanzen mit je
|
||||||
|
* 20 belegen 60 der voreingestellt 100 Verbindungen von PostgreSQL, und
|
||||||
|
* daneben brauchen der Aufraeum-CronJob, die Sicherung und gelegentlich eine
|
||||||
|
* Konsole noch welche. Deshalb einstellbar statt fest verdrahtet — beim
|
||||||
|
* Hochskalieren ist das der Wert, der mitwandern muss.
|
||||||
|
*/
|
||||||
|
function poolSize(): number {
|
||||||
|
const raw = process.env.DATABASE_POOL_SIZE?.trim();
|
||||||
|
if (!raw) return DEFAULT_POOL_SIZE;
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
console.error(
|
||||||
|
`[db] DATABASE_POOL_SIZE="${raw}" ist keine ganze Zahl groesser 0 — es wird mit ${DEFAULT_POOL_SIZE} gearbeitet.`
|
||||||
|
);
|
||||||
|
return DEFAULT_POOL_SIZE;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
|
||||||
|
* aufgibt. Lieber ein klarer Fehler nach zehn Sekunden als eine Seite, die
|
||||||
|
* ewig laedt — und im Log ein Hinweis darauf, dass der Pool zu klein ist.
|
||||||
|
*/
|
||||||
|
const POOL_ACQUIRE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert
|
* Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert
|
||||||
* einen expliziten Treiber-Adapter im Client — welcher, haengt von
|
* einen expliziten Treiber-Adapter im Client — welcher, haengt von
|
||||||
@@ -16,7 +64,20 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
|
|||||||
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
|
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
|
||||||
|
|
||||||
if (provider === "mysql") {
|
if (provider === "mysql") {
|
||||||
|
// Bewusst unveraendert die Verbindungszeichenkette: der MariaDB-Adapter
|
||||||
|
// nimmt entweder sie ODER ein Einstellungsobjekt aus Host, Benutzer,
|
||||||
|
// Passwort und Datenbank — beides zugleich geht nicht, die Zeichenkette
|
||||||
|
// muesste also zerlegt werden. Der Produktivbetrieb laeuft auf
|
||||||
|
// PostgreSQL (DATABASE_PROVIDER-Vorgabe); fuer MySQL gilt weiterhin die
|
||||||
|
// Voreinstellung des Treibers.
|
||||||
return new PrismaMariaDb(connectionString);
|
return new PrismaMariaDb(connectionString);
|
||||||
}
|
}
|
||||||
return new PrismaPg({ connectionString });
|
return new PrismaPg({
|
||||||
|
connectionString,
|
||||||
|
max: poolSize(),
|
||||||
|
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
|
||||||
|
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
|
||||||
|
// irgendwo ein Fehler sichtbar wuerde.
|
||||||
|
connectionTimeoutMillis: POOL_ACQUIRE_TIMEOUT_MS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+104
-57
@@ -12,35 +12,71 @@ export interface GoogleReview {
|
|||||||
profilePhotoUrl: string | null;
|
profilePhotoUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
|
|
||||||
const GOOGLE_TIMEOUT_MS = 5000;
|
const GOOGLE_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
interface PlacesDetailsResponse {
|
/**
|
||||||
result?: {
|
* Nur diese Felder werden angefordert. Die Places API rechnet nach
|
||||||
rating?: number;
|
* angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte
|
||||||
user_ratings_total?: number;
|
* Daten, die diese Seite nie anzeigt.
|
||||||
reviews?: {
|
*/
|
||||||
author_name: string;
|
const FIELD_MASK = "rating,userRatingCount,reviews";
|
||||||
rating: number;
|
|
||||||
text?: string;
|
interface PlacesTextV1 {
|
||||||
relative_time_description: string;
|
text?: string;
|
||||||
author_url?: string;
|
}
|
||||||
profile_photo_url?: string;
|
|
||||||
}[];
|
interface PlacesReviewV1 {
|
||||||
|
rating?: number;
|
||||||
|
relativePublishTimeDescription?: string;
|
||||||
|
text?: PlacesTextV1;
|
||||||
|
originalText?: PlacesTextV1;
|
||||||
|
authorAttribution?: {
|
||||||
|
displayName?: string;
|
||||||
|
uri?: string;
|
||||||
|
photoUri?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlacesDetailsV1Response {
|
||||||
|
rating?: number;
|
||||||
|
userRatingCount?: number;
|
||||||
|
reviews?: PlacesReviewV1[];
|
||||||
|
/** Nur im Fehlerfall gesetzt — anders als frueher antwortet die API dann auch mit einem Fehlerstatus. */
|
||||||
|
error?: {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
};
|
};
|
||||||
status: string;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
|
* Ruft aktuelle Google-Rezensionen fuer den in GOOGLE_PLACE_ID hinterlegten
|
||||||
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint).
|
* Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null
|
||||||
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die
|
* zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen
|
||||||
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt.
|
* zurueckfaellt.
|
||||||
*
|
*
|
||||||
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als
|
* Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der
|
||||||
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als
|
* alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein
|
||||||
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id).
|
* Geschmacksurteil: Google laesst die alte "Places API" in seit Maerz 2025
|
||||||
|
* angelegten Cloud-Projekten gar nicht mehr aktivieren. Ein Aufruf des alten
|
||||||
|
* Endpunkts wird dort beantwortet mit
|
||||||
|
*
|
||||||
|
* REQUEST_DENIED — The provided API key is invalid.
|
||||||
|
*
|
||||||
|
* also mit einer Aussage ueber den Schluessel, obwohl der Schluessel in
|
||||||
|
* Ordnung ist und keinerlei Einschraenkung traegt. Wer dieser Meldung folgt,
|
||||||
|
* sucht den Fehler zwangslaeufig an der falschen Stelle.
|
||||||
|
*
|
||||||
|
* Einrichtung (siehe auch README, Abschnitt "Google-Rezensionen"):
|
||||||
|
* 1. In der Google Cloud Console die API "Places API (New)" aktivieren —
|
||||||
|
* nicht die gleichnamige alte, nicht die Maps JavaScript API.
|
||||||
|
* 2. Abrechnung fuer das Projekt aktivieren; ohne sie antwortet auch die
|
||||||
|
* neue API mit einem Berechtigungsfehler.
|
||||||
|
* 3. Unter "Anmeldedaten" einen API-SCHLUESSEL erzeugen. Ein OAuth-Client
|
||||||
|
* ist etwas anderes und funktioniert hier nicht.
|
||||||
|
* 4. Schluessel als GOOGLE_PLACES_API_KEY setzen, die Place-ID als
|
||||||
|
* GOOGLE_PLACE_ID (zu finden ueber
|
||||||
|
* https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||||
*/
|
*/
|
||||||
export async function fetchGoogleReviews(): Promise<{
|
export async function fetchGoogleReviews(): Promise<{
|
||||||
reviews: GoogleReview[];
|
reviews: GoogleReview[];
|
||||||
@@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{
|
|||||||
const placeId = process.env.GOOGLE_PLACE_ID;
|
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||||
if (!apiKey || !placeId) return null;
|
if (!apiKey || !placeId) return null;
|
||||||
|
|
||||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
// Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert —
|
||||||
url.searchParams.set("place_id", placeId);
|
// der Pfad braucht sie genau einmal mit Praefix.
|
||||||
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST,
|
const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
|
||||||
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
|
|
||||||
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so
|
const url = new URL(`https://places.googleapis.com/v1/${resourceName}`);
|
||||||
// blieb der Tippfehler unbemerkt.
|
url.searchParams.set("languageCode", "de");
|
||||||
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
|
|
||||||
url.searchParams.set("language", "de");
|
|
||||||
url.searchParams.set("reviews_sort", "newest");
|
|
||||||
url.searchParams.set("key", apiKey);
|
|
||||||
|
|
||||||
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
|
||||||
// ohne die Rezensionen spuerbar veralten zu lassen.
|
// die Rezensionen spuerbar veralten zu lassen.
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
// Der Schluessel gehoert bei dieser API in den Header, nicht in die
|
||||||
|
// Adresszeile — so steht er nicht in fremden Zugriffsprotokollen.
|
||||||
|
"X-Goog-Api-Key": apiKey,
|
||||||
|
"X-Goog-FieldMask": FIELD_MASK,
|
||||||
|
},
|
||||||
next: { revalidate: 3600 },
|
next: { revalidate: 3600 },
|
||||||
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
|
||||||
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
|
||||||
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
|
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().catch(() => null)) as PlacesDetailsV1Response | null;
|
||||||
if (data.status !== "OK" || !data.result) {
|
|
||||||
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel
|
if (!response.ok || data?.error) {
|
||||||
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST"
|
// Laut melden statt still zurueckfallen: ein falscher Schluessel, eine
|
||||||
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf
|
// nicht aktivierte API und eine unbekannte Place-ID sehen auf der
|
||||||
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
// Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
|
||||||
|
const detail = data?.error?.message ?? `HTTP ${response.status}`;
|
||||||
console.warn(
|
console.warn(
|
||||||
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.`
|
`[google-reviews] Places API (New) hat den Abruf abgelehnt: ${detail}. Zu pruefen: Ist "Places API (New)" im Google-Cloud-Projekt aktiviert, ist die Abrechnung aktiv, und ist GOOGLE_PLACES_API_KEY ein API-Schluessel (kein OAuth-Client)?`
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!data) {
|
||||||
|
console.warn("[google-reviews] Antwort der Places API war kein gueltiges JSON.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const reviews: GoogleReview[] = (data.result.reviews ?? [])
|
const reviews: GoogleReview[] = (data.reviews ?? [])
|
||||||
|
.map((review) => {
|
||||||
|
// "text" ist die (ggf. uebersetzte) Fassung in languageCode,
|
||||||
|
// "originalText" die Originalsprache. Fehlt die Uebersetzung, ist das
|
||||||
|
// Original immer noch besser als gar kein Zitat.
|
||||||
|
const text = (review.text?.text ?? review.originalText?.text ?? "").trim();
|
||||||
|
const author = review.authorAttribution?.displayName?.trim() ?? "";
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
rating: review.rating ?? 0,
|
||||||
|
text,
|
||||||
|
relativeTime: review.relativePublishTimeDescription ?? "",
|
||||||
|
authorUrl: review.authorAttribution?.uri ?? null,
|
||||||
|
profilePhotoUrl: review.authorAttribution?.photoUri ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
|
||||||
// Startseite waeren sie eine leere Sprechblase.
|
// Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem
|
||||||
.filter((r) => r.text?.trim())
|
// die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle.
|
||||||
.map((r) => ({
|
.filter((review) => review.text.length > 0 && review.author.length > 0);
|
||||||
author: r.author_name,
|
|
||||||
rating: r.rating,
|
|
||||||
text: r.text!.trim(),
|
|
||||||
relativeTime: r.relative_time_description,
|
|
||||||
authorUrl: r.author_url ?? null,
|
|
||||||
profilePhotoUrl: r.profile_photo_url ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reviews,
|
reviews,
|
||||||
rating: data.result.rating ?? null,
|
rating: data.rating ?? null,
|
||||||
totalCount: data.result.user_ratings_total ?? null,
|
totalCount: data.userRatingCount ?? null,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
|
||||||
|
|||||||
Reference in new issue
Block a user