*/ public array $removeImageIds = []; /** @var array{fields: array, total_hits: int}|null */ public ?array $redactionPreview = null; public function mount(int $id): void { $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); } } /** * Pflicht-Vorschau (Konzept §Spur B): Ohne Diff keine Ausführung. */ public function previewAnonymization(AnonymizationService $service): void { $this->authorize('compliance:manage'); $pressRelease = $this->request()->pressRelease; abort_unless($pressRelease !== null, 404); if ($this->redactionTermList() === [] && ! $this->detachContacts && $this->removeImageIds === []) { $this->addError('redactionTerms', __('Mindestens einen Schwärzbegriff eingeben oder Kontakte/Bilder auswählen.')); return; } $this->redactionPreview = $service->preview( $pressRelease, $this->redactionTermList(), $this->replacementValue(), ); } public function executeAnonymization(AnonymizationService $service): void { $this->authorize('compliance:manage'); $request = $this->request(); $pressRelease = $request->pressRelease; abort_unless($pressRelease !== null, 404); if ($this->redactionPreview === null) { $this->addError('redactionTerms', __('Bitte zuerst die Vorschau prüfen.')); return; } $service->execute( $pressRelease, $this->redactionTermList(), $this->replacementValue(), $this->detachContacts, $this->removeImageIds, legalRequest: $request, ); $request->update([ 'status' => LegalRequestStatus::Resolved->value, 'admin_note' => $this->adminNote !== '' ? $this->adminNote : null, 'resolved_by_user_id' => auth()->id(), 'resolved_at' => now(), 'executed_at' => now(), ]); $this->sendExecutedMail($request, 'anonymized'); $this->redactionPreview = null; $this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.'); } /** * Alternative zur Schwärzung (Matrix-Test §1/§2): Bei Vanity-/Personen-PMs * bleibt nach Schwärzung nur ein Rumpf — dann ist Depublizierung das * mildere Restmittel (im DSGVO-Fall kostenlos, umkehrbar). */ public function depublishRelease(PressReleaseService $service): void { $this->authorize('compliance:manage'); $request = $this->request(); $pressRelease = $request->pressRelease; abort_unless($pressRelease !== null, 404); if ($pressRelease->status !== PressReleaseStatus::Published) { $this->addError('redactionTerms', __('Nur veröffentlichte PMs können depubliziert werden.')); return; } $service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference(), $request); $request->update([ 'status' => LegalRequestStatus::Resolved->value, 'admin_note' => $this->adminNote !== '' ? $this->adminNote : null, 'resolved_by_user_id' => auth()->id(), 'resolved_at' => now(), '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). */ public function restoreSnapshot(\App\Services\PressRelease\CorrectionService $correctionService): void { $this->authorize('compliance:manage'); $request = $this->request(); $snapshot = $request->redactionSnapshots()->latest('id')->first(); abort_unless($snapshot !== null, 404); $correctionService->restoreSnapshot($snapshot, auth()->user()); $request->update([ 'status' => LegalRequestStatus::InProgress->value, 'executed_at' => null, ]); $this->notification = __('Vorher-Stand wiederhergestellt — Anfrage wieder in Bearbeitung.'); } /** * @return list */ private function redactionTermList(): array { return collect(preg_split('/\r\n|\r|\n/', $this->redactionTerms) ?: []) ->map(fn (string $term): string => trim($term)) ->filter() ->unique() ->values() ->all(); } private function replacementValue(): string { return trim($this->redactionReplacement) !== '' ? trim($this->redactionReplacement) : AnonymizationService::DEFAULT_REPLACEMENT; } public function canRedact(LegalRequest $request): bool { return $request->pressRelease !== null && in_array($request->type, [LegalRequestType::Dsgvo, LegalRequestType::PersonalRights], true); } /** * HTML-Inhalt lesbar und kopierbar machen: Block-Enden zu Zeilenumbrüchen, * Tags raus — der Admin kopiert Begriffe direkt aus dieser Ansicht. */ public function displayValue(?string $value): string { // Ohne "?"+">" im Pattern — der Volt-Compiler würde die Sequenz als // PHP-Schließtag lesen und die Klasse zerschneiden. $withBreaks = preg_replace('#]*>|#i', "\n", (string) $value) ?? ''; return trim(strip_tags($withBreaks)); } /** * @return array */ public function redactableFieldLabels(): array { return [ 'title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords'), ]; } public function markInProgress(): void { $this->authorize('compliance:manage'); $this->request()->update(['status' => LegalRequestStatus::InProgress->value]); $this->notification = __('Status auf „In Bearbeitung" gesetzt.'); } public function resolve(): void { $this->authorize('compliance:manage'); $this->finish(LegalRequestStatus::Resolved, __('Anfrage als erledigt markiert.')); } public function reject(): void { $this->authorize('compliance:manage'); $this->finish(LegalRequestStatus::Rejected, __('Anfrage abgelehnt.')); } public function reopen(): void { $this->authorize('compliance:manage'); $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.images', '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 ============== --}} {{ $request->status->label() }} {{ __('Zurück') }} @if ($notification)
{{ $notification }}
@endif
{{-- ============== ANFRAGE ============== --}}
{{ __('Anliegen') }}
{{ $request->message ?: __('Keine Nachricht hinterlegt.') }}
{{ __('Vorgangsnummer') }}
{{ $request->reference() }}
@if ($request->requesterName())
{{ __('Name') }}
{{ $request->requesterName() }}
@endif
{{ __('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 @elseif ($request->reportedUrl()) {{ __('Keine PM zugeordnet — gemeldete URL:') }} {{ $request->reportedUrl() }} @else {{ __('PM wurde gelöscht') }} @endif
{{-- ============== ANONYMISIERUNG (Spur B) ============== --}} @if ($this->canRedact($request) && $request->status->isOpen())
{{ __('Anonymisierung (Schwärzung)') }}

{{ __('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.') }}

@if ((array) ($request->payload['redaction'] ?? []) !== [])
{{ __('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) {{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }} @endif
@endif
@if ($request->pressRelease->images->isNotEmpty()) {{ __('Bilder entfernen (Klarname in Pressebild!)') }}
@foreach ($request->pressRelease->images as $image) @endforeach
@endif
{{ $redactionPreview === null ? __('Vorschau erstellen (Pflicht)') : __('Vorschau aktualisieren') }} @if ($redactionPreview !== null) {{ __('Anonymisieren ausführen') }} @endif @if ($request->pressRelease->status === \App\Enums\PressReleaseStatus::Published) {{ __('Stattdessen depublizieren') }} @endif
{{-- Kompletter PM-Inhalt: vor der Vorschau das Original (Begriffe von hier kopieren), danach der Stand nach Schwärzung. --}}

{{ $redactionPreview === null ? __('Aktueller Inhalt — Begriffe von hier in das Feld oben kopieren') : __('Vorschau nach Schwärzung') }}

@if ($redactionPreview !== null) {{ __(':count Ersetzung(en)', ['count' => $redactionPreview['total_hits']]) }} @endif
@foreach ($this->redactableFieldLabels() as $field => $label) @php $fieldValue = $redactionPreview['fields'][$field]['after'] ?? $request->pressRelease->getAttribute($field); $fieldHits = $redactionPreview['fields'][$field]['hits'] ?? 0; @endphp @continue(blank($fieldValue))
{{ $label }}@if ($fieldHits > 0) · {{ $fieldHits }}× {{ __('ersetzt') }}@endif

{{ $this->displayValue($fieldValue) }}

@endforeach @if ($redactionPreview !== null && $redactionPreview['total_hits'] === 0)

{{ __('Keine Texttreffer — es würden nur Kontakte/Bilder entfernt.') }}

@endif
@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
{{ __('Verifikation') }}
{{ $request->verification_method?->label() ?? __('nicht verifiziert') }} @if ($request->verified_at) · {{ $request->verified_at->format('d.m.Y H:i') }} @endif
@if ($request->executed_at)
{{ __('Ausgeführt') }}
{{ $request->executed_at->format('d.m.Y H:i') }}
@endif
@if ($request->executed_at && $request->redactionSnapshots()->exists()) {{ __('Rückgängig machen (Snapshot)') }} @endif
@if ($request->status->isOpen()) @if ($request->status !== \App\Enums\LegalRequestStatus::InProgress) {{ __('In Bearbeitung nehmen') }} @endif {{ __('Als erledigt markieren') }} {{ __('Ablehnen') }} @else {{ __('Wieder öffnen') }} @endif