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:
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");
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user