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:
parent
2a622044f3
commit
95007da826
16 changed files with 1139 additions and 0 deletions
30
app/Enums/LegalRequestStatus.php
Normal file
30
app/Enums/LegalRequestStatus.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LegalRequestStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case InProgress = 'in_progress';
|
||||
case Resolved = 'resolved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => '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;
|
||||
}
|
||||
}
|
||||
34
app/Enums/LegalRequestType.php
Normal file
34
app/Enums/LegalRequestType.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LegalRequestType: string
|
||||
{
|
||||
case Dsgvo = 'dsgvo';
|
||||
case PersonalRights = 'personal_rights';
|
||||
case Report = 'report';
|
||||
|
||||
/**
|
||||
* Interne (Admin-)Bezeichnung.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Dsgvo => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/LegalRequestController.php
Normal file
143
app/Http/Controllers/LegalRequestController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
68
app/Models/LegalRequest.php
Normal file
68
app/Models/LegalRequest.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use Database\Factories\LegalRequestFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Rechts-/Compliance-Anfrage (WS-3) – DSGVO, Persönlichkeitsrecht oder Meldung.
|
||||
* Bewusst OHNE PortalScope: der Admin-Bereich „Recht & Compliance" sieht alle
|
||||
* Portale; das Portal wird zur Anzeige/Filterung als eigene Spalte gehalten.
|
||||
*/
|
||||
class LegalRequest extends Model
|
||||
{
|
||||
/** @use HasFactory<LegalRequestFactory> */
|
||||
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<LegalRequest> $query
|
||||
* @return Builder<LegalRequest>
|
||||
*/
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
LegalRequestStatus::Open->value,
|
||||
LegalRequestStatus::InProgress->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
database/factories/LegalRequestFactory.php
Normal file
45
database/factories/LegalRequestFactory.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<LegalRequest>
|
||||
*/
|
||||
class LegalRequestFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Rechts-/Compliance-Anfragen (WS-3): DSGVO-Anonymisierung, Persönlichkeitsrecht
|
||||
* und öffentliche Meldungen laufen in eine gemeinsame Admin-Queue. Keine KI –
|
||||
* jede Anfrage wird manuell bearbeitet.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('legal_requests', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@
|
|||
$reviewCount = $canAdmin
|
||||
? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount()
|
||||
: 0;
|
||||
$legalOpenCount = $canAdmin
|
||||
? \App\Models\LegalRequest::query()->open()->count()
|
||||
: 0;
|
||||
@endphp
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
|
|
@ -137,6 +140,15 @@
|
|||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Recht & Compliance --}}
|
||||
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
|
||||
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
|
||||
:current="request()->routeIs('admin.legal-requests.*')"
|
||||
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
|
||||
{{ __('Anfragen') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Billing --}}
|
||||
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
|
||||
|
|
|
|||
187
resources/views/livewire/admin/legal-requests/index.blade.php
Normal file
187
resources/views/livewire/admin/legal-requests/index.blade.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Models\LegalRequest;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url(as: 'status', except: 'open')]
|
||||
public string $statusFilter = 'open';
|
||||
|
||||
#[Url(as: 'type', except: 'all')]
|
||||
public string $typeFilter = 'all';
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->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',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="page-header">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Administration · Recht & Compliance') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Recht & Compliance') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('DSGVO-Anonymisierung, Persönlichkeitsrechte und Meldungen zu Pressemitteilungen – manuelle Bearbeitung, keine KI.') }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
|
||||
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
|
||||
<x-slot:meta>{{ __('alle Anfragen') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
{{-- ============== FILTER ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Filter') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex flex-col gap-4 sm:flex-row">
|
||||
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
|
||||
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
<option value="resolved">{{ __('Erledigt') }}</option>
|
||||
<option value="rejected">{{ __('Abgelehnt') }}</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="typeFilter" class="w-full sm:w-56">
|
||||
<option value="all">{{ __('Alle Typen') }}</option>
|
||||
@foreach ($typeOptions as $typeOption)
|
||||
<option value="{{ $typeOption->value }}">{{ $typeOption->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== TABELLE ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anfragen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __(':count Einträge', ['count' => $requests->total()]) }}
|
||||
</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Typ') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Pressemitteilung') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Absender') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($requests as $request)
|
||||
<flux:table.row :key="$request->id">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ $request->type->label() }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="badge dot {{ $this->statusBadgeClass($request->status) }}">
|
||||
{{ $request->status->label() }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink)]">
|
||||
{{ \Illuminate\Support\Str::limit($request->pressRelease?->title ?? __('PM gelöscht'), 60) }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $request->requester_email ?: __('anonym') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $request->created_at?->format('d.m.Y H:i') ?? '-' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="filled" icon="eye"
|
||||
href="{{ route('admin.legal-requests.show', $request->id) }}" wire:navigate>
|
||||
{{ __('Öffnen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.scale class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Keine Anfragen in dieser Ansicht') }}
|
||||
</div>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
|
||||
{{ $requests->links('components.portal.pagination') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
201
resources/views/livewire/admin/legal-requests/show.blade.php
Normal file
201
resources/views/livewire/admin/legal-requests/show.blade.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Models\LegalRequest;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $adminNote = '';
|
||||
|
||||
public string $notification = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->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',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="page-header">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Recht & Compliance · Anfrage') }}</span>
|
||||
<span class="badge dot {{ $this->statusBadgeClass($request->status) }}">{{ $request->status->label() }}</span>
|
||||
</div>
|
||||
<h1 class="text-[28px] font-bold tracking-[-0.5px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ $request->type->label() }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<flux:button icon="arrow-left" variant="filled" href="{{ route('admin.legal-requests.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if ($notification)
|
||||
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
|
||||
class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||||
{{ $notification }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
{{-- ============== ANFRAGE ============== --}}
|
||||
<article class="panel xl:col-span-2">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] text-[color:var(--color-ink)] whitespace-pre-line">
|
||||
{{ $request->message ?: __('Keine Nachricht hinterlegt.') }}
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]">
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)] break-all">
|
||||
@if ($request->requester_email)
|
||||
<a href="mailto:{{ $request->requester_email }}"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2">{{ $request->requester_email }}</a>
|
||||
@else
|
||||
{{ __('anonym') }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Eingegangen') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $request->created_at?->format('d.m.Y H:i') ?? '-' }}</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Pressemitteilung') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">
|
||||
@if ($request->pressRelease)
|
||||
{{ $request->pressRelease->title }}
|
||||
@if (\Illuminate\Support\Facades\Route::has('admin.press-releases.show'))
|
||||
<a href="{{ route('admin.press-releases.show', $request->pressRelease->id) }}" wire:navigate
|
||||
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
|
||||
@endif
|
||||
@else
|
||||
{{ __('PM wurde gelöscht') }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BEARBEITUNG ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bearbeitung') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:field>
|
||||
<flux:textarea wire:model="adminNote" :label="__('Interne Notiz')" rows="4"
|
||||
:placeholder="__('Wie wurde die Anfrage bearbeitet?')" />
|
||||
</flux:field>
|
||||
|
||||
@if ($request->resolver)
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Zuletzt bearbeitet von :name am :date', [
|
||||
'name' => $request->resolver->name,
|
||||
'date' => $request->resolved_at?->format('d.m.Y H:i') ?? '-',
|
||||
]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
@if ($request->status->isOpen())
|
||||
@if ($request->status !== \App\Enums\LegalRequestStatus::InProgress)
|
||||
<flux:button variant="filled" icon="clock" wire:click="markInProgress">
|
||||
{{ __('In Bearbeitung nehmen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="primary" icon="check" wire:click="resolve">
|
||||
{{ __('Als erledigt markieren') }}
|
||||
</flux:button>
|
||||
<flux:button variant="danger" icon="x-mark" wire:click="reject">
|
||||
{{ __('Ablehnen') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
|
||||
{{ __('Wieder öffnen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
144
resources/views/web/legal-request.blade.php
Normal file
144
resources/views/web/legal-request.blade.php
Normal file
|
|
@ -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.
|
||||
--}}
|
||||
<section
|
||||
x-data="{ type: @js(old('type', $type->value)), emailRequired() { return ['dsgvo', 'personal_rights'].includes(this.type); } }"
|
||||
class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-10"
|
||||
>
|
||||
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}"
|
||||
class="text-[12px] text-ink-3 hover:text-ink transition-colors">← Zurück zur Pressemitteilung</a>
|
||||
|
||||
<div class="eyebrow muted text-[10px] mt-4 mb-2">Recht & Compliance</div>
|
||||
<h1 class="font-serif text-[26px] font-semibold tracking-[-0.3px] m-0 mb-2 text-ink">
|
||||
Anfrage zu dieser Pressemitteilung
|
||||
</h1>
|
||||
<p class="text-[13px] text-ink-3 mb-1">
|
||||
Betrifft: <span class="text-ink-2 font-medium">{{ $release->title }}</span>
|
||||
</p>
|
||||
<p class="text-[12.5px] text-ink-3 leading-relaxed mb-6">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@if (session('legal-status'))
|
||||
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
|
||||
{{ session('legal-status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('legal-request.store', ['slug' => $release->slug]) }}" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="type" class="block text-[12px] font-semibold text-ink mb-1.5">Art der Anfrage</label>
|
||||
<select id="type" name="type" x-model="type"
|
||||
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">
|
||||
@foreach ($types as $case)
|
||||
<option value="{{ $case->value }}" @selected(old('type', $type->value) === $case->value)>
|
||||
{{ $case->publicLabel() }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('type')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Hinweistext je Anfragetyp (ENTWURF – siehe TODO oben). --}}
|
||||
<div x-cloak x-show="type === 'report'"
|
||||
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
|
||||
<strong class="text-ink">Meldung:</strong>
|
||||
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.
|
||||
</div>
|
||||
<div x-cloak x-show="type === 'dsgvo'"
|
||||
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
|
||||
<strong class="text-ink">Datenschutz / DSGVO:</strong>
|
||||
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.
|
||||
</div>
|
||||
<div x-cloak x-show="type === 'personal_rights'"
|
||||
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
|
||||
<strong class="text-ink">Persönlichkeitsrecht:</strong>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="requester_email" class="block text-[12px] font-semibold text-ink mb-1.5">
|
||||
E-Mail-Adresse
|
||||
<span class="text-ink-3 font-normal" x-show="emailRequired()">(erforderlich)</span>
|
||||
<span class="text-ink-3 font-normal" x-show="!emailRequired()" x-cloak>(optional)</span>
|
||||
</label>
|
||||
<input id="requester_email" type="email" name="requester_email" value="{{ old('requester_email') }}"
|
||||
autocomplete="email"
|
||||
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
|
||||
@error('requester_email')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-[12px] font-semibold text-ink mb-1.5">Ihr Anliegen</label>
|
||||
<textarea id="message" name="message" rows="6" required
|
||||
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand"
|
||||
placeholder="Bitte beschreiben Sie Ihr Anliegen.">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{--
|
||||
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.
|
||||
--}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" value="{{ old('website') }}" />
|
||||
<label for="homepage">Homepage</label>
|
||||
<input id="homepage" type="text" name="homepage" tabindex="-1" autocomplete="off" value="{{ old('homepage') }}" />
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-brand hover:bg-brand-deep text-white px-5 py-2.5 text-[13px] font-semibold transition-colors">
|
||||
Anfrage absenden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-[11.5px] text-ink-3 leading-relaxed">
|
||||
Hinweis: Diese Angaben werden ausschließlich zur Bearbeitung Ihrer Anfrage
|
||||
verarbeitet. Weitere Informationen in der
|
||||
<a href="{{ route('datenschutz') }}" class="underline hover:text-ink">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
@endsection
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
routes/legal.php
Normal file
25
routes/legal.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\LegalRequestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Recht & Compliance – öffentliche Routen (WS-3)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Öffentliche Rechts-/Compliance-Anfrage zu einer Pressemitteilung: DSGVO-
|
||||
| Anonymisierung (E), Persönlichkeitsrecht (F) und Meldung. Ohne Login; speist
|
||||
| die Admin-Queue „Recht & Compliance". Bewusst in einer eigenen Datei (nicht in
|
||||
| web.php) gehalten, damit der Compliance-Slice unabhängig vom laufenden
|
||||
| Web-Frontend-Umbau bleibt. Host-agnostisch eingebunden (siehe domains.php) –
|
||||
| der Controller leitet das Portal aus dem Host ab.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::get('/release/{slug}/rechtliches', [LegalRequestController::class, 'create'])
|
||||
->name('legal-request.create');
|
||||
|
||||
Route::post('/release/{slug}/rechtliches', [LegalRequestController::class, 'store'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('legal-request.store');
|
||||
194
tests/Feature/LegalRequestTest.php
Normal file
194
tests/Feature/LegalRequestTest.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue