/** * Client fuer die DHL Location Finder Unified API — sucht Packstationen in * der Naehe einer Postleitzahl, damit sich beim Checkout eine Packstation * statt einer Hausanschrift als Lieferziel waehlen laesst. * * Bewusst getrennt von lib/dhl.ts: das ist ein ANDERER DHL-Dienst mit * anderer Authentifizierung. Die Standortsuche braucht nur den App-API-Key * (Header "DHL-API-Key"), nicht den OAuth2/ROPC-Weg und keinen * Geschaeftskundenvertrag. Die Packstation-Auswahl funktioniert damit auch, * solange die Versandmarken-Erstellung noch nicht eingerichtet ist. * * Endpunkt laut oeffentlicher API-Referenz (developer.dhl.com/api-reference/ * location-finder, Stand September 2026): * GET https://api.dhl.com/location-finder/v1/find-by-address * ?countryCode=DE&postalCode=...&locationType=locker&providerType=parcel * Die Packstation-Nummer steht in location.keywordId; location.ids[0].locationId * ist ein zusammengesetzter Systemcode und NICHT die Nummer, die Kundschaft * auf dem Geraet sieht. */ import { z } from "zod"; /** Wird geworfen, wenn DHL_API_KEY fehlt — die Suche ist dann nicht nutzbar. */ export class PackstationNotConfiguredError extends Error { constructor() { super( "Die Packstation-Suche ist noch nicht konfiguriert. Bitte DHL_API_KEY in der .env-Datei setzen (siehe .env.example)." ); this.name = "PackstationNotConfiguredError"; } } /** Wird bei einem fehlgeschlagenen Aufruf der DHL-Standortsuche geworfen. */ export class PackstationApiError extends Error { constructor(message: string) { super(message); this.name = "PackstationApiError"; } } const LOCATION_FINDER_BASE_URL = "https://api.dhl.com/location-finder/v1"; // Ohne Timeout blockiert eine haengende (nicht fehlerhafte, sondern nie // antwortende) DHL-API den Request-Handler unbegrenzt — gleiche Begruendung // wie bei DHL_REQUEST_TIMEOUT_MS in lib/dhl.ts, hier kuerzer, weil die Suche // waehrend des Checkouts interaktiv passiert. const REQUEST_TIMEOUT_MS = 8_000; /** * DHL nennt als Einstiegskontingent 500 Aufrufe pro TAG. Ein Suchfeld im * Checkout kann das schnell aufbrauchen, deshalb ein kurzer Cache pro * Suchbegriff: Packstation-Standorte aendern sich nicht im Minutentakt, und * mehrere Kund:innen aus derselben Stadt suchen dieselbe Postleitzahl. */ const CACHE_TTL_MS = 15 * 60 * 1000; const MAX_CACHE_ENTRIES = 500; const cache = new Map(); export interface Packstation { /** Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145"). */ number: string; /** Anzeigename laut DHL, z.B. "Packstation 145". */ name: string; street: string; zip: string; city: string; /** Luftlinie in Metern zum Suchmittelpunkt, sofern DHL sie mitliefert. */ distanceMeters: number | null; } // Nur die Felder, die tatsaechlich gebraucht werden — alles Weitere bleibt // bewusst ungeparst, damit ein zusaetzliches Feld aufseiten DHLs die Suche // nicht bricht. Umgekehrt faellt eine fehlende Packstation-Nummer hier // sichtbar durch, statt als leerer String in einer Bestellung zu landen. const locationSchema = z.object({ location: z.object({ keywordId: z.string().min(1), keyword: z.string().optional(), }), name: z.string().optional(), distance: z.number().optional(), place: z.object({ address: z.object({ postalCode: z.string(), addressLocality: z.string(), streetAddress: z.string().optional(), }), }), }); const responseSchema = z.object({ locations: z.array(z.unknown()).optional(), }); export function isPackstationSearchConfigured(): boolean { return Boolean(process.env.DHL_API_KEY?.trim()); } interface FindPackstationsOptions { /** Deutsche Postleitzahl, fuenfstellig. */ postalCode: string; /** Optionaler Ort — schraenkt bei mehrdeutigen Postleitzahlen weiter ein. */ city?: string; /** Obergrenze der Treffer (DHL-seitig zusaetzlich begrenzt). */ limit?: number; } /** * Sucht Packstationen zu einer Postleitzahl. Liefert eine nach Entfernung * aufsteigend sortierte Liste; DHL sortiert bereits so, die Sortierung hier * ist nur die Absicherung dagegen, dass sich das stillschweigend aendert. */ export async function findPackstations({ postalCode, city, limit = 10, }: FindPackstationsOptions): Promise { const apiKey = process.env.DHL_API_KEY?.trim(); if (!apiKey) throw new PackstationNotConfiguredError(); const cacheKey = `${postalCode}|${city ?? ""}|${limit}`; const cached = cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.value; const query = new URLSearchParams({ countryCode: "DE", postalCode, // "locker" = Packstation; "parcel" grenzt auf Post & Parcel Germany ein, // damit keine DHL-Express-Standorte in der Liste landen. locationType: "locker", providerType: "parcel", limit: String(Math.min(Math.max(limit, 1), 50)), }); if (city) query.set("addressLocality", city); let response: Response; try { response = await fetch(`${LOCATION_FINDER_BASE_URL}/find-by-address?${query}`, { headers: { "DHL-API-Key": apiKey, Accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { throw new PackstationApiError( error instanceof Error && error.name === "TimeoutError" ? "Die DHL-Standortsuche hat nicht rechtzeitig geantwortet." : "Die DHL-Standortsuche ist nicht erreichbar." ); } // 404 bedeutet bei diesem Endpunkt "keine Standorte gefunden", nicht // "Endpunkt existiert nicht" — das ist ein gueltiges, leeres Ergebnis und // darf nicht als Fehler in der Oberflaeche landen. if (response.status === 404) { rememberInCache(cacheKey, []); return []; } if (!response.ok) { throw new PackstationApiError( `Die DHL-Standortsuche hat mit Status ${response.status} geantwortet.` ); } let payload: unknown; try { payload = await response.json(); } catch { throw new PackstationApiError("Die DHL-Standortsuche hat keine gueltige Antwort geliefert."); } const parsed = responseSchema.safeParse(payload); if (!parsed.success) { throw new PackstationApiError("Die Antwort der DHL-Standortsuche hat ein unerwartetes Format."); } const packstations = parseLocations(parsed.data.locations ?? []); rememberInCache(cacheKey, packstations); return packstations; } /** * Als eigene Funktion exportiert, damit sich das Parsen der DHL-Antwort ohne * Netzwerkzugriff testen laesst (siehe dhl-locations.test.ts). */ export function parseLocations(entries: unknown[]): Packstation[] { return entries .map((entry) => locationSchema.safeParse(entry)) // Einzelne unvollstaendige Eintraege ueberspringen, statt die ganze Suche // scheitern zu lassen: ein Standort ohne Nummer ist fuer uns unbrauchbar, // die uebrigen bleiben aber gueltig. .filter((result) => result.success) .map(({ data }): Packstation => ({ number: data.location.keywordId, name: data.name?.trim() || `Packstation ${data.location.keywordId}`, street: data.place.address.streetAddress?.trim() ?? "", zip: data.place.address.postalCode, city: data.place.address.addressLocality, distanceMeters: typeof data.distance === "number" ? data.distance : null, })) .sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity)); } function rememberInCache(key: string, value: Packstation[]): void { // Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen // (gleiches Muster wie in lib/rate-limit.ts). if (cache.size > MAX_CACHE_ENTRIES) { const now = Date.now(); for (const [existingKey, entry] of cache) { if (entry.expiresAt <= now) cache.delete(existingKey); } } cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value }); }