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]>
174 lines
7.2 KiB
TypeScript
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}`);
|
|
}
|