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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user