feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory";
import { isLocale, defaultLocale } from "@/i18n/config";
import { requireAdmin, str, strOrNull } from "./_shared";
const ORDER_STATUSES = [
"NEU",
"BEZAHLT",
"IN_VORBEREITUNG",
"VERSANDBEREIT",
"VERSENDET",
"ABHOLBEREIT",
"ABGEHOLT",
"STORNIERT",
] as const;
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
if (!ORDER_STATUSES.includes(status)) return;
const result = await prisma.$transaction(async (tx) => {
const previous = await tx.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
email: true,
firstName: true,
locale: true,
items: { select: { productId: true, quantity: true } },
},
});
if (!previous) return null;
await tx.order.update({ where: { id: orderId }, data: { status } });
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
await restockOrderItems(tx, previous.items);
}
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
if (status === "VERSENDET") {
shipment = await tx.shipment.upsert({
where: { orderId },
create: { orderId, shippedAt: new Date() },
update: { shippedAt: new Date() },
select: { carrier: true, trackingNumber: true },
});
}
if (status === "ABHOLBEREIT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, readyAt: new Date() },
update: { readyAt: new Date() },
});
}
if (status === "ABGEHOLT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, pickedUpAt: new Date() },
update: { pickedUpAt: new Date() },
});
}
return { ...previous, shipment };
});
if (result) {
await logAdminAction(admin, {
action: "order.status_change",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${result.status} → ${status}`,
});
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
const locale = isLocale(result.locale) ? result.locale : defaultLocale;
if (status === "VERSENDET" && result.status !== "VERSENDET") {
const carrier = result.shipment?.carrier ?? null;
const trackingNumber = result.shipment?.trackingNumber ?? null;
const { subject, html, text } = await shipmentEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: result.email, subject, html, text });
}
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
});
await sendMail({ to: result.email, subject, html, text });
}
}
revalidatePath("/admin/bestellungen");
}
/**
* Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
* eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die
* Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung
* per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu
* stehen (die zu diesem Zeitpunkt schon verschickt war).
*/
export async function updateShipmentTrackingAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const carrier = strOrNull(formData, "carrier");
const trackingNumber = strOrNull(formData, "trackingNumber");
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
});
if (!order) return;
await prisma.shipment.upsert({
where: { orderId },
create: { orderId, carrier, trackingNumber },
update: { carrier, trackingNumber },
});
await logAdminAction(admin, {
action: "order.tracking_update",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
});
if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale,
orderNumber: order.orderNumber,
firstName: order.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: order.email, subject, html, text });
}
revalidatePath("/admin/bestellungen");
revalidatePath(`/admin/bestellungen/${orderId}`);
}