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

+11
View File
@@ -29,6 +29,17 @@ export const returnRequestSchema = z.object({
});
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
// Elektronische Widerrufsfunktion (§ 356a Abs. 2 BGB): Pflicht sind NUR Name,
// Vertragsidentifikation und E-Mail — jedes weitere Pflichtfeld waere eine
// unzulaessige Huerde. Die Nachricht bleibt daher optional.
export const withdrawalSchema = z.object({
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
orderNumber: z.string().trim().min(1, "Bitte gib deine Bestellnummer an.").max(40),
email: emailSchema,
message: z.string().trim().max(2000).optional().transform((value) => value || null),
});
export type WithdrawalInput = z.infer<typeof withdrawalSchema>;
export const businessInquirySchema = z.object({
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),