recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal

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>
This commit is contained in:
Kevin Adametz 2026-07-23 13:02:57 +00:00
parent b4b2e013d6
commit 9afbedddeb
40 changed files with 2507 additions and 169 deletions

View file

@ -3,9 +3,11 @@
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestExecuted;
use App\Models\LegalRequest;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -38,6 +40,16 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
$this->id = $id;
$this->adminNote = (string) ($this->request()->admin_note ?? '');
$this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT;
// Spur B (Phase 5): strukturierter Schwärzungsauftrag aus dem
// verifizierten Web-Formular befüllt den Editor vor — der Admin
// prüft, ergänzt und führt aus.
$redaction = (array) ($this->request()->payload['redaction'] ?? []);
if ($redaction !== []) {
$this->redactionTerms = implode("\n", (array) ($redaction['terms'] ?? []));
$this->detachContacts = (bool) ($redaction['detach_contacts'] ?? true);
}
}
/**
@ -94,6 +106,8 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'anonymized');
$this->redactionPreview = null;
$this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.');
}
@ -117,8 +131,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
return;
}
$service->depublish($pressRelease);
$pressRelease->lockSync('Depublizierung im Meldeverfahren '.$request->reference());
$service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference());
$request->update([
'status' => LegalRequestStatus::Resolved->value,
@ -128,9 +141,26 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
'executed_at' => now(),
]);
$this->sendExecutedMail($request, 'depublished');
$this->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.');
}
/**
* Umsetzungs-Bestätigung an die meldende Person (Phase 5) — nur wenn
* eine Antragsteller-Adresse vorliegt (interne Vorgänge haben keine).
*
* @param 'anonymized'|'depublished' $action
*/
private function sendExecutedMail(LegalRequest $request, string $action): void
{
if (! $request->requester_email) {
return;
}
Mail::to($request->requester_email)->queue(new LegalRequestExecuted($request->fresh(), $action));
}
/**
* Rückgängig machen: spielt den verschlüsselten Vorher-Snapshot einer
* ausgeführten Korrektur/Anonymisierung zurück (bis zur Löschfrist).
@ -370,6 +400,15 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
{{ __('Schwärzt personenbezogene Daten über Wortgrenzen (inkl. Varianten wie „Herr Teplitsky", „Teplitskys"). Kostenlos, ohne KI-Gate; Alt-Werte werden verschlüsselt gesichert und die Re-Sync-Sperre gesetzt.') }}
</p>
@if ((array) ($request->payload['redaction'] ?? []) !== [])
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
{{ __('Strukturierter Schwärzungsauftrag aus dem Web-Formular — Begriffe und Optionen sind unten vorbefüllt (:hits Treffer laut Antragsteller-Vorschau).', ['hits' => (int) ($request->payload['redaction']['preview_total_hits'] ?? 0)]) }}
@if ($request->payload['redaction']['remove_images_requested'] ?? false)
<span class="font-semibold">{{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }}</span>
@endif
</div>
@endif
<flux:field>
<flux:textarea wire:model="redactionTerms" rows="4"
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"

View file

@ -100,7 +100,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->categoryId = $pr->category_id;
$this->title = $pr->title;
$this->subtitle = $pr->subtitle ?? '';
$this->text = $pr->text;
$this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text);
$this->keywords = $pr->keywords ?? '';
$this->backlinkUrl = $pr->backlink_url ?? '';
$this->boilerplateOverride = $pr->boilerplate_override ?? '';