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

+9
View File
@@ -145,6 +145,15 @@ APICORE_SYNC_PASSWORD=
# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
# cached Ergebnisse 15 Minuten.
# SENDUNGSVERFOLGUNG auf /bestellung-verfolgen: laeuft ueber die DHL Shipment
# Tracking – Unified API (lib/dhl-tracking.ts), ebenfalls nur mit API-Key.
# Die DHL-App muss dafuer im Developer Portal zusaetzlich das Produkt
# "Shipment Tracking - Unified" abonniert haben. Ist es in einer EIGENEN App
# freigeschaltet, deren Key in DHL_TRACKING_API_KEY eintragen — sonst wird
# DHL_API_KEY verwendet. Ohne freigeschalteten Key zeigt die Seite wie bisher
# nur Sendungsnummer + Link zu dhl.de. Ergebnisse werden pro Sendung
# 10 Minuten (zugestellt: 12 Stunden) gecached, um das Kontingent zu schonen.
DHL_TRACKING_API_KEY=
DHL_API_KEY=
DHL_API_SECRET=
DHL_GKP_USERNAME=
+3
View File
@@ -121,6 +121,9 @@ stringData:
DHL_GKP_USERNAME: ""
DHL_GKP_PASSWORD: ""
DHL_ACCOUNT_NUMBER: ""
# Optional: eigener Key fuer die DHL-Sendungsverfolgung auf
# /bestellung-verfolgen. Leer = DHL_API_KEY wird verwendet.
DHL_TRACKING_API_KEY: ""
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
+29 -1
View File
@@ -4,7 +4,9 @@ import { Container, SectionHeading, Button } from "@/components/ui";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { getOrderForTracking } from "@/lib/account-data";
import { formatPrice, formatDate } from "@/lib/format";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { getCarrierTrackingUrl, isDhlCarrier } from "@/lib/shipment-tracking";
import { getDhlTracking, type DhlTrackingResult } from "@/lib/dhl-tracking";
import { ShipmentTrackingHistory } from "@/components/shipment-tracking-history";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -32,6 +34,18 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
}
}
// Erst NACH erfolgreicher Pruefung von Bestellnummer + E-Mail bei DHL
// nachfragen: sonst liesse sich diese Seite als offener Proxy fuer beliebige
// Sendungsabfragen missbrauchen und das DHL-Kontingent leerraeumen.
let dhlTracking: DhlTrackingResult = { kind: "disabled" };
if (order?.shipment?.trackingNumber && isDhlCarrier(order.shipment.carrier)) {
dhlTracking = await getDhlTracking({
trackingNumber: order.shipment.trackingNumber,
postalCode: order.zip,
locale,
});
}
return (
<div className="py-20">
<Container className="max-w-xl">
@@ -150,6 +164,20 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
</p>
) : null}
<ShipmentTrackingHistory
result={dhlTracking}
locale={locale}
labels={{
heading: t.shipmentInfo.historyHeading,
ariaLabel: t.shipmentInfo.historyAriaLabel,
estimatedDelivery: t.shipmentInfo.estimatedDelivery,
notYetScanned: t.shipmentInfo.notYetScanned,
unavailable: t.shipmentInfo.historyUnavailable,
dataSource: t.shipmentInfo.dataSource,
statuses: t.shipmentInfo.statuses,
}}
/>
<div className="mt-5 flex items-center justify-between gap-4">
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
{order.invoice ? (
@@ -0,0 +1,100 @@
import type { DhlTrackingResult, DhlTrackingStatus } from "@/lib/dhl-tracking";
import type { Locale } from "@/i18n/config";
import { formatDate, formatDateTime } from "@/lib/format";
export interface ShipmentTrackingHistoryLabels {
heading: string;
ariaLabel: string;
estimatedDelivery: (date: string) => string;
notYetScanned: string;
unavailable: string;
dataSource: string;
statuses: Record<DhlTrackingStatus, string>;
}
const STATUS_DOT: Record<DhlTrackingStatus, string> = {
"pre-transit": "bg-ink/30",
transit: "bg-honey-dark",
delivered: "bg-forest",
failure: "bg-red-500",
unknown: "bg-ink/30",
};
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
"pre-transit": "text-ink/70",
transit: "text-honey-dark",
delivered: "text-forest",
failure: "text-red-700",
unknown: "text-ink/70",
};
/**
* Zeigt den DHL-Sendungsverlauf im eigenen Layout, damit Kundschaft nicht
* auf dhl.de wechseln muss. Rendert fuer "disabled" nichts — solange kein
* Tracking-Key hinterlegt ist, bleibt die Seite exakt wie bisher (Nummer +
* Link). Alle Texte aus der DHL-Antwort landen ausschliesslich als
* React-Textknoten im DOM, nie als HTML.
*/
export function ShipmentTrackingHistory({
result,
locale,
labels,
}: {
result: DhlTrackingResult;
locale: Locale;
labels: ShipmentTrackingHistoryLabels;
}) {
if (result.kind === "disabled") return null;
if (result.kind !== "ok") {
return (
<section className="mt-5 border-t border-ink/10 pt-5">
<h3 className="text-sm text-forest">{labels.heading}</h3>
<p className="mt-2 text-xs text-ink/65 leading-relaxed">
{result.kind === "not-found" ? labels.notYetScanned : labels.unavailable}
</p>
</section>
);
}
const { info } = result;
return (
<section className="mt-5 border-t border-ink/10 pt-5" aria-label={labels.ariaLabel}>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm text-forest">{labels.heading}</h3>
<span className={`text-xs ${STATUS_TEXT[info.status]}`}>{labels.statuses[info.status]}</span>
</div>
{info.estimatedDelivery && info.status !== "delivered" ? (
<p className="mt-1 text-xs text-ink/65">
{labels.estimatedDelivery(formatDate(info.estimatedDelivery, locale))}
</p>
) : null}
{info.events.length > 0 ? (
<ol className="mt-4">
{info.events.map((event, i) => (
<li key={`${event.timestamp.getTime()}-${i}`} className="relative pl-5 pb-4 last:pb-0">
{i < info.events.length - 1 ? (
<span className="absolute left-[4px] top-3 bottom-0 w-px bg-ink/15" aria-hidden="true" />
) : null}
<span
className={`absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full ${i === 0 ? STATUS_DOT[event.status] : "bg-ink/20"}`}
aria-hidden="true"
/>
<p className={`text-sm ${i === 0 ? "text-ink" : "text-ink/70"}`}>{event.description}</p>
<p className="mt-0.5 text-xs text-ink/55">
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
{event.location ? ` · ${event.location}` : null}
</p>
</li>
))}
</ol>
) : (
<p className="mt-2 text-xs text-ink/65">{labels.notYetScanned}</p>
)}
<p className="mt-4 text-[11px] text-ink/45">{labels.dataSource}</p>
</section>
);
}
+15 -1
View File
@@ -259,7 +259,7 @@ export const de: Dictionary = {
{
heading: "4. Bestellung und Vertragsabwicklung",
paragraphs: [
"Für den Versand übermitteln wir deinen Namen und die Lieferanschrift an unseren Versanddienstleister DHL (DHL Paket GmbH, Bonn). Wählst du die Lieferung an eine Packstation oder Filiale, kommt die von dir gewählte Standortnummer hinzu. Suchst du im Bestellvorgang nach einer Packstation, wird dein eingegebener Ort beziehungsweise deine Postleitzahl zur Abfrage an DHL weitergegeben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Für den Versand übermitteln wir deinen Namen und die Lieferanschrift an unseren Versanddienstleister DHL (DHL Paket GmbH, Bonn). Wählst du die Lieferung an eine Packstation oder Filiale, kommt die von dir gewählte Standortnummer hinzu. Suchst du im Bestellvorgang nach einer Packstation, wird dein eingegebener Ort beziehungsweise deine Postleitzahl zur Abfrage an DHL weitergegeben. Rufst du den Stand deiner Sendung unter „Bestellung verfolgen“ ab, fragen wir ihn mit Sendungsnummer und Postleitzahl der Lieferanschrift bei DHL ab und zeigen ihn dir direkt bei uns an. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Bei einer Bestellung erheben wir die für die Vertrags-, Zahlungs- und Versandabwicklung notwendigen Daten (Name, Adresse, E-Mail, ggf. Telefonnummer sowie die bestellten Artikel). Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).",
"Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy). Soweit dabei Daten an mit Stripe verbundene Unternehmen außerhalb der EU/des EWR (insbesondere Stripe, Inc. in den USA) übermittelt werden, erfolgt dies auf Grundlage von EU-Standardvertragsklauseln gemäß Art. 46 DSGVO.",
"Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.",
@@ -912,6 +912,20 @@ export const de: Dictionary = {
carrierLabel: "Versanddienstleister",
trackingNumberLabel: "Sendungsnummer",
trackPackage: "Sendung verfolgen",
historyHeading: "Sendungsverlauf",
historyAriaLabel: "Sendungsverlauf von DHL",
estimatedDelivery: (date) => `Voraussichtliche Zustellung: ${date}`,
notYetScanned:
"DHL hat das Paket noch nicht erfasst. Das kann nach dem Versand einige Stunden dauern – schau gern später noch einmal vorbei.",
historyUnavailable: "Der Sendungsverlauf lässt sich gerade nicht laden. Bitte versuche es später noch einmal.",
dataSource: "Daten von DHL",
statuses: {
"pre-transit": "Angekündigt",
transit: "Unterwegs",
delivered: "Zugestellt",
failure: "Zustellproblem",
unknown: "Status unbekannt",
},
},
errorPage: {
eyebrow: "Fehler",
+15 -1
View File
@@ -256,7 +256,7 @@ export const en: Dictionary = {
{
heading: "4. Order and Contract Processing",
paragraphs: [
"For shipping, we pass your name and delivery address to our shipping provider DHL (DHL Paket GmbH, Bonn, Germany). If you choose delivery to a Packstation or branch, the location number you selected is added. If you search for a Packstation during checkout, the town or postcode you enter is sent to DHL to perform that search. Legal basis: Art. 6(1)(b) GDPR.",
"For shipping, we pass your name and delivery address to our shipping provider DHL (DHL Paket GmbH, Bonn, Germany). If you choose delivery to a Packstation or branch, the location number you selected is added. If you search for a Packstation during checkout, the town or postcode you enter is sent to DHL to perform that search. When you check your shipment under “Track order”, we request its status from DHL using the tracking number and the delivery postcode and show it to you directly on our site. Legal basis: Art. 6(1)(b) GDPR.",
"When you place an order, we collect the data necessary for contract, payment, and shipping processing (name, address, email, phone number if provided, and the items ordered). The legal basis is Art. 6(1)(b) GDPR (contract performance).",
"Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy). Where this involves transferring data to Stripe-affiliated companies outside the EU/EEA (in particular Stripe, Inc. in the USA), this is done on the basis of EU Standard Contractual Clauses pursuant to Art. 46 GDPR.",
"Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).",
@@ -900,6 +900,20 @@ export const en: Dictionary = {
carrierLabel: "Carrier",
trackingNumberLabel: "Tracking number",
trackPackage: "Track package",
historyHeading: "Shipment history",
historyAriaLabel: "DHL shipment history",
estimatedDelivery: (date) => `Estimated delivery: ${date}`,
notYetScanned:
"DHL hasn't scanned the parcel yet. This can take a few hours after dispatch – please check back later.",
historyUnavailable: "The shipment history can't be loaded right now. Please try again later.",
dataSource: "Data provided by DHL",
statuses: {
"pre-transit": "Announced",
transit: "In transit",
delivered: "Delivered",
failure: "Delivery issue",
unknown: "Status unknown",
},
},
errorPage: {
eyebrow: "Error",
+13
View File
@@ -589,6 +589,19 @@ export interface Dictionary {
carrierLabel: string;
trackingNumberLabel: string;
trackPackage: string;
historyHeading: string;
historyAriaLabel: string;
estimatedDelivery: (date: string) => string;
notYetScanned: string;
historyUnavailable: string;
dataSource: string;
statuses: {
"pre-transit": string;
transit: string;
delivered: string;
failure: string;
unknown: string;
};
};
errorPage: {
eyebrow: string;
+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";
}