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

+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 —