recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert, verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich). Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff, transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung, Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore. Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten- Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse (neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg. Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %, Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als Change-Anfrage (Interim bis 10-€-Checkout, Phase 7). Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
58c0cc3f23
commit
b4b2e013d6
42 changed files with 3868 additions and 1 deletions
36
resources/views/emails/legal/report-link.blade.php
Normal file
36
resources/views/emails/legal/report-link.blade.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<x-emails.layout title="Bestätigungslink für Ihre Pressemitteilung" preheader="Über diesen Link können Sie Änderungs-, Lösch- und Datenschutz-Anliegen zu Ihrer Pressemitteilung stellen.">
|
||||
<p>Guten Tag,</p>
|
||||
|
||||
<p>
|
||||
für die folgende Pressemitteilung wurde über unser Meldeverfahren ein
|
||||
Änderungs- oder Lösch-Anliegen gestartet:
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<dt>Pressemitteilung</dt>
|
||||
<dd>{{ $pressRelease->title }}</dd>
|
||||
</dl>
|
||||
|
||||
<p>
|
||||
Wenn Sie dieses Anliegen stellen möchten, bestätigen Sie bitte über den
|
||||
folgenden Link, dass Sie Zugriff auf die hinterlegte Pressekontaktadresse
|
||||
haben. Anschließend können Sie Ihr Anliegen auswählen:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ $verificationUrl }}">{{ $verificationUrl }}</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Der Link ist gültig bis <strong>{{ $expiresAt->format('d.m.Y H:i') }} Uhr</strong>
|
||||
und kann nur einmal verwendet werden.
|
||||
</p>
|
||||
|
||||
<div class="note">
|
||||
<p>
|
||||
Sie haben kein Anliegen gestartet? Dann können Sie diese E-Mail ignorieren —
|
||||
ohne Klick auf den Link passiert nichts. Der Link wurde ausschließlich an die
|
||||
in der Veröffentlichung hinterlegte Pressekontaktadresse gesendet.
|
||||
</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -16,10 +20,190 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
|
|||
|
||||
public string $notification = '';
|
||||
|
||||
/** Schwärzbegriffe inkl. Varianten, ein Begriff pro Zeile. */
|
||||
public string $redactionTerms = '';
|
||||
|
||||
public string $redactionReplacement = '';
|
||||
|
||||
public bool $detachContacts = true;
|
||||
|
||||
/** @var list<int> */
|
||||
public array $removeImageIds = [];
|
||||
|
||||
/** @var array{fields: array<string, array{hits: int, after: string}>, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->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);
|
||||
$pressRelease->lockSync('Depublizierung im Meldeverfahren '.$request->reference());
|
||||
|
||||
$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->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
*/
|
||||
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('#<br[^>]*>|</(p|div|h[1-6]|li|blockquote)>#i', "\n", (string) $value) ?? '';
|
||||
|
||||
return trim(strip_tags($withBreaks));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function redactableFieldLabels(): array
|
||||
{
|
||||
return [
|
||||
'title' => __('Titel'),
|
||||
'subtitle' => __('Untertitel'),
|
||||
'text' => __('Text'),
|
||||
'boilerplate_override' => __('Boilerplate'),
|
||||
'keywords' => __('Keywords'),
|
||||
];
|
||||
}
|
||||
|
||||
public function markInProgress(): void
|
||||
|
|
@ -70,7 +254,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
|
|||
|
||||
private function request(): LegalRequest
|
||||
{
|
||||
return LegalRequest::query()->with(['pressRelease', 'resolver'])->findOrFail($this->id);
|
||||
return LegalRequest::query()->with(['pressRelease.images', 'resolver'])->findOrFail($this->id);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -175,6 +359,107 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== ANONYMISIERUNG (Spur B) ============== --}}
|
||||
@if ($this->canRedact($request) && $request->status->isOpen())
|
||||
<article class="panel xl:col-span-2">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anonymisierung (Schwärzung)') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('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>
|
||||
|
||||
<flux:field>
|
||||
<flux:textarea wire:model="redactionTerms" rows="4"
|
||||
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"
|
||||
:placeholder="'Matvey Teplitsky Herr Teplitsky Teplitskys'" />
|
||||
<flux:error name="redactionTerms" />
|
||||
</flux:field>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:input wire:model="redactionReplacement"
|
||||
:label="__('Ersetzungswert (Rolle/Platzhalter, nie leer)')"
|
||||
:placeholder="\App\Services\PressRelease\AnonymizationService::DEFAULT_REPLACEMENT" />
|
||||
</flux:field>
|
||||
<div class="pt-6">
|
||||
<flux:checkbox wire:model="detachContacts"
|
||||
:label="__('Kontakt-Zuordnungen der PM lösen (Ansprechpartner raus)')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($request->pressRelease->images->isNotEmpty())
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Bilder entfernen (Klarname in Pressebild!)') }}</flux:label>
|
||||
<div class="space-y-1">
|
||||
@foreach ($request->pressRelease->images as $image)
|
||||
<flux:checkbox wire:model="removeImageIds" value="{{ $image->id }}"
|
||||
:label="($image->title ?: $image->path)" />
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
<flux:button variant="filled" icon="eye" wire:click="previewAnonymization">
|
||||
{{ $redactionPreview === null ? __('Vorschau erstellen (Pflicht)') : __('Vorschau aktualisieren') }}
|
||||
</flux:button>
|
||||
@if ($redactionPreview !== null)
|
||||
<flux:button variant="primary" icon="shield-check" wire:click="executeAnonymization"
|
||||
wire:confirm="{{ __('Schwärzung jetzt ausführen? Alt-Werte werden verschlüsselt gesichert, die Re-Sync-Sperre wird gesetzt.') }}">
|
||||
{{ __('Anonymisieren ausführen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@if ($request->pressRelease->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<flux:button variant="danger" icon="archive-box-arrow-down" wire:click="depublishRelease"
|
||||
wire:confirm="{{ __('PM stattdessen depublizieren (Tombstone, umkehrbar)? Richtig, wenn nach Schwärzung nur ein Rumpf bliebe.') }}">
|
||||
{{ __('Stattdessen depublizieren') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Kompletter PM-Inhalt: vor der Vorschau das Original (Begriffe
|
||||
von hier kopieren), danach der Stand nach Schwärzung. --}}
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-[12.5px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ $redactionPreview === null
|
||||
? __('Aktueller Inhalt — Begriffe von hier in das Feld oben kopieren')
|
||||
: __('Vorschau nach Schwärzung') }}
|
||||
</p>
|
||||
@if ($redactionPreview !== null)
|
||||
<span class="badge dot {{ $redactionPreview['total_hits'] > 0 ? 'ok' : 'warn' }}">
|
||||
{{ __(':count Ersetzung(en)', ['count' => $redactionPreview['total_hits']]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@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))
|
||||
<div class="text-[12.5px]">
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)]">
|
||||
{{ $label }}@if ($fieldHits > 0) · {{ $fieldHits }}× {{ __('ersetzt') }}@endif
|
||||
</span>
|
||||
<p class="mt-1 text-[color:var(--color-ink)] whitespace-pre-line">{{ $this->displayValue($fieldValue) }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if ($redactionPreview !== null && $redactionPreview['total_hits'] === 0)
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Keine Texttreffer — es würden nur Kontakte/Bilder entfernt.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== BEARBEITUNG ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
|
|
@ -195,6 +480,31 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
|
|||
</p>
|
||||
@endif
|
||||
|
||||
<dl class="text-[12px] text-[color:var(--color-ink-3)] space-y-1">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>{{ __('Verifikation') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">
|
||||
{{ $request->verification_method?->label() ?? __('nicht verifiziert') }}
|
||||
@if ($request->verified_at)
|
||||
· {{ $request->verified_at->format('d.m.Y H:i') }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@if ($request->executed_at)
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>{{ __('Ausgeführt') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $request->executed_at->format('d.m.Y H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
@if ($request->executed_at && $request->redactionSnapshots()->exists())
|
||||
<flux:button variant="filled" icon="arrow-uturn-left" wire:click="restoreSnapshot"
|
||||
wire:confirm="{{ __('Vorher-Stand aus dem verschlüsselten Snapshot wiederherstellen? Die Re-Sync-Sperre bleibt bestehen.') }}">
|
||||
{{ __('Rückgängig machen (Snapshot)') }}
|
||||
</flux:button>
|
||||
@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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public bool $depublicationConfirmed = false;
|
||||
|
||||
/** Spur A/D (Phase 4): Korrektur-Formular sichtbar? */
|
||||
public bool $correctionOpen = false;
|
||||
|
||||
/** @var array<string, string> Bearbeitbare Feld-Werte der Korrektur. */
|
||||
public array $correctionValues = [];
|
||||
|
||||
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool}|null */
|
||||
public ?array $correctionPreview = null;
|
||||
|
||||
/**
|
||||
* Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.):
|
||||
* ['label' => Add-on-Name, 'required' => int, 'available' => int].
|
||||
|
|
@ -294,6 +303,130 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
Flux::modal('insufficient-credits')->show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spur A/D (Meldeverfahren Phase 4): Korrektur-Formular öffnen und mit
|
||||
* den aktuellen Werten vorbefüllen.
|
||||
*/
|
||||
public function startCorrection(): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
$this->correctionValues = [
|
||||
'title' => (string) ($pr->title ?? ''),
|
||||
'subtitle' => (string) ($pr->subtitle ?? ''),
|
||||
'text' => (string) ($pr->text ?? ''),
|
||||
'keywords' => (string) ($pr->keywords ?? ''),
|
||||
];
|
||||
$this->correctionOpen = true;
|
||||
$this->correctionPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pflicht-Vorschau: Levenshtein-Gate stuft klein (kostenlos, sofort)
|
||||
* vs. groß (Spur D, 10 €) ein — der Nutzer wählt das nie selbst.
|
||||
*/
|
||||
public function previewCorrection(\App\Services\PressRelease\CorrectionService $correctionService): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
$classification = $correctionService->classify($pr, $this->correctionValues);
|
||||
|
||||
if (! $classification['has_changes']) {
|
||||
$this->addError('correction', __('Es wurden keine Änderungen erkannt.'));
|
||||
$this->correctionPreview = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetErrorBag('correction');
|
||||
$this->correctionPreview = $classification;
|
||||
}
|
||||
|
||||
public function applyCorrection(\App\Services\PressRelease\CorrectionService $correctionService): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
if ($this->correctionPreview === null) {
|
||||
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$legalRequest = $correctionService->apply(
|
||||
$pr,
|
||||
$this->correctionValues,
|
||||
(string) auth()->user()->email,
|
||||
\App\Enums\LegalRequestVerificationMethod::LoggedIn,
|
||||
auth()->user(),
|
||||
);
|
||||
} catch (\App\Services\PressRelease\CorrectionTooLargeException) {
|
||||
$this->addError('correction', __('Die Änderung überschreitet den Rahmen einer kleinen Korrektur — bitte als kostenpflichtige Änderung einreichen.'));
|
||||
|
||||
return;
|
||||
} catch (\App\Services\PressRelease\CorrectionRateLimitException $e) {
|
||||
$this->addError('correction', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->correctionOpen = false;
|
||||
$this->correctionPreview = null;
|
||||
Flux::toast(__('Korrektur gespeichert (:reference) — die Änderung ist sofort live.', ['reference' => $legalRequest->reference()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Interim bis zum 10-€-Checkout (Phase 7): größere Änderung als
|
||||
* verifizierte Anfrage in die Admin-Queue geben — Zahlungsaufforderung
|
||||
* folgt, keine Kosten ohne Bestätigung.
|
||||
*/
|
||||
public function submitPaidChangeRequest(): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
if ($this->correctionPreview === null) {
|
||||
$this->addError('correction', __('Bitte zuerst die Vorschau prüfen.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$alreadyOpen = \App\Models\LegalRequest::query()
|
||||
->where('press_release_id', $pr->id)
|
||||
->where('type', \App\Enums\LegalRequestType::Change->value)
|
||||
->open()
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->addError('correction', __('Für diese Pressemitteilung liegt bereits eine offene Änderungs-Anfrage vor.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$legalRequest = \App\Models\LegalRequest::query()->create([
|
||||
'press_release_id' => $pr->id,
|
||||
'portal' => $pr->portal?->value,
|
||||
'type' => \App\Enums\LegalRequestType::Change->value,
|
||||
'status' => \App\Enums\LegalRequestStatus::Open->value,
|
||||
'requester_email' => (string) auth()->user()->email,
|
||||
'message' => __('Kostenpflichtige Änderung (10 €) über das Kunden-Panel eingereicht.'),
|
||||
'payload' => [
|
||||
'source' => 'self_service',
|
||||
'new_values' => $this->correctionValues,
|
||||
],
|
||||
'requester_ip' => request()->ip(),
|
||||
'verified_at' => now(),
|
||||
'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value,
|
||||
]);
|
||||
|
||||
$this->correctionOpen = false;
|
||||
$this->correctionPreview = null;
|
||||
Flux::toast(__('Änderungs-Anfrage :reference eingereicht — Sie erhalten vor der Umsetzung eine Zahlungsaufforderung.', ['reference' => $legalRequest->reference()]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu):
|
||||
* protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt
|
||||
|
|
@ -557,6 +690,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
|
||||
{{ __('Vorschau-Link') }}
|
||||
</flux:button>
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
{{-- Meldeverfahren (Phase 3): Eigentümer überspringt die
|
||||
Magic-Link-Verifikation (Zuordnung = Verifikation). --}}
|
||||
<form method="POST" action="{{ route('me.press-releases.report', ['pressReleaseId' => $pr->id]) }}">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="filled" icon="flag">
|
||||
{{ __('Ändern/Melden') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
@endif
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
|
|
@ -1142,6 +1285,88 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== ÄNDERUNG (Meldeverfahren Phase 4, Spur A/D) ==============
|
||||
Kleine Korrektur (Levenshtein-Gate) kostenlos + sofort live; größere
|
||||
Änderung wird automatisch als Spur D (10 €) eingestuft. --}}
|
||||
@if ($pr->status === PressReleaseStatus::Published)
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Änderung & Korrektur') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.pencil-square class="size-[18px]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}</h3>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
|
||||
{{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (neuer Text, max. Distanz über :percent %) kosten 10 € und durchlaufen erneut die Freigabe. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent')]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $correctionOpen)
|
||||
<flux:button size="sm" variant="filled" icon="pencil" wire:click="startCorrection">
|
||||
{{ __('Korrektur/Änderung starten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
<flux:field>
|
||||
<flux:input wire:model="correctionValues.title" :label="__('Titel')" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="correctionValues.subtitle" :label="__('Untertitel')" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:textarea wire:model="correctionValues.text" rows="10" :label="__('Text')" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:input wire:model="correctionValues.keywords" :label="__('Keywords')" />
|
||||
</flux:field>
|
||||
|
||||
<flux:error name="correction" />
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewCorrection">
|
||||
{{ $correctionPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
|
||||
</flux:button>
|
||||
@if ($correctionPreview !== null && $correctionPreview['is_minor'])
|
||||
<flux:button size="sm" variant="primary" icon="check" wire:click="applyCorrection"
|
||||
wire:confirm="{{ __('Korrektur jetzt speichern? Die Änderung ist sofort öffentlich sichtbar.') }}">
|
||||
{{ __('Kostenlos speichern — sofort live') }}
|
||||
</flux:button>
|
||||
@elseif ($correctionPreview !== null)
|
||||
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="submitPaidChangeRequest">
|
||||
{{ __('Als kostenpflichtige Änderung einreichen (10 €)') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button size="sm" variant="ghost" wire:click="$set('correctionOpen', false)">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if ($correctionPreview !== null)
|
||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-1">
|
||||
<p class="font-semibold text-[color:var(--color-ink)] m-0">
|
||||
{{ $correctionPreview['is_minor']
|
||||
? __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.')
|
||||
: __('Einstufung: Größere Änderung (max. Distanz :percent %) — 10 € mit erneutem Freigabelauf.', ['percent' => $correctionPreview['max_distance_percent']]) }}
|
||||
</p>
|
||||
@foreach ($correctionPreview['fields'] as $field => $meta)
|
||||
@continue(! $meta['changed'])
|
||||
<p class="m-0 text-[color:var(--color-ink-2)]">
|
||||
{{ $field }}: {{ __('Änderungsdistanz :percent %', ['percent' => $meta['distance_percent']]) }}
|
||||
</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== DEPUBLIKATION (T12, Meldeverfahren Weg 3) ==============
|
||||
Bewusst ganz unten (QA-Befund 07.07.): seltener Ausnahme-Service,
|
||||
soll nicht zwischen Add-ons und Inhalt um Aufmerksamkeit konkurrieren. --}}
|
||||
|
|
|
|||
73
resources/views/web/melden-pm-anliegen.blade.php
Normal file
73
resources/views/web/melden-pm-anliegen.blade.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
@extends('web.layouts.web-master')
|
||||
|
||||
@section('title', __('static.pm_report.concerns_title'))
|
||||
@section('meta_description', __('static.pm_report.meta'))
|
||||
|
||||
@section('content')
|
||||
<x-web.static-page
|
||||
:eyebrow="__('static.legal.eyebrow')"
|
||||
:title="__('static.pm_report.concerns_title')"
|
||||
:breadcrumb="__('static.pm_report.breadcrumb')"
|
||||
>
|
||||
<div class="max-w-[640px]">
|
||||
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2">
|
||||
<p class="font-semibold text-ink mb-1">{{ $pressRelease->title }}</p>
|
||||
<p>{{ __('static.pm_report.verified_as', ['email' => $contactEmail]) }}</p>
|
||||
</div>
|
||||
|
||||
@if (session('pm-report-status'))
|
||||
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
|
||||
{{ session('pm-report-status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
<fieldset>
|
||||
<legend class="block text-[12px] font-semibold text-ink mb-2">{{ __('static.pm_report.concern_legend') }}</legend>
|
||||
<div class="space-y-2">
|
||||
@foreach ([
|
||||
'correction' => ['label' => __('static.pm_report.track_correction'), 'desc' => __('static.pm_report.track_correction_desc'), 'cost' => __('static.pm_report.cost_free')],
|
||||
'dsgvo' => ['label' => __('static.pm_report.track_dsgvo'), 'desc' => __('static.pm_report.track_dsgvo_desc'), 'cost' => __('static.pm_report.cost_free')],
|
||||
'depublication' => ['label' => __('static.pm_report.track_depublication'), 'desc' => __('static.pm_report.track_depublication_desc'), 'cost' => '35 €'],
|
||||
'change' => ['label' => __('static.pm_report.track_change'), 'desc' => __('static.pm_report.track_change_desc'), 'cost' => '10 €'],
|
||||
] as $value => $track)
|
||||
<label class="flex items-start gap-3 border border-bg-rule px-3 py-2.5 cursor-pointer hover:bg-bg-elev">
|
||||
<input type="radio" name="concern" value="{{ $value }}" required class="mt-0.5"
|
||||
@checked(old('concern') === $value) />
|
||||
<span class="text-[13px] text-ink">
|
||||
<span class="font-semibold">{{ $track['label'] }}</span>
|
||||
<span class="text-ink-3">· {{ $track['cost'] }}</span>
|
||||
<span class="block text-[12.5px] text-ink-2">{{ $track['desc'] }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('concern')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.pm_report.label_message') }}</label>
|
||||
<textarea id="message" name="message" rows="6" required minlength="10" maxlength="5000"
|
||||
placeholder="{{ __('static.pm_report.placeholder_message') }}"
|
||||
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<p class="text-[12.5px] text-ink-3 leading-relaxed">
|
||||
{{ __('static.pm_report.concern_note') }}
|
||||
</p>
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
|
||||
{{ __('static.pm_report.concern_submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-web.static-page>
|
||||
@endsection
|
||||
87
resources/views/web/melden-pm.blade.php
Normal file
87
resources/views/web/melden-pm.blade.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
@extends('web.layouts.web-master')
|
||||
|
||||
@section('title', __('static.pm_report.title'))
|
||||
@section('meta_description', __('static.pm_report.meta'))
|
||||
|
||||
@section('content')
|
||||
<x-web.static-page
|
||||
:eyebrow="__('static.legal.eyebrow')"
|
||||
:title="__('static.pm_report.title')"
|
||||
:breadcrumb="__('static.pm_report.breadcrumb')"
|
||||
>
|
||||
<div class="max-w-[640px]">
|
||||
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
|
||||
{{ __('static.pm_report.intro') }}
|
||||
</p>
|
||||
|
||||
{{-- Transparenz vorab: kostenlos vs. kostenpflichtig (Anti-Zombie). --}}
|
||||
<div class="border border-bg-rule mb-6">
|
||||
<table class="w-full text-[12.5px]">
|
||||
<tbody>
|
||||
@foreach ([
|
||||
['label' => __('static.pm_report.track_correction'), 'cost' => __('static.pm_report.cost_free')],
|
||||
['label' => __('static.pm_report.track_dsgvo'), 'cost' => __('static.pm_report.cost_free')],
|
||||
['label' => __('static.pm_report.track_depublication'), 'cost' => '35 €'],
|
||||
['label' => __('static.pm_report.track_change'), 'cost' => '10 €'],
|
||||
] as $track)
|
||||
<tr class="border-b border-bg-rule last:border-b-0">
|
||||
<td class="px-3 py-2 text-ink">{{ $track['label'] }}</td>
|
||||
<td class="px-3 py-2 text-right font-semibold text-ink">{{ $track['cost'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (session('pm-report-status'))
|
||||
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
|
||||
{{ session('pm-report-status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('pm-report-error'))
|
||||
<div class="mb-6 px-4 py-3 border border-red-300 bg-red-50 text-[13px] text-red-700" role="alert">
|
||||
{{ session('pm-report-error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('pm-report-manual'))
|
||||
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
|
||||
{{ __('static.pm_report.manual_fallback') }}
|
||||
<a href="{{ route('melden.form', ['way' => 'kontaktdaten']) }}" class="underline underline-offset-2 hover:text-ink">
|
||||
{{ __('static.pm_report.manual_fallback_link') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('melden.pm.store') }}" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
{{-- Honeypot --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reported_url" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.report_form.label_url') }}</label>
|
||||
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url') }}" required
|
||||
placeholder="https://…"
|
||||
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('reported_url')
|
||||
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<p class="text-[12.5px] text-ink-3 leading-relaxed">
|
||||
{{ __('static.pm_report.verification_note') }}
|
||||
</p>
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
|
||||
{{ __('static.pm_report.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-web.static-page>
|
||||
@endsection
|
||||
|
|
@ -34,6 +34,17 @@
|
|||
<p>{!! __('static.report.intro_direct') !!}</p>
|
||||
<p>{!! __('static.report.intro_free') !!}</p>
|
||||
|
||||
{{-- Meldeverfahren v2 (Phase 3): Self-Service-Einstieg über die
|
||||
hinterlegte Pressekontaktadresse. --}}
|
||||
<div class="my-6 px-4 py-4 border border-bg-rule bg-bg-elev">
|
||||
<p class="font-semibold text-ink mb-1">{{ __('static.pm_report.title') }}</p>
|
||||
<p class="text-[13px] text-ink-2 mb-3">{{ __('static.pm_report.verification_note') }}</p>
|
||||
<a href="{{ route('melden.pm') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-brand text-white text-[13px] font-semibold hover:opacity-90 no-underline">
|
||||
{{ __('static.pm_report.breadcrumb') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>{{ __('static.report.way1_heading') }}</h2>
|
||||
<p>{{ __('static.report.way1_intro') }}</p>
|
||||
<ul>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue