fix: Google-Rezensionen auf "Places API (New)" umgestellt

Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.

Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.

Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.

Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.

Zwei Verhaltensaenderungen, beide unvermeidbar:
  - Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
    Rezensionen in Googles eigener Reihenfolge.
  - Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
    Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
    Zuordnung zur Quelle.

README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.

Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.

tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 06:16:19 +02:00
1 parent 9b10a9f0c5
commit 6639da130e
5 files changed
+160 -68

No files matched your search

+5 -1
View File
@@ -62,7 +62,11 @@ SITE_ADDRESS=
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
# automatisch die statischen Beispiel-Stimmen als Fallback.
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API".
# 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
GOOGLE_PLACES_API_KEY=
GOOGLE_PLACE_ID=
+18 -8
View File
@@ -209,22 +209,32 @@ 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.
**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 des eigenen Eintrags findet sich ü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 |
|----------------|-----------|
| `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 |
| `… API … not enabled` / `SERVICE_DISABLED` | „Places API (New)" ist im Projekt nicht aktiviert |
| `The provided API key is invalid` | Meist: alter Endpunkt oder alte API — bei dieser Fassung des Codes eher ein tatsächlich falscher Schlüssel |
| `billing … not enabled` | Kein Rechnungskonto am Projekt |
| `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.
**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
+9
View File
@@ -44,6 +44,15 @@ data:
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
# 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"
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
+24 -2
View File
@@ -18,7 +18,29 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
* werden.
*/
const POOL_SIZE = 20;
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
@@ -52,7 +74,7 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
}
return new PrismaPg({
connectionString,
max: POOL_SIZE,
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.
+104 -57
View File
@@ -12,35 +12,71 @@ export interface GoogleReview {
profilePhotoUrl: string | null;
}
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
const GOOGLE_TIMEOUT_MS = 5000;
interface PlacesDetailsResponse {
result?: {
rating?: number;
user_ratings_total?: number;
reviews?: {
author_name: string;
rating: number;
text?: string;
relative_time_description: string;
author_url?: string;
profile_photo_url?: string;
}[];
/**
* Nur diese Felder werden angefordert. Die Places API rechnet nach
* angeforderten Feldern ab — eine Maske mit "*" waere teurer und lieferte
* Daten, die diese Seite nie anzeigt.
*/
const FIELD_MASK = "rating,userRatingCount,reviews";
interface PlacesTextV1 {
text?: 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
* Google-Maps-Eintrag ab (Places API, Legacy "Place Details"-Endpoint).
* Ohne konfigurierte Zugangsdaten wird null zurueckgegeben, sodass die
* Startseite auf die statischen Beispiel-Stimmen zurueckfaellt.
* Google-Maps-Eintrag ab. Ohne konfigurierte Zugangsdaten wird null
* zurueckgegeben, sodass die Startseite auf die statischen Beispiel-Stimmen
* zurueckfaellt.
*
* Setup: Google-Cloud-Projekt mit aktivierter "Places API", API-Key als
* GOOGLE_PLACES_API_KEY, die Place-ID des Google-Maps-Eintrags als
* GOOGLE_PLACE_ID (zu finden ueber https://developers.google.com/maps/documentation/places/web-service/place-id).
* Verwendet wird die "Places API (New)" (places.googleapis.com/v1), NICHT der
* alte Endpunkt maps.googleapis.com/maps/api/place/details/json. Das ist kein
* 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<{
reviews: GoogleReview[];
@@ -51,60 +87,71 @@ export async function fetchGoogleReviews(): Promise<{
const placeId = process.env.GOOGLE_PLACE_ID;
if (!apiKey || !placeId) return null;
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.set("place_id", placeId);
// "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("reviews_sort", "newest");
url.searchParams.set("key", apiKey);
// Die Place-ID wird mal als "ChIJ..." und mal als "places/ChIJ..." notiert —
// der Pfad braucht sie genau einmal mit Praefix.
const resourceName = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
const url = new URL(`https://places.googleapis.com/v1/${resourceName}`);
url.searchParams.set("languageCode", "de");
try {
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
// ohne die Rezensionen spuerbar veralten zu lassen.
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten, ohne
// die Rezensionen spuerbar veralten zu lassen.
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 },
// 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;
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.
const data = (await response.json().catch(() => null)) as PlacesDetailsV1Response | null;
if (!response.ok || data?.error) {
// Laut melden statt still zurueckfallen: ein falscher Schluessel, eine
// nicht aktivierte API und eine unbekannte Place-ID sehen auf der
// Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
const detail = data?.error?.message ?? `HTTP ${response.status}`;
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;
}
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
// Startseite waeren sie eine leere Sprechblase.
.filter((r) => r.text?.trim())
.map((r) => ({
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,
}));
// Startseite waeren sie eine leere Sprechblase. Ohne Namen fehlt zudem
// die von den Nutzungsbedingungen verlangte Zuordnung zur Quelle.
.filter((review) => review.text.length > 0 && review.author.length > 0);
return {
reviews,
rating: data.result.rating ?? null,
totalCount: data.result.user_ratings_total ?? null,
rating: data.rating ?? null,
totalCount: data.userRatingCount ?? null,
};
} catch (error: unknown) {
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —