Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal — rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout (SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre, Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent (50 %) verhindert die verdeckte Selbst-Depublizierung. Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D, neuer abbestellbarer Typ press_release_corrected). Außerdem: - Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert (Lücke im bezahlten Webhook-Pfad geschlossen) - Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis; Store auf report/dsgvo/personal_rights gehärtet - Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt des Web-Formulars - Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung): Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt → Firmendaten - Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel, Wochenlimit-Hinweis in Text und Bremsen-Meldung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
190 lines
7.5 KiB
PHP
190 lines
7.5 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Enums\LegalRequestStatus;
|
||
use App\Enums\LegalRequestType;
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Mail\LegalRequestReceived;
|
||
use App\Mail\LegalRequestSubmitted;
|
||
use App\Models\LegalRequest;
|
||
use App\Models\PressRelease;
|
||
use App\Scopes\PortalScope;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Mail;
|
||
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;
|
||
|
||
// Zweistufige Triage (Meldeverfahren v2): Absender-Anliegen
|
||
// (Korrektur/Änderung/Depublizierung) laufen über den Magic-Link-Weg,
|
||
// nicht über dieses Formular.
|
||
$initialConcern = match ($type) {
|
||
LegalRequestType::Dsgvo => 'dsgvo',
|
||
LegalRequestType::PersonalRights => 'personal_rights',
|
||
LegalRequestType::Correction, LegalRequestType::Depublication, LegalRequestType::Change => 'owner',
|
||
default => 'report',
|
||
};
|
||
|
||
return view('web.legal-request', [
|
||
'release' => $release,
|
||
'initialConcern' => $initialConcern,
|
||
// Passwortloser Portal-Login (Entscheidung 23.07.): Der Anmeldelink
|
||
// führt Pressekontakte direkt ins pressekonto — dort werden ihre
|
||
// PMs im Konto zusammengeführt und alle Selbstservice-Funktionen
|
||
// (Korrektur, Änderung, Depublizierung, Datenschutz) stehen bereit.
|
||
'portalMagicLinkUrl' => rtrim(config('domains.domain_portal_url', config('app.url')), '/').'/anmeldelink',
|
||
]);
|
||
}
|
||
|
||
public function store(Request $request, string $slug): RedirectResponse
|
||
{
|
||
$release = $this->resolvePublishedRelease($slug);
|
||
|
||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||
// nichts speichern. Bewusst VOR der Validierung — eine size:0-Regel
|
||
// würde mit ihrer Fehlermeldung einem Bot die Falle verraten.
|
||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||
return $this->done($release);
|
||
}
|
||
|
||
$validated = $request->validate([
|
||
// Bewusst NICHT das volle Enum: Absender-Spuren (Korrektur,
|
||
// Änderung, Depublizierung) haben eigene verifizierte Flows und
|
||
// dürfen hier nicht als anonyme Queue-Anfrage einlaufen.
|
||
'type' => ['required', Rule::in([
|
||
LegalRequestType::Report->value,
|
||
LegalRequestType::Dsgvo->value,
|
||
LegalRequestType::PersonalRights->value,
|
||
])],
|
||
// 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'],
|
||
]);
|
||
|
||
$type = LegalRequestType::from($validated['type']);
|
||
|
||
$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 = 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(),
|
||
]);
|
||
|
||
// T10/DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung mit
|
||
// Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) +
|
||
// interner Hinweis ans Melde-Postfach.
|
||
if ($legalRequest->requester_email) {
|
||
Mail::to($legalRequest->requester_email)->queue(
|
||
(new LegalRequestReceived($legalRequest))->afterCommit()
|
||
);
|
||
}
|
||
Mail::to($this->operatorRecipient())->queue(
|
||
(new LegalRequestSubmitted($legalRequest))->afterCommit()
|
||
);
|
||
|
||
return redirect()
|
||
->route('release.detail', ['slug' => $release->slug])
|
||
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist unter der Vorgangsnummer :reference eingegangen und wird von unserem Team geprüft.', [
|
||
'reference' => $legalRequest->reference(),
|
||
]));
|
||
}
|
||
|
||
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 operatorRecipient(): string
|
||
{
|
||
$brand = str_contains(request()->getHost(), 'presseecho')
|
||
? config('domains.domains.presseecho.brand', [])
|
||
: config('domains.domains.businessportal24.brand', []);
|
||
|
||
return $brand['report_email'] ?? (string) config('legal.contact_email');
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|