feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen

/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.

- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
  Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
  damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 03:27:58 +02:00
1 parent 2659db64bd
commit 01eaca39dc
10 files changed
+674 -5

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const ENV_KEYS = ["DHL_API_KEY", "DHL_TRACKING_API_KEY"] as const;
const originalEnv: Record<string, string | undefined> = {};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
/** Ein Ereignis im Format der DHL Shipment Tracking – Unified API. */
function dhlEvent(overrides: Record<string, unknown> = {}) {
return {
timestamp: "2026-09-24T08:15:00Z",
location: { address: { addressLocality: "Augsburg", postalCode: "86150", countryCode: "DE" } },
statusCode: "transit",
status: "In Zustellung",
description: "Die Sendung wurde in das Zustellfahrzeug geladen.",
...overrides,
};
}
function dhlPayload(shipmentOverrides: Record<string, unknown> = {}) {
return {
shipments: [
{
id: "00340434161234567890",
service: "parcel-de",
status: dhlEvent(),
estimatedTimeOfDelivery: "2026-09-25T18:00:00Z",
events: [
dhlEvent({
timestamp: "2026-09-23T17:40:00Z",
statusCode: "pre-transit",
status: "Elektronisch angekündigt",
description: "Die Sendung wurde elektronisch angekündigt.",
location: undefined,
}),
dhlEvent(),
],
...shipmentOverrides,
},
],
};
}
const QUERY = { trackingNumber: "00340434161234567890", postalCode: "86450", locale: "de" as const };
// dhl-tracking.ts haelt Ergebnisse in einem modulweiten Cache — pro Test ein
// frisches Modul, damit kein Treffer aus einem vorherigen Test den erwarteten
// fetch-Aufruf stillschweigend ueberspringt (gleiches Muster wie dhl.test.ts).
beforeEach(() => {
vi.resetModules();
for (const key of ENV_KEYS) {
originalEnv[key] = process.env[key];
delete process.env[key];
}
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) delete process.env[key];
else process.env[key] = originalEnv[key];
}
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("parseTrackingResponse", () => {
it("liefert Status, voraussichtliche Zustellung und Ereignisse, neuestes zuerst", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload());
expect(info?.status).toBe("transit");
expect(info?.statusText).toBe("In Zustellung");
expect(info?.estimatedDelivery?.toISOString()).toBe("2026-09-25T18:00:00.000Z");
expect(info?.events.map((e) => e.status)).toEqual(["transit", "pre-transit"]);
expect(info?.events[0].location).toBe("Augsburg");
expect(info?.events[1].location).toBeNull();
});
it("bildet unbekannte Statuscodes auf 'unknown' ab, statt die Antwort zu verwerfen", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ status: dhlEvent({ statusCode: "neuer-code" }) }));
expect(info?.status).toBe("unknown");
});
it("ueberspringt Ereignisse mit ungueltigem Zeitstempel", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ timestamp: "kein-datum" }), dhlEvent()] }));
expect(info?.events).toHaveLength(1);
});
it("kuerzt uebermaessig lange Texte, damit eine fremde Antwort das Layout nicht sprengt", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ description: "x".repeat(5000) })] }));
expect(info?.events[0].description.length).toBeLessThanOrEqual(300);
});
it("liefert null bei einer Antwort ohne Sendung", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
expect(parseTrackingResponse({ shipments: [] })).toBeNull();
expect(parseTrackingResponse("<html>")).toBeNull();
});
});
describe("getDhlTracking", () => {
it("ruft ohne API-Key das Netzwerk gar nicht erst auf", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "disabled" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("schickt eine offensichtlich ungueltige Sendungsnummer nicht an DHL", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking({ ...QUERY, trackingNumber: "../x?y=1" })).resolves.toEqual({ kind: "not-found" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("fragt mit API-Key-Header, Service, PLZ und Sprache an und liefert die Ereignisse", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
const result = await getDhlTracking(QUERY);
expect(result.kind).toBe("ok");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedUrl = new URL(url);
expect(parsedUrl.origin + parsedUrl.pathname).toBe("https://api-eu.dhl.com/track/shipments");
expect(parsedUrl.searchParams.get("trackingNumber")).toBe(QUERY.trackingNumber);
expect(parsedUrl.searchParams.get("service")).toBe("parcel-de");
expect(parsedUrl.searchParams.get("recipientPostalCode")).toBe("86450");
expect(parsedUrl.searchParams.get("language")).toBe("de");
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("api-key");
});
it("bevorzugt DHL_TRACKING_API_KEY, falls das Tracking in einer eigenen DHL-App freigeschaltet ist", async () => {
process.env.DHL_API_KEY = "shipping-key";
process.env.DHL_TRACKING_API_KEY = "tracking-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("tracking-key");
});
it("beantwortet eine wiederholte Abfrage aus dem Cache, um das DHL-Kontingent zu schonen", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
await getDhlTracking(QUERY);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("meldet 404 als 'not-found' — die Sendung ist DHL (noch) nicht bekannt", async () => {
process.env.DHL_API_KEY = "api-key";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ status: 404 }, 404)));
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "not-found" });
});
it.each([
["Kontingent erschoepft", () => Promise.resolve(jsonResponse({}, 429))],
["falscher Key", () => Promise.resolve(jsonResponse({}, 401))],
["Netzwerkfehler", () => Promise.reject(new TypeError("fetch failed"))],
["kaputtes JSON", () => Promise.resolve(new Response("<html>", { status: 200 }))],
])("liefert bei %s 'unavailable' statt die Seite scheitern zu lassen", async (_label, impl) => {
process.env.DHL_API_KEY = "api-key";
vi.stubGlobal("fetch", vi.fn().mockImplementation(impl));
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "unavailable" });
});
it("wiederholt eine fehlgeschlagene Abfrage nicht sofort, sondern haelt kurz Abstand", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 429));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
await getDhlTracking(QUERY);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* Client fuer die DHL Shipment Tracking – Unified API — holt den Sendungs-
* verlauf serverseitig, damit /bestellung-verfolgen ihn im eigenen Layout
* anzeigen kann, statt Kundschaft auf dhl.de weiterzuleiten.
*
* Wie die Standortsuche (lib/dhl-locations.ts) braucht dieser Dienst nur den
* App-API-Key (Header "DHL-API-Key"), kein OAuth2 und keinen
* Geschaeftskundenvertrag. Voraussetzung ist, dass die DHL-App im Developer
* Portal das Produkt "Shipment Tracking - Unified" abonniert hat. Ist das in
* einer EIGENEN App freigeschaltet, wird DHL_TRACKING_API_KEY bevorzugt.
*
* Endpunkt laut oeffentlicher API-Referenz (developer.dhl.com/api-reference/
* shipment-tracking):
* GET https://api-eu.dhl.com/track/shipments
* ?trackingNumber=...&service=parcel-de&recipientPostalCode=...&language=de
* Mit recipientPostalCode liefert DHL fuer Pakete in Deutschland den
* ausfuehrlicheren Verlauf; ohne sie nur einen groben Status.
*
* Diese Funktion wirft nie: Fehler werden als { kind: "unavailable" }
* gemeldet, damit die Tracking-Seite immer noch Sendungsnummer und
* DHL-Link zeigt, wenn DHL gestoert ist oder das Kontingent aufgebraucht ist.
*/
import { z } from "zod";
import type { Locale } from "@/i18n/config";
const TRACKING_URL = "https://api-eu.dhl.com/track/shipments";
// Kuerzer als bei der Versandmarken-Erstellung: die Abfrage blockiert das
// Rendern der Tracking-Seite, Kundschaft wartet also direkt darauf.
const REQUEST_TIMEOUT_MS = 6_000;
/**
* Das Einstiegskontingent der Unified API ist knapp (Stand der Recherche
* einige hundert Aufrufe pro Tag). Deshalb ein Cache pro Sendung:
* - unterwegs: 10 Minuten — DHL aktualisiert ohnehin nur alle paar Stunden
* - zugestellt: 12 Stunden — dort aendert sich nichts mehr
* - nicht gefunden / Fehler: kurze Sperre, damit ein Neuladen der Seite oder
* ein 429 nicht sofort den naechsten Aufruf ausloest
*/
const CACHE_TTL_IN_TRANSIT_MS = 10 * 60 * 1000;
const CACHE_TTL_DELIVERED_MS = 12 * 60 * 60 * 1000;
const CACHE_TTL_NOT_FOUND_MS = 10 * 60 * 1000;
const CACHE_TTL_UNAVAILABLE_MS = 2 * 60 * 1000;
const MAX_CACHE_ENTRIES = 1000;
/** Obergrenze fuer Texte aus der DHL-Antwort — fremde Daten sollen das Layout nicht sprengen. */
const MAX_TEXT_LENGTH = 300;
const MAX_EVENTS = 50;
/**
* DHL-Sendungsnummern bestehen aus Buchstaben und Ziffern (Paketnummern 12-20
* Ziffern, internationale Nummern z.B. "JJD..." oder "LX...DE"). Alles andere
* geht gar nicht erst raus — die Nummer stammt zwar aus dem Admin-Bereich,
* landet aber in einer URL zu einem fremden Dienst.
*/
const TRACKING_NUMBER_PATTERN = /^[A-Za-z0-9]{8,39}$/;
export const DHL_TRACKING_STATUSES = ["pre-transit", "transit", "delivered", "failure", "unknown"] as const;
export type DhlTrackingStatus = (typeof DHL_TRACKING_STATUSES)[number];
export interface DhlTrackingEvent {
timestamp: Date;
status: DhlTrackingStatus;
/** Kurztext laut DHL, z.B. "In Zustellung". */
statusText: string;
/** Ausfuehrlicher Text laut DHL; faellt auf statusText zurueck. */
description: string;
/** Ort, sofern DHL einen mitliefert. */
location: string | null;
}
export interface DhlTrackingInfo {
status: DhlTrackingStatus;
statusText: string;
estimatedDelivery: Date | null;
/** Neuestes Ereignis zuerst. */
events: DhlTrackingEvent[];
}
export type DhlTrackingResult =
| { kind: "ok"; info: DhlTrackingInfo }
| { kind: "not-found" }
| { kind: "unavailable" }
| { kind: "disabled" };
// Nur die Felder, die tatsaechlich angezeigt werden — zusaetzliche Felder
// aufseiten DHLs duerfen das Parsen nicht brechen.
const eventSchema = z.object({
timestamp: z.string(),
statusCode: z.string().optional(),
status: z.string().optional(),
description: z.string().optional(),
location: z
.object({ address: z.object({ addressLocality: z.string().optional() }).optional() })
.optional(),
});
const shipmentSchema = z.object({
status: z.unknown().optional(),
estimatedTimeOfDelivery: z.string().optional(),
events: z.array(z.unknown()).optional(),
});
const responseSchema = z.object({
shipments: z.array(shipmentSchema).min(1),
});
const cache = new Map<string, { expiresAt: number; value: DhlTrackingResult }>();
function getApiKey(): string | null {
return process.env.DHL_TRACKING_API_KEY?.trim() || process.env.DHL_API_KEY?.trim() || null;
}
export function isDhlTrackingConfigured(): boolean {
return getApiKey() !== null;
}
interface GetDhlTrackingOptions {
trackingNumber: string;
/** Postleitzahl der Lieferanschrift (bei Packstation: die der Packstation). */
postalCode: string;
locale: Locale;
}
export async function getDhlTracking({
trackingNumber,
postalCode,
locale,
}: GetDhlTrackingOptions): Promise<DhlTrackingResult> {
const apiKey = getApiKey();
if (!apiKey) return { kind: "disabled" };
const normalizedNumber = trackingNumber.replace(/\s+/g, "");
if (!TRACKING_NUMBER_PATTERN.test(normalizedNumber)) return { kind: "not-found" };
const cacheKey = `${normalizedNumber}|${postalCode}|${locale}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
const result = await fetchTracking(apiKey, normalizedNumber, postalCode, locale);
rememberInCache(cacheKey, result);
return result;
}
async function fetchTracking(
apiKey: string,
trackingNumber: string,
postalCode: string,
locale: Locale
): Promise<DhlTrackingResult> {
const query = new URLSearchParams({ trackingNumber, service: "parcel-de", language: locale });
if (/^\d{5}$/.test(postalCode)) query.set("recipientPostalCode", postalCode);
let response: Response;
try {
response = await fetch(`${TRACKING_URL}?${query}`, {
headers: { "DHL-API-Key": apiKey, Accept: "application/json" },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
cache: "no-store",
});
} catch (error: unknown) {
const reason = error instanceof Error && error.name === "TimeoutError" ? "Zeitueberschreitung" : "nicht erreichbar";
console.warn(`[dhl-tracking] Sendungsverfolgung ${reason}.`);
return { kind: "unavailable" };
}
// 404 = DHL kennt die Sendung (noch) nicht, z.B. direkt nach dem Erstellen
// der Versandmarke. Kein Fehler, sondern ein gueltiger Zwischenstand.
if (response.status === 404) return { kind: "not-found" };
if (!response.ok) {
// Nur den Status loggen: die Sendungsnummer gehoert zu einer Bestellung
// und hat im Server-Log nichts verloren.
console.warn(`[dhl-tracking] DHL hat mit Status ${response.status} geantwortet.`);
return { kind: "unavailable" };
}
let payload: unknown;
try {
payload = await response.json();
} catch {
console.warn("[dhl-tracking] Antwort war kein gueltiges JSON.");
return { kind: "unavailable" };
}
const info = parseTrackingResponse(payload);
if (!info) {
console.warn("[dhl-tracking] Antwort hatte ein unerwartetes Format.");
return { kind: "unavailable" };
}
return { kind: "ok", info };
}
/**
* Als eigene Funktion exportiert, damit sich das Parsen der DHL-Antwort ohne
* Netzwerkzugriff testen laesst (siehe dhl-tracking.test.ts).
*/
export function parseTrackingResponse(payload: unknown): DhlTrackingInfo | null {
const parsed = responseSchema.safeParse(payload);
if (!parsed.success) return null;
const shipment = parsed.data.shipments[0];
const events = (shipment.events ?? [])
.map(parseEvent)
.filter((event): event is DhlTrackingEvent => event !== null)
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, MAX_EVENTS);
// Der Gesamtstatus steht separat in shipment.status; fehlt er, ist das
// neueste Ereignis die beste Naeherung.
const current = parseEvent(shipment.status) ?? events[0] ?? null;
return {
status: current?.status ?? "unknown",
statusText: current?.statusText ?? "",
estimatedDelivery: parseDate(shipment.estimatedTimeOfDelivery),
events,
};
}
function parseEvent(entry: unknown): DhlTrackingEvent | null {
const parsed = eventSchema.safeParse(entry);
if (!parsed.success) return null;
const timestamp = parseDate(parsed.data.timestamp);
if (!timestamp) return null;
const statusText = clean(parsed.data.status);
return {
timestamp,
status: toStatus(parsed.data.statusCode),
statusText,
description: clean(parsed.data.description) || statusText,
location: clean(parsed.data.location?.address?.addressLocality) || null,
};
}
function toStatus(code: string | undefined): DhlTrackingStatus {
return (DHL_TRACKING_STATUSES as readonly string[]).includes(code ?? "") ? (code as DhlTrackingStatus) : "unknown";
}
function parseDate(value: string | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function clean(value: string | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_TEXT_LENGTH);
}
function ttlFor(result: DhlTrackingResult): number {
switch (result.kind) {
case "ok":
return result.info.status === "delivered" ? CACHE_TTL_DELIVERED_MS : CACHE_TTL_IN_TRANSIT_MS;
case "not-found":
return CACHE_TTL_NOT_FOUND_MS;
default:
return CACHE_TTL_UNAVAILABLE_MS;
}
}
function rememberInCache(key: string, value: DhlTrackingResult): void {
// Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen
// (gleiches Muster wie in lib/dhl-locations.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() + ttlFor(value), value });
}
+6 -2
View File
@@ -7,9 +7,13 @@
*/
export function getCarrierTrackingUrl(carrier: string | null, trackingNumber: string | null): string | null {
if (!carrier || !trackingNumber) return null;
const normalized = carrier.trim().toLowerCase();
if (normalized === "dhl") {
if (isDhlCarrier(carrier)) {
return `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(trackingNumber)}`;
}
return null;
}
/** Der Versanddienstleister wird im Admin-Bereich als Freitext gepflegt. */
export function isDhlCarrier(carrier: string | null): boolean {
return carrier?.trim().toLowerCase() === "dhl";
}