diff --git a/app/Enums/LegalRequestStatus.php b/app/Enums/LegalRequestStatus.php new file mode 100644 index 0000000..4df5f7b --- /dev/null +++ b/app/Enums/LegalRequestStatus.php @@ -0,0 +1,30 @@ + 'Offen', + self::InProgress => 'In Bearbeitung', + self::Resolved => 'Erledigt', + self::Rejected => 'Abgelehnt', + }; + } + + /** + * Offene Anfragen, die in der Queue Aufmerksamkeit brauchen (für die Bremse + * „1 offene Anfrage pro PM/Typ" und den Nav-Zähler). + */ + public function isOpen(): bool + { + return $this === self::Open || $this === self::InProgress; + } +} diff --git a/app/Enums/LegalRequestType.php b/app/Enums/LegalRequestType.php new file mode 100644 index 0000000..0158a9e --- /dev/null +++ b/app/Enums/LegalRequestType.php @@ -0,0 +1,34 @@ + 'DSGVO-Anonymisierung', + self::PersonalRights => 'Persönlichkeitsrecht', + self::Report => 'Meldung', + }; + } + + /** + * Öffentliche Bezeichnung im Bürger-Formular. + */ + public function publicLabel(): string + { + return match ($this) { + self::Dsgvo => 'Löschung/Anonymisierung meiner Daten (DSGVO)', + self::PersonalRights => 'Verletzung von Persönlichkeitsrechten', + self::Report => 'Pressemitteilung melden', + }; + } +} diff --git a/app/Http/Controllers/LegalRequestController.php b/app/Http/Controllers/LegalRequestController.php new file mode 100644 index 0000000..77bb9e6 --- /dev/null +++ b/app/Http/Controllers/LegalRequestController.php @@ -0,0 +1,143 @@ +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(); + } +} diff --git a/app/Models/LegalRequest.php b/app/Models/LegalRequest.php new file mode 100644 index 0000000..b739dd6 --- /dev/null +++ b/app/Models/LegalRequest.php @@ -0,0 +1,68 @@ + */ + use HasFactory; + + protected $fillable = [ + 'press_release_id', + 'portal', + 'type', + 'status', + 'requester_email', + 'message', + 'payload', + 'requester_ip', + 'resolved_by_user_id', + 'admin_note', + 'resolved_at', + ]; + + protected function casts(): array + { + return [ + 'type' => LegalRequestType::class, + 'status' => LegalRequestStatus::class, + 'payload' => 'array', + 'resolved_at' => 'datetime', + ]; + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + public function resolver(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by_user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeOpen(Builder $query): Builder + { + return $query->whereIn('status', [ + LegalRequestStatus::Open->value, + LegalRequestStatus::InProgress->value, + ]); + } +} diff --git a/database/factories/LegalRequestFactory.php b/database/factories/LegalRequestFactory.php new file mode 100644 index 0000000..35af88c --- /dev/null +++ b/database/factories/LegalRequestFactory.php @@ -0,0 +1,45 @@ + + */ +class LegalRequestFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'press_release_id' => PressRelease::factory(), + 'portal' => 'presseecho', + 'type' => fake()->randomElement(LegalRequestType::cases()), + 'status' => LegalRequestStatus::Open, + 'requester_email' => fake()->safeEmail(), + 'message' => fake()->sentence(), + 'payload' => null, + 'requester_ip' => fake()->ipv4(), + ]; + } + + public function type(LegalRequestType $type): static + { + return $this->state(['type' => $type]); + } + + public function resolved(): static + { + return $this->state([ + 'status' => LegalRequestStatus::Resolved, + 'resolved_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_06_16_134458_create_legal_requests_table.php b/database/migrations/2026_06_16_134458_create_legal_requests_table.php new file mode 100644 index 0000000..27ae36f --- /dev/null +++ b/database/migrations/2026_06_16_134458_create_legal_requests_table.php @@ -0,0 +1,46 @@ +id(); + + // Bezug zur PM; bleibt erhalten (Audit), wenn die PM gelöscht wird. + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->string('portal', 40)->nullable(); + + $table->string('type', 40); // dsgvo | personal_rights | report + $table->string('status', 40)->default('open'); + + $table->string('requester_email')->nullable(); + $table->text('message')->nullable(); + $table->json('payload')->nullable(); // strukturierte Zusatzfelder + $table->string('requester_ip', 45)->nullable(); + + $table->foreignId('resolved_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('admin_note')->nullable(); + $table->timestamp('resolved_at')->nullable(); + + $table->timestamps(); + + $table->index(['status', 'type']); + $table->index(['press_release_id', 'type', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('legal_requests'); + } +}; diff --git a/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md b/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md index 3334d23..3ef75a1 100644 --- a/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md +++ b/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md @@ -61,6 +61,8 @@ Vom **Duplicate-Content-Dokument** ist die Entscheidung ohnehin „nichts bauen" - **Aufwand:** ~2–3 Tage. **Fundament für WS-3.** ### WS-3 — Änderungs-/Lösch-Pfade A · B · E · F (Phase-2-Doc §3.2/3.3) · Launch-Pflicht +**Status (16.06.):** Rechts-Kern **E · F + Melden + Admin-Queue erledigt** – Tabelle `legal_requests`, öffentliches Formular `/release/{slug}/rechtliches` (DSGVO/Persönlichkeitsrecht/Meldung, enumeration-neutral, Honeypot, Rate-Limit, Bremse „1 offene Anfrage/PM+Typ"), „Melden"-Button + E/F-Einstieg auf der PM-Detailseite verdrahtet, Admin-Bereich „Recht & Compliance" (Nav-Badge mit Offen-Zähler, Queue index/show mit Status erledigt/abgelehnt/wieder öffnen + interner Notiz). Tests grün. **Offen:** B (Kontaktdaten – teils durch Firmen-Scope WS-2 abgedeckt) und A (Tippfehler/Levenshtein) – separat zu entscheiden. + Alle hinter WS-2 **oder** regulärem Login. - **B — Kontaktdaten ändern** (einfachster Pfad zuerst): Formular, direkt übernommen, Versionierung im Hintergrund. Kostenfrei. - **A — Tippfehler/Grammatik:** Inline-Editor mit **Levenshtein-Diff** (kein KI-Call). Kleine Änderung ohne Zahlen/Namen → automatisch; Graubereich → Hinweis „bitte als Korrektur (Pfad C, Phase 2) einreichen". Bremse: 1 Einreichung/PM/24 h + Account-Tages-Cap. diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index 9bae8c4..9df8c3e 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -126,6 +126,7 @@ Aus einer gezielten Auth-Prüfung umgesetzt: 1. `2026_06_15_101337_backfill_email_verified_at_for_existing_users` — verhindert Lockout. 2. `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` — schließt die Admin-Überberechtigung. 3. `2026_06_16_103238_add_oauth_provider_columns_to_users` — Provider-Verknüpfung für Google-Login. +4. `2026_06_16_134458_create_legal_requests_table` — Queue für Recht & Compliance (WS-3: DSGVO/Persönlichkeitsrecht/Meldungen). **Nach Deploy prüfen:** - `editor`-Rolle hat keine Legacy-User mehr. diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index f5e34b9..1adbedc 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -39,6 +39,9 @@ $reviewCount = $canAdmin ? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount() : 0; + $legalOpenCount = $canAdmin + ? \App\Models\LegalRequest::query()->open()->count() + : 0; @endphp @@ -137,6 +140,15 @@ + {{-- Recht & Compliance --}} + + + {{ __('Anfragen') }} + + + {{-- Billing --}} resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function with(): array + { + $requests = LegalRequest::query() + ->with('pressRelease:id,title,slug') + ->when($this->statusFilter === 'open', fn ($query) => $query->open()) + ->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter)) + ->when($this->typeFilter !== 'all', fn ($query) => $query->where('type', $this->typeFilter)) + ->latest() + ->paginate(50); + + return [ + 'requests' => $requests, + 'typeOptions' => LegalRequestType::cases(), + 'openCount' => LegalRequest::query()->open()->count(), + 'totalCount' => LegalRequest::query()->count(), + ]; + } + + public function statusBadgeClass(LegalRequestStatus $status): string + { + return match ($status) { + LegalRequestStatus::Open => 'warn', + LegalRequestStatus::InProgress => 'hub', + LegalRequestStatus::Resolved => 'ok', + LegalRequestStatus::Rejected => 'err', + }; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + {{-- ============== KPI-Reihe ============== --}} +
+ + {{ __('zu bearbeiten') }} + {{ __('offen + in Bearbeitung') }} + + + {{ __('alle Anfragen') }} + {{ __('inkl. erledigt') }} + +
+ + {{-- ============== FILTER ============== --}} +
+
+ {{ __('Filter') }} +
+
+ + + + + + + + + + @foreach ($typeOptions as $typeOption) + + @endforeach + +
+
+ + {{-- ============== TABELLE ============== --}} +
+
+ {{ __('Anfragen') }} + + {{ __(':count Einträge', ['count' => $requests->total()]) }} + +
+ + + {{ __('Typ') }} + {{ __('Status') }} + {{ __('Pressemitteilung') }} + {{ __('Absender') }} + {{ __('Eingegangen') }} + {{ __('Aktion') }} + + + + @forelse ($requests as $request) + + + + {{ $request->type->label() }} + + + + + {{ $request->status->label() }} + + + + + {{ \Illuminate\Support\Str::limit($request->pressRelease?->title ?? __('PM gelöscht'), 60) }} + + + + + {{ $request->requester_email ?: __('anonym') }} + + + + + {{ $request->created_at?->format('d.m.Y H:i') ?? '-' }} + + + + + {{ __('Öffnen') }} + + + + @empty + + +
+
+ +
+
+ {{ __('Keine Anfragen in dieser Ansicht') }} +
+
+
+
+ @endforelse +
+
+ +
+ {{ $requests->links('components.portal.pagination') }} +
+
+
diff --git a/resources/views/livewire/admin/legal-requests/show.blade.php b/resources/views/livewire/admin/legal-requests/show.blade.php new file mode 100644 index 0000000..178485a --- /dev/null +++ b/resources/views/livewire/admin/legal-requests/show.blade.php @@ -0,0 +1,201 @@ +id = $id; + $this->adminNote = (string) ($this->request()->admin_note ?? ''); + } + + public function markInProgress(): void + { + $this->request()->update(['status' => LegalRequestStatus::InProgress->value]); + $this->notification = __('Status auf „In Bearbeitung" gesetzt.'); + } + + public function resolve(): void + { + $this->finish(LegalRequestStatus::Resolved, __('Anfrage als erledigt markiert.')); + } + + public function reject(): void + { + $this->finish(LegalRequestStatus::Rejected, __('Anfrage abgelehnt.')); + } + + public function reopen(): void + { + $this->request()->update([ + 'status' => LegalRequestStatus::Open->value, + 'resolved_by_user_id' => null, + 'resolved_at' => null, + ]); + $this->notification = __('Anfrage wieder geöffnet.'); + } + + private function finish(LegalRequestStatus $status, string $message): void + { + $this->request()->update([ + 'status' => $status->value, + 'admin_note' => $this->adminNote !== '' ? $this->adminNote : null, + 'resolved_by_user_id' => auth()->id(), + 'resolved_at' => now(), + ]); + + $this->notification = $message; + } + + private function request(): LegalRequest + { + return LegalRequest::query()->with(['pressRelease', 'resolver'])->findOrFail($this->id); + } + + public function with(): array + { + return ['request' => $this->request()]; + } + + public function statusBadgeClass(LegalRequestStatus $status): string + { + return match ($status) { + LegalRequestStatus::Open => 'warn', + LegalRequestStatus::InProgress => 'hub', + LegalRequestStatus::Resolved => 'ok', + LegalRequestStatus::Rejected => 'err', + }; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + @if ($notification) +
+ + {{ $notification }} +
+ @endif + +
+ {{-- ============== ANFRAGE ============== --}} +
+
+ {{ __('Anliegen') }} +
+
+
+ {{ $request->message ?: __('Keine Nachricht hinterlegt.') }} +
+ +
+
+
{{ __('Absender') }}
+
+ @if ($request->requester_email) + {{ $request->requester_email }} + @else + {{ __('anonym') }} + @endif +
+
+
+
{{ __('Eingegangen') }}
+
{{ $request->created_at?->format('d.m.Y H:i') ?? '-' }}
+
+
+
{{ __('Pressemitteilung') }}
+
+ @if ($request->pressRelease) + {{ $request->pressRelease->title }} + @if (\Illuminate\Support\Facades\Route::has('admin.press-releases.show')) + {{ __('öffnen') }} + @endif + @else + {{ __('PM wurde gelöscht') }} + @endif +
+
+
+
+
+ + {{-- ============== BEARBEITUNG ============== --}} +
+
+ {{ __('Bearbeitung') }} +
+
+ + + + + @if ($request->resolver) +

+ {{ __('Zuletzt bearbeitet von :name am :date', [ + 'name' => $request->resolver->name, + 'date' => $request->resolved_at?->format('d.m.Y H:i') ?? '-', + ]) }} +

+ @endif + +
+ @if ($request->status->isOpen()) + @if ($request->status !== \App\Enums\LegalRequestStatus::InProgress) + + {{ __('In Bearbeitung nehmen') }} + + @endif + + {{ __('Als erledigt markieren') }} + + + {{ __('Ablehnen') }} + + @else + + {{ __('Wieder öffnen') }} + + @endif +
+
+
+
+
diff --git a/resources/views/web/legal-request.blade.php b/resources/views/web/legal-request.blade.php new file mode 100644 index 0000000..f8f163c --- /dev/null +++ b/resources/views/web/legal-request.blade.php @@ -0,0 +1,144 @@ +@extends('web.layouts.web-master') + +@section('title', 'Recht & Compliance – '.$release->title) +@section('meta_description', 'Datenschutz-, Persönlichkeitsrechts- oder Meldeanfrage zu einer Pressemitteilung.') + +@section('content') +{{-- + TODO (Inhalt/Recht): Die untenstehenden Hinweistexte je Anfragetyp sind ein + erster ENTWURF und müssen noch fachlich/rechtlich finalisiert werden. Hier + gehört für jeden Typ konkret hinein, WIE der jeweilige Vorgang funktioniert: + - DSGVO (E): Rechtsgrundlage (Art. 17 DSGVO), welche Daten betroffen sein + können, ob/wie ein Identitätsnachweis nötig ist, Bearbeitungs- + frist (i.d.R. 30 Tage), was nach Einreichung passiert. + - Persönlichkeitsrecht (F): welche Inhalte beanstandet werden können + (unwahre Tatsachen, Bildrechte …), Nachweise, Ablauf der Prüfung, + mögliche Folgen (Anpassung/Entfernung). + - Meldung: welche Gründe sinnvoll sind (Spam, irreführend, rechtswidrig), + dass jede Meldung manuell geprüft wird, E-Mail optional. + Diese Regeln mit Recht/Legal abstimmen, bevor sie live gehen. +--}} +
+ ← Zurück zur Pressemitteilung + +
Recht & Compliance
+

+ Anfrage zu dieser Pressemitteilung +

+

+ Betrifft: {{ $release->title }} +

+

+ Datenschutz (DSGVO), Persönlichkeitsrechte und Meldungen werden von unserem + Team manuell geprüft. Bitte wählen Sie die Art Ihrer Anfrage – dazu erscheint + ein Hinweis mit den genauen Regeln. +

+ + @if (session('legal-status')) +
+ {{ session('legal-status') }} +
+ @endif + +
+ @csrf + +
+ + + @error('type') +

{{ $message }}

+ @enderror +
+ + {{-- Hinweistext je Anfragetyp (ENTWURF – siehe TODO oben). --}} +
+ Meldung: + Melden Sie diese Pressemitteilung, wenn Sie sie für Spam, irreführend oder + rechtswidrig halten. Bitte beschreiben Sie den Grund konkret. Jede Meldung + wird manuell geprüft. Eine E-Mail-Adresse ist optional, hilft aber bei + Rückfragen. +
+
+ Datenschutz / DSGVO: + Sie können die Löschung oder Anonymisierung Ihrer personenbezogenen Daten + verlangen (Art. 17 DSGVO). Bitte geben Sie an, welche Angaben Sie betreffen. + Zur Rückmeldung und ggf. Identitätsprüfung benötigen wir Ihre E-Mail-Adresse. + Die Bearbeitung erfolgt in der Regel innerhalb von 30 Tagen. +
+
+ Persönlichkeitsrecht: + Wenn diese Mitteilung Ihre Persönlichkeitsrechte verletzt (z. B. unwahre + Tatsachenbehauptungen, Bildrechte), schildern Sie bitte konkret, welche + Inhalte betroffen sind. Für Rückfragen benötigen wir Ihre E-Mail-Adresse. + Bei berechtigten Ansprüchen wird der Inhalt geprüft und ggf. angepasst oder + entfernt. +
+ +
+ + + @error('requester_email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('message') +

{{ $message }}

+ @enderror +
+ + {{-- + Bot-Schutz: versteckte Honeypot-Felder. Für Menschen unsichtbar + (display:none via .hidden), für Bots aber im DOM. Werden sie ausgefüllt, + verwirft der Controller die Anfrage still. Die Namen ('website', + 'homepage') sind bewusst „attraktiv" für Auto-Filler. Zusätzlich greift + serverseitig ein Rate-Limit pro IP/PM. + Mögliche Erweiterung später: zeitbasierte Falle (signierter Render- + Zeitstempel, Ablehnung bei Absenden < 2s) oder Captcha bei Missbrauch. + --}} + + + +
+ +

+ Hinweis: Diese Angaben werden ausschließlich zur Bearbeitung Ihrer Anfrage + verarbeitet. Weitere Informationen in der + Datenschutzerklärung. +

+
+@endsection diff --git a/routes/admin.php b/routes/admin.php index 9ae8256..7df5f85 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -60,6 +60,10 @@ Route::middleware(['auth', 'verified', EnsureUserIsAdmin::class, LogSlowAdminReq Volt::route('admin/contacts/create', 'admin.contacts.create')->name('admin.contacts.create'); Volt::route('admin/contacts/{id}/edit', 'admin.contacts.edit')->name('admin.contacts.edit'); + // Recht & Compliance (WS-3): Queue für DSGVO-/Persönlichkeitsrechts-/Meldungen + Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index'); + Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show'); + // Billing Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index'); Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf'); diff --git a/routes/domains.php b/routes/domains.php index 92ec76a..fa394be 100644 --- a/routes/domains.php +++ b/routes/domains.php @@ -38,6 +38,9 @@ Route::domain($domainPortal)->group(function () { require __DIR__.'/web.php'; +// Öffentliche Recht-&-Compliance-Routen (WS-3), host-agnostisch wie web.php. +require __DIR__.'/legal.php'; + // Theme 1 für presseecho.test Route::domain($domainPresseecho)->group(function () { // Web-Routes laden diff --git a/routes/legal.php b/routes/legal.php new file mode 100644 index 0000000..cf17923 --- /dev/null +++ b/routes/legal.php @@ -0,0 +1,25 @@ +name('legal-request.create'); + +Route::post('/release/{slug}/rechtliches', [LegalRequestController::class, 'store']) + ->middleware('throttle:10,1') + ->name('legal-request.store'); diff --git a/tests/Feature/LegalRequestTest.php b/tests/Feature/LegalRequestTest.php new file mode 100644 index 0000000..3cde06f --- /dev/null +++ b/tests/Feature/LegalRequestTest.php @@ -0,0 +1,194 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +/** + * Default-Testhost ist „localhost" → der Controller löst auf Businessportal24 + * auf. Daher die PM bewusst für dieses Portal veröffentlichen. + */ +function publishedRelease(): PressRelease +{ + return PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->create(); +} + +test('the legal request form renders with per-type hints and hidden honeypots', function () { + /** @var TestCase $this */ + $release = publishedRelease(); + + $this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'report'])) + ->assertOk() + ->assertSee($release->title) + // Hinweistexte je Typ sind serverseitig vorhanden (Umschaltung via Alpine). + ->assertSee('Art. 17 DSGVO') + ->assertSee('Persönlichkeitsrecht') + // Versteckte Honeypot-Felder im DOM. + ->assertSee('name="website"', false) + ->assertSee('name="homepage"', false); +}); + +test('the form 404s for a non-published release', function () { + /** @var TestCase $this */ + $draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(); + + $this->get(route('legal-request.create', ['slug' => $draft->slug])) + ->assertNotFound(); +}); + +test('a dsgvo request is stored in the queue', function () { + /** @var TestCase $this */ + $release = publishedRelease(); + + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::Dsgvo->value, + 'requester_email' => 'betroffen@example.test', + 'message' => 'Bitte meine personenbezogenen Daten anonymisieren.', + ])->assertRedirect(route('release.detail', ['slug' => $release->slug])); + + $request = LegalRequest::query()->firstOrFail(); + + expect($request->type)->toBe(LegalRequestType::Dsgvo); + expect($request->status)->toBe(LegalRequestStatus::Open); + expect($request->press_release_id)->toBe($release->id); + expect($request->requester_email)->toBe('betroffen@example.test'); + expect($request->portal)->toBe(Portal::Businessportal24->value); +}); + +test('a personal rights request requires an email', function () { + /** @var TestCase $this */ + $release = publishedRelease(); + + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::PersonalRights->value, + 'message' => 'Diese Meldung verletzt meine Persönlichkeitsrechte.', + ])->assertSessionHasErrors('requester_email'); + + expect(LegalRequest::query()->count())->toBe(0); +}); + +test('a report can be submitted without an email and feeds the same queue', function () { + /** @var TestCase $this */ + $release = publishedRelease(); + + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::Report->value, + 'message' => 'Diese Pressemitteilung wirkt wie Spam.', + ])->assertRedirect(route('release.detail', ['slug' => $release->slug])); + + $request = LegalRequest::query()->firstOrFail(); + + expect($request->type)->toBe(LegalRequestType::Report); + expect($request->requester_email)->toBeNull(); +}); + +test('a filled honeypot field blocks bots without creating a request', function (string $field) { + /** @var TestCase $this */ + $release = publishedRelease(); + + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::Report->value, + 'message' => 'Spam spam spam spam.', + $field => 'http://spam.example', + ]); + + expect(LegalRequest::query()->count())->toBe(0); +})->with(['website', 'homepage']); + +test('only one open request per release and type, other types still allowed', function () { + /** @var TestCase $this */ + $release = publishedRelease(); + + LegalRequest::factory() + ->type(LegalRequestType::Dsgvo) + ->create(['press_release_id' => $release->id, 'status' => LegalRequestStatus::Open]); + + // Zweite offene DSGVO-Anfrage → keine Dublette. + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::Dsgvo->value, + 'requester_email' => 'noch@example.test', + 'message' => 'Nochmal dieselbe Anfrage bitte bearbeiten.', + ]); + + expect(LegalRequest::query()->where('type', LegalRequestType::Dsgvo->value)->count())->toBe(1); + + // Anderer Typ bleibt möglich. + $this->post(route('legal-request.store', ['slug' => $release->slug]), [ + 'type' => LegalRequestType::Report->value, + 'message' => 'Andere Art von Anliegen zu dieser PM.', + ]); + + expect(LegalRequest::query()->where('type', LegalRequestType::Report->value)->count())->toBe(1); +}); + +test('the admin queue lists open requests', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + // Kurzer, fixer Titel: die Queue kürzt Titel via Str::limit(…, 60). + $release = PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->create(['title' => 'Compliance-Test PM']); + LegalRequest::factory() + ->type(LegalRequestType::PersonalRights) + ->create(['press_release_id' => $release->id, 'status' => LegalRequestStatus::Open]); + + Volt::actingAs($admin) + ->test('admin.legal-requests.index') + ->assertOk() + ->assertSee('Compliance-Test PM') + ->assertSee(LegalRequestType::PersonalRights->label()); +}); + +test('an admin can resolve a request with a note', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $legalRequest = LegalRequest::factory() + ->type(LegalRequestType::Dsgvo) + ->create(['status' => LegalRequestStatus::Open]); + + Volt::actingAs($admin) + ->test('admin.legal-requests.show', ['id' => $legalRequest->id]) + ->set('adminNote', 'Daten anonymisiert.') + ->call('resolve'); + + $legalRequest->refresh(); + + expect($legalRequest->status)->toBe(LegalRequestStatus::Resolved); + expect($legalRequest->resolved_by_user_id)->toBe($admin->id); + expect($legalRequest->resolved_at)->not->toBeNull(); + expect($legalRequest->admin_note)->toBe('Daten anonymisiert.'); +}); + +test('an admin can reject a request', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $legalRequest = LegalRequest::factory() + ->type(LegalRequestType::Report) + ->create(['status' => LegalRequestStatus::Open]); + + Volt::actingAs($admin) + ->test('admin.legal-requests.show', ['id' => $legalRequest->id]) + ->call('reject'); + + expect($legalRequest->fresh()->status)->toBe(LegalRequestStatus::Rejected); +});