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

@@ -0,0 +1,27 @@
-- Elektronische Widerrufsfunktion (§ 356a BGB, Pflicht seit 19.06.2026)
-- und Nachweis der Newsletter-Einwilligung (Art. 7 Abs. 1 DSGVO).
-- CreateTable
CREATE TABLE "withdrawal_declarations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"email" TEXT NOT NULL,
"message" TEXT,
"locale" TEXT NOT NULL DEFAULT 'de',
"orderId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmationSentAt" TIMESTAMP(3),
CONSTRAINT "withdrawal_declarations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_receivedAt_idx" ON "withdrawal_declarations"("receivedAt");
-- AddForeignKey
ALTER TABLE "withdrawal_declarations" ADD CONSTRAINT "withdrawal_declarations_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
-- Nullable: fuer bestehende Abonnements ist der Zeitpunkt nicht bekannt.
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmedAt" TIMESTAMP(3);
@@ -0,0 +1,9 @@
-- Bearbeitungsstand der Widerrufserklaerungen: null = offen. Die Zahl offener
-- Widerrufe steht im Admin-Menue, damit die 14-Tage-Erstattungsfrist
-- (§ 357 Abs. 1 BGB) nicht uebersehen wird.
-- AlterTable
ALTER TABLE "withdrawal_declarations" ADD COLUMN "processedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_processedAt_idx" ON "withdrawal_declarations"("processedAt");
+32
View File
@@ -439,6 +439,7 @@ model Order {
pickup Pickup?
invoice Invoice?
returnRequest ReturnRequest?
withdrawalDeclarations WithdrawalDeclaration[]
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
@@ -649,6 +650,9 @@ model NewsletterSubscriber {
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique
subscribedAt DateTime @default(now())
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
confirmedAt DateTime?
unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@ -805,3 +809,31 @@ model BusinessInquiry {
@@map("business_inquiries")
}
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
/// automatische Zuordnung, wenn beides passt.
model WithdrawalDeclaration {
id String @id @default(cuid())
name String
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
orderNumber String
email String
message String?
locale String @default("de")
orderId String?
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
receivedAt DateTime @default(now())
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
confirmationSentAt DateTime?
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
processedAt DateTime?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([receivedAt])
@@index([processedAt])
@@map("withdrawal_declarations")
}