Files
Wabenhain/src/app/admin/actions/dhl.ts
T
Nick SzaunigandClaude Opus 5.5 839bbff595 feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)
Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 04:36:36 +02:00

174 lines
7.2 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared";
import { setFlash } from "@/lib/flash";
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
// daher eindeutig von jeder realen Sendungsnummer unterscheidbar.
const DHL_CLAIM_SENTINEL = "__dhl_label_pending__";
/**
* Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine
* bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im
* Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine
* Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei
* DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer
* klaren Meldung ab, statt stillschweigend zu ueberschreiben.
*/
export async function createDhlLabelAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const detailPath = `/admin/bestellungen/${orderId}`;
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
items: { select: { weightGrams: true, quantity: true } },
payment: { select: { status: true } },
shipment: { select: { trackingNumber: true } },
},
});
if (!order) {
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
}
if (order.fulfillmentMethod !== "VERSAND") {
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
}
if (order.payment?.status !== "SUCCEEDED") {
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
}
if (order.shipment?.trackingNumber) {
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
}
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
// findUnique-Check allein ist nicht ausreichend — zwei schnell
// aufeinanderfolgende Klicks koennten sonst beide passieren und zwei
// echte, kostenpflichtige DHL-Sendungen anlegen (gleiche Race-Condition
// wie beim Retouren-Erstattungs-Fix). Ein Shipment-Datensatz kann bereits
// OHNE DHL existieren (z.B. automatisch beim Statuswechsel auf
// "VERSENDET", siehe admin/actions/orders.ts, oder manuell erfasst) —
// ein reines "create()" mit Unique-Constraint waere daher in diesen
// Faellen faelschlich blockiert. Serializable-Transaktion + Sentinel-Wert
// statt der echten Sendungsnummer loest beide Faelle korrekt atomar.
let claimed: boolean;
try {
claimed = await prisma.$transaction(
async (tx) => {
const existing = await tx.shipment.findUnique({ where: { orderId }, select: { trackingNumber: true } });
if (existing?.trackingNumber) return false;
await tx.shipment.upsert({
where: { orderId },
create: { orderId, carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
update: { carrier: "DHL", trackingNumber: DHL_CLAIM_SENTINEL },
});
return true;
},
{ isolationLevel: "Serializable" }
);
} catch {
claimed = false;
}
if (!claimed) {
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
}
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
const company = await getCompanyInfo();
let result: DhlCreateShipmentResult;
try {
result = await createDhlShipmentLabel({
reference: order.orderNumber,
weightGrams: totalWeightGrams,
shipper: {
companyName: company.name,
firstName: "",
lastName: "",
street: company.address.street,
zip: company.address.zip,
city: company.address.city,
// Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst
// fest verdrahtet statt aus dem lokalisierten Anzeigetext
// ("Deutschland") geparst.
country: "DE",
phone: company.phone,
email: company.email,
},
receiver: {
firstName: order.firstName,
lastName: order.lastName,
street: order.street,
zip: order.zip,
city: order.city,
// Bei gesetzter Packstation-Nummer baut lib/dhl.ts daraus einen
// Locker-Empfaenger statt einer Hausanschrift.
packstationNumber: order.packstationNumber,
postNumber: order.postNumber,
country: order.country,
phone: order.phone,
email: order.email,
},
});
} catch (error: unknown) {
// Claim zuruecksetzen, damit ein erneuter Versuch moeglich ist — nur
// wenn der Sentinel-Wert seitdem nicht durch etwas anderes (z.B. eine
// manuelle Tracking-Eingabe waehrend der laufenden Anfrage) ersetzt
// wurde, um dessen Aenderung nicht versehentlich zu ueberschreiben.
await prisma.shipment
.updateMany({ where: { orderId, trackingNumber: DHL_CLAIM_SENTINEL }, data: { trackingNumber: null } })
.catch(() => {});
if (error instanceof DhlNotConfiguredError) {
return fail(detailPath, error.message);
}
if (error instanceof DhlApiError) {
return fail(detailPath, error.message);
}
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
}
try {
await prisma.shipment.upsert({
where: { orderId },
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
});
} catch (error: unknown) {
// Die DHL-Marke wurde bereits kostenpflichtig erstellt — geht die
// Sendungsnummer hier verloren, ist sie ueber die DHL-API nicht erneut
// abrufbar. Deshalb laut loggen (fuer manuelle Nacherfassung ueber das
// Bestellungs-Detail, siehe admin/actions/orders.ts) statt sie
// stillschweigend verschwinden zu lassen. Der Sentinel-Claim bleibt
// bewusst unangetastet, damit kein zweiter — erneut kostenpflichtiger —
// Versuch ueber die UI ausgeloest werden kann.
console.error(
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
error
);
return fail(
detailPath,
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
);
}
await logAdminAction(admin, {
action: "order.dhl_label_created",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`,
});
revalidatePath("/admin/bestellungen");
revalidatePath(detailPath);
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
}