feat(legal): electronic withdrawal function and remaining compliance fixes

Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 11:14:02 +02:00
1 parent 4ab1a7edae
commit dcfad4e011
27 files changed
+1049 -34

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { withdrawalSchema } from "@/lib/validation";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { sendMail } from "@/lib/mail";
import { withdrawalReceiptEmail } from "@/lib/email-templates";
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal";
import { getLocale } from "@/i18n/locale";
const WITHDRAWALS_PER_WINDOW = 10;
const WINDOW_MS = 60 * 60 * 1000;
/**
* Elektronische Widerrufsfunktion (§ 356a BGB), zweite Stufe "Widerruf
* bestaetigen". Bewusst OHNE Turnstile: eine fehlschlagende Pruefung duerfte
* die Ausuebung eines gesetzlichen Rechts nicht verhindern. Missbrauch
* begrenzen Herkunftspruefung und Rate-Limit.
*
* Die Antwort ist fuer passende und unpassende Bestellnummern identisch —
* sonst liessen sich Bestellnummern samt E-Mail-Adresse durchprobieren. Die
* Erklaerung wird in jedem Fall gespeichert: sie ist mit dem Zugang wirksam
* abgegeben, die Zuordnung zur Bestellung ist unsere Aufgabe (Admin).
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp();
if (!rateLimit(`withdrawal:${ip}`, WITHDRAWALS_PER_WINDOW, WINDOW_MS)) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine E-Mail." },
{ status: 429 }
);
}
const body: unknown = await request.json().catch(() => null);
const parsed = withdrawalSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
const locale = await getLocale();
const order = await prisma.order.findUnique({
where: { orderNumber: input.orderNumber },
select: { id: true, email: true },
});
const orderId = order && order.email.trim().toLowerCase() === input.email ? order.id : null;
const declaration = await prisma.withdrawalDeclaration.create({
data: { ...input, locale, orderId },
});
const receipt = await withdrawalReceiptEmail(input, declaration.receivedAt, locale);
const confirmationSent = await sendMail({ to: input.email, ...receipt });
if (confirmationSent) {
await prisma.withdrawalDeclaration.update({
where: { id: declaration.id },
data: { confirmationSentAt: new Date() },
});
}
// Interne Benachrichtigung: ein Widerruf hat eine 14-Tage-Frist fuer die
// Erstattung und darf nicht bis zum naechsten Blick ins Admin liegen bleiben.
const adminEmail = process.env.ADMIN_EMAIL;
if (adminEmail) {
const noticeLines = [
...withdrawalDeclarationLines(input, "de"),
"",
`Eingegangen am: ${formatReceivedAt(declaration.receivedAt, "de")}`,
orderId ? "Automatisch der Bestellung zugeordnet." : "Keine passende Bestellung gefunden — bitte manuell prüfen.",
`Eingangsbestätigung an Kundschaft: ${confirmationSent ? "versendet" : "NICHT versendet"}`,
];
await sendMail({
to: adminEmail,
subject: `Neuer Widerruf zu ${input.orderNumber.replace(/[\r\n]+/g, " ")}`,
text: noticeLines.join("\n"),
html: `<p style="font-family:Arial,sans-serif;line-height:1.6;">${noticeLines.map(escapeHtml).join("<br>")}</p>`,
});
}
return NextResponse.json(
{
receivedAt: formatReceivedAt(declaration.receivedAt, locale),
lines: withdrawalDeclarationLines(input, locale),
confirmationSent,
},
{ headers: { "Cache-Control": "no-store" } }
);
}