WS-3: Recht & Compliance – Rechts-Kern (DSGVO/Persönlichkeitsrecht/Melden + Queue)

Launch-pflichtiger Compliance-Slice: öffentliche Anfrage zu einer PM speist eine
manuelle Admin-Queue (keine KI).

- Migration legal_requests + Model + Enums (Type: dsgvo/personal_rights/report,
  Status: open/in_progress/resolved/rejected) + Factory.
- Öffentliches Formular /release/{slug}/rechtliches (LegalRequestController +
  web/legal-request.blade.php): typ-abhängiger Hinweistext (Alpine), E-Mail bei
  DSGVO/Persönlichkeitsrecht erforderlich, zwei versteckte Honeypot-Felder,
  Rate-Limit + Bremse "1 offene Anfrage pro PM/Typ". Regeltexte als Entwurf mit
  TODO für rechtliche Finalisierung markiert.
- Routen bewusst in eigener routes/legal.php (entkoppelt vom laufenden Web-Umbau),
  host-agnostisch via domains.php eingebunden.
- Admin-Bereich "Recht & Compliance": Sidebar-Nav mit Offen-Zähler, Volt-Queue
  index/show (in Bearbeitung/erledigt/abgelehnt/wieder öffnen + interne Notiz).
- Tests: je Typ, Honeypots (Dataset), Bremse, Admin-Queue + Status-Übergänge.
- Doku: Detailplan WS-3-Status + Deployment-Migrationsreihenfolge ergänzt.

Hinweis: Der "Melden"-/E&F-Button auf der PM-Detailseite (release-detail.blade.php)
wird mit dem separaten Web-Frontend-Commit verdrahtet; Ziel ist legal-request.create.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 14:20:05 +00:00
parent 2a622044f3
commit 95007da826
16 changed files with 1139 additions and 0 deletions

View file

@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Öffentliche Rechts-/Compliance-Anfragen (WS-3): DSGVO-Anonymisierung (E),
* Persönlichkeitsrecht (F) und Meldungen speisen dieselbe Admin-Queue. Keine KI.
*/
class LegalRequestController extends Controller
{
public function create(Request $request, string $slug): View
{
$release = $this->resolvePublishedRelease($slug);
$type = LegalRequestType::tryFrom((string) $request->query('type')) ?? LegalRequestType::Report;
return view('web.legal-request', [
'release' => $release,
'type' => $type,
'types' => LegalRequestType::cases(),
]);
}
public function store(Request $request, string $slug): RedirectResponse
{
$release = $this->resolvePublishedRelease($slug);
$validated = $request->validate([
'type' => ['required', Rule::enum(LegalRequestType::class)],
// E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei
// einer Meldung optional.
'requester_email' => [
Rule::requiredIf(fn () => in_array($request->input('type'), [
LegalRequestType::Dsgvo->value,
LegalRequestType::PersonalRights->value,
], true)),
'nullable', 'email', 'max:255',
],
'message' => ['required', 'string', 'min:10', 'max:5000'],
// Honeypots: müssen leer bleiben (versteckte Felder, nur für Bots).
'website' => ['nullable', 'size:0'],
'homepage' => ['nullable', 'size:0'],
]);
$type = LegalRequestType::from($validated['type']);
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// nichts speichern (keine Fehlermeldung, die einem Bot etwas verrät).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done($release);
}
$this->ensureNotRateLimited($release);
// Bremse: max. 1 offene Anfrage pro PM + Typ (nicht hart gesperrt andere
// Typen und spätere Anfragen nach Erledigung bleiben möglich).
$hasOpen = LegalRequest::query()
->where('press_release_id', $release->getKey())
->where('type', $type->value)
->open()
->exists();
if ($hasOpen) {
return back()->with('legal-status', __('Zu dieser Pressemitteilung liegt bereits eine offene Anfrage dieser Art vor. Wir bearbeiten sie schnellstmöglich.'));
}
RateLimiter::hit($this->throttleKey($release), 3600);
LegalRequest::query()->create([
'press_release_id' => $release->getKey(),
'portal' => $release->portal?->value,
'type' => $type->value,
'status' => LegalRequestStatus::Open->value,
'requester_email' => $validated['requester_email'] ?? null,
'message' => $validated['message'],
'requester_ip' => $request->ip(),
]);
return $this->done($release);
}
private function done(PressRelease $release): RedirectResponse
{
return redirect()
->route('release.detail', ['slug' => $release->slug])
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist eingegangen und wird von unserem Team geprüft.'));
}
private function resolvePublishedRelease(string $slug): PressRelease
{
$portal = str_contains(request()->getHost(), 'presseecho')
? Portal::Presseecho
: Portal::Businessportal24;
$release = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', [$portal->value, Portal::Both->value])
->where('status', PressReleaseStatus::Published)
->where('language', 'de')
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where('slug', $slug)
->first();
abort_if($release === null, 404);
return $release;
}
private function ensureNotRateLimited(PressRelease $release): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($release), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($release));
throw ValidationException::withMessages([
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
]);
}
private function throttleKey(PressRelease $release): string
{
return 'legal-request|'.request()->ip().'|'.$release->getKey();
}
}