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,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { businessInquirySchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = businessInquirySchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { desiredDeliveryDate, ...rest } = parsed.data;
|
||||
|
||||
await prisma.businessInquiry.create({
|
||||
data: {
|
||||
...rest,
|
||||
desiredDeliveryDate: desiredDeliveryDate ? new Date(desiredDeliveryDate) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { checkoutSchema } from "@/lib/validation";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getShippingCostCents } from "@/lib/shipping";
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
|
||||
function generateOrderNumber(): string {
|
||||
const year = new Date().getFullYear();
|
||||
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und
|
||||
// praktisch kollisionsfrei (das @unique-Constraint faengt den Rest ab).
|
||||
const random = randomBytes(4).toString("hex").toUpperCase();
|
||||
return `WH-${year}-${random}`;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Bestellversuche. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = checkoutSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
if (input.fulfillmentMethod === "VERSAND") {
|
||||
if (!input.street || !input.zip || !input.city) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte gib eine vollständige Lieferadresse an." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = input.items.map((item) => item.productId);
|
||||
const products = await getProductsByIds(productIds);
|
||||
|
||||
if (products.length !== productIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const orderItemsData = input.items.map((cartItem) => {
|
||||
const product = products.find((p) => p.id === cartItem.productId)!;
|
||||
return {
|
||||
productId: product.id,
|
||||
batchId: product.batchId,
|
||||
productName: product.name,
|
||||
unitPriceCents: product.priceCents,
|
||||
quantity: cartItem.quantity,
|
||||
weightGrams: product.weightGrams,
|
||||
totalCents: product.priceCents * cartItem.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
|
||||
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
||||
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
|
||||
// einer Menge zusammengefasst, nicht separat berechnet) — kein
|
||||
// wertbasierter Freibetrag mehr.
|
||||
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const shippingCents =
|
||||
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
// haelt die Rabattlogik einfach nachvollziehbar.
|
||||
let discountCents = 0;
|
||||
let discountCode: { id: string; code: string } | null = null;
|
||||
if (input.discountCode) {
|
||||
const now = new Date();
|
||||
const code = await prisma.discountCode.findUnique({
|
||||
where: { code: input.discountCode.trim().toUpperCase() },
|
||||
});
|
||||
|
||||
if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// usedCount allein reicht bei einem Nutzungslimit nicht: er wird erst bei
|
||||
// bestaetigter Zahlung erhoeht (s.u.), zaehlt also nicht bereits laufende,
|
||||
// noch unbezahlte Checkouts mit demselben Code mit. Ohne diese Pruefung
|
||||
// koennten mehrere parallele Checkout-Versuche (z.B. mehrere Tabs) einen
|
||||
// maxUses:1-Code alle gleichzeitig als gueltig durchgehen und am Ende
|
||||
// mehrfach tatsaechlich bezahlt einloesen.
|
||||
if (code.maxUses !== null) {
|
||||
const pendingUses = await prisma.order.count({
|
||||
where: { discountCodeId: code.id, status: "NEU" },
|
||||
});
|
||||
if (code.usedCount + pendingUses >= code.maxUses) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCode = code;
|
||||
} else {
|
||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||
// gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden
|
||||
// waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig,
|
||||
// daher keine pendingUses-Pruefung wie beim manuellen Code oben.
|
||||
await ensureBulkDiscountCode();
|
||||
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||
const now = new Date();
|
||||
const code = candidates
|
||||
.filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity))
|
||||
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||
if (code) {
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCode = code;
|
||||
}
|
||||
}
|
||||
// usedCount wird bewusst erst im Webhook bei bestaetigter Zahlung erhoeht,
|
||||
// nicht hier — sonst wuerde ein abgebrochener Stripe-Checkout einen
|
||||
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
||||
|
||||
const totalCents = subtotalCents - discountCents + shippingCents;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
||||
// nur fuer registrierte, eingeloggte Kund:innen.
|
||||
if (customer && !customer.emailVerifiedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
let order;
|
||||
try {
|
||||
// Bestellanlage und Bestandsreservierung atomar: das bedingte
|
||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||
order = await prisma.$transaction(async (tx) => {
|
||||
for (const item of orderItemsData) {
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||
data: { quantity: { decrement: item.quantity } },
|
||||
});
|
||||
if (reserved.count === 0) {
|
||||
throw new OutOfStockError(item.productName);
|
||||
}
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerId: customer?.id,
|
||||
locale,
|
||||
fulfillmentMethod: input.fulfillmentMethod,
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone,
|
||||
street: input.street ?? "",
|
||||
zip: input.zip ?? "",
|
||||
city: input.city ?? "",
|
||||
country: input.country,
|
||||
subtotalCents,
|
||||
shippingCents,
|
||||
discountCents,
|
||||
discountCodeId: discountCode?.id,
|
||||
totalCents,
|
||||
giftMessage: input.giftMessage || null,
|
||||
items: {
|
||||
create: orderItemsData,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutOfStockError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `„${error.message}“ ist leider nicht mehr in der gewünschten Menge verfügbar. Bitte passe deinen Warenkorb an.`,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
|
||||
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde
|
||||
// sich sonst in success_url/cancel_url der Stripe-Session einschleusen —
|
||||
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
|
||||
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
|
||||
const origin = getBaseUrl();
|
||||
|
||||
// Stripe erlaubt keine negativen Preise in line_items — ein Rabatt wird
|
||||
// stattdessen als einmaliger Coupon auf die Session angewendet, damit
|
||||
// Produktpositionen weiterhin ihre echten Einzelpreise zeigen.
|
||||
let discounts: { coupon: string }[] | undefined;
|
||||
if (discountCents > 0) {
|
||||
const coupon = await stripe.coupons.create({
|
||||
amount_off: discountCents,
|
||||
currency: "eur",
|
||||
duration: "once",
|
||||
name: input.discountCode?.trim().toUpperCase() ?? discountCode?.code,
|
||||
});
|
||||
discounts = [{ coupon: coupon.id }];
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer_email: input.email,
|
||||
line_items: [
|
||||
...orderItemsData.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: item.unitPriceCents,
|
||||
product_data: { name: `${item.productName} (${item.weightGrams} g)` },
|
||||
},
|
||||
})),
|
||||
...(shippingCents > 0
|
||||
? [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: shippingCents,
|
||||
product_data: { name: "Versand" },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
...(discounts ? { discounts } : {}),
|
||||
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
|
||||
cancel_url: `${origin}/checkout/cancel`,
|
||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
|
||||
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
|
||||
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
|
||||
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
|
||||
// und dieser Endpunkt ist ohne Authentifizierung erreichbar.
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.",
|
||||
orderNumber: order.orderNumber,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
||||
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
||||
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed"
|
||||
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt
|
||||
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
|
||||
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
|
||||
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
|
||||
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
|
||||
* eine Kundschaft den Checkout einfach verlaesst, ohne zu bezahlen — ohne
|
||||
* diesen Handler bliebe der beim Checkout reservierte Lagerbestand fuer
|
||||
* diese Bestellung dauerhaft blockiert.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: "STRIPE_WEBHOOK_SECRET ist nicht konfiguriert." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
const rawBody = await request.text();
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature ?? "", webhookSecret);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Signaturprüfung fehlgeschlagen";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// Bei Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
|
||||
// SEPA-Lastschrift) ist payment_status hier noch "unpaid" — die
|
||||
// eigentliche Bestaetigung kommt dann als eigenes
|
||||
// checkout.session.async_payment_succeeded-Ereignis (siehe unten).
|
||||
// Ohne diese Pruefung wuerden wir die Bestellung faelschlich als
|
||||
// bezahlt markieren, obwohl das Geld noch gar nicht eingezogen ist.
|
||||
if (session.payment_status !== "unpaid") {
|
||||
await fulfillOrder(session);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.async_payment_succeeded") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await fulfillOrder(session);
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.async_payment_failed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.expired") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (!orderId) return;
|
||||
|
||||
// Transaktional + Status-Guard (Muster wie failOrder unten): verhindert,
|
||||
// dass zwei parallel zugestellte Webhook-Kopien denselben Gutschein
|
||||
// doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits
|
||||
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
||||
// Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird.
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "BEZAHLT" },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
|
||||
const updated = await tx.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
if (updated.discountCodeId) {
|
||||
await tx.discountCode.update({
|
||||
where: { id: updated.discountCodeId },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr
|
||||
// im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun.
|
||||
if (!order) return;
|
||||
|
||||
await prisma.payment.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
stripePaymentIntentId:
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||
status: "SUCCEEDED",
|
||||
amountCents: session.amount_total ?? order.totalCents,
|
||||
paidAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.invoice.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||
totalCents: order.totalCents,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
// E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern
|
||||
// lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein
|
||||
// Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen.
|
||||
const { subject, html, text } = await orderConfirmationEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
fulfillmentMethod: order.fulfillmentMethod,
|
||||
items: order.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
weightGrams: item.weightGrams,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
subtotalCents: order.subtotalCents,
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
totalCents: order.totalCents,
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
giftMessage: order.giftMessage,
|
||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
}
|
||||
|
||||
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (!orderId) 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 } },
|
||||
},
|
||||
});
|
||||
// Nur von "NEU" aus stornieren: verhindert doppelte Bestandsrueckbuchung,
|
||||
// falls Stripe denselben Webhook erneut zustellt, und laesst bereits
|
||||
// bezahlte/stornierte Bestellungen unangetastet.
|
||||
if (!previous || previous.status !== "NEU") return null;
|
||||
|
||||
await tx.order.update({ where: { id: orderId }, data: { status: "STORNIERT" } });
|
||||
|
||||
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
|
||||
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
|
||||
await restockOrderItems(tx, previous.items);
|
||||
|
||||
await tx.payment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, status: "FAILED", amountCents: session.amount_total ?? 0 },
|
||||
update: { status: "FAILED" },
|
||||
});
|
||||
|
||||
return previous;
|
||||
});
|
||||
|
||||
if (result) {
|
||||
const { subject, html, text } = await paymentFailedEmail({
|
||||
locale: isLocale(result.locale) ? result.locale : defaultLocale,
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function GET() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`data-export:${ip}:${customer.id}`, EXPORT_ATTEMPTS_PER_WINDOW, EXPORT_WINDOW_MS)) {
|
||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([
|
||||
prisma.address.findMany({ where: { customerId: customer.id } }),
|
||||
prisma.order.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { items: true, invoice: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.review.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { product: { select: { name: true } } },
|
||||
}),
|
||||
prisma.wishlistItem.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { product: { select: { name: true } } },
|
||||
}),
|
||||
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||
]);
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
account: {
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
phone: customer.phone,
|
||||
createdAt: customer.createdAt,
|
||||
twoFactorEnabled: customer.twoFactorEnabled,
|
||||
},
|
||||
addresses: addresses.map((a) => ({
|
||||
label: a.label,
|
||||
firstName: a.firstName,
|
||||
lastName: a.lastName,
|
||||
street: a.street,
|
||||
zip: a.zip,
|
||||
city: a.city,
|
||||
country: a.country,
|
||||
isDefault: a.isDefault,
|
||||
})),
|
||||
orders: orders.map((o) => ({
|
||||
orderNumber: o.orderNumber,
|
||||
createdAt: o.createdAt,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
fulfillmentMethod: o.fulfillmentMethod,
|
||||
invoiceNumber: o.invoice?.invoiceNumber ?? null,
|
||||
items: o.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
})),
|
||||
reviews: reviews.map((r) => ({
|
||||
product: r.product.name,
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
createdAt: r.createdAt,
|
||||
isApproved: r.isApproved,
|
||||
})),
|
||||
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
||||
newsletter: newsletter
|
||||
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||
: { subscribed: false, confirmed: false },
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="wabenhain-meine-daten-${customer.id.slice(-8)}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashVerificationToken } from "@/lib/email-verification";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
const fail = (message: string) =>
|
||||
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
|
||||
|
||||
if (!token) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||
where: { tokenHash: hashVerificationToken(token) },
|
||||
});
|
||||
|
||||
const isValid =
|
||||
verificationToken && verificationToken.usedAt === null && verificationToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig oder abgelaufen. Bitte fordere über /konto einen neuen an.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: verificationToken.customerId },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
}),
|
||||
prisma.emailVerificationToken.update({
|
||||
where: { id: verificationToken.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||
if (!subscriber) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: subscriber.id },
|
||||
data: { confirmed: true, unsubscribedAt: null },
|
||||
});
|
||||
await ensureNewsletterDiscountCode();
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=ok`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { newsletterSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { newsletterConfirmEmail } from "@/lib/email-templates";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = newsletterSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Ungültige E-Mail-Adresse." }, { status: 400 });
|
||||
}
|
||||
|
||||
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
if (existing?.confirmed) {
|
||||
// Bereits bestaetigter Abonnent meldet sich erneut an — keine neue Mail
|
||||
// noetig, aber auch keine Fehlermeldung (verraet sonst, dass die
|
||||
// E-Mail-Adresse bereits existiert).
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("hex");
|
||||
const subscriber = await prisma.newsletterSubscriber.upsert({
|
||||
where: { email: parsed.data.email },
|
||||
create: { email: parsed.data.email, confirmationToken: token },
|
||||
update: { confirmationToken: token, unsubscribedAt: null },
|
||||
});
|
||||
|
||||
const confirmUrl = `${getBaseUrl()}/api/newsletter/confirm?token=${subscriber.confirmationToken}`;
|
||||
const { subject, html, text } = await newsletterConfirmEmail(await getLocale(), confirmUrl);
|
||||
await sendMail({ to: parsed.data.email, subject, html, text });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||
if (!subscriber) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: subscriber.id },
|
||||
data: { unsubscribedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=unsubscribed`);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getActiveProducts } from "@/lib/data";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const SUGGESTION_LIMIT = 5;
|
||||
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
||||
const SUGGEST_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Liefert bis zu 5 passende Produkte fuer die Live-Suchvorschlaege im
|
||||
* Header — dieselbe Suchlogik wie die Shop-Uebersicht (getActiveProducts),
|
||||
* nur ohne vollen Seiten-Reload. Rein oeffentliche, bereits im Shop
|
||||
* sichtbare Produktdaten, keine Authentifizierung noetig, aber wie jeder
|
||||
* andere oeffentliche Endpunkt hier rate-limitiert (grosszuegig, da bei
|
||||
* jedem Tastenanschlag aufgerufen).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`search-suggest:${ip}`, SUGGEST_ATTEMPTS_PER_WINDOW, SUGGEST_WINDOW_MS)) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 429 });
|
||||
}
|
||||
|
||||
const query = new URL(request.url).searchParams.get("q")?.trim() ?? "";
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const products = await getActiveProducts({ query, onlyAvailable: true });
|
||||
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
price: formatPrice(product.priceCents, product.currency),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
Reference in new issue
Block a user