Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 fb9c2353c6 feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit
die aktiven Listen nur zeigen, was noch zu tun ist:
- Bestellungen: versendet/abgeholt/storniert, keine offene Retoure,
  seit einem Monat nicht bearbeitet
- Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet
- Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert)

Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle
5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind.
Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand
archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen
landen im Audit-Log.

Neue Felder archivedAt/archivedManually samt Index an orders,
return_requests und products (Migration 20260925010000_archive_fields).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:05:10 +02:00
Nick SzaunigandClaude Opus 5.5 01eaca39dc 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]>
2026-09-25 03:27:58 +02:00
21 changed files with 1446 additions and 59 deletions

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 — # von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag —
# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und # der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und
# cached Ergebnisse 15 Minuten. # 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_KEY=
DHL_API_SECRET= DHL_API_SECRET=
DHL_GKP_USERNAME= DHL_GKP_USERNAME=
+3
View File
@@ -121,6 +121,9 @@ stringData:
DHL_GKP_USERNAME: "" DHL_GKP_USERNAME: ""
DHL_GKP_PASSWORD: "" DHL_GKP_PASSWORD: ""
DHL_ACCOUNT_NUMBER: "" 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 # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst. # Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
@@ -0,0 +1,31 @@
-- Archiv fuer Bestellungen, Retouren und Produkte (siehe lib/archive.ts).
--
-- archivedAt gesetzt = Eintrag erscheint im Admin-Tab "Archiv" statt in der
-- aktiven Liste. Die Automatik archiviert abgeschlossene Eintraege nach einem
-- Monat ohne Bearbeitung (Produkte: Bestand 0) und holt sie zurueck, sobald
-- sie wieder offen sind. archivedManually markiert Eintraege, die jemand
-- von Hand archiviert hat — die fasst die Automatik nicht an.
--
-- Nullable bzw. mit Default: bestehende Zeilen gelten als aktiv; der erste
-- Aufruf der jeweiligen Admin-Liste archiviert faellige Altbestaende.
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "return_requests" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "products" ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedManually" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "orders_archivedAt_idx" ON "orders"("archivedAt");
-- CreateIndex
CREATE INDEX "return_requests_archivedAt_idx" ON "return_requests"("archivedAt");
-- CreateIndex
CREATE INDEX "products_archivedAt_idx" ON "products"("archivedAt");
+26
View File
@@ -255,6 +255,13 @@ model Product {
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben. /// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]") images Json @default("[]")
isActive Boolean @default(true) isActive Boolean @default(true)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -266,6 +273,7 @@ model Product {
wishlistItems WishlistItem[] wishlistItems WishlistItem[]
stockNotifications StockNotification[] stockNotifications StockNotification[]
@@index([archivedAt])
@@map("products") @@map("products")
} }
@@ -412,6 +420,14 @@ model Order {
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail). /// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String? giftMessage String?
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -434,6 +450,8 @@ model Order {
@@index([createdAt]) @@index([createdAt])
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId). /// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
@@index([discountCodeId]) @@index([discountCodeId])
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
@@index([archivedAt])
@@map("orders") @@map("orders")
} }
@@ -451,11 +469,19 @@ model ReturnRequest {
orderId String @unique orderId String @unique
reason String reason String
status ReturnStatus @default(ANGEFRAGT) status ReturnStatus @default(ANGEFRAGT)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([archivedAt])
@@map("return_requests") @@map("return_requests")
} }
+1
View File
@@ -14,3 +14,4 @@ export * from "./actions/discount-codes";
export * from "./actions/content"; export * from "./actions/content";
export * from "./actions/apicore-sync"; export * from "./actions/apicore-sync";
export * from "./actions/dhl"; export * from "./actions/dhl";
export * from "./actions/archive";
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import type { ArchiveKind } from "@/lib/archive";
import { requireAdmin, str } from "./_shared";
const LIST_PATH: Record<ArchiveKind, string> = {
orders: "/admin/bestellungen",
returns: "/admin/retouren",
products: "/admin/produkte",
};
const ENTITY_TYPE: Record<ArchiveKind, string> = {
orders: "Order",
returns: "ReturnRequest",
products: "Product",
};
function isArchiveKind(value: string): value is ArchiveKind {
return value in LIST_PATH;
}
/**
* Archiviert einen Eintrag von Hand oder holt ihn zurueck.
*
* Archivieren setzt archivedManually, damit die Automatik den Eintrag nicht
* beim naechsten Seitenaufruf wieder in die aktive Liste holt.
* Wiederherstellen setzt die Markierung zurueck; da @updatedAt dabei die
* Bearbeitungszeit erneuert, bleibt der Eintrag einen weiteren Monat aktiv.
*/
export async function setArchivedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const kind = str(formData, "kind");
const id = str(formData, "id");
const archive = str(formData, "mode") === "archive";
if (!isArchiveKind(kind) || !id) return;
const data = archive
? { archivedAt: new Date(), archivedManually: true }
: { archivedAt: null, archivedManually: false };
// updateMany statt update: eine nicht (mehr) vorhandene ID ist dann kein
// Fehler, sondern schlicht count 0 — z.B. nach dem Loeschen in einem
// zweiten Tab.
const where = { id };
const result =
kind === "orders"
? await prisma.order.updateMany({ where, data })
: kind === "returns"
? await prisma.returnRequest.updateMany({ where, data })
: await prisma.product.updateMany({ where, data });
if (result.count === 0) return;
await logAdminAction(admin, {
action: archive ? "archive.manual_archive" : "archive.manual_restore",
entityType: ENTITY_TYPE[kind],
entityId: id,
});
revalidatePath(LIST_PATH[kind]);
}
+68 -20
View File
@@ -8,31 +8,43 @@ import type { OrderStatus } from "@/generated/prisma/client";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { q, status, error, message } = await props.searchParams; const { q, status, error, message, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined; const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const isArchive = isArchiveView(ansicht);
const orders = await prisma.order.findMany({ await ensureAutoArchived("orders");
where: {
...(statusFilter ? { status: statusFilter } : {}), const filterWhere = {
...(query ...(statusFilter ? { status: statusFilter } : {}),
? { ...(query
OR: [ ? {
{ orderNumber: insensitiveContains(query) }, OR: [
{ email: insensitiveContains(query) }, { orderNumber: insensitiveContains(query) },
{ lastName: insensitiveContains(query) }, { email: insensitiveContains(query) },
], { lastName: insensitiveContains(query) },
} ],
: {}), }
}, : {}),
orderBy: { createdAt: "desc" }, };
take: 100,
include: { items: true }, const [orders, activeCount, archivedCount] = await Promise.all([
}); prisma.order.findMany({
where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null },
orderBy: { createdAt: "desc" },
take: 100,
include: { items: true },
}),
prisma.order.count({ where: { ...filterWhere, archivedAt: null } }),
prisma.order.count({ where: { ...filterWhere, archivedAt: { not: null } } }),
]);
return ( return (
<div> <div>
@@ -48,7 +60,21 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> <p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null} ) : null}
<AdminArchiveTabs
basePath="/admin/bestellungen"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{ q: query, status: statusFilter ?? "" }}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null}
<label className="text-sm"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
<input <input
@@ -78,7 +104,10 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{t.adminOrdersPage.filterButton} {t.adminOrdersPage.filterButton}
</button> </button>
{query || statusFilter ? ( {query || statusFilter ? (
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline"> <Link
href={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
className="text-sm text-ink/65 underline"
>
{t.adminOrdersPage.resetFilter} {t.adminOrdersPage.resetFilter}
</Link> </Link>
) : null} ) : null}
@@ -95,6 +124,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<th className="p-4">{t.adminOrdersPage.colTotal}</th> <th className="p-4">{t.adminOrdersPage.colTotal}</th>
<th className="p-4">{t.adminOrdersPage.colStatus}</th> <th className="p-4">{t.adminOrdersPage.colStatus}</th>
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th> <th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
<th className="p-4" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
@@ -141,13 +171,31 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<td className="p-4"> <td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup} {order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
</td> </td>
<td className="p-4 whitespace-nowrap">
<AdminArchiveToggle
kind="orders"
id={order.id}
archivedAt={order.archivedAt}
archivedManually={order.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="p-6 text-ink/65"> <p className="p-6 text-ink/65">
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet} {query || statusFilter
? t.adminOrdersPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: t.adminOrdersPage.noOrdersYet}
</p> </p>
) : null} ) : null}
</div> </div>
+52 -11
View File
@@ -7,20 +7,32 @@ import { deleteProductAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) { export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const products = await prisma.product.findMany({ const isArchive = isArchiveView(searchParams.ansicht);
include: {
harvest: true, await ensureAutoArchived("products");
batch: true,
inventory: true, const [products, activeCount, archivedCount] = await Promise.all([
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten. prisma.product.findMany({
_count: { select: { orderItems: true } }, where: { archivedAt: isArchive ? { not: null } : null },
}, include: {
orderBy: { createdAt: "desc" }, harvest: true,
}); batch: true,
inventory: true,
// Entscheidet, ob geloescht werden darf — siehe Loeschknopf unten.
_count: { select: { orderItems: true } },
},
orderBy: { createdAt: "desc" },
}),
prisma.product.count({ where: { archivedAt: null } }),
prisma.product.count({ where: { archivedAt: { not: null } } }),
]);
return ( return (
<div> <div>
@@ -34,6 +46,19 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<ApiCoreSyncResult searchParams={searchParams} /> <ApiCoreSyncResult searchParams={searchParams} />
<AdminArchiveTabs
basePath="/admin/produkte"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{}}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleProducts}</p> : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -75,6 +100,20 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4"> <Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4">
{t.adminProductsPage.edit} {t.adminProductsPage.edit}
</Link> </Link>
<span className="mr-4">
<AdminArchiveToggle
kind="products"
id={product.id}
archivedAt={product.archivedAt}
archivedManually={product.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</span>
{/* Loeschen nur, solange nie bestellt: OrderItem verweist mit {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit
onDelete: Restrict auf das Produkt, damit gestellte onDelete: Restrict auf das Produkt, damit gestellte
Rechnungen ihren Bezug behalten (siehe Rechnungen ihren Bezug behalten (siehe
@@ -100,7 +139,9 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
</tbody> </tbody>
</table> </table>
{products.length === 0 ? ( {products.length === 0 ? (
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p> <p className="p-6 text-ink/65">
{isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet}
</p>
) : null} ) : null}
</div> </div>
+72 -23
View File
@@ -6,6 +6,9 @@ import { insensitiveContains } from "@/lib/prisma-search";
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions"; import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) { export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -15,35 +18,58 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
ABGELEHNT: t.adminReturnsPage.statusRejected, ABGELEHNT: t.adminReturnsPage.statusRejected,
ERSTATTET: t.adminReturnsPage.statusRefunded, ERSTATTET: t.adminReturnsPage.statusRefunded,
}; };
const { q } = await props.searchParams; const { q, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : ""; const query = typeof q === "string" ? q.trim() : "";
const isArchive = isArchiveView(ansicht);
const returnRequests = await prisma.returnRequest.findMany({ await ensureAutoArchived("returns");
where: query
? { const filterWhere = query
order: { ? {
OR: [ order: {
{ orderNumber: insensitiveContains(query) }, OR: [
{ firstName: insensitiveContains(query) }, { orderNumber: insensitiveContains(query) },
{ lastName: insensitiveContains(query) }, { firstName: insensitiveContains(query) },
{ email: insensitiveContains(query) }, { lastName: insensitiveContains(query) },
], { email: insensitiveContains(query) },
}, ],
} },
: undefined, }
orderBy: { createdAt: "desc" }, : {};
take: 100,
include: { const [returnRequests, activeCount, archivedCount] = await Promise.all([
order: { include: { items: true } }, prisma.returnRequest.findMany({
}, where: { ...filterWhere, archivedAt: isArchive ? { not: null } : null },
}); orderBy: { createdAt: "desc" },
take: 100,
include: {
order: { include: { items: true } },
},
}),
prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: null } }),
prisma.returnRequest.count({ where: { ...filterWhere, archivedAt: { not: null } } }),
]);
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1>
<AdminArchiveTabs
basePath="/admin/retouren"
isArchive={isArchive}
counts={{ active: activeCount, archived: archivedCount }}
keepParams={{ q: query }}
labels={{
ariaLabel: t.adminArchive.tabsAriaLabel,
active: t.adminArchive.tabActive,
archive: t.adminArchive.tabArchive,
}}
/>
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReturns}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null}
<label className="text-sm"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
<input <input
@@ -58,7 +84,10 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
{t.adminReturnsPage.filterButton} {t.adminReturnsPage.filterButton}
</button> </button>
{query ? ( {query ? (
<Link href="/admin/retouren" className="text-sm text-ink/65 underline"> <Link
href={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
className="text-sm text-ink/65 underline"
>
{t.adminReturnsPage.resetFilter} {t.adminReturnsPage.resetFilter}
</Link> </Link>
) : null} ) : null}
@@ -75,7 +104,23 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p> <p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
</div> </div>
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p> <div className="text-right">
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p>
<div className="mt-2">
<AdminArchiveToggle
kind="returns"
id={request.id}
archivedAt={request.archivedAt}
archivedManually={request.archivedManually}
labels={{
archive: t.adminArchive.archive,
restore: t.adminArchive.restore,
archivedOn: t.adminArchive.archivedOn,
manualHint: t.adminArchive.manualHint,
}}
/>
</div>
</div>
</div> </div>
<ul className="mt-4 text-sm text-ink/70 space-y-1"> <ul className="mt-4 text-sm text-ink/70 space-y-1">
@@ -119,7 +164,11 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
))} ))}
{returnRequests.length === 0 ? ( {returnRequests.length === 0 ? (
<p className="text-ink/65"> <p className="text-ink/65">
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet} {query
? t.adminReturnsPage.noResultsFiltered
: isArchive
? t.adminArchive.emptyArchive
: t.adminReturnsPage.noRequestsYet}
</p> </p>
) : null} ) : null}
</div> </div>
+29 -1
View File
@@ -4,7 +4,9 @@ import { Container, SectionHeading, Button } from "@/components/ui";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui"; import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { getOrderForTracking } from "@/lib/account-data"; import { getOrderForTracking } from "@/lib/account-data";
import { formatPrice, formatDate } from "@/lib/format"; 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 { rateLimit, getClientIp } from "@/lib/rate-limit";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 ( return (
<div className="py-20"> <div className="py-20">
<Container className="max-w-xl"> <Container className="max-w-xl">
@@ -150,6 +164,20 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
</p> </p>
) : null} ) : 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"> <div className="mt-5 flex items-center justify-between gap-4">
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p> <p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
{order.invoice ? ( {order.invoice ? (
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { setArchivedAction } from "@/app/admin/actions";
import { ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE, type ArchiveKind } from "@/lib/archive";
import { formatDate } from "@/lib/format";
/**
* Tabs "Aktiv" / "Archiv" fuer die Admin-Listen Bestellungen, Retouren und
* Produkte. Reine Links statt Client-State: der Tab steht in der URL
* (?ansicht=archiv), bleibt also beim Neuladen, nach einer Server Action und
* beim Teilen eines Links erhalten. Uebrige Filter (Suche, Status) bleiben
* beim Wechsel stehen.
*/
export function AdminArchiveTabs({
basePath,
isArchive,
counts,
keepParams,
labels,
}: {
basePath: string;
isArchive: boolean;
counts: { active: number; archived: number };
keepParams: Record<string, string>;
labels: { ariaLabel: string; active: string; archive: string };
}) {
const hrefFor = (archive: boolean): string => {
const params = new URLSearchParams(Object.entries(keepParams).filter(([, value]) => value !== ""));
if (archive) params.set(ARCHIVE_VIEW_PARAM, ARCHIVE_VIEW_VALUE);
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
};
const tabClass = (selected: boolean): string =>
`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
selected ? "border-forest text-forest" : "border-transparent text-ink/60 hover:text-forest"
}`;
return (
<nav aria-label={labels.ariaLabel} className="flex gap-1 border-b border-ink/10 mb-6">
<Link href={hrefFor(false)} aria-current={!isArchive ? "page" : undefined} className={tabClass(!isArchive)}>
{labels.active} <span className="text-xs text-ink/50">({counts.active})</span>
</Link>
<Link href={hrefFor(true)} aria-current={isArchive ? "page" : undefined} className={tabClass(isArchive)}>
{labels.archive} <span className="text-xs text-ink/50">({counts.archived})</span>
</Link>
</nav>
);
}
/** Knopf "Archivieren" bzw. "Wiederherstellen" samt Archivdatum. */
export function AdminArchiveToggle({
kind,
id,
archivedAt,
archivedManually,
labels,
}: {
kind: ArchiveKind;
id: string;
archivedAt: Date | null;
archivedManually: boolean;
labels: { archive: string; restore: string; archivedOn: (date: string) => string; manualHint: string };
}) {
return (
<form action={setArchivedAction} className="inline">
<input type="hidden" name="kind" value={kind} />
<input type="hidden" name="id" value={id} />
<input type="hidden" name="mode" value={archivedAt ? "restore" : "archive"} />
{archivedAt ? (
<span className="block text-xs text-ink/55 mb-1">
{labels.archivedOn(formatDate(archivedAt))}
{archivedManually ? ` · ${labels.manualHint}` : null}
</span>
) : null}
<button type="submit" className="text-xs text-ink/65 underline hover:text-forest">
{archivedAt ? labels.restore : labels.archive}
</button>
</form>
);
}
@@ -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>
);
}
+31 -1
View File
@@ -259,7 +259,7 @@ export const de: Dictionary = {
{ {
heading: "4. Bestellung und Vertragsabwicklung", heading: "4. Bestellung und Vertragsabwicklung",
paragraphs: [ 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).", "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.", "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.", "Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.",
@@ -912,6 +912,36 @@ export const de: Dictionary = {
carrierLabel: "Versanddienstleister", carrierLabel: "Versanddienstleister",
trackingNumberLabel: "Sendungsnummer", trackingNumberLabel: "Sendungsnummer",
trackPackage: "Sendung verfolgen", 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",
},
},
adminArchive: {
tabsAriaLabel: "Ansicht",
tabActive: "Aktiv",
tabArchive: "Archiv",
archive: "Archivieren",
restore: "Wiederherstellen",
archivedOn: (date) => `Archiviert am ${date}`,
manualHint: "von Hand",
autoRuleOrders:
"Versendete, abgeholte und stornierte Bestellungen ohne offene Retoure landen nach einem Monat ohne Bearbeitung automatisch hier.",
autoRuleReturns:
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
autoRuleProducts:
"Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.",
emptyArchive: "Das Archiv ist leer.",
}, },
errorPage: { errorPage: {
eyebrow: "Fehler", eyebrow: "Fehler",
+30 -1
View File
@@ -256,7 +256,7 @@ export const en: Dictionary = {
{ {
heading: "4. Order and Contract Processing", heading: "4. Order and Contract Processing",
paragraphs: [ 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).", "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.", "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).", "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,35 @@ export const en: Dictionary = {
carrierLabel: "Carrier", carrierLabel: "Carrier",
trackingNumberLabel: "Tracking number", trackingNumberLabel: "Tracking number",
trackPackage: "Track package", 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",
},
},
adminArchive: {
tabsAriaLabel: "View",
tabActive: "Active",
tabArchive: "Archive",
archive: "Archive",
restore: "Restore",
archivedOn: (date) => `Archived on ${date}`,
manualHint: "manually",
autoRuleOrders:
"Shipped, picked-up and cancelled orders without an open return move here automatically after one month without changes.",
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
autoRuleProducts:
"Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.",
emptyArchive: "The archive is empty.",
}, },
errorPage: { errorPage: {
eyebrow: "Error", eyebrow: "Error",
+26
View File
@@ -589,6 +589,32 @@ export interface Dictionary {
carrierLabel: string; carrierLabel: string;
trackingNumberLabel: string; trackingNumberLabel: string;
trackPackage: 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;
};
};
adminArchive: {
tabsAriaLabel: string;
tabActive: string;
tabArchive: string;
archive: string;
restore: string;
archivedOn: (date: string) => string;
manualHint: string;
autoRuleOrders: string;
autoRuleReturns: string;
autoRuleProducts: string;
emptyArchive: string;
}; };
errorPage: { errorPage: {
eyebrow: string; eyebrow: string;
+50
View File
@@ -0,0 +1,50 @@
/**
* Fuehrt die Archiv-Automatik (lib/archive.ts) beim Oeffnen einer Admin-Liste
* aus. Bewusst KEINE Server Action ("use server" fehlt absichtlich): sonst
* waere das ein eigener, von aussen aufrufbarer POST-Endpunkt.
*/
import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth";
import { logAdminAction } from "@/lib/audit-log";
import { runAutoArchive, type ArchiveKind } from "@/lib/archive";
/**
* Die Regeln haengen an "ein Monat" — minutengenau muss das nicht sein. Die
* Sperre haelt die zwei UPDATEs aus jedem einzelnen Seitenaufruf heraus
* (Filtern, Blaettern, Zurueck nach einer Aktion).
*/
const MIN_INTERVAL_MS = 5 * 60 * 1000;
const lastRun = new Map<ArchiveKind, number>();
const ENTITY_TYPE: Record<ArchiveKind, string> = {
orders: "Order",
returns: "ReturnRequest",
products: "Product",
};
export async function ensureAutoArchived(kind: ArchiveKind): Promise<void> {
const now = Date.now();
if (now - (lastRun.get(kind) ?? 0) < MIN_INTERVAL_MS) return;
// Das Admin-Layout prueft bereits; hier zusaetzlich, weil diese Funktion
// schreibt und nie ohne angemeldeten Admin laufen soll.
const admin = await getCurrentAdmin();
if (!admin) return;
lastRun.set(kind, now);
try {
const result = await runAutoArchive(prisma, kind, new Date(now));
if (result.archived > 0 || result.restored > 0) {
await logAdminAction(admin, {
action: "archive.auto",
entityType: ENTITY_TYPE[kind],
detail: `automatisch: ${result.archived} archiviert, ${result.restored} wiederhergestellt`,
});
}
} catch (error: unknown) {
// Die Liste soll trotzdem erscheinen; beim naechsten Aufruf nach Ablauf
// der Sperre wird es erneut versucht.
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[archive] Automatik fuer "${kind}" fehlgeschlagen: ${message}`);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import {
ARCHIVE_AFTER_DAYS,
archiveCutoff,
orderArchiveWhere,
orderUnarchiveWhere,
productArchiveWhere,
productUnarchiveWhere,
returnArchiveWhere,
returnUnarchiveWhere,
runAutoArchive,
type ArchiveDb,
} from "./archive";
const NOW = new Date("2026-09-25T12:00:00Z");
function fakeDb(counts: { archived: number; restored: number }) {
const model = () => ({
updateMany: vi
.fn()
.mockResolvedValueOnce({ count: counts.archived })
.mockResolvedValueOnce({ count: counts.restored }),
});
return { order: model(), returnRequest: model(), product: model() };
}
describe("archiveCutoff", () => {
it("liegt genau einen Monat (30 Tage) vor jetzt", () => {
expect(ARCHIVE_AFTER_DAYS).toBe(30);
expect(archiveCutoff(NOW).toISOString()).toBe("2026-08-26T12:00:00.000Z");
});
});
describe("orderArchiveWhere", () => {
const where = orderArchiveWhere(archiveCutoff(NOW));
it("archiviert nur versendete, abgeholte und stornierte Bestellungen", () => {
expect(where.status).toEqual({ in: ["VERSENDET", "ABGEHOLT", "STORNIERT"] });
});
it("verlangt, dass die letzte Bearbeitung mehr als einen Monat zurueckliegt", () => {
expect(where.updatedAt).toEqual({ lte: archiveCutoff(NOW) });
expect(where.archivedAt).toBeNull();
});
it("laesst Bestellungen mit offener oder frisch bearbeiteter Retoure aktiv", () => {
expect(where.OR).toEqual([
{ returnRequest: { is: null } },
{
returnRequest: {
is: { status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] }, updatedAt: { lte: archiveCutoff(NOW) } },
},
},
]);
});
});
describe("orderUnarchiveWhere", () => {
it("holt nur automatisch archivierte Bestellungen zurueck, wenn Status oder Retoure wieder offen sind", () => {
expect(orderUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
OR: [
{ status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
],
});
});
});
describe("returnArchiveWhere / returnUnarchiveWhere", () => {
it("archiviert angenommene, abgelehnte und erstattete Retouren nach einem Monat ohne Bearbeitung", () => {
expect(returnArchiveWhere(archiveCutoff(NOW))).toEqual({
archivedAt: null,
status: { in: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
updatedAt: { lte: archiveCutoff(NOW) },
});
});
it("holt eine automatisch archivierte Retoure zurueck, sobald sie wieder offen ist", () => {
expect(returnUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
status: { notIn: ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] },
});
});
});
describe("productArchiveWhere / productUnarchiveWhere", () => {
it("archiviert Produkte, die seit ueber einem Monat Bestand 0 haben und nicht bearbeitet wurden", () => {
const cutoff = archiveCutoff(NOW);
expect(productArchiveWhere(cutoff)).toEqual({
archivedAt: null,
updatedAt: { lte: cutoff },
OR: [{ inventory: { is: null } }, { inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } }],
});
});
it("holt ein automatisch archiviertes Produkt zurueck, sobald wieder Bestand da ist", () => {
expect(productUnarchiveWhere()).toEqual({
archivedAt: { not: null },
archivedManually: false,
inventory: { is: { quantity: { gt: 0 } } },
});
});
});
describe("runAutoArchive", () => {
it("archiviert faellige Eintraege und holt wieder offene zurueck, jeweils nur fuer die angefragte Liste", async () => {
const db = fakeDb({ archived: 3, restored: 1 });
const result = await runAutoArchive(db as unknown as ArchiveDb, "orders", NOW);
expect(result).toEqual({ archived: 3, restored: 1 });
expect(db.order.updateMany).toHaveBeenNthCalledWith(1, {
where: orderArchiveWhere(archiveCutoff(NOW)),
data: { archivedAt: NOW, archivedManually: false },
});
expect(db.order.updateMany).toHaveBeenNthCalledWith(2, {
where: orderUnarchiveWhere(),
data: { archivedAt: null },
});
expect(db.product.updateMany).not.toHaveBeenCalled();
expect(db.returnRequest.updateMany).not.toHaveBeenCalled();
});
it("nutzt fuer Retouren und Produkte die jeweils eigenen Regeln", async () => {
const db = fakeDb({ archived: 0, restored: 0 });
await runAutoArchive(db as unknown as ArchiveDb, "returns", NOW);
await runAutoArchive(db as unknown as ArchiveDb, "products", NOW);
expect(db.returnRequest.updateMany.mock.calls[0][0].where).toEqual(returnArchiveWhere(archiveCutoff(NOW)));
expect(db.product.updateMany.mock.calls[0][0].where).toEqual(productArchiveWhere(archiveCutoff(NOW)));
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Archiv fuer Bestellungen, Retouren und Produkte im Admin-Bereich.
*
* Abgeschlossene Eintraege wandern nach einem Monat ohne Bearbeitung in den
* Tab "Archiv", damit die aktiven Listen nur zeigen, was noch zu tun ist:
* - Bestellungen: Status VERSENDET/ABGEHOLT/STORNIERT und keine offene Retoure
* - Retouren: Status ANGENOMMEN/ABGELEHNT/ERSTATTET
* - Produkte: Bestand 0 — Inventory.updatedAt ist dabei der Zeitpunkt der
* letzten Bestandsaenderung, also der Moment, seit dem der Bestand 0 ist.
* Nur Admin-Sicht: der Shop zeigt ein archiviertes Produkt unveraendert.
*
* Die Automatik laeuft beim Oeffnen der jeweiligen Admin-Liste (kein
* CronJob) und holt automatisch archivierte Eintraege zurueck, sobald sie
* wieder offen sind (Status geaendert, neue Retoure, Bestand aufgefuellt).
* Von Hand archivierte Eintraege (archivedManually) fasst sie nicht an.
* "Wiederherstellen" per Hand setzt ueber @updatedAt die Bearbeitungszeit neu
* — der Eintrag bleibt dadurch einen weiteren Monat aktiv, statt beim
* naechsten Seitenaufruf sofort wieder zu verschwinden.
*
* Die Datenbank wird hereingereicht statt importiert, damit sich die Regeln
* ohne DATABASE_URL testen lassen (siehe archive.test.ts).
*/
import type { Prisma } from "@/generated/prisma/client";
export const ARCHIVE_AFTER_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
export const ORDER_DONE_STATUSES = ["VERSENDET", "ABGEHOLT", "STORNIERT"] as const;
export const RETURN_DONE_STATUSES = ["ANGENOMMEN", "ABGELEHNT", "ERSTATTET"] as const;
export type ArchiveKind = "orders" | "returns" | "products";
/** URL-Parameter fuer den Archiv-Tab, z.B. /admin/bestellungen?ansicht=archiv */
export const ARCHIVE_VIEW_PARAM = "ansicht";
export const ARCHIVE_VIEW_VALUE = "archiv";
export function isArchiveView(value: unknown): boolean {
return value === ARCHIVE_VIEW_VALUE;
}
export function archiveCutoff(now: Date): Date {
return new Date(now.getTime() - ARCHIVE_AFTER_DAYS * DAY_MS);
}
export function orderArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
status: { in: [...ORDER_DONE_STATUSES] },
updatedAt: { lte: cutoff },
// Eine Retoure haengt in eigener Tabelle — ihre Bearbeitung aendert
// Order.updatedAt nicht und muss deshalb eigens abgewartet werden.
OR: [
{ returnRequest: { is: null } },
{ returnRequest: { is: { status: { in: [...RETURN_DONE_STATUSES] }, updatedAt: { lte: cutoff } } } },
],
} satisfies Prisma.OrderWhereInput;
}
export function orderUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
OR: [
{ status: { notIn: [...ORDER_DONE_STATUSES] } },
{ returnRequest: { is: { status: "ANGEFRAGT" } } },
],
} satisfies Prisma.OrderWhereInput;
}
export function returnArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
status: { in: [...RETURN_DONE_STATUSES] },
updatedAt: { lte: cutoff },
} satisfies Prisma.ReturnRequestWhereInput;
}
export function returnUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
status: { notIn: [...RETURN_DONE_STATUSES] },
} satisfies Prisma.ReturnRequestWhereInput;
}
export function productArchiveWhere(cutoff: Date) {
return {
archivedAt: null,
updatedAt: { lte: cutoff },
OR: [
// Ohne Inventory-Zeile gilt der Bestand als 0 (so zeigt ihn auch die Liste).
{ inventory: { is: null } },
{ inventory: { is: { quantity: { lte: 0 }, updatedAt: { lte: cutoff } } } },
],
} satisfies Prisma.ProductWhereInput;
}
export function productUnarchiveWhere() {
return {
archivedAt: { not: null },
archivedManually: false,
inventory: { is: { quantity: { gt: 0 } } },
} satisfies Prisma.ProductWhereInput;
}
interface UpdateManyDelegate<Where> {
updateMany(args: { where: Where; data: { archivedAt: Date | null; archivedManually?: boolean } }): Promise<{
count: number;
}>;
}
/** Der Ausschnitt des Prisma-Clients, den die Automatik braucht. */
export interface ArchiveDb {
order: UpdateManyDelegate<Prisma.OrderWhereInput>;
returnRequest: UpdateManyDelegate<Prisma.ReturnRequestWhereInput>;
product: UpdateManyDelegate<Prisma.ProductWhereInput>;
}
export interface AutoArchiveResult {
archived: number;
restored: number;
}
export async function runAutoArchive(db: ArchiveDb, kind: ArchiveKind, now: Date = new Date()): Promise<AutoArchiveResult> {
const cutoff = archiveCutoff(now);
const archiveData = { archivedAt: now, archivedManually: false };
const restoreData = { archivedAt: null };
// Jeweils ein einzelnes UPDATE ... WHERE: die Bedingung wird atomar gegen
// den aktuellen Zeilenstand geprueft, ein gleichzeitiger Statuswechsel kann
// also keinen gerade wieder offenen Eintrag ins Archiv schieben.
switch (kind) {
case "orders": {
const archived = await db.order.updateMany({ where: orderArchiveWhere(cutoff), data: archiveData });
const restored = await db.order.updateMany({ where: orderUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
case "returns": {
const archived = await db.returnRequest.updateMany({ where: returnArchiveWhere(cutoff), data: archiveData });
const restored = await db.returnRequest.updateMany({ where: returnUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
case "products": {
const archived = await db.product.updateMany({ where: productArchiveWhere(cutoff), data: archiveData });
const restored = await db.product.updateMany({ where: productUnarchiveWhere(), data: restoreData });
return { archived: archived.count, restored: restored.count };
}
}
}
+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 { export function getCarrierTrackingUrl(carrier: string | null, trackingNumber: string | null): string | null {
if (!carrier || !trackingNumber) return null; if (!carrier || !trackingNumber) return null;
const normalized = carrier.trim().toLowerCase(); if (isDhlCarrier(carrier)) {
if (normalized === "dhl") {
return `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(trackingNumber)}`; return `https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=${encodeURIComponent(trackingNumber)}`;
} }
return null; return null;
} }
/** Der Versanddienstleister wird im Admin-Bereich als Freitext gepflegt. */
export function isDhlCarrier(carrier: string | null): boolean {
return carrier?.trim().toLowerCase() === "dhl";
}