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
30
app/Console/Commands/PurgeRedactionSnapshots.php
Normal file
30
app/Console/Commands/PurgeRedactionSnapshots.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Löscht verschlüsselte Vorher-Snapshots des Meldeverfahrens nach Ablauf
|
||||
* ihrer Löschfrist (purge_after, gesetzt aus legal.snapshot_retention_days).
|
||||
* Danach ist eine Schwärzung endgültig — genau so gewollt: Alt-Werte dürfen
|
||||
* nicht unbegrenzt weiterleben. Läuft täglich via Scheduler.
|
||||
*/
|
||||
class PurgeRedactionSnapshots extends Command
|
||||
{
|
||||
protected $signature = 'snapshots:purge';
|
||||
|
||||
protected $description = 'Löscht abgelaufene Schwärzungs-Snapshots (Löschfrist überschritten).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$deleted = PressReleaseRedactionSnapshot::query()
|
||||
->dueForPurge()
|
||||
->delete();
|
||||
|
||||
$this->info("Schwärzungs-Snapshots bereinigt: {$deleted} Einträge gelöscht.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ enum ActivityType: string
|
|||
case EmailUnsubscribe = 'email_unsubscribe';
|
||||
case AdminGrant = 'admin_grant';
|
||||
case CheckoutStarted = 'checkout_started';
|
||||
case SyncLock = 'sync_lock';
|
||||
case Anonymization = 'anonymization';
|
||||
case Correction = 'correction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -43,6 +46,9 @@ enum ActivityType: string
|
|||
self::EmailUnsubscribe => 'Mail-Abmeldung',
|
||||
self::AdminGrant => 'Gutschrift',
|
||||
self::CheckoutStarted => 'Zur Zahlung',
|
||||
self::SyncLock => 'Re-Sync-Sperre',
|
||||
self::Anonymization => 'Anonymisierung',
|
||||
self::Correction => 'Korrektur',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +68,9 @@ enum ActivityType: string
|
|||
self::EmailUnsubscribe => 'orange',
|
||||
self::AdminGrant => 'amber',
|
||||
self::CheckoutStarted => 'yellow',
|
||||
self::SyncLock => 'red',
|
||||
self::Anonymization => 'red',
|
||||
self::Correction => 'blue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ enum LegalRequestType: string
|
|||
case Dsgvo = 'dsgvo';
|
||||
case PersonalRights = 'personal_rights';
|
||||
case Report = 'report';
|
||||
case Correction = 'correction';
|
||||
case Depublication = 'depublication';
|
||||
case Change = 'change';
|
||||
|
||||
/**
|
||||
* Interne (Admin-)Bezeichnung.
|
||||
|
|
@ -17,6 +20,9 @@ enum LegalRequestType: string
|
|||
self::Dsgvo => 'DSGVO-Anonymisierung',
|
||||
self::PersonalRights => 'Persönlichkeitsrecht',
|
||||
self::Report => 'Meldung',
|
||||
self::Correction => 'Kleine Korrektur',
|
||||
self::Depublication => 'Depublizierung',
|
||||
self::Change => 'Änderung',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +35,9 @@ enum LegalRequestType: string
|
|||
self::Dsgvo => 'Löschung/Anonymisierung meiner Daten (DSGVO)',
|
||||
self::PersonalRights => 'Verletzung von Persönlichkeitsrechten',
|
||||
self::Report => 'Pressemitteilung melden',
|
||||
self::Correction => 'Kleine Korrektur (kostenlos)',
|
||||
self::Depublication => 'Pressemitteilung offline nehmen',
|
||||
self::Change => 'Inhaltliche Änderung',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
app/Enums/LegalRequestVerificationMethod.php
Normal file
25
app/Enums/LegalRequestVerificationMethod.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Wie die Berechtigung des Antragstellers im Meldeverfahren nachgewiesen
|
||||
* wurde: Magic-Link an die hinterlegte Pressekontaktadresse (Regelfall),
|
||||
* manuelle Prüfung durch den Support (Kanal tot, §6-Fallback) oder
|
||||
* eingeloggter Creator mit zugeordneter PM.
|
||||
*/
|
||||
enum LegalRequestVerificationMethod: string
|
||||
{
|
||||
case MagicLink = 'magic_link';
|
||||
case Manual = 'manual';
|
||||
case LoggedIn = 'logged_in';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MagicLink => 'Magic-Link',
|
||||
self::Manual => 'Manuell geprüft',
|
||||
self::LoggedIn => 'Eingeloggter Creator',
|
||||
};
|
||||
}
|
||||
}
|
||||
339
app/Http/Controllers/Web/PressReleaseReportController.php
Normal file
339
app/Http/Controllers/Web/PressReleaseReportController.php
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\LegalRequestReceived;
|
||||
use App\Mail\LegalRequestSubmitted;
|
||||
use App\Mail\PressReleaseReportLink;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Meldeverfahren v2, Phase 3 — Deflektions-Trichter „PM melden/ändern":
|
||||
* URL einfügen → System schlägt den hinterlegten Pressekontakt nach →
|
||||
* Magic-Link an diesen (nie an eine eingegebene Adresse) → verifizierte
|
||||
* Anliegen-Auswahl (vier Spuren). Kanal tot → Verweis auf die manuelle
|
||||
* Verifikation über das bestehende DSGVO-Formular (§6-Fallback).
|
||||
*/
|
||||
class PressReleaseReportController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'pm_report.verified';
|
||||
|
||||
/** @var array<string, LegalRequestType> */
|
||||
public const CONCERNS = [
|
||||
'correction' => LegalRequestType::Correction,
|
||||
'dsgvo' => LegalRequestType::Dsgvo,
|
||||
'depublication' => LegalRequestType::Depublication,
|
||||
'change' => LegalRequestType::Change,
|
||||
];
|
||||
|
||||
public function showIntake(): View
|
||||
{
|
||||
return view('web.melden-pm');
|
||||
}
|
||||
|
||||
public function requestLink(Request $request, string $edition, MagicLinkGenerator $generator): RedirectResponse
|
||||
{
|
||||
// Honeypot vor der Validierung (sonst verrät die Regel die Falle).
|
||||
if (filled($request->input('website'))) {
|
||||
return back()->with('pm-report-status', __('static.pm_report.flash_link_sent', ['email' => '***']));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reported_url' => ['required', 'url', 'max:500'],
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
$pressRelease = $this->matchPublishedRelease($validated['reported_url']);
|
||||
|
||||
if (! $pressRelease) {
|
||||
throw ValidationException::withMessages([
|
||||
'reported_url' => __('static.pm_report.validation_not_found'),
|
||||
]);
|
||||
}
|
||||
|
||||
$contactEmail = $pressRelease->pressContactEmail();
|
||||
|
||||
// Kanal tot / keine Adresse hinterlegt (Huraux-Fall): kein
|
||||
// vertrauenswürdiger Kanal → manuelle Verifikation über das
|
||||
// bestehende Formular, Admin prüft (§6-Fallback).
|
||||
if (! $contactEmail) {
|
||||
return back()->with('pm-report-manual', true);
|
||||
}
|
||||
|
||||
// Rate-Limit pro PM: Der Link-Versand darf kein Werkzeug werden,
|
||||
// fremde Kontakt-Postfächer zu fluten.
|
||||
$perReleaseKey = 'pm-report-link|'.$pressRelease->id;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($perReleaseKey, (int) config('legal.report_links_per_day'))) {
|
||||
throw ValidationException::withMessages([
|
||||
'reported_url' => __('static.pm_report.throttle_release'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($perReleaseKey, 86400);
|
||||
|
||||
$link = $generator->createReportLink($pressRelease, $contactEmail);
|
||||
|
||||
Mail::to($contactEmail)->queue(
|
||||
(new PressReleaseReportLink(
|
||||
$pressRelease,
|
||||
route('melden.pm.verify', ['edition' => $edition, 'token' => $link['plain_token']]),
|
||||
$link['expires_at'],
|
||||
))->afterCommit()
|
||||
);
|
||||
|
||||
return back()->with('pm-report-status', __('static.pm_report.flash_link_sent', [
|
||||
'email' => $this->maskEmail($contactEmail),
|
||||
]));
|
||||
}
|
||||
|
||||
public function verify(Request $request, string $edition, string $token): RedirectResponse
|
||||
{
|
||||
$magicLink = MagicLink::query()
|
||||
->where('token_hash', hash('sha256', $token))
|
||||
->where('purpose', 'press_release_report')
|
||||
->first();
|
||||
|
||||
if (! $magicLink || $magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) {
|
||||
return redirect()
|
||||
->route('melden.pm', ['edition' => $edition])
|
||||
->with('pm-report-error', __('static.pm_report.flash_link_invalid'));
|
||||
}
|
||||
|
||||
// Atomar beanspruchen (Muster MagicLinkConsumeController): nur ein
|
||||
// paralleler Request darf den Link verbrauchen.
|
||||
$claimed = MagicLink::query()
|
||||
->whereKey($magicLink->id)
|
||||
->whereNull('consumed_at')
|
||||
->update(['consumed_at' => now(), 'ip_consumed' => $request->ip()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return redirect()
|
||||
->route('melden.pm', ['edition' => $edition])
|
||||
->with('pm-report-error', __('static.pm_report.flash_link_invalid'));
|
||||
}
|
||||
|
||||
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||
$contactEmail = (string) ($magicLink->payload['contact_email'] ?? '');
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, [
|
||||
'press_release_id' => $pressReleaseId,
|
||||
'contact_email' => $contactEmail,
|
||||
'method' => LegalRequestVerificationMethod::MagicLink->value,
|
||||
'expires_at' => now()->addMinutes((int) config('legal.report_session_minutes'))->timestamp,
|
||||
]);
|
||||
|
||||
return redirect()->route('melden.pm.anliegen', ['edition' => $edition]);
|
||||
}
|
||||
|
||||
public function showConcerns(Request $request, string $edition): View|RedirectResponse
|
||||
{
|
||||
$verified = $this->verifiedContext($request);
|
||||
|
||||
if (! $verified) {
|
||||
return redirect()
|
||||
->route('melden.pm', ['edition' => $edition])
|
||||
->with('pm-report-error', __('static.pm_report.flash_session_expired'));
|
||||
}
|
||||
|
||||
return view('web.melden-pm-anliegen', [
|
||||
'pressRelease' => $verified['press_release'],
|
||||
'contactEmail' => $this->maskEmail($verified['contact_email']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeConcern(Request $request, string $edition): RedirectResponse
|
||||
{
|
||||
$verified = $this->verifiedContext($request);
|
||||
|
||||
if (! $verified) {
|
||||
return redirect()
|
||||
->route('melden.pm', ['edition' => $edition])
|
||||
->with('pm-report-error', __('static.pm_report.flash_session_expired'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'concern' => ['required', 'string', 'in:'.implode(',', array_keys(self::CONCERNS))],
|
||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
]);
|
||||
|
||||
$pressRelease = $verified['press_release'];
|
||||
$type = self::CONCERNS[$validated['concern']];
|
||||
|
||||
// Bremse: 1 offene Anfrage pro PM/Typ — verhindert Duplikate in der Queue.
|
||||
$alreadyOpen = LegalRequest::query()
|
||||
->where('press_release_id', $pressRelease->id)
|
||||
->where('type', $type->value)
|
||||
->open()
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
throw ValidationException::withMessages([
|
||||
'concern' => __('static.pm_report.validation_already_open'),
|
||||
]);
|
||||
}
|
||||
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'portal' => $pressRelease->portal?->value,
|
||||
'type' => $type->value,
|
||||
'status' => LegalRequestStatus::Open->value,
|
||||
'requester_email' => $verified['contact_email'],
|
||||
'message' => $validated['message'],
|
||||
'payload' => [
|
||||
'source' => 'self_service',
|
||||
'concern' => $validated['concern'],
|
||||
],
|
||||
'requester_ip' => $request->ip(),
|
||||
'verified_at' => now(),
|
||||
'verification_method' => $verified['method'],
|
||||
]);
|
||||
|
||||
Mail::to($legalRequest->requester_email)->queue(
|
||||
(new LegalRequestReceived($legalRequest))->afterCommit()
|
||||
);
|
||||
Mail::to($this->operatorRecipient($request))->queue(
|
||||
(new LegalRequestSubmitted($legalRequest))->afterCommit()
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('melden.pm.anliegen', ['edition' => $edition])
|
||||
->with('pm-report-status', __('static.pm_report.flash_concern_received', [
|
||||
'reference' => $legalRequest->reference(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifizierten Kontext aus der Session lesen — abgelaufene Marker und
|
||||
* verschwundene PMs beenden die Sitzung.
|
||||
*
|
||||
* @return array{press_release: PressRelease, contact_email: string, method: string}|null
|
||||
*/
|
||||
private function verifiedContext(Request $request): ?array
|
||||
{
|
||||
$verified = (array) $request->session()->get(self::SESSION_KEY, []);
|
||||
|
||||
if (($verified['expires_at'] ?? 0) < now()->timestamp) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$pressRelease = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->find((int) ($verified['press_release_id'] ?? 0));
|
||||
|
||||
if (! $pressRelease) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'press_release' => $pressRelease,
|
||||
'contact_email' => (string) ($verified['contact_email'] ?? ''),
|
||||
'method' => (string) ($verified['method'] ?? LegalRequestVerificationMethod::MagicLink->value),
|
||||
];
|
||||
}
|
||||
|
||||
private function matchPublishedRelease(string $url): ?PressRelease
|
||||
{
|
||||
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->where('slug', urldecode($matches[1]))
|
||||
->where('status', PressReleaseStatus::Published)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* „kontakt@firma.de" → „k***@firma.de" — die Adresse selbst wird nie
|
||||
* angezeigt und nie aus dem Formular übernommen.
|
||||
*/
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
|
||||
return Str::substr($local, 0, 1).'***@'.$domain;
|
||||
}
|
||||
|
||||
private function operatorRecipient(Request $request): string
|
||||
{
|
||||
if (str_contains($request->getHost(), 'pressekonto')) {
|
||||
$brand = config('domains.domains.pressekonto.brand', []);
|
||||
} else {
|
||||
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
|
||||
}
|
||||
|
||||
return $brand['report_email'] ?? (string) config('legal.contact_email');
|
||||
}
|
||||
|
||||
private function ensureNotRateLimited(Request $request): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'reported_url' => __('static.report_form.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function throttleKey(Request $request): string
|
||||
{
|
||||
return 'pm-report|'.$request->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingeloggter Creator mit zugeordneter PM: überspringt die Magic-Link-
|
||||
* Verifikation (verification_method=logged_in) und landet direkt in der
|
||||
* Anliegen-Auswahl.
|
||||
*/
|
||||
public function startFromCustomer(Request $request, int $pressReleaseId): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 403);
|
||||
|
||||
$pressRelease = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressReleaseId)
|
||||
->accessibleBy($request->user())
|
||||
->first();
|
||||
|
||||
abort_unless($pressRelease !== null, 403);
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'contact_email' => (string) $request->user()->email,
|
||||
'method' => LegalRequestVerificationMethod::LoggedIn->value,
|
||||
'expires_at' => now()->addMinutes((int) config('legal.report_session_minutes'))->timestamp,
|
||||
]);
|
||||
|
||||
return redirect()->route('melden.pm.anliegen', ['edition' => 'de']);
|
||||
}
|
||||
}
|
||||
40
app/Mail/PressReleaseReportLink.php
Normal file
40
app/Mail/PressReleaseReportLink.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Verifikations-Link des Meldeverfahrens (Phase 3): geht immer an die
|
||||
* hinterlegte Pressekontaktadresse, nie an eine im Formular eingegebene —
|
||||
* wer den Link öffnet, ist strukturell verifiziert.
|
||||
*/
|
||||
class PressReleaseReportLink extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly PressRelease $pressRelease,
|
||||
public readonly string $verificationUrl,
|
||||
public readonly Carbon $expiresAt,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Bestätigungslink: Änderung/Meldung zu Ihrer Pressemitteilung',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.legal.report-link');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ namespace App\Models;
|
|||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Rechts-/Compliance-Anfrage (WS-3) – DSGVO, Persönlichkeitsrecht oder Meldung.
|
||||
|
|
@ -29,9 +31,13 @@ class LegalRequest extends Model
|
|||
'message',
|
||||
'payload',
|
||||
'requester_ip',
|
||||
'verified_at',
|
||||
'verification_method',
|
||||
'single_purchase_id',
|
||||
'resolved_by_user_id',
|
||||
'admin_note',
|
||||
'resolved_at',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -40,7 +46,10 @@ class LegalRequest extends Model
|
|||
'type' => LegalRequestType::class,
|
||||
'status' => LegalRequestStatus::class,
|
||||
'payload' => 'array',
|
||||
'verified_at' => 'datetime',
|
||||
'verification_method' => LegalRequestVerificationMethod::class,
|
||||
'resolved_at' => 'datetime',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +89,23 @@ class LegalRequest extends Model
|
|||
return $this->belongsTo(User::class, 'resolved_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bezahlvorgang der kostenpflichtigen Spuren (Depublizierung/Änderung).
|
||||
*/
|
||||
public function singlePurchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SinglePurchase::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselte Vorher-Snapshots ausgeführter Mutationen (Korrektur/
|
||||
* Anonymisierung) — Grundlage der Rückgängig-Option bis zur Löschfrist.
|
||||
*/
|
||||
public function redactionSnapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(PressReleaseRedactionSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<LegalRequest> $query
|
||||
* @return Builder<LegalRequest>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\BoostPlacement;
|
||||
use App\Enums\Portal;
|
||||
|
|
@ -13,6 +14,7 @@ use App\Enums\PressReleaseStatus;
|
|||
use App\Http\Middleware\SetEdition;
|
||||
use App\Models\Concerns\HasUniqueSlug;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use Closure;
|
||||
use Database\Factories\PressReleaseFactory;
|
||||
|
|
@ -115,6 +117,7 @@ class PressRelease extends Model
|
|||
'no_export' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'depublished_at' => 'datetime',
|
||||
'sync_locked_at' => 'datetime',
|
||||
'scheduled_at' => 'datetime',
|
||||
'embargo_at' => 'datetime',
|
||||
'withdrawal_waiver_consented_at' => 'datetime',
|
||||
|
|
@ -131,6 +134,58 @@ class PressRelease extends Model
|
|||
return $this->scheduled_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-Sync-Sperre (Meldeverfahren): Nach einer Mutation über das
|
||||
* Meldeverfahren darf kein Legacy-Import/API-Re-Sync die PM mehr
|
||||
* überschreiben — sonst kommt der alte Stand (z. B. ein geschwärzter
|
||||
* Klarname) zurück. Idempotent; die erste Sperre bleibt maßgeblich.
|
||||
*/
|
||||
public function lockSync(string $reason): void
|
||||
{
|
||||
if ($this->isSyncLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'sync_locked_at' => now(),
|
||||
'sync_lock_reason' => Str::limit($reason, 250),
|
||||
])->save();
|
||||
|
||||
app(ActivityLog::class)->record(
|
||||
ActivityType::SyncLock,
|
||||
sprintf('Re-Sync-Sperre gesetzt: %s', $reason),
|
||||
subject: $this,
|
||||
actorLabel: 'System',
|
||||
);
|
||||
}
|
||||
|
||||
public function isSyncLocked(): bool
|
||||
{
|
||||
return $this->sync_locked_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinterlegte Pressekontaktadresse für das Meldeverfahren: erst der
|
||||
* PM-Kontakt, dann die Firmen-Adresse. Bewusst ohne PortalScope — die
|
||||
* Meldeseite läuft host-agnostisch auf allen Domains.
|
||||
*/
|
||||
public function pressContactEmail(): ?string
|
||||
{
|
||||
$contactEmail = $this->contacts()
|
||||
->withoutGlobalScopes()
|
||||
->whereNotNull('email')
|
||||
->where('email', '!=', '')
|
||||
->value('email');
|
||||
|
||||
if (is_string($contactEmail) && $contactEmail !== '') {
|
||||
return $contactEmail;
|
||||
}
|
||||
|
||||
$companyEmail = $this->company()->withoutGlobalScopes()->value('email');
|
||||
|
||||
return is_string($companyEmail) && $companyEmail !== '' ? $companyEmail : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrfrist (Embargo) in der Anwendungs-Zeitzone (Europe/Berlin).
|
||||
*/
|
||||
|
|
|
|||
73
app/Models/PressReleaseRedactionSnapshot.php
Normal file
73
app/Models/PressReleaseRedactionSnapshot.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\PressReleaseRedactionSnapshotFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Verschlüsselter Vorher-Snapshot einer Meldeverfahren-Mutation
|
||||
* (Anonymisierung/Depublizierung). Existiert nur für die Reversibilität
|
||||
* innerhalb der Löschfrist — Alt-Werte leben nie im Klartext in Logs
|
||||
* oder Payloads weiter. Wird nach purge_after durch snapshots:purge
|
||||
* gelöscht (config legal.snapshot_retention_days).
|
||||
*/
|
||||
class PressReleaseRedactionSnapshot extends Model
|
||||
{
|
||||
/** @use HasFactory<PressReleaseRedactionSnapshotFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'legal_request_id',
|
||||
'press_release_id',
|
||||
'encrypted_before',
|
||||
'purge_after',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'encrypted_before' => 'encrypted:array',
|
||||
'purge_after' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot mit Löschfrist ab heute (config legal.snapshot_retention_days).
|
||||
*
|
||||
* @param array<string, mixed> $before
|
||||
*/
|
||||
public static function capture(PressRelease $pressRelease, array $before, ?LegalRequest $legalRequest = null): self
|
||||
{
|
||||
return self::query()->create([
|
||||
'legal_request_id' => $legalRequest?->getKey(),
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'encrypted_before' => $before,
|
||||
'purge_after' => now()->addDays((int) config('legal.snapshot_retention_days'))->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function legalRequest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LegalRequest::class);
|
||||
}
|
||||
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<PressReleaseRedactionSnapshot> $query
|
||||
* @return Builder<PressReleaseRedactionSnapshot>
|
||||
*/
|
||||
public function scopeDueForPurge(Builder $query): Builder
|
||||
{
|
||||
return $query->whereDate('purge_after', '<', now()->toDateString());
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,44 @@ class MagicLinkGenerator
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldeverfahren (Phase 3): Verifikations-Link an die hinterlegte
|
||||
* Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert —
|
||||
* kein Ermessen, kein Ausweis. Ältere unverbrauchte Report-Links derselben
|
||||
* PM werden entwertet (nur der jüngste Link zählt).
|
||||
*
|
||||
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
|
||||
*/
|
||||
public function createReportLink(PressRelease $pressRelease, string $contactEmail, ?int $ttlHours = null): array
|
||||
{
|
||||
$plainToken = Str::random(64);
|
||||
$tokenHash = hash('sha256', $plainToken);
|
||||
$expiresAt = now()->addHours($ttlHours ?? (int) config('legal.report_link_ttl_hours'));
|
||||
|
||||
MagicLink::query()
|
||||
->where('purpose', 'press_release_report')
|
||||
->whereNull('consumed_at')
|
||||
->where('payload->press_release_id', $pressRelease->id)
|
||||
->update(['consumed_at' => now()]);
|
||||
|
||||
$magicLink = MagicLink::query()->create([
|
||||
'user_id' => $pressRelease->user_id,
|
||||
'token_hash' => $tokenHash,
|
||||
'purpose' => 'press_release_report',
|
||||
'payload' => [
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'contact_email' => $contactEmail,
|
||||
],
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'magic_link' => $magicLink,
|
||||
'plain_token' => $plainToken,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public read-only share link for a press release. Token holder can view
|
||||
* the press release in any state (draft/review/rejected/published) until
|
||||
|
|
|
|||
|
|
@ -83,6 +83,16 @@ class PressReleaseImporter
|
|||
return;
|
||||
}
|
||||
|
||||
// Re-Sync-Sperre (Meldeverfahren): Eine über das Meldeverfahren
|
||||
// mutierte PM (Korrektur/Anonymisierung/Depublizierung) darf auch
|
||||
// per --force nie mit dem Legacy-Stand überschrieben werden — sonst
|
||||
// kommt z. B. ein geschwärzter Klarname zurück.
|
||||
if ($alreadyImported && $this->isSyncLocked($legacyPortal, $row->id)) {
|
||||
$result->incrementSkipped();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($ctx->dryRun) {
|
||||
$result->incrementImported();
|
||||
|
||||
|
|
@ -261,6 +271,15 @@ class PressReleaseImporter
|
|||
}
|
||||
}
|
||||
|
||||
private function isSyncLocked(string $legacyPortal, int $legacyId): bool
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
->where('legacy_portal', $legacyPortal)
|
||||
->where('legacy_id', $legacyId)
|
||||
->whereNotNull('sync_locked_at')
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $base, string $portal, string $language, ?int $excludeId = null): string
|
||||
{
|
||||
$slug = $base;
|
||||
|
|
|
|||
230
app/Services/PressRelease/AnonymizationService.php
Normal file
230
app/Services/PressRelease/AnonymizationService.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Services\ActivityLog;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Schwärzung personenbezogener Daten in einer Pressemitteilung
|
||||
* (Meldeverfahren v2, Spur B — Phase 2).
|
||||
*
|
||||
* Ersetzt Freitext-Schwärzwerte inkl. Varianten über Wortgrenzen in den
|
||||
* Textfeldern, löst Kontakt-Zuordnungen, entfernt markierte Bilder/Anhänge
|
||||
* und sichert die Vorher-Werte verschlüsselt (Reversibilität, nie Klartext
|
||||
* in Logs). Bewusst OHNE KI-Gate und OHNE Credit-Bezug — der DSGVO-Pfad
|
||||
* ist Compliance-Pflicht (Art. 12 Abs. 5) und darf an keinem Freigabelauf
|
||||
* oder Bezahlschritt hängen.
|
||||
*/
|
||||
class AnonymizationService
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const REDACTABLE_FIELDS = ['title', 'subtitle', 'text', 'boilerplate_override', 'keywords'];
|
||||
|
||||
public const DEFAULT_REPLACEMENT = '[entfernt]';
|
||||
|
||||
public function __construct(private readonly ActivityLog $activityLog) {}
|
||||
|
||||
/**
|
||||
* Pflicht-Vorschau: Was würde ersetzt? Verändert die PM nicht.
|
||||
*
|
||||
* @param list<string> $terms
|
||||
* @return array{fields: array<string, array{hits: int, after: string}>, total_hits: int}
|
||||
*/
|
||||
public function preview(PressRelease $pressRelease, array $terms, string $replacement = self::DEFAULT_REPLACEMENT): array
|
||||
{
|
||||
$fields = [];
|
||||
$totalHits = 0;
|
||||
|
||||
foreach (self::REDACTABLE_FIELDS as $field) {
|
||||
$value = (string) ($pressRelease->getAttribute($field) ?? '');
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$after, $hits] = $this->redact($value, $terms, $replacement);
|
||||
|
||||
if ($hits > 0) {
|
||||
$fields[$field] = ['hits' => $hits, 'after' => $after];
|
||||
$totalHits += $hits;
|
||||
}
|
||||
}
|
||||
|
||||
return ['fields' => $fields, 'total_hits' => $totalHits];
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt die Schwärzung aus: Snapshot → Text-Replace → Kontakte lösen →
|
||||
* Bilder/Anhänge entfernen → Re-Sync-Sperre → Cache-Invalidierung →
|
||||
* Verlaufs-Eintrag ohne Klartext-Altwerte.
|
||||
*
|
||||
* @param list<string> $terms
|
||||
* @param list<int> $removeImageIds
|
||||
* @param list<int> $removeAttachmentIds
|
||||
*/
|
||||
public function execute(
|
||||
PressRelease $pressRelease,
|
||||
array $terms,
|
||||
string $replacement = self::DEFAULT_REPLACEMENT,
|
||||
bool $detachContacts = true,
|
||||
array $removeImageIds = [],
|
||||
array $removeAttachmentIds = [],
|
||||
?LegalRequest $legalRequest = null,
|
||||
): PressReleaseRedactionSnapshot {
|
||||
$totalHits = 0;
|
||||
|
||||
$snapshot = DB::transaction(function () use (
|
||||
$pressRelease, $terms, $replacement, $detachContacts,
|
||||
$removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits,
|
||||
): PressReleaseRedactionSnapshot {
|
||||
$contactIds = $detachContacts
|
||||
? $pressRelease->contacts()->pluck('contacts.id')->all()
|
||||
: [];
|
||||
|
||||
$images = $pressRelease->images()->whereIn('id', $removeImageIds)->get();
|
||||
$attachments = $pressRelease->attachments()->whereIn('id', $removeAttachmentIds)->get();
|
||||
|
||||
$snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
'fields' => $pressRelease->only(self::REDACTABLE_FIELDS),
|
||||
'contact_ids' => $contactIds,
|
||||
'images' => $images->map(fn ($image) => $image->only(['id', 'disk', 'path', 'title']))->all(),
|
||||
'attachments' => $attachments->map(fn ($attachment) => $attachment->only(['id', 'disk', 'path']))->all(),
|
||||
'terms' => $terms,
|
||||
'replacement' => $replacement,
|
||||
], $legalRequest);
|
||||
|
||||
$changedFields = [];
|
||||
|
||||
foreach (self::REDACTABLE_FIELDS as $field) {
|
||||
$value = (string) ($pressRelease->getAttribute($field) ?? '');
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$after, $hits] = $this->redact($value, $terms, $replacement);
|
||||
|
||||
if ($hits > 0) {
|
||||
$pressRelease->setAttribute($field, $after);
|
||||
$changedFields[] = $field;
|
||||
$totalHits += $hits;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changedFields !== []) {
|
||||
$pressRelease->save();
|
||||
}
|
||||
|
||||
if ($contactIds !== []) {
|
||||
$pressRelease->contacts()->detach($contactIds);
|
||||
}
|
||||
|
||||
// Klarname in Pressebild/PDF: Zeile soft-löschen (raus aus der
|
||||
// Auslieferung); auf der public-Disk zusätzlich die Dateien
|
||||
// entfernen, weil sie sonst per Direkt-URL erreichbar blieben.
|
||||
foreach ($images as $image) {
|
||||
$this->deletePublicFiles($image->disk, array_filter([
|
||||
$image->path,
|
||||
...array_values((array) ($image->variants ?? [])),
|
||||
]));
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$this->deletePublicFiles($attachment->disk, array_filter([$attachment->path]));
|
||||
$attachment->delete();
|
||||
}
|
||||
|
||||
$pressRelease->lockSync(
|
||||
'Anonymisierung im Meldeverfahren'.($legalRequest ? ' '.$legalRequest->reference() : '')
|
||||
);
|
||||
|
||||
return $snapshot;
|
||||
});
|
||||
|
||||
$this->forgetSitemapCaches();
|
||||
|
||||
// Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die
|
||||
// personenbezogenen Daten im Verlauf weiter.
|
||||
$this->activityLog->record(
|
||||
ActivityType::Anonymization,
|
||||
sprintf(
|
||||
'PM anonymisiert%s: %d Ersetzung(en), %d Kontakt(e) gelöst, %d Bild(er), %d Anhang/Anhänge entfernt.',
|
||||
$legalRequest ? ' ('.$legalRequest->reference().')' : '',
|
||||
$totalHits,
|
||||
count($snapshot->encrypted_before['contact_ids'] ?? []),
|
||||
count($snapshot->encrypted_before['images'] ?? []),
|
||||
count($snapshot->encrypted_before['attachments'] ?? []),
|
||||
),
|
||||
user: Auth::user(),
|
||||
subject: $pressRelease,
|
||||
meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey()],
|
||||
);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzung über Wortgrenzen: Lookarounds auf Unicode-Buchstaben/Ziffern
|
||||
* statt \b, damit Umlaute korrekt begrenzen und weder Teiltreffer
|
||||
* stehenbleiben noch Wörter wie „maximal" bei Begriff „Max" zerlegt
|
||||
* werden. Längste Begriffe zuerst („Matvey Teplitsky" vor „Teplitsky").
|
||||
*
|
||||
* @param list<string> $terms
|
||||
* @return array{0: string, 1: int}
|
||||
*/
|
||||
private function redact(string $value, array $terms, string $replacement): array
|
||||
{
|
||||
$totalHits = 0;
|
||||
|
||||
$normalized = collect($terms)
|
||||
->map(fn (string $term): string => trim($term))
|
||||
->filter()
|
||||
->unique()
|
||||
->sortByDesc(fn (string $term): int => mb_strlen($term))
|
||||
->values();
|
||||
|
||||
foreach ($normalized as $term) {
|
||||
$pattern = '/(?<![\pL\pN])'.preg_quote($term, '/').'(?![\pL\pN])/iu';
|
||||
$value = (string) preg_replace($pattern, $replacement, $value, -1, $hits);
|
||||
$totalHits += (int) $hits;
|
||||
}
|
||||
|
||||
return [$value, $totalHits];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
*/
|
||||
private function deletePublicFiles(?string $disk, array $paths): void
|
||||
{
|
||||
if ($disk !== 'public' || $paths === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitemap-Caches aller Hosts verwerfen (SitemapController cached 1h je
|
||||
* Host) — nach einer Schwärzung darf kein Cache den alten Stand halten.
|
||||
*/
|
||||
private function forgetSitemapCaches(): void
|
||||
{
|
||||
foreach ((array) config('domains.domains', []) as $domain) {
|
||||
$host = parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST);
|
||||
|
||||
if ($host) {
|
||||
Cache::forget('web.sitemap.'.$host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/Services/PressRelease/CorrectionRateLimitException.php
Normal file
18
app/Services/PressRelease/CorrectionRateLimitException.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Spur-A-Bremse: zu viele Selbstbedienungs-Korrekturen für diese PM im
|
||||
* Zeitfenster (legal.corrections_per_week) — verhindert, dass die kostenlose
|
||||
* Korrektur per Salami-Taktik zum Voll-Editor wird.
|
||||
*/
|
||||
class CorrectionRateLimitException extends Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Für diese Pressemitteilung wurden zuletzt bereits mehrere Korrekturen gespeichert. Bitte versuchen Sie es später erneut.');
|
||||
}
|
||||
}
|
||||
329
app/Services/PressRelease/CorrectionService.php
Normal file
329
app/Services/PressRelease/CorrectionService.php
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Kleine Korrektur (Meldeverfahren v2, Spur A — Phase 4): Selbstbedienungs-
|
||||
* Korrektur ohne neuen Inhalt, sofort live. Die Free/Paid-Einstufung trifft
|
||||
* NICHT der Antragsteller, sondern das Levenshtein-Gate: bleibt die relative
|
||||
* Änderungsdistanz jedes Felds unter legal.correction_max_distance_percent,
|
||||
* ist es Spur A (kostenlos) — darüber Spur D (kostenpflichtige Änderung).
|
||||
*/
|
||||
class CorrectionService
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const CORRECTABLE_FIELDS = ['title', 'subtitle', 'text', 'keywords'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityLog $activityLog,
|
||||
private readonly PressReleaseService $pressReleaseService,
|
||||
private readonly PressReleaseHtmlSanitizer $sanitizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Einstufung klein/groß je Feld — verändert nichts.
|
||||
*
|
||||
* @param array<string, string|null> $newValues
|
||||
* @return array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool}
|
||||
*/
|
||||
public function classify(PressRelease $pressRelease, array $newValues): array
|
||||
{
|
||||
$threshold = (int) config('legal.correction_max_distance_percent');
|
||||
$fields = [];
|
||||
$maxPercent = 0.0;
|
||||
$hasChanges = false;
|
||||
|
||||
$allMinor = true;
|
||||
|
||||
foreach (self::CORRECTABLE_FIELDS as $field) {
|
||||
if (! array_key_exists($field, $newValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$old = $this->comparableText((string) ($pressRelease->getAttribute($field) ?? ''));
|
||||
$new = $this->comparableText((string) ($newValues[$field] ?? ''));
|
||||
|
||||
if ($old === $new) {
|
||||
$fields[$field] = ['distance_percent' => 0.0, 'changed' => false];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$percent = $this->distancePercent($old, $new);
|
||||
|
||||
// Mini-Edit-Ausnahme: bis 2 Zeichen ist es immer ein Tippfehler-Fix
|
||||
// — sonst würde bei kurzen Titeln schon ein einzelner Buchstabe
|
||||
// die Prozent-Grenze reißen und Spur A ins Leere laufen.
|
||||
$isMinorField = $percent <= $threshold || $this->isTinyEdit($old, $new);
|
||||
|
||||
$fields[$field] = ['distance_percent' => $percent, 'changed' => true];
|
||||
$maxPercent = max($maxPercent, $percent);
|
||||
$hasChanges = true;
|
||||
$allMinor = $allMinor && $isMinorField;
|
||||
}
|
||||
|
||||
return [
|
||||
'fields' => $fields,
|
||||
'max_distance_percent' => $maxPercent,
|
||||
'has_changes' => $hasChanges,
|
||||
'is_minor' => $hasChanges && $allMinor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine kleine Korrektur aus: Snapshot (Rückgängig-Option) →
|
||||
* Felder schreiben → Re-Sync-Sperre → Verlauf → Re-Score ohne
|
||||
* Status-Wirkung. Wirft, wenn das Gate reißt oder die Bremse greift.
|
||||
*
|
||||
* @param array<string, string|null> $newValues
|
||||
*
|
||||
* @throws CorrectionTooLargeException
|
||||
* @throws CorrectionRateLimitException
|
||||
*/
|
||||
public function apply(
|
||||
PressRelease $pressRelease,
|
||||
array $newValues,
|
||||
string $requesterEmail,
|
||||
LegalRequestVerificationMethod $method,
|
||||
?User $actor = null,
|
||||
): LegalRequest {
|
||||
$classification = $this->classify($pressRelease, $newValues);
|
||||
|
||||
if (! $classification['is_minor']) {
|
||||
throw new CorrectionTooLargeException;
|
||||
}
|
||||
|
||||
$this->assertNotRateLimited($pressRelease);
|
||||
|
||||
$legalRequest = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): LegalRequest {
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'portal' => $pressRelease->portal?->value,
|
||||
'type' => LegalRequestType::Correction->value,
|
||||
'status' => LegalRequestStatus::Resolved->value,
|
||||
'requester_email' => $requesterEmail,
|
||||
'message' => null,
|
||||
'payload' => [
|
||||
'source' => 'self_service',
|
||||
'fields' => $classification['fields'],
|
||||
],
|
||||
'verified_at' => now(),
|
||||
'verification_method' => $method->value,
|
||||
'resolved_at' => now(),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS),
|
||||
], $legalRequest);
|
||||
|
||||
foreach ($classification['fields'] as $field => $meta) {
|
||||
if (! $meta['changed']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = (string) ($newValues[$field] ?? '');
|
||||
|
||||
if ($field === 'text' && $this->sanitizer->isHtml($value)) {
|
||||
$value = $this->sanitizer->clean($value);
|
||||
}
|
||||
|
||||
$pressRelease->setAttribute($field, $value !== '' ? $value : null);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
$pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference());
|
||||
|
||||
return $legalRequest;
|
||||
});
|
||||
|
||||
WebCaches::forgetSitemaps();
|
||||
|
||||
$changedFields = collect($classification['fields'])
|
||||
->filter(fn (array $meta): bool => $meta['changed'])
|
||||
->keys()
|
||||
->implode(', ');
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf(
|
||||
'Kleine Korrektur (%s) gespeichert: Feld(er) %s, max. Distanz %.1f %%.',
|
||||
$legalRequest->reference(),
|
||||
$changedFields,
|
||||
$classification['max_distance_percent'],
|
||||
),
|
||||
user: $actor,
|
||||
subject: $pressRelease,
|
||||
meta: ['legal_request_id' => $legalRequest->id],
|
||||
);
|
||||
|
||||
// Re-Check ohne Status-Wirkung — konsistent mit dem bestehenden
|
||||
// Edit-Verhalten; Spur A hat bewusst kein Freigabelauf-Gate.
|
||||
$this->pressReleaseService->reprocessAfterContentChange($pressRelease);
|
||||
|
||||
return $legalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rückgängig machen (Admin): stellt die Feld-Werte aus dem verschlüsselten
|
||||
* Snapshot wieder her und hängt gelöste Kontakte wieder an. Bilder werden
|
||||
* nur wiederhergestellt, wenn ihre Dateien noch existieren (nicht public
|
||||
* gelöscht). Die Re-Sync-Sperre bleibt bestehen.
|
||||
*/
|
||||
public function restoreSnapshot(PressReleaseRedactionSnapshot $snapshot, ?User $actor = null): void
|
||||
{
|
||||
$pressRelease = $snapshot->pressRelease()->withoutGlobalScopes()->firstOrFail();
|
||||
$before = $snapshot->encrypted_before;
|
||||
|
||||
DB::transaction(function () use ($pressRelease, $before): void {
|
||||
foreach ((array) ($before['fields'] ?? []) as $field => $value) {
|
||||
$pressRelease->setAttribute($field, $value);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
|
||||
$contactIds = (array) ($before['contact_ids'] ?? []);
|
||||
|
||||
if ($contactIds !== []) {
|
||||
$pressRelease->contacts()->syncWithoutDetaching($contactIds);
|
||||
}
|
||||
|
||||
foreach ((array) ($before['images'] ?? []) as $imageMeta) {
|
||||
if (($imageMeta['disk'] ?? null) === 'public') {
|
||||
continue; // Datei wurde physisch gelöscht — Zeile bliebe kaputt.
|
||||
}
|
||||
|
||||
$pressRelease->images()->withTrashed()->whereKey($imageMeta['id'] ?? 0)->restore();
|
||||
}
|
||||
});
|
||||
|
||||
WebCaches::forgetSitemaps();
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf('Snapshot #%d wiederhergestellt — Vorher-Stand der PM zurückgespielt.', $snapshot->id),
|
||||
user: $actor,
|
||||
subject: $pressRelease,
|
||||
meta: ['snapshot_id' => $snapshot->id, 'legal_request_id' => $snapshot->legal_request_id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CorrectionRateLimitException
|
||||
*/
|
||||
private function assertNotRateLimited(PressRelease $pressRelease): void
|
||||
{
|
||||
$recent = LegalRequest::query()
|
||||
->where('press_release_id', $pressRelease->id)
|
||||
->where('type', LegalRequestType::Correction->value)
|
||||
->whereNotNull('executed_at')
|
||||
->where('executed_at', '>=', now()->subWeek())
|
||||
->count();
|
||||
|
||||
if ($recent >= (int) config('legal.corrections_per_week')) {
|
||||
throw new CorrectionRateLimitException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis 2 Zeichen Distanz ist jede Änderung ein Tippfehler-Fix (nur für
|
||||
* Werte im levenshtein()-Limit von 255 Bytes bestimmbar).
|
||||
*/
|
||||
private function isTinyEdit(string $old, string $new): bool
|
||||
{
|
||||
return strlen($old) <= 255
|
||||
&& strlen($new) <= 255
|
||||
&& levenshtein($old, $new) <= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleichsbasis: HTML raus, Whitespace normalisiert — reine
|
||||
* Markup-Änderungen zählen nicht als inhaltliche Distanz.
|
||||
*/
|
||||
private function comparableText(string $value): string
|
||||
{
|
||||
$plain = strip_tags($value);
|
||||
$collapsed = preg_replace('/\s+/u', ' ', $plain) ?? $plain;
|
||||
|
||||
return trim($collapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative Änderungsdistanz in Prozent. Kurze Felder zeichenbasiert
|
||||
* (ein Tippfehler im Titel bleibt „klein"), lange Texte wortbasiert —
|
||||
* PHPs levenshtein() ist auf 255 Bytes begrenzt und zeichenweises DP
|
||||
* wäre bei langen Texten zu teuer.
|
||||
*/
|
||||
private function distancePercent(string $old, string $new): float
|
||||
{
|
||||
if (strlen($old) <= 255 && strlen($new) <= 255) {
|
||||
$max = max(mb_strlen($old), mb_strlen($new), 1);
|
||||
|
||||
return round(levenshtein($old, $new) / $max * 100, 1);
|
||||
}
|
||||
|
||||
$oldWords = preg_split('/\s+/u', $old, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$newWords = preg_split('/\s+/u', $new, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$max = max(count($oldWords), count($newWords), 1);
|
||||
$lengthDiffPercent = abs(count($oldWords) - count($newWords)) / $max * 100;
|
||||
|
||||
// Die Längendifferenz ist eine untere Schranke der Distanz — ab 50 %
|
||||
// ist das sicher „groß", das teure DP kann entfallen. Ebenso bei
|
||||
// extrem langen Texten (DP-Kostengrenze).
|
||||
if ($lengthDiffPercent >= 50.0 || $max > 20000) {
|
||||
return max(round($lengthDiffPercent, 1), 50.0);
|
||||
}
|
||||
|
||||
return round($this->wordLevenshtein($oldWords, $newWords) / $max * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klassisches Levenshtein-DP auf Wort-Ebene (zwei Zeilen Speicher).
|
||||
*
|
||||
* @param list<string> $a
|
||||
* @param list<string> $b
|
||||
*/
|
||||
private function wordLevenshtein(array $a, array $b): int
|
||||
{
|
||||
$countA = count($a);
|
||||
$countB = count($b);
|
||||
|
||||
if ($countA === 0) {
|
||||
return $countB;
|
||||
}
|
||||
|
||||
if ($countB === 0) {
|
||||
return $countA;
|
||||
}
|
||||
|
||||
$previous = range(0, $countB);
|
||||
|
||||
for ($i = 1; $i <= $countA; $i++) {
|
||||
$current = [$i];
|
||||
|
||||
for ($j = 1; $j <= $countB; $j++) {
|
||||
$current[$j] = min(
|
||||
$previous[$j] + 1,
|
||||
$current[$j - 1] + 1,
|
||||
$previous[$j - 1] + ($a[$i - 1] === $b[$j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
|
||||
$previous = $current;
|
||||
}
|
||||
|
||||
return $previous[$countB];
|
||||
}
|
||||
}
|
||||
17
app/Services/PressRelease/CorrectionTooLargeException.php
Normal file
17
app/Services/PressRelease/CorrectionTooLargeException.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Die Änderung überschreitet die Spur-A-Grenze (legal.correction_max_distance_percent)
|
||||
* — sie ist keine kleine Korrektur mehr, sondern eine kostenpflichtige Änderung (Spur D).
|
||||
*/
|
||||
class CorrectionTooLargeException extends Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Die Änderung überschreitet den Rahmen einer kleinen Korrektur.');
|
||||
}
|
||||
}
|
||||
27
app/Support/WebCaches.php
Normal file
27
app/Support/WebCaches.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Invalidierung der Web-Auslieferungs-Caches nach Inhalts-Mutationen
|
||||
* (Meldeverfahren: Korrektur, Anonymisierung, Depublizierung).
|
||||
*/
|
||||
class WebCaches
|
||||
{
|
||||
/**
|
||||
* Sitemap-Caches aller Hosts verwerfen (SitemapController cached 1h je
|
||||
* Host) — nach einer Mutation darf kein Cache den alten Stand halten.
|
||||
*/
|
||||
public static function forgetSitemaps(): void
|
||||
{
|
||||
foreach ((array) config('domains.domains', []) as $domain) {
|
||||
$host = parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST);
|
||||
|
||||
if ($host) {
|
||||
Cache::forget('web.sitemap.'.$host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,4 +31,57 @@ return [
|
|||
|
||||
'contact_email' => env('LEGAL_CONTACT_EMAIL', 'info@pressekonto.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meldeverfahren v2 (Entscheidungen E3–E5, 22.07.2026)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| reversal_window_days: Zeitfenster, in dem eine Depublizierung/
|
||||
| Anonymisierung über den Admin rückgängig gemacht werden kann.
|
||||
|
|
||||
| snapshot_retention_days: Löschfrist der verschlüsselten Vorher-
|
||||
| Snapshots (press_release_redaction_snapshots, snapshots:purge).
|
||||
|
|
||||
| correction_max_distance_percent: maximale relative Levenshtein-
|
||||
| Distanz pro Feld, bis zu der eine Selbstbedienungs-Korrektur als
|
||||
| „klein/kostenlos" (Spur A) gilt; darüber → Spur D (kostenpflichtig).
|
||||
|
|
||||
*/
|
||||
|
||||
'reversal_window_days' => (int) env('LEGAL_REVERSAL_WINDOW_DAYS', 14),
|
||||
'snapshot_retention_days' => (int) env('LEGAL_SNAPSHOT_RETENTION_DAYS', 30),
|
||||
'correction_max_distance_percent' => (int) env('LEGAL_CORRECTION_MAX_DISTANCE_PERCENT', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meldeverfahren: Magic-Link-Verifikation (Phase 3, E7)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| report_link_ttl_hours: Gültigkeit des Verifikations-Links an die
|
||||
| hinterlegte Pressekontaktadresse.
|
||||
|
|
||||
| report_session_minutes: Wie lange der verifizierte Bereich (Anliegen-
|
||||
| Auswahl) nach Klick auf den Link nutzbar bleibt.
|
||||
|
|
||||
| report_links_per_day: Rate-Limit pro PM — mehr Link-Mails pro Tag
|
||||
| gehen nicht raus (Schutz vor Fluten fremder Kontakt-Postfächer).
|
||||
|
|
||||
*/
|
||||
|
||||
'report_link_ttl_hours' => (int) env('LEGAL_REPORT_LINK_TTL_HOURS', 72),
|
||||
'report_session_minutes' => (int) env('LEGAL_REPORT_SESSION_MINUTES', 60),
|
||||
'report_links_per_day' => (int) env('LEGAL_REPORT_LINKS_PER_DAY', 3),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meldeverfahren: Spur A — Kleine Korrektur (Phase 4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| corrections_per_week: maximale Selbstbedienungs-Korrekturen pro PM in
|
||||
| 7 Tagen — verhindert den kostenlosen Voll-Editor per Salami-Taktik.
|
||||
|
|
||||
*/
|
||||
|
||||
'corrections_per_week' => (int) env('LEGAL_CORRECTIONS_PER_WEEK', 1),
|
||||
|
||||
];
|
||||
|
|
|
|||
34
database/factories/PressReleaseRedactionSnapshotFactory.php
Normal file
34
database/factories/PressReleaseRedactionSnapshotFactory.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<PressReleaseRedactionSnapshot>
|
||||
*/
|
||||
class PressReleaseRedactionSnapshotFactory extends Factory
|
||||
{
|
||||
protected $model = PressReleaseRedactionSnapshot::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'press_release_id' => PressRelease::factory(),
|
||||
'encrypted_before' => [
|
||||
'title' => fake()->sentence(),
|
||||
'text' => fake()->paragraph(),
|
||||
],
|
||||
'purge_after' => now()->addDays(30)->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
public function dueForPurge(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'purge_after' => now()->subDay()->toDateString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Re-Sync-Sperre (Meldeverfahren v2, Phase 1): Eine gesetzte Sperre bedeutet,
|
||||
* dass die PM durch das Meldeverfahren mutiert wurde (Korrektur, Anonymisierung,
|
||||
* Depublizierung, Änderung) und kein Schreiben über Legacy-API/Importer mehr
|
||||
* erfolgen darf — sonst holt ein Re-Import den alten Stand/Klarnamen zurück.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Ein kombiniertes ALTER statt zweier Blueprint-Einzelstatements:
|
||||
// Wegen des FULLTEXT-Index kann MySQL hier nicht INSTANT/INPLACE
|
||||
// arbeiten und kopiert die große Tabelle vollständig — mit einem
|
||||
// Statement passiert das nur einmal. SQLite (Tests) kennt kein
|
||||
// Mehrspalten-ADD und läuft über den Blueprint-Zweig.
|
||||
if (DB::connection()->getDriverName() === 'mysql') {
|
||||
DB::statement(
|
||||
'ALTER TABLE `press_releases` '
|
||||
.'ADD COLUMN `sync_locked_at` TIMESTAMP NULL, '
|
||||
.'ADD COLUMN `sync_lock_reason` VARCHAR(255) NULL'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->timestamp('sync_locked_at')->nullable();
|
||||
$table->string('sync_lock_reason')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->dropColumn(['sync_locked_at', 'sync_lock_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Meldeverfahren v2 (Phase 1): LegalRequest trägt künftig auch die Self-Service-
|
||||
* Spuren (Korrektur/Depublizierung/Änderung) und dokumentiert die Verifikation
|
||||
* (Magic-Link, manuell oder eingeloggter Creator) sowie die Ausführung.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('legal_requests', function (Blueprint $table) {
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->string('verification_method', 40)->nullable();
|
||||
$table->foreignId('single_purchase_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamp('executed_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('legal_requests', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('single_purchase_id');
|
||||
$table->dropColumn(['verified_at', 'verification_method', 'executed_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Verschlüsselte Vorher-Snapshots für Anonymisierung/Depublizierung
|
||||
* (Meldeverfahren v2, Phase 1): ermöglichen Reversibilität, ohne dass
|
||||
* Alt-Werte im Klartext in Logs oder Payloads weiterleben. Nach Ablauf
|
||||
* von purge_after werden die Zeilen durch snapshots:purge gelöscht
|
||||
* (Löschfrist: config legal.snapshot_retention_days).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('press_release_redaction_snapshots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Snapshot bleibt als Audit-Hülle erhalten, wenn Anfrage/PM gelöscht werden.
|
||||
$table->foreignId('legal_request_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->longText('encrypted_before');
|
||||
$table->date('purge_after')->index();
|
||||
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('press_release_redaction_snapshots');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Meldeverfahren v2 (Phase 3): neuer Magic-Link-Zweck press_release_report —
|
||||
* der Verifikations-Link an die hinterlegte Pressekontaktadresse.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('magic_links', function (Blueprint $table) {
|
||||
$table->enum('purpose', [
|
||||
'login',
|
||||
'press_release_access',
|
||||
'invoice_access',
|
||||
'initial_setup',
|
||||
'press_release_report',
|
||||
])->default('login')->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Links des entfallenden Zwecks zuerst räumen, sonst scheitert die
|
||||
// Rückverengung des Enums an bestehenden Zeilen.
|
||||
Schema::table('magic_links', function (Blueprint $table) {
|
||||
$table->enum('purpose', [
|
||||
'login',
|
||||
'press_release_access',
|
||||
'invoice_access',
|
||||
'initial_setup',
|
||||
])->default('login')->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
238
docs/Decision-Update/Entwicklungsplan Meldeverfahren.md
Normal file
238
docs/Decision-Update/Entwicklungsplan Meldeverfahren.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# Entwicklungsplan: Meldeverfahren v2 (Selbstbedienungs-Flow „PM melden")
|
||||
|
||||
**Stand:** 22.07.2026
|
||||
**Grundlage:** `Lösch- und Änderungsanfragen.md` (Entscheidungsachse, Free/Paid-Matrix, §11 Self-Service-Ausbau) und `Pressemitteilung melden.md` (Vier-Spuren-Flow)
|
||||
**Ziel:** Portal übernimmt den gesamten Post-Publication-Zustand selbst (connektar = write-once). Vier Spuren: Kleine Korrektur (frei), DSGVO-Anonymisierung (frei, Admin-Gate), Depublizierung (kostenpflichtig), Änderung (kostenpflichtig, KI-Freigabelauf).
|
||||
|
||||
---
|
||||
|
||||
## 0. Ist-Stand: Was bereits existiert (Bauen auf, nicht neu)
|
||||
|
||||
Die Codebasis enthält bereits ein **Meldeverfahren v0.1** und fast alle benötigten Bausteine. Der Plan ist ein **Ausbau**, kein Neubau.
|
||||
|
||||
| Baustein | Vorhanden | Relevante Dateien |
|
||||
|---|---|---|
|
||||
| Melde-Queue (DSGVO / Persönlichkeitsrecht / Meldung) | ✅ v0.1: Intake → Admin-Queue, manuelle Erledigung | `app/Models/LegalRequest.php`, `app/Enums/LegalRequestType.php`, `app/Enums/LegalRequestStatus.php` |
|
||||
| Öffentliche Meldeformulare (Honeypot, Rate-Limit, DSA-Eingangsbestätigung `M-{id}`) | ✅ `melden/{way}` + PM-gebunden `/release/{slug}/rechtliches` | `app/Http/Controllers/Web/ReportFormController.php`, `app/Http/Controllers/LegalRequestController.php`, `routes/legal.php` |
|
||||
| Admin-Queue „Recht & Compliance" (`can:compliance:manage`) | ✅ Status-Handling (open/in_progress/resolved/rejected), aber **keine Ausführungs-Aktionen** (kein Anonymisieren/Depublizieren aus der Queue) | `resources/views/livewire/admin/legal-requests/{index,show}.blade.php`, `routes/admin.php` |
|
||||
| Magic-Link-Infrastruktur (sha256-Token, purpose, payload, TTL, Consume) | ✅ purposes `login`, `press_release_access` | `app/Models/MagicLink.php`, `app/Services/Auth/MagicLinkGenerator.php`, `app/Http/Controllers/Auth/MagicLinkConsumeController.php`, `app/Console/Commands/PurgeMagicLinks.php` |
|
||||
| Depublizierung + Tombstone | ✅ `depublish()` → Archived + `depublished_at`, Detail-URL liefert **HTTP 410** Tombstone | `app/Services/PressRelease/PressReleaseService.php:394`, `app/Http/Controllers/Web/ReleaseDetailController.php`, `resources/views/web/release-tombstone.blade.php` |
|
||||
| Bezahlte Depublizierung (Stripe-Checkout + Webhook-Fulfillment) | ✅ aber nur **eingeloggt** (`routes/customer.php:52`) | `app/Services/Billing/StripeCheckoutService.php:452`, `app/Listeners/ProcessStripeWebhook.php:475`, `app/Enums/SinglePurchaseType.php` |
|
||||
| Credits (Ledger, atomar) | ✅ | `app/Services/Billing/CreditWalletService.php` |
|
||||
| KI-Prüfung (grün/gelb/rot, Audit) | ✅ Klassifikation + Routing bei Erst-Freigabe; Re-Check nach Edit **ohne** Status-Wirkung | `app/Jobs/ClassifyPressRelease.php`, `PressReleaseService::routeByClassification():176`, `reprocessAfterContentChange():150`, `app/Models/KiAudit.php` |
|
||||
| Hinterlegte Pressekontaktadresse | ✅ `Contact.email` (Pivot `press_release_contact`), Fallback `Company.email` | `app/Models/Contact.php`, `app/Models/Company.php` |
|
||||
| Audit/Verlauf | ✅ `ActivityLog::record()` + `PressReleaseStatusLog` (mit `source`) | `app/Services/ActivityLog.php`, `app/Models/PressReleaseStatusLog.php` |
|
||||
| connektar-Eingang | ✅ Legacy-API (`store()` → Review/KI bzw. Sofort-Publish), Bulk-Import mit `LegacyImportMap`-Skip-Guard | `app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php`, `app/Services/Import/PressReleaseImporter.php` |
|
||||
|
||||
**Fehlende Kernstücke (= dieser Plan):** Re-Sync-Sperre als PM-Flag, Anonymisierungs-Engine (Wortgrenzen-Replace + Diff), Magic-Link-Verifikation für das Meldeverfahren, Self-Service-Portal mit den vier Spuren, Admin-Ausführungsaktionen in der Queue, Gast-Checkout für Depublizierung/Änderung, Reversibilitätsfenster, verschlüsselte Alt-Wert-Snapshots mit Löschfrist.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phasenübersicht
|
||||
|
||||
Reihenfolge nach Nutzen und Abhängigkeit: Zuerst die Bausteine, die den **manuellen Interimsprozess** sofort absichern (Re-Sync-Sperre, Anonymisierung im Admin), dann Verifikation, dann die Self-Service-Spuren.
|
||||
|
||||
```
|
||||
Phase 1 Datenmodell + Re-Sync-Sperre ✅ umgesetzt 22.07.2026
|
||||
Phase 2 Anonymisierungs-Engine + Admin-Ausführung ✅ umgesetzt 22.07.2026
|
||||
Phase 3 Magic-Link-Verifikation + Intake-Trichter ✅ umgesetzt 22.07.2026
|
||||
Phase 4 Spur A — Kleine Korrektur (self-service) ✅ umgesetzt 22.07.2026 (panel-first)
|
||||
Phase 5 Spur B — DSGVO-Einreichung (self-service → Admin-Gate)
|
||||
Phase 6 Spur C — Depublizierung (Gast-Checkout + Reversibilitätsfenster)
|
||||
Phase 7 Spur D — Änderung (Gast-Checkout + KI-Freigabelauf)
|
||||
Phase 8 Querschnitt-Abschluss: Kommunikation, Auslieferungs-Checks, Doku
|
||||
```
|
||||
|
||||
Jede Phase ist eigenständig deploybar; nach Phase 2 ist der Interims-Triage-Prozess (§4 des Konzepts) technisch vollständig unterstützt.
|
||||
|
||||
### Stand 22.07.2026 abends — was noch fehlt (Einstieg für morgen)
|
||||
|
||||
**Phasen 1–4 sind umgesetzt und getestet** (Details in den Phasen-Abschnitten). Offen:
|
||||
|
||||
1. **Phase 5 — Spur B strukturiert:** Das DSGVO-Anliegen im verifizierten Web-Bereich ist bisher Freitext; geplant ist das strukturierte Schwärzungsformular (Felder + Varianten) mit Pflicht-Diff aus `AnonymizationService::preview()`, das den fertigen Schwärzungsauftrag in die Admin-Queue legt (Admin-Editor vorbefüllt).
|
||||
2. **Phase 6 — Spur C Gast-Checkout:** 35-€-Checkout für Magic-Link-Verifizierte (ohne Login); Reversibilitätsfenster-Hinweis („umkehrbar bis …", `legal.reversal_window_days`); **`lockSync()` auch im bestehenden Webhook-Fulfillment `fulfillDepublication()` nachrüsten** (bezahlte Panel-Depublizierung setzt die Sperre bisher nicht!).
|
||||
3. **Phase 7 — Spur D Checkout:** 10 € (`billing.change_price_cents` anlegen), Gast + Panel; nach Zahlung KI-Freigabelauf auf Pending-Payload (grün/gelb → live, rot → kein Verbrauch, erneut einreichbar). Ersetzt den Interim-Weg „Change-Anfrage in die Queue".
|
||||
4. **Spur A im Web-Pfad:** Korrektur-Formular (CorrectionService-Engine) auch für Magic-Link-Verifizierte statt Queue-Anfrage; Bestätigungsmail an die Kontaktadresse.
|
||||
5. **Phase 8 — Querschnitt:** connektar-Template-Stopp in allen Texten prüfen, `noindex` im Tombstone verifizieren, zweistufige Eskalations-Mailtexte (Ablehnung mit Aufsichtsbehörden-Hinweis), Doku-Abgleich.
|
||||
6. **Ops:** Dev-DB-Migration (`sync_locked_at`-ALTER auf `press_releases`, FULLTEXT-bedingt langsamer Tabellen-Copy) lief am Abend des 22.07. noch; danach `php artisan migrate` für die restlichen Migrationen (`magic_links`-Enum) ausführen. Test-DB ist vollständig migriert, alle 41 Legal-Tests grün.
|
||||
7. **Konfig-Feinjustierung (22.07. abends):** `correction_max_distance_percent` auf **5 %** und `corrections_per_week` auf **1** verschärft (statt 15 %/2 aus E5). Dazu im Service die **Mini-Edit-Ausnahme**: Änderungen ≤ 2 Zeichen gelten immer als klein — sonst würde bei kurzen Titeln ein einzelner Tippfehler die Prozent-Grenze reißen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Datenmodell + Re-Sync-Sperre ✅ **umgesetzt (22.07.2026)**
|
||||
|
||||
**Warum zuerst:** „Jede Mutation setzt die Re-Sync-Sperre" ist strukturell zwingend (§7), sonst überschreibt ein Re-Import jede Anonymisierung — das wäre ein DSGVO-Verstoß nach erfolgter Löschung.
|
||||
|
||||
### 1.1 Migrationen ✅
|
||||
|
||||
- ✅ `press_releases`: `sync_locked_at` (nullable timestamp) + `sync_lock_reason` (string) — `2026_07_22_151506_add_sync_lock_to_press_releases_table.php`. **Umsetzungshinweis:** Wegen des FULLTEXT-Index kann MySQL hier nicht INSTANT arbeiten (voller Tabellen-Copy, ~175k Zeilen); die Migration nutzt deshalb **ein kombiniertes ALTER** für beide Spalten statt zweier Blueprint-Einzelstatements und verzichtet auf `after()`-Positionierung. Merkregel für spätere Migrationen an `press_releases`: Spalten-Adds immer bündeln.
|
||||
- ✅ `legal_requests` erweitert — `2026_07_22_151507_add_meldeverfahren_fields_to_legal_requests_table.php`:
|
||||
- Neue `LegalRequestType`-Cases: `Correction` (Spur A), `Depublication` (Spur C), `Change` (Spur D). `Dsgvo` bleibt Spur B.
|
||||
- Neue Spalten: `verified_at`, `verification_method` (Enum `LegalRequestVerificationMethod`: `magic_link`/`manual`/`logged_in`), `single_purchase_id` (FK, Spur C/D), `executed_at`.
|
||||
- Diff-/Schwärzungsdaten kommen in `payload` (json, existiert) — Nutzung ab Phase 4/5.
|
||||
- ✅ Neue Tabelle `press_release_redaction_snapshots` (`2026_07_22_151507_create_..._table.php`) + Modell `PressReleaseRedactionSnapshot` mit `capture()` (Cast `encrypted:array`, `purge_after` aus `config('legal.snapshot_retention_days')`), Scope `dueForPurge()` und Factory. → Reversibilität ohne Klartext-Altwerte im Log; Test sichert ab, dass der Klartext nie unverschlüsselt in der DB liegt.
|
||||
|
||||
### 1.2 Re-Sync-Sperre durchsetzen ✅
|
||||
|
||||
- ✅ `PressRelease::lockSync(string $reason)` + `isSyncLocked()` — idempotent (erste Sperre bleibt maßgeblich), schreibt Verlaufs-Eintrag (neuer `ActivityType::SyncLock`, Badge rot). Wird von **jeder** Meldeverfahren-Mutation aufgerufen (Phasen 2, 4, 5, 6, 7).
|
||||
- ✅ `PressReleaseImporter`: gesperrte PMs werden **auch bei `--force`** übersprungen (Guard `isSyncLocked()` vor `updateOrCreate`, zählt als „Übersprungen").
|
||||
- ℹ️ `LegacyPressReleaseController`: kein Guard nötig — die Legacy-API hat heute **keinen Update-Pfad** (`store()` legt immer neu an, `show()` liest nur). Sollte je ein Update-Endpunkt entstehen, gehört der `sync_locked_at`-Check (→ `409`/`423`) dorthin.
|
||||
|
||||
### 1.3 Tests ✅ (`tests/Feature/Legal/SyncLockTest.php`, 5 Tests grün)
|
||||
|
||||
- ✅ `lockSync()`: Felder gesetzt, Verlaufs-Eintrag, idempotent (zweiter Aufruf ändert nichts).
|
||||
- ✅ Importer überspringt gesperrte PM trotz `--force` (Schwärzung bleibt erhalten); ungesperrte PM wird weiter aktualisiert.
|
||||
- ✅ Snapshot: Klartext liegt nie unverschlüsselt in der DB, `purge_after` aus Config, `dueForPurge()`-Scope.
|
||||
- Angrenzende Bestandstests (Importer-Datumslogik, LegalRequest, ActivityLog — 23 Tests) weiter grün; Pint sauber.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Anonymisierungs-Engine + Admin-Ausführung ✅ **umgesetzt (22.07.2026)**
|
||||
|
||||
**Ziel:** Der Admin kann aus der bestehenden Queue (`admin/legal-requests/show`) eine DSGVO-Anfrage tatsächlich **ausführen** statt nur „resolved" zu klicken. Das trägt sofort die Referenzfälle Huraux/Gleue/Rambo.
|
||||
|
||||
### 2.1 Service `App\Services\PressRelease\AnonymizationService` ✅
|
||||
|
||||
- ✅ `preview(PM, Begriffe, Ersetzungswert)`: Pflicht-Vorschau ohne Mutation — Treffer + Nachher-Wert je Feld (`title`, `subtitle`, `text`, `boilerplate_override`, `keywords`; `teaser_*` sind Integer und entfallen). Genutzt von Admin (Phase 2), wiederverwendbar im Self-Service (Phase 5).
|
||||
- ✅ Ersetzung über Wortgrenzen: **Unicode-Lookarounds** (`(?<![\pL\pN])…(?![\pL\pN])`) statt `\b`, damit Umlaute korrekt begrenzen — „Max" zerlegt „maximal" nicht, „Mustermanns" bleibt ohne explizite Variante stehen. Case-insensitive, längste Begriffe zuerst. Ersetzungswert default `[entfernt]`, nie Leerstring.
|
||||
- ✅ `execute()`: Transaktion — verschlüsselter Snapshot (`capture()`: Felder, Kontakt-IDs, Bild-/Anhang-Metadaten, Begriffe) → Replace → Kontakt-Pivot lösen (optional) → markierte **Bilder/Anhänge** soft-löschen, auf public-Disk zusätzlich Dateien inkl. Varianten physisch löschen (sonst per Direkt-URL erreichbar) → `lockSync()` → Sitemap-Cache aller Hosts invalidieren (interne Suche liest live) → `ActivityLog` (neuer Typ `Anonymization`) **ohne Alt-Werte und ohne Schwärzbegriffe**.
|
||||
- ✅ Kein KI-Gate, kein Credit-Abzug — der Service berührt weder Klassifikations-Routing noch `CreditWalletService`.
|
||||
|
||||
### 2.2 Admin-Queue ausgebaut (`admin/legal-requests/show`) ✅
|
||||
|
||||
- ✅ Panel „Anonymisierung (Schwärzung)" bei `type=dsgvo`/`personal_rights` mit PM-Bezug und offenem Status: Schwärzbegriffe (ein Begriff/Variante pro Zeile), Ersetzungswert, Checkbox „Kontakt-Zuordnungen lösen", Bild-Auswahl → **Pflicht-Vorschau** (Ausführen-Button erscheint erst danach; Ausführen ohne Vorschau wird serverseitig abgewiesen) → „Anonymisieren ausführen" oder **„Stattdessen depublizieren"** (Matrix-Test §1/§2 menschlich; Vanity-PM → Depublizierung als milderes Restmittel).
|
||||
- ✅ Depublizieren-Aktion: `PressReleaseService::depublish()` + `lockSync()` + Aktenvermerk über `admin_note`; setzt `executed_at` + `resolved`. Deckt den Fallback „Kanal tot" (Huraux) ab — Admin führt nach manueller Prüfung aus, umkehrbar (Status-Restore, `published_at` bleibt).
|
||||
- ✅ Detail zeigt `verification_method`/`verified_at` („nicht verifiziert" bis Phase 3) und `executed_at`. Queue-Index-Spalte folgt mit Phase 3, wenn Verifikation real befüllt wird.
|
||||
|
||||
### 2.3 Purge-Command ✅
|
||||
|
||||
✅ `snapshots:purge` (`PurgeRedactionSnapshots`, Scope `dueForPurge()`), Scheduler täglich 03:10 mit `withoutOverlapping` + Failure-Alert in `routes/console.php`.
|
||||
|
||||
### 2.4 Tests ✅ (9 neue Tests grün)
|
||||
|
||||
- ✅ `AnonymizationServiceTest`: Wortgrenzen/Varianten/Genitiv, Vorschau mutiert nicht, Snapshot verschlüsselt (Klartext weder im DB-Rohwert noch im Verlauf), Kontakte gelöst, Sperre gesetzt, Bilder soft-gelöscht + public-Dateien entfernt, Purge-Command löscht nur fällige Snapshots.
|
||||
- ✅ `AdminLegalRequestExecutionTest`: Vorschau→Ausführen-Happy-Path, Ausführen ohne Vorschau abgewiesen, Depublizieren-Alternative, verboten ohne `compliance:manage`.
|
||||
- Angrenzende Suiten (37 Tests: Legal, Admin-Grenzen, ActivityLog) weiter grün; Pint sauber.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Magic-Link-Verifikation + Intake-Trichter ✅ **umgesetzt (22.07.2026)**
|
||||
|
||||
**Ziel:** Die Meldeseite wird vom Kontaktformular zum **Deflektions-Trichter**: URL einfügen → System schlägt hinterlegten Kontakt nach → Magic-Link an diesen → verifizierter Zugang zur Anliegen-Auswahl.
|
||||
|
||||
### 3.1 Intake ✅ (`PressReleaseReportController`, Routen `melden/pm*` in `routes/web.php`)
|
||||
|
||||
- ✅ Neue Seite „PM melden/ändern" (`web/melden-pm.blade.php`): URL-Feld → `matchPublishedRelease()` → bei PM-Fund + Pressekontakt-Mail (`PressRelease::pressContactEmail()`: PM-Kontakt via Pivot, Fallback Firmen-Adresse, ohne PortalScope) Magic-Link-Versand. Vier-Anliegen-Tabelle mit Kosten **vorab** auf der Seite (Anti-Zombie). Callout-Einstieg auf der bestehenden `melden`-Übersichtsseite.
|
||||
- ✅ Adresse **nie angezeigt/übernommen** — nur maskiert („p***@firma.example").
|
||||
- ✅ Kanal tot: Hinweis mit Link auf das bestehende manuelle Formular (`melden/kontaktdaten`) — dort verifiziert der Admin anhand eines Nachweises (§6-Fallback, Ausführung über Phase 2). *(Bewusst kein neues Formular — der DSA-Weg existiert bereits.)*
|
||||
- ✅ Rate-Limits: pro IP (5/h) **und** pro PM (`legal.report_links_per_day` = 3/Tag) + Honeypot + `throttle:10,1`.
|
||||
|
||||
### 3.2 Magic-Link ✅
|
||||
|
||||
- ✅ `MagicLinkGenerator::createReportLink()`: purpose `press_release_report` (**Enum-Migration** `2026_07_22_171134_add_report_purpose_to_magic_links_table.php` — `purpose` ist ein DB-Enum!), payload `{press_release_id, contact_email}`, `user_id` = PM-Owner, TTL `legal.report_link_ttl_hours` = 72; ältere unverbrauchte Report-Links derselben PM werden entwertet.
|
||||
- ✅ Mailable `PressReleaseReportLink` (queued, `afterCommit`) mit Hinweis „kein Anliegen gestartet? → ignorieren".
|
||||
- ✅ Consume-Route (`melden/pm/verifizieren/{token}`, throttled): atomares Claim (Muster `MagicLinkConsumeController`) → Session-Marker (`legal.report_session_minutes` = 60) → Anliegen-Auswahl. Kein Login.
|
||||
- ✅ Eingeloggter Creator: POST `me.press-releases.report` (prüft `accessibleBy`, `verification_method=logged_in`), Button „Ändern/Melden" auf der Kunden-PM-Detailseite (nur published).
|
||||
|
||||
### 3.3 Anliegen-Auswahl ✅ (`web/melden-pm-anliegen.blade.php`)
|
||||
|
||||
- ✅ Vier Spuren mit Kosten-Labels (kostenlos / kostenlos / 35 € / 10 €) + Beschreibung; Auswahl erzeugt `LegalRequest` mit `type`, `verified_at`, `verification_method`, `requester_email` = Kontaktadresse; Eingangsbestätigung (`LegalRequestReceived`) + Operator-Mail. Bremse „1 offene Anfrage pro PM/Typ" beim Store durchgesetzt.
|
||||
- ℹ️ **Interim bis Phase 4–7:** Alle vier Spuren landen zunächst als verifizierte Anfragen in der Admin-Queue (DSGVO/Depublizierung dort seit Phase 2 direkt ausführbar). Seiten-Text verspricht ehrlich: kostenpflichtige Anliegen erst nach Zahlungsaufforderung, keine Kosten ohne Bestätigung. Die Self-Service-Ausführung (Spur A sofort, C/D mit Checkout) ersetzt das schrittweise.
|
||||
- ✅ Lang-Keys `static.pm_report.*` in `lang/de` + `lang/en`.
|
||||
|
||||
### 3.4 Tests ✅ (`PressReleaseReportFlowTest`, 10 Tests grün)
|
||||
|
||||
Link geht an hinterlegte, nie an Formular-Adresse (maskiert); unbekannte URL abgelehnt; toter Kanal → manueller Fallback; Rate-Limit pro PM; Consume nur einmal; Anliegen landet verifiziert in der Queue; Doppel-Anfrage geblockt; Session-Pflicht; Owner-Einstieg; Fremd-User 403.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Spur A: Kleine Korrektur (kostenlos, sofort) ✅ **umgesetzt (22.07.2026, panel-first)**
|
||||
|
||||
**Bestätigte Entscheidung:** Korrekturen gehen **sofort live** (kein Admin-Gate), landen im Verlauf und sind über den verschlüsselten Snapshot **rückgängig machbar**.
|
||||
|
||||
- ✅ `CorrectionService` (`app/Services/PressRelease/CorrectionService.php`):
|
||||
- `classify()`: relative Änderungsdistanz je Feld (`title`, `subtitle`, `text`, `keywords`), Gate ≤ `legal.correction_max_distance_percent` (15 %, E5). **Hybrid-Metrik:** kurze Felder zeichenbasiert (ein Tippfehler im Titel bleibt „klein"), lange Texte wortbasiertes Levenshtein-DP (PHPs `levenshtein()` ist auf 255 Bytes begrenzt); Vergleich auf `strip_tags`-Basis — reine Markup-Änderungen zählen nicht. Der Nutzer wählt die Einstufung **nie selbst**.
|
||||
- `apply()`: Snapshot (Rückgängig-Basis) → Felder schreiben (Text durch `PressReleaseHtmlSanitizer`) → `lockSync()` → `LegalRequest` (`correction`, `resolved`+`executed_at`, verifiziert) → Verlauf (`ActivityType::Correction`) → Sitemap-Cache → `reprocessAfterContentChange()` (Re-Score ohne Status-Wirkung, kein Freigabelauf).
|
||||
- Bremse: `legal.corrections_per_week` = 2 (DB-basiert über ausgeführte Correction-Requests, auditierbar).
|
||||
- `restoreSnapshot()`: spielt Vorher-Stand zurück (Felder + Kontakte; Bilder nur wenn Dateien noch existieren), Sperre bleibt, Verlaufs-Eintrag.
|
||||
- ✅ **Panel-Abschnitt „Änderung & Korrektur"** (Kunden-PM-Detail, direkt über Depublikation): ein Einstieg, Formular vorbefüllt → Pflicht-Vorschau zeigt Einstufung + Distanz je Feld → **klein**: „Kostenlos speichern — sofort live"; **groß**: „Als kostenpflichtige Änderung einreichen (10 €)" — Interim bis zum Phase-7-Checkout: erzeugt verifizierte `Change`-Anfrage in der Admin-Queue (Doppel-Bremse), Zahlungsaufforderung folgt.
|
||||
- ✅ **Admin-Rückgängig**: Button „Rückgängig machen (Snapshot)" in der Queue bei ausgeführten Anfragen mit Snapshot (gilt für Korrektur **und** Anonymisierung, bis zur Löschfrist) — Anfrage geht zurück auf „In Bearbeitung".
|
||||
- ✅ Gemeinsame Cache-Invalidierung nach `App\Support\WebCaches::forgetSitemaps()` extrahiert (genutzt von Correction + Anonymization).
|
||||
- ✅ Tests (11 neue, alle grün): Tippfehler klein / Umschreibung groß / Markup-only = keine Änderung, Sofort-Live mit Snapshot+Sperre+Verlauf, Zu-groß-Abwehr, Wochen-Bremse, Restore (Service + Admin-UI), kein Credit-Bezug, Panel-Happy-Paths, Vorschau-Pflicht.
|
||||
- ℹ️ **Noch offen aus Phase 4:** dieselben Formulare im verifizierten Web-Bereich (Magic-Link-Besucher) — aktuell erzeugt Spur A dort noch eine Queue-Anfrage; die `CorrectionService`-Engine ist dafür wiederverwendbar. Nutzer-Bestätigungsmail nach Korrektur bewusst weggelassen (Owner sieht das Ergebnis sofort im Panel), für den Web-Pfad nachrüsten.
|
||||
|
||||
## Phase 5 — Spur B: DSGVO-Einreichung self-service → Admin-Gate
|
||||
|
||||
- Formular: Strukturfelder zum Leeren angeboten + Freitext-Schwärzwerte inkl. Varianten → Pflicht-Diff (Preview aus `AnonymizationService::preview()`) → **Einreichung** (nicht Ausführung): `LegalRequest` `type=dsgvo`, payload = Schwärzungsauftrag, Status `open`.
|
||||
- Admin-Queue zeigt den vorbereiteten Diff (Phase-2-Editor, vorbefüllt) → Admin bestätigt **anonymisieren** oder wählt **depublizieren** → `AnonymizationService::execute()` bzw. `depublish()` + Snapshot.
|
||||
- Bestätigungsmail an Antragsteller bei Einreichung (Fristbeginn dokumentiert, Vorgangsnummer `M-{id}`) und bei Ausführung.
|
||||
- **Wording:** In der UI „Schwärzung/Entfernung personenbezogener Daten", nicht „Anonymisierung im DSGVO-Vollsinn" (Guardrail — nicht überversprechen).
|
||||
- Bremse: 1 offene Anfrage pro PM/Typ (Logik existiert konzeptionell in `LegalRequestStatus::isOpen()` — beim Store durchsetzen).
|
||||
- Tests: Einreichung erzeugt keinen Datenbank-Write an der PM; Ausführung nur durch Admin; nie Credits.
|
||||
|
||||
## Phase 6 — Spur C: Depublizierung (35 €, Gast-Checkout, umkehrbar)
|
||||
|
||||
- **Wiederverwendung:** `StripeCheckoutService::forDepublication()` + `ProcessStripeWebhook::fulfillDepublication()` existieren. Neu ist der **Gast-Pfad**: Checkout ohne Login aus dem verifizierten Bereich.
|
||||
- Variante (empfohlen): `SinglePurchase` ohne `user_id`-Zwang bzw. auf PM-Owner gebucht, `legal_request_id` als Referenz; Stripe-Session mit `customer_email` = verifizierte Kontaktadresse. Webhook-Fulfillment bleibt identisch (idempotent, row-locked).
|
||||
- Nach Zahlung: `depublish()` → Tombstone (410 + noindex existiert) → `lockSync()` → `LegalRequest` `resolved`/`executed_at` → Bestätigungsmail mit Hinweis auf Suchindex-Trägheit (Cache-Aktualisierung bei Google dauert — Textbaustein).
|
||||
- **Reversibilitätsfenster:** `depublished_at` + `config('legal.reversal_window_days')` (Standard **14 Tage**, E3): innerhalb der Frist Wiederherstellung über Admin (Snapshot nicht nötig — Status-Restore reicht, `published_at` bleibt). Hinweis im Bestätigungsschritt vor Zahlung („umkehrbar bis …").
|
||||
- Preis: 35 € live via `config('billing.depublication_price_cents')` (existiert). Die systemische 25-Credit-Variante für eingeloggte Kunden bleibt unberührt — aber Test sichert ab, dass der **DSGVO-Pfad (Spur B) nie** in diesen Checkout mündet.
|
||||
- Tests: Gast-Checkout-Fulfillment, Reversibilitätsfenster, Tombstone + Sitemap-Ausschluss.
|
||||
|
||||
## Phase 7 — Spur D: Änderung (10 €, KI-Freigabelauf)
|
||||
|
||||
- Preis: **10 €** (E1), neuer Config-Wert `billing.change_price_cents` = 1000.
|
||||
- Formular: neuen Text/Feldwerte eingeben (Voll-Editor light, analog `customer/press-releases/edit`-Formularregeln aus `ProvidesPressReleaseFormRules`) → Preis anzeigen → Gast-Checkout (`SinglePurchaseType`-Case `Change`, `adHocLineItem`-Muster).
|
||||
- Nach Zahlung: Änderung in **Entwurfs-Kopie/Pending-Payload** halten (nicht direkt live), dann `ClassifyPressRelease` auf den neuen Inhalt:
|
||||
- **grün/gelb** → Änderung anwenden, PM bleibt/wird published, `lockSync()`, Kauf auf `Consumed`, Mail „live".
|
||||
- **rot** → abgelehnt, Änderung verworfen, **kein Verbrauch** (E2): Kauf bleibt un-konsumiert, Antragsteller kann mit überarbeitetem Text erneut einreichen ohne erneut zu zahlen; Mail mit Ablehnungsgrund.
|
||||
- KiAudit-Trail entsteht automatisch über den bestehenden Job.
|
||||
- Tests: rot verwirft ohne PM-Änderung; grün wendet an; Webhook-Idempotenz.
|
||||
|
||||
## Phase 8 — Querschnitt-Abschluss
|
||||
|
||||
1. **Template-/Text-Stopp connektar (Sofortmaßnahme §7):** Alle Frontend-/Mail-Texte prüfen (`melden*`-Views, Mail-Templates, `lang/`-Strings): kein Verweis mehr auf „kostenfreier Weg über connektar"; Ersatz: Verweis auf die neue Meldeseite. *(Antwortvorlagen außerhalb des Systems sind organisatorisch zu ändern — nicht Teil des Codes, im Rollout-Termin erwähnen.)*
|
||||
2. **Auslieferungs-Checkliste (§5) verifizieren:** Tombstone 410 ✅, `noindex` im Tombstone-View prüfen/ergänzen, Sitemap schließt Archived aus (Scope `visibleInArchive` prüfen) + Cache-Invalidierung nach Mutation, interne Suche (FULLTEXT auf `press_releases` — liest live, kein Rebuild nötig; Test ergänzen), **RSS/Feeds: existieren noch nicht, sind aber geplant** (E8) — beim künftigen Feed-Feature zwingend: Archived/anonymisierte PMs ausschließen und Feed-Cache bei Meldeverfahren-Mutationen invalidieren (Anforderung dort einplanen).
|
||||
3. **Eskalationsstufen-Texte:** Mail-Vorlagen zweistufig — Angebots-/Eingangsmail ohne Aufsichtsbehörden-Hinweis, Ablehnungsmail (aus Admin-Queue `reject()`) mit Art.-12-Pflichthinweis. Textbausteine in `lang/`.
|
||||
4. **Doku:** kurzer Abschnitt in `docs/konzept/` bzw. Update dieses Ordners; `CLAUDE.md` unberührt.
|
||||
5. **Gesamt-Testlauf:** `vendor/bin/sail artisan test --compact` + Pint.
|
||||
|
||||
---
|
||||
|
||||
## 2. Getroffene Entscheidungen (bestätigt 22.07.2026)
|
||||
|
||||
| # | Entscheidung | Umsetzung im Plan |
|
||||
|---|---|---|
|
||||
| **E1** | Zwei getrennte Preise: **35 €** = komplette Depublizierung einer PM (Spur C), **10 €** = Änderung einer PM (Spur D) | Spur C: `billing.depublication_price_cents` (existiert); Spur D: neuer Config-Wert `billing.change_price_cents` = 1000 → Phase 7 |
|
||||
| **E2** | Bei KI-„rot" **kein Verbrauch** — es wird nichts abgezogen | Kauf bleibt un-konsumiert (Status nicht `Consumed`); der Antragsteller kann mit überarbeitetem Text erneut in den Freigabelauf, ohne erneut zu zahlen → Phase 7 |
|
||||
| **E3** | Reversibilitätsfenster als **Config-Variable, Standard 14 Tage** | `legal.reversal_window_days` = 14 → Phase 6 |
|
||||
| **E4** | Snapshot-Löschfrist als **Config-Variable, Standard 30 Tage** | `legal.snapshot_retention_days` = 30; bestimmt `purge_after` → Phasen 1.1 / 2.3 |
|
||||
| **E5** | Levenshtein-Schwellwert Spur A als Config-Variable; Start 15 %, am 22.07. abends auf **5 %** verschärft (+ Mini-Edit-Ausnahme ≤ 2 Zeichen) | `legal.correction_max_distance_percent` = 5, `legal.corrections_per_week` = 1 → Phase 4 |
|
||||
| **E6** | Spur B bleibt **admin-gegated**; Automatisierung später bei guter Kalibrierung | Wie geplant → Phasen 2 / 5 |
|
||||
| **E7** | Magic-Link-TTL/Session: **bestehende System-Mechanik wiederverwenden** | Kein Sondersystem — `MagicLinkGenerator`-Muster mit purpose-spezifischer TTL wie bei `login`/`press_release_access`; TTL des Report-Links als Config-Wert analog der vorhandenen Defaults → Phase 3 |
|
||||
| **E8** | **RSS/Feeds werden in Zukunft entwickelt** | Anforderung fürs künftige Feed-Feature vorgemerkt: Archived/anonymisierte PMs ausschließen, Feed-Cache bei Meldeverfahren-Mutationen invalidieren → Phase 8 / Abschnitt 3 |
|
||||
|
||||
## 3. Nicht-Code-Punkte aus den Konzeptdokumenten (zur Nachverfolgung, außerhalb dieses Plans)
|
||||
|
||||
- Anwaltsagenda §10 (Unentgeltlichkeits-Grenze, UWG connektar, Realnamen-Änderung, Art. 19 Syndication, AI-Act-Co-Betreiber).
|
||||
- Syndication-Vertrag mit connektar (Änderungs-/Löschpropagierung, Art.-19-Zuständigkeit einfordern).
|
||||
- Externe Antwortvorlagen (Mail-Templates außerhalb des Systems) vom connektar-Verweis befreien.
|
||||
- **RSS/Feed-Feature** (künftige Entwicklung, E8): muss Archived/anonymisierte PMs ausschließen und die Re-Sync-Sperre bzw. Mutationen des Meldeverfahrens respektieren (Cache-Invalidierung).
|
||||
|
||||
## 4. Testing & Qualität (durchgehend)
|
||||
|
||||
- Pest-Feature-Tests je Phase (Konventionen: `RefreshDatabase` global, `RolesAndPermissionsSeeder` im `beforeEach`, Factories `PressRelease::factory()->published()->forPortal(...)`; Vorbilder: `tests/Feature/Billing/DepublicationTest.php`, `tests/Feature/LegalRequestTest.php`, `tests/Feature/Admin/AdminPressReleaseActionsTest.php`).
|
||||
- Kritische Invarianten als eigene Tests: (a) DSGVO-Pfad zieht **nie** Credits/Checkout, (b) jede Mutation setzt `sync_locked_at`, (c) Importer/API respektieren die Sperre, (d) ActivityLog enthält keine Klartext-Altwerte, (e) Magic-Link geht nie an Formular-Adresse.
|
||||
- `vendor/bin/sail bin pint --dirty` vor jedem Abschluss.
|
||||
|
||||
## Änderungshistorie
|
||||
|
||||
| Datum | Änderung |
|
||||
|---|---|
|
||||
| 22.07.2026 | Erstfassung auf Basis Ist-Stand-Analyse der Codebasis (Meldeverfahren v0.1, Magic-Link-, Depublizierungs- und Billing-Infrastruktur) |
|
||||
| 22.07.2026 (rev. 2) | Entscheidungen E1–E8 bestätigt und eingearbeitet: Spur D = 10 €, Depublizierung = 35 €; kein Verbrauch bei KI-rot; Reversibilitätsfenster 14 Tage / Snapshot-Frist 30 Tage / Levenshtein 15 % jeweils per Config; Magic-Link nutzt bestehende Mechanik; RSS/Feeds als künftiges Feature vorgemerkt |
|
||||
| 22.07.2026 (rev. 3) | **Phase 1 umgesetzt:** 3 Migrationen, `lockSync()`/`isSyncLocked()`, Importer-Guard (auch `--force`), `LegalRequestType`-Cases + `LegalRequestVerificationMethod`, Snapshot-Modell mit `capture()`/Factory, `ActivityType::SyncLock`, Config E3–E5 in `config/legal.php`, 5 neue Pest-Tests grün. Erkenntnisse: Legacy-API hat keinen Update-Pfad (API-Guard entfällt vorerst); Spalten-Adds an `press_releases` wegen FULLTEXT-Index immer als ein kombiniertes ALTER (voller Tabellen-Copy, kein INSTANT) |
|
||||
| 22.07.2026 (rev. 4) | **Phase 2 umgesetzt:** `AnonymizationService` (Unicode-Wortgrenzen-Replace, Pflicht-`preview()`, transaktionales `execute()` mit Snapshot/Kontakt-Detach/Bild-Löschung/Sperre/Sitemap-Invalidierung), Admin-Queue-Panel Anonymisieren/Depublizieren mit Pflicht-Vorschau, `ActivityType::Anonymization`, `snapshots:purge` + Scheduler, 9 neue Pest-Tests grün |
|
||||
| 22.07.2026 (rev. 5) | Admin-Schwärzungs-Editor: kompletter PM-Inhalt als kopierbare Vorschau (vorher/nachher in einem Block), Ersetzungswert vorbefüllt. **Phase 3 umgesetzt:** Intake `melden/pm` (Kosten-Tabelle vorab, Honeypot, IP+PM-Rate-Limits), `createReportLink` + Enum-Migration `press_release_report`, Mailable, atomare Verifikation mit Session-Marker, Anliegen-Auswahl erzeugt verifizierte Queue-Anfragen (Bremse 1 offene Anfrage/PM/Typ), toter-Kanal-Fallback auf manuelles Formular, Owner-Einstieg aus dem Kunden-Panel, Lang-Keys de/en, 10 neue Pest-Tests grün |
|
||||
| 22.07.2026 (rev. 6) | **Phase 4 umgesetzt (panel-first, Entscheidung: sofort live + rückgängig machbar):** `CorrectionService` mit Hybrid-Levenshtein-Gate (Zeichen kurz / Wörter lang, strip_tags-Basis), Panel-Abschnitt „Änderung & Korrektur" über der Depublikation (ein Einstieg, System stuft klein/groß ein; groß = Interim-Queue-Anfrage bis Phase-7-Checkout), Wochen-Bremse per Config, Snapshot-Restore im Admin (Korrektur + Anonymisierung), `ActivityType::Correction`, `WebCaches::forgetSitemaps()` extrahiert, 11 neue Pest-Tests grün |
|
||||
| 22.07.2026 (rev. 7) | Konfig verschärft (5 % / 1 Korrektur pro Woche) + Mini-Edit-Ausnahme ≤ 2 Zeichen im Gate; Abschnitt „Stand & was noch fehlt" für den Wiedereinstieg ergänzt; Tagescommit |
|
||||
183
docs/Decision-Update/Lösch- und Änderungsanfragen.md
Normal file
183
docs/Decision-Update/Lösch- und Änderungsanfragen.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# Lösch- und Änderungsanfragen (Meldeverfahren)
|
||||
|
||||
**Stand:** 22.07.2026 **Status:** Aktive Arbeitsgrundlage für Live-Portale (Interim) + Vorlage für Systemabbildung (Pfad E/F/G, Magic-Link) **Hinweis:** Interne Einordnung, keine Rechtsberatung. Die mit → _Anwaltsagenda_ markierten Punkte vor Serieneinsatz anwaltlich absichern.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entscheidungsachse (Kernprinzip)
|
||||
|
||||
Nicht das betroffene **Datenfeld** entscheidet über kostenfrei vs. kostenpflichtig, sondern zwei Faktoren zusammen:
|
||||
|
||||
1. **Wer ist betroffen** — natürliche Person (DSGVO greift) oder juristische Person (kein Betroffenenrecht)?
|
||||
2. **Was bleibt nach Anonymisierung übrig** — ein eigenständig zulässiges, werthaltiges Reststück, oder ein wertloses/widersprüchliches Artefakt?
|
||||
|
||||
„Personenbezogen" umfasst **nicht nur Kontaktfelder**, sondern auch Name, Autorenschaft und inhaltliche Aussagen einer identifizierbaren Person. Das Entfernen der Kontaktfelder allein erfüllt einen Art.-17-Anspruch nicht, wenn der personenbezogene Kern (z. B. der Realname) bestehen bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Free/Paid-Matrix
|
||||
|
||||
|Konstellation|Reststück nach Anonymisierung|Vorgehen|Kosten|
|
||||
|---|---|---|---|
|
||||
|Natürliche Person, PM über/von ihr|wertlos, nicht eigenständig|**Volldepublizierung**|**kostenfrei**|
|
||||
|Natürliche Person, nur Kontaktdaten (z. B. Ansprechpartner in Firmen-PM)|tragfähige Firmen-PM bleibt|Kontaktdaten raus, Rest bleibt|**kostenfrei**|
|
||||
|Natürliche Person, Realname-Änderung/-Entfernung|dieselbe PM ohne Realnamen|Änderung umsetzen|**kostenfrei** (Betroffenenrecht-Nähe → _Anwaltsagenda_)|
|
||||
|Juristische Person, Register-/Firmendaten (§ 9 HGB)|eigenständig zulässig|Kontaktdaten der nat. Person kostenfrei; Volldepublizierung optional|**optional kostenpflichtig**|
|
||||
|
||||
**Preis-Überlebensbedingung (kumulativ):** Eine Gebühr ist nur dort tragfähig, wo (a) **kein** Personenbezug einer natürlichen Person besteht **und** (b) ein **tatsächlich funktionierender** kostenfreier Alternativweg existiert. Fehlt eine der beiden Bedingungen, ist die Gebühr angreifbar (Art. 12 Abs. 5 DSGVO bzw. UWG).
|
||||
|
||||
> **Revisit-Bedingung:** Der connektar-Weg steht als Gratisweg nicht zur Verfügung (§7). Bedingung (b) wird stattdessen durch den **Magic-Link-Self-Service** (§11) erfüllt: kleine Korrekturen und DSGVO-Anliegen laufen dort kostenfrei. Eine Gebühr (Spur 3) ist damit tragfähig für **größere, diskretionäre** Änderungen und die freiwillige Depublizierung zulässiger Inhalte — nicht für Personenbezug einer natürlichen Person.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vier Referenzfälle
|
||||
|
||||
|Fall|Betroffener|Datenart|Besonderheit|Entscheidung|
|
||||
|---|---|---|---|---|
|
||||
|**HWP** (Kaymaz)|juristische Person (GmbH)|Register-/Firmendaten, § 9 HGB|Streumail, ursprünglich an viaductus gerichtet; Frist-Platzhalter nicht ausgefüllt|Persönliche Kontaktdaten kostenfrei raus; Firmeneintrag bleibt; Volldepublizierung **optional 35 €**|
|
||||
|**Huraux**|natürliche Person|private Adresse, Telefon, Name|Absenderadresse ≠ hinterlegte Adresse; hinterlegter Kanal (Domain) **tot**|Vollstattgabe, kostenfrei, **ohne** formale Verifikation → Aktenvermerk + umkehrbare Depublizierung|
|
||||
|**Gleue**|natürliche Person|private Kontaktdaten|„Dringend"-Rhetorik (keine Frist); Kanal intakt|Vollstattgabe, kostenfrei, **Magic-Link** an hinterlegte Adresse `mariana@gleue.com`|
|
||||
|**Rambo**|natürliche Person (Spokesperson in Firmen-PM)|Realname „Matvey Teplitsky" → „Alex"|**connektar verweigert Fremdportal-Änderung** (Holthaus); Legitimation via Firmen-Mail liegt vor|Änderung **kostenfrei** direkt umsetzen; kein connektar-Verweis mehr|
|
||||
|
||||
**Achse, die alle vier aufspannen:** _juristisch/natürlich_ × _Kanal funktioniert/tot_ × _Löschung/Änderung_. Deckt die Hauptvarianten des späteren Pfad-E/F/G-Splits ab.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interims-Triage-Prozess (4 Stufen)
|
||||
|
||||
1. **Verifizieren** — Stammt die Anfrage nachweislich vom Betroffenen/Berechtigten? Abgleich Absender ↔ hinterlegte Adresse. Verifikation nur bei **begründeten Zweifeln** (Art. 12 Abs. 6), nie pauschal.
|
||||
2. **Klassifizieren** — Natürliche oder juristische Person? Personenbezogener Kern oder reine Firmendaten? Löschung oder Änderung?
|
||||
3. **Entscheiden** nach Free/Paid-Matrix (§2). Bei connektar-Herkunft: parallel Löschung/Änderung an der Quelle anstoßen und Re-Sync sperren.
|
||||
4. **Antworten** fristwahrend (innerhalb eines Monats ab Eingang).
|
||||
|
||||
**Eskalationsstufe-2-Regel (Aufsichtsbehörden-Hinweis):** Der Hinweis „Ihnen steht der Weg zur Aufsichtsbehörde offen" gehört **nicht** in ein Schreiben, das ein kostenfreies Angebot macht und den Ball beim Empfänger lässt — dort wirkt er wie eine Einladung zur Beschwerde. Er gehört erst in den **zweiten** Brief, wenn ein Begehren tatsächlich (teilweise) **abgelehnt** wird; dann ist er Art.-12-Pflichthygiene, keine Provokation. Neutraler Abschluss im Angebotsfall: „Für Rückfragen stehen wir gerne zur Verfügung."
|
||||
|
||||
---
|
||||
|
||||
## 5. Technische Checkliste bei Umsetzung
|
||||
|
||||
Bei DSGVO-Löschung einer natürlichen Person reicht ein bloßer Tombstone (noindex, URL erhalten) **nicht** — die personenbezogenen Daten müssen tatsächlich aus der Auslieferung verschwinden. Umzusetzen sind:
|
||||
|
||||
- [ ] Depublizierung / Entfernung der Daten aus dem sichtbaren Inhalt
|
||||
- [ ] Zugehörige **Bilder und Anhänge**
|
||||
- [ ] **Interne Suchtreffer**
|
||||
- [ ] **Feed-/RSS-Ausgaben**
|
||||
- [ ] **Sitemap-Einträge**
|
||||
- [ ] `noindex` gesetzt + zur Entfernung aus dem Suchindex eingereicht (Cache-Aktualisierung dauert; kein unmittelbarer Einfluss → im Schreiben erwähnen)
|
||||
- [ ] **Re-Sync-Sperre**: Eintrag dauerhaft gegen erneutes Einspielen über die Schnittstelle sperren
|
||||
- [ ] **Art. 19 DSGVO**: connektar über Löschverlangen informieren, Löschung an der Quelle anstoßen; Betroffenen für netzwerkweite Entfernung an connektar verweisen
|
||||
- [ ] **Kein Credit-Abzug** im DSGVO-Pfad (Art. 12 Abs. 5) — technisch prüfen, dass der DSGVO-Pfad **nicht** die reguläre 25-Credit-Depublizierungslogik zieht → _Anwaltsagenda-nah / technisch kritisch_
|
||||
- [ ] Bei Handeln ohne formale Verifikation (toter Kanal): **Aktenvermerk** (Art. 5 Abs. 2) + **umkehrbare** Depublizierung + Hinweis auf Wiederherstellbarkeit
|
||||
|
||||
---
|
||||
|
||||
## 6. Identitätsprüfung (Art. 12 Abs. 6)
|
||||
|
||||
- **Grundsatz:** Nachweis nur bei begründeten Zweifeln (z. B. Absenderadresse ≠ hinterlegte Adresse). Löschverlangen ist ein möglicher **Angriffsvektor** — wer die PM liest, kennt Name/Anschrift/Telefon.
|
||||
- **Regelweg (neues System):** **Magic-Link** an die in der Veröffentlichung hinterlegte Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert — kein Ermessen, kein Ausweis.
|
||||
- **Nie verlangen:** Ausweiskopie o. ä. (Datenminimierung; holt zusätzliche sensible Daten ins Haus).
|
||||
- **Fallback „Kanal tot":** Risiko-Asymmetrie kippt Richtung Umsetzen, sobald kein Missbrauchsmotiv erkennbar ist (eigene Privatdaten einer natürlichen Person). Dann: Aktenvermerk statt Verifikation, umkehrbare Depublizierung. → **Systemlücke dokumentieren:** Der Ablauf braucht einen definierten Fallback „hinterlegter Kontaktkanal nicht mehr erreichbar", sonst Lücke für genau diese Konstellation.
|
||||
- **Rhetorik ignorieren:** „Dringend/Notfall" ändert die Monatsfrist nicht und darf die Prüfung nicht überspringen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Strukturproblem connektar (KRITISCH)
|
||||
|
||||
**Fund (Fall Rambo):** connektar (Holthaus) nimmt nachträgliche Änderungen **ausschließlich auf eigenen Portalen** vor und überträgt sie **nicht** auf Fremdportale. Das widerspricht direkt der bisherigen Standardaussage „connektar aktualisiert das kostenfrei bei uns über die API".
|
||||
|
||||
**Präzisierung (bestätigt 22.07.2026):** Die technische **Möglichkeit** einer Propagierung _besteht_ — connektar **nutzt sie nur nicht** bzw. verweigert sie für Fremdportale. Es ist damit **kein technisches Naturgesetz, sondern ein Verhandlungspunkt** für den Syndication-Vertrag. Praktische Konsequenz für den Betrieb bleibt: nicht darauf verlassen.
|
||||
|
||||
**Konsequenzen:**
|
||||
|
||||
- Der beworbene kostenfreie connektar-Weg **steht für Fremdportal-Änderungen faktisch nicht zur Verfügung**. Kunden landen in der Sackgasse und dann beim Bezahlservice → Trichter-Optik, **UWG-Risiko** (irreführende Angabe) + Anti-Zombie-Widerspruch.
|
||||
- Betrifft **alle** bisherigen Vorlagen (Änderung _und_ Löschung/Depublizierung).
|
||||
|
||||
**Architektur-Entscheidung:** connektar wird als **write-once** behandelt. Das Portal **übernimmt den gesamten Post-Publication-Zustand selbst** (Änderung, Löschung, Depublizierung) — realisiert über den Self-Service-Ausbau (§11) plus Admin. Die **Re-Sync-Sperre** ist damit nicht optional, sondern strukturell zwingend, sonst überschreiben periodische Re-Imports jede Mutation.
|
||||
|
||||
**Sofortmaßnahmen:**
|
||||
|
||||
1. **Template-Stopp:** „Kostenfreier Weg über connektar" **aus allen Antwortvorlagen entfernen** (auch aus HWP-/Depublizierungs-Texten). Ersatz: Self-Service-Portal (§11) bzw. direkte kostenfreie Umsetzung.
|
||||
2. **Syndication-Vertrag priorisieren:** Muss Änderungs-/Löschpropagierung und **Art.-19-Zuständigkeit** regeln — und, da die Möglichkeit besteht, deren **Nutzung durch connektar einfordern**. Dieser Fall ist der konkrete Beleg, warum der Vertrag nicht warten kann.
|
||||
|
||||
---
|
||||
|
||||
## 8. Argumentationsreserve — Vier Säulen (nur juristische Person / Registerdaten)
|
||||
|
||||
Für die begründete **Ablehnung** einer Komplettlöschung von Firmen-/Registerdaten (Fall HWP-Typ). Kurzform für den Brief: „Register-/Firmendaten sind nach § 9 HGB öffentlich und betreffen die GmbH als juristische Person, der die genannten Datenschutzrechte nicht zustehen; eine Löschpflicht besteht daher nicht."
|
||||
|
||||
1. **Kein Schutzsubjekt:** DSGVO schützt nur natürliche Personen; GmbH-Daten fallen nicht unter Art. 17/21.
|
||||
2. **Gesetzlich öffentlich:** Handelsregisterdaten nach § 9 HGB für jedermann einsehbar; kein Recht auf „Herauslöschen" rechtmäßiger Registertatsachen aus Drittberichterstattung.
|
||||
3. **Rechtsgrundlage + Ausnahme:** Art. 6 Abs. 1 lit. f (berechtigtes Interesse), nicht Einwilligung; Art. 17 Abs. 3 lit. a nimmt Meinungs-/Informationsfreiheit vom Löschanspruch aus; bei echter PM zusätzlich Medienprivileg (Art. 85 + LPG).
|
||||
4. **Widerspruch ≠ automatische Löschung:** Art. 21 löst Abwägung aus; überwiegendes öffentliches Informationsinteresse an Wirtschaftsvorgängen trägt die Weiterverarbeitung. „Ohne meine Zustimmung" läuft leer, weil Einwilligung nie Rechtsgrundlage war.
|
||||
|
||||
> **Grenze der Reserve:** Trägt nur Firmen-/Sachdaten. Direkte **persönliche Kontaktdaten** einer natürlichen Person werden dennoch kostenfrei entfernt. Bei **Presskit-/Profilseiten** (aus Aggregat-/Registerdaten erzeugt) ist Säule 3 (Medienprivileg) schwach — Argumentation stützt sich dann auf Säulen 1, 2, 4.
|
||||
|
||||
---
|
||||
|
||||
## 9. Geschäftsentscheidungen (nicht auf Anwaltsagenda)
|
||||
|
||||
- **Tombstone statt Hartlöschung** als Standard für zulässige Depublizierung (noindex, gegen Re-Sync gesperrt) — konsistent mit Systemarchitektur; keine „aus der Datenbank entfernt"-Formulierung mehr.
|
||||
- **Freiwilliger Depublizierungsservice** (35 €/39 € live; systemisch 25 Credits) nur dort anbieten, wo die Preis-Überlebensbedingung (§2) erfüllt ist.
|
||||
- **Anti-Zombie-Filter:** Bei natürlicher Person, kaputtem Gratisweg oder Kleinstaufwand nicht auf einer 35–39-€-Gebühr bestehen; Reputations-/UWG-Risiko übersteigt den Ertrag.
|
||||
- **Keine Löschung/Depublizierung als Zahlungshebel.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Offene Anwaltsfragen (Agenda-Ergänzungen)
|
||||
|
||||
1. **Grenzziehung Unentgeltlichkeit:** Wo verläuft die Grenze zwischen unentgeltlicher Erfüllung nach Art. 17 und einem entgeltlichen **freiwilligen** Depublizierungsservice? Ist die Preisgestaltung tragfähig, wenn das Reststück nach Anonymisierung eigenständig zulässig wäre?
|
||||
2. **UWG-Risiko connektar-Weg:** Bewertung des als kostenfrei beworbenen connektar-Wegs, der auf Fremdportalen faktisch nicht funktioniert (irreführende Angabe / Trichter in Bezahlservice?).
|
||||
3. **Namensänderung natürliche Person:** Ab wann ist die Entfernung/Änderung eines Realnamens ein **unentgeltliches** Betroffenenrecht (Art. 16/17) statt eines kostenpflichtigen Edits?
|
||||
4. **Art. 19 in der Syndication:** Wer trägt die Benachrichtigungspflicht gegenüber weiteren Empfängern — Portal oder Verteiler? Regelung im Syndication-Vertrag.
|
||||
5. **(bereits offen)** Co-Betreiber-Status nach Art. 50(4) AI Act — separate Agenda.
|
||||
|
||||
---
|
||||
|
||||
## 11. Self-Service-Ausbau Magic-Link (Ziel-Architektur)
|
||||
|
||||
**Ziel:** Anfragevolumen reduzieren, indem Änderungen/Löschungen self-service über den Magic-Link laufen — Portal übernimmt den Post-Publication-Zustand ohnehin selbst (§7).
|
||||
|
||||
### Drei Spuren
|
||||
|
||||
|Spur|Umfang|Kosten|Ablauf|
|
||||
|---|---|---|---|
|
||||
|**1 — Kleine Korrektur**|Tippfehler, Kontaktdaten, Name einer natürlichen Person|kostenlos|self-service, sofort; rate-limited, Levenshtein-gated (= Pfad A/B)|
|
||||
|**2 — DSGVO-Löschung/Anonymisierung**|Entfernung personenbezogener Daten einer natürlichen Person|**kostenlos, nie Credits** (Art. 12 Abs. 5)|self-service-Einreichung → **Admin-Review** vor Ausführung (= Pfad E/F)|
|
||||
|**3 — Größere Änderung / freiwillige Depublizierung**|Fließtext umschreiben/ergänzen, Bildtausch, Depublizierung zulässiger (Firmen-)Einträge|kostenpflichtig|self-service Checkout, Credits/Stripe (= Pfad G)|
|
||||
|
||||
### Abgrenzungsgrund (kritisch)
|
||||
|
||||
**Die Free/Paid-Einstufung darf nie der Antragsteller selbst wählen.** Sonst wählt ein HWP-Typ „DSGVO-Löschung" und erhält die kostenlose Komplett-Depublizierung eines rechtlich zulässigen Firmeneintrags, der eigentlich Spur 3 (bezahlt) wäre → der Bezahlkanal leckt aus.
|
||||
|
||||
- Spur 1: Auto-Run vertretbar (geringer Missbrauchswert).
|
||||
- Spur 2: **admin-gegated** — dort wird der Matrix-Test (§1) menschlich angewandt (natürliche vs. juristische Person; Reststück eigenständig zulässig?). Kein Komfort-, sondern Abgrenzungsgrund.
|
||||
- Spur 3: kein Gate nötig (wer zahlt, hat kein Trick-Motiv).
|
||||
|
||||
### Verifikation
|
||||
|
||||
Der Magic-Link geht **immer an die hinterlegte Pressekontaktadresse**, nie an die im Formular eingegebene → selbst bei Fremdantrag ist nur der echte Kontakt handlungsfähig (strukturelle Verifikation, kein Ermessen).
|
||||
|
||||
- **Adresse hinterlegt** (Regelfall): Link raus → Self-Service-Portal mit den drei Spuren.
|
||||
- **Keine Adresse hinterlegt / tot** (Huraux-Fall): kein vertrauenswürdiger Kanal → **vorgeschaltete manuelle Mail-Verifikation** + Admin, umkehrbare Depublizierung, Aktenvermerk (§6-Fallback).
|
||||
|
||||
### Webseiten-Intake (Meldeverfahren-Seite)
|
||||
|
||||
Vom Kontaktformular zum **Deflektions-Trichter:** URL einfügen → Anliegen wählen → System schlägt hinterlegten Kontakt nach → Magic-Link an diesen; Seite erklärt vorab transparent kostenlos vs. kostenpflichtig (Anti-Zombie).
|
||||
|
||||
- **Pflicht:** Formular **rate-limiten** (pro URL/IP), sonst Werkzeug zum Fluten fremder Kontakt-Postfächer mit Magic-Link-Mails.
|
||||
- Jede Mutation setzt automatisch die **Re-Sync-Sperre** (§5, §7).
|
||||
|
||||
### Zu bestätigen (offene Business-Entscheidungen)
|
||||
|
||||
1. **Spur 2 admin-gegated?** — Empfehlung: ja (sonst Leck oben). Alternative Auto-Run nur für „eindeutige" Fälle scheitert daran, „eindeutig" maschinell zu bestimmen.
|
||||
2. **Exakte Grenze Spur 1 ↔ 3.** — Vorschlag: Feld-/Textkorrektur ohne neuen Inhalt = klein/kostenlos; neuer/umgeschriebener Fließtext, Bildtausch, Depublizierung = groß/kostenpflichtig.
|
||||
3. **Bestätigungs-/Reversibilitätsfenster** für self-service Depublizierung — Empfehlung: ja (Irreversibilität bei einem Klick).
|
||||
|
||||
---
|
||||
|
||||
## Änderungshistorie
|
||||
|
||||
|Datum|Änderung|
|
||||
|---|---|
|
||||
|22.07.2026|Erstfassung; vier Referenzfälle (HWP, Huraux, Gleue, Rambo), connektar-Strukturproblem, Free/Paid-Matrix, Triage-Prozess, Anwaltsagenda-Ergänzungen|
|
||||
|22.07.2026 (rev. 2)|connektar präzisiert (Möglichkeit besteht, ungenutzt → write-once, Portal übernimmt Post-Publication-Zustand); §11 Self-Service-Ausbau Magic-Link (drei Spuren, Abgrenzungsgrund, Verifikation, Intake) ergänzt; §2 Revisit-Bedingung aktualisiert|
|
||||
153
docs/Decision-Update/Pressemitteilung melden.md
Normal file
153
docs/Decision-Update/Pressemitteilung melden.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Flow: Pressemitteilung melden
|
||||
|
||||
**Stand:** 22.07.2026 **Zweck:** Selbstbedienungs-Flow für Änderungs-, Lösch- und DSGVO-Anliegen zu einer veröffentlichten Pressemitteilung. Reduziert manuelle Anfragen; Portal übernimmt den Post-Publication-Zustand selbst (connektar = write-once). **Hinweis:** Interne Konzeptbeschreibung, keine Rechtsberatung.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht der vier Anliegen
|
||||
|
||||
| Anliegen | Ergebnis | Kosten | Freigabe |
|
||||
| ------------------------ | ------------------------------------------ | -------------------------- | --------------------------------- |
|
||||
| **Kleine Korrektur** | Feld-/Textkorrektur ohne neuen Inhalt | kostenlos | automatisch (rate-limited) |
|
||||
| **DSGVO-Anonymisierung** | Personendaten geschwärzt, PM bleibt online | **kostenlos, nie Credits** | Admin (später ggf. automatisiert) |
|
||||
| **Depublizierung** | PM offline (Tombstone, umkehrbar) | 35 € | Zahlung |
|
||||
| **Änderung** | Inhaltsedit + neuer Freigabelauf | 10 € | KI-Prüfung |
|
||||
|
||||
**Reframe DSGVO:** DSGVO führt zu **Anonymisierung**, nicht zu Depublizierung. Das ist das mildere, gesetzlich vorgesehene Mittel; der redaktionelle Kern bleibt erhalten (Art. 85). Depublizierung ist der separate, kostenpflichtige Fall.
|
||||
|
||||
---
|
||||
|
||||
## Diagramm
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["PM melden (Website oder Dashboard)"]
|
||||
QLogin{"Eingeloggter Creator mit zugeordneter PM?"}
|
||||
PickOwn["PM aus eigenen Beitraegen waehlen"]
|
||||
URL["PM per URL identifizieren"]
|
||||
QMail{"Kontaktadresse hinterlegt?"}
|
||||
ML["Magic-Link an hinterlegte Adresse"]
|
||||
Click["Link bestaetigt = verifiziert"]
|
||||
Manual["Hinweis: manuelle Verifizierung, Nachweis an Support"]
|
||||
Admin0["Admin prueft Berechtigung"]
|
||||
Anliegen{"Anliegen waehlen"}
|
||||
|
||||
KA["Feld-/Textkorrektur ohne neuen Inhalt"]
|
||||
KDiff["Vorschau / Diff"]
|
||||
KSave["Sofort speichern, rate-limited"]
|
||||
|
||||
DA["Zu schwaerzende Felder + Namensvarianten eingeben"]
|
||||
DDiff["Pflicht-Vorschau / Diff, Wortgrenzen"]
|
||||
DAdmin{"Admin-Freigabe: anonymisieren oder depublizieren?"}
|
||||
DExec["Replace + Assets entfernen + Re-Index"]
|
||||
|
||||
PCheck["Checkout 35 EUR"]
|
||||
PPay["Zahlung"]
|
||||
PTomb["Tombstone: offline + noindex, umkehrbar"]
|
||||
|
||||
CA["Neuen Text eingeben"]
|
||||
CPrice["Preis beziffern + Checkout"]
|
||||
CPay["Zahlung"]
|
||||
CAI{"Neuer Freigabelauf: KI-Pruefung"}
|
||||
CLive["Live"]
|
||||
CRej["Abgelehnt, kein Verbrauch"]
|
||||
|
||||
Sync["Re-Sync-Sperre setzen"]
|
||||
Done["Nutzer-Benachrichtigung + Audit-Log ohne Klartext-Altwerte"]
|
||||
|
||||
Start --> QLogin
|
||||
QLogin -- Ja --> PickOwn --> Anliegen
|
||||
QLogin -- Nein --> URL --> QMail
|
||||
QMail -- Ja --> ML --> Click --> Anliegen
|
||||
QMail -- "Nein / tot" --> Manual --> Admin0 --> Anliegen
|
||||
|
||||
Anliegen -- "Kleine Korrektur (frei)" --> KA
|
||||
Anliegen -- "DSGVO (frei)" --> DA
|
||||
Anliegen -- "Depublizierung (35 EUR)" --> PCheck
|
||||
Anliegen -- "Aenderung (kostenpflichtig)" --> CA
|
||||
|
||||
KA --> KDiff --> KSave --> Sync
|
||||
|
||||
DA --> DDiff --> DAdmin
|
||||
DAdmin -- Anonymisieren --> DExec --> Sync
|
||||
DAdmin -- "Depublizieren noetig" --> PTomb
|
||||
|
||||
PCheck --> PPay --> PTomb --> Sync
|
||||
|
||||
CA --> CPrice --> CPay --> CAI
|
||||
CAI -- "gruen / gelb" --> CLive --> Sync
|
||||
CAI -- rot --> CRej --> Done
|
||||
|
||||
Sync --> Done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt-für-Schritt
|
||||
|
||||
### 1. Einstieg & Identität
|
||||
|
||||
Einstieg über die Website („Pressemitteilung melden") oder das eingeloggte Dashboard.
|
||||
|
||||
- **Eingeloggter Creator** mit zugeordneter PM → wählt die PM aus seinen Beiträgen, keine weitere Verifikation nötig.
|
||||
- **Nicht eingeloggt** → PM per URL identifizieren → Verifikation über Magic-Link.
|
||||
|
||||
### 2. Verifikation (wenn nicht eingeloggt)
|
||||
|
||||
Der Magic-Link geht **immer an die hinterlegte Firmen-/Pressekontaktadresse**, nie an eine im Formular eingegebene Adresse. Damit ist selbst bei einem Fremdantrag nur der echte Kontakt handlungsfähig — die Verifikation ist strukturell.
|
||||
|
||||
- **Adresse hinterlegt** → Link raus, Klick verifiziert, weiter zur Anliegen-Auswahl.
|
||||
- **Keine Adresse hinterlegt / tot** → Hinweis mit Alternativweg: manuelle Verifizierung (Berechtigungsnachweis an den Support), Admin prüft, Umsetzung umkehrbar + Aktenvermerk (Art. 5 Abs. 2).
|
||||
|
||||
### 3. Anliegen wählen
|
||||
|
||||
Vier Optionen (siehe Übersicht). Die Free/Paid-Einstufung wird **nicht** vom Nutzer frei gewählt: DSGVO läuft über Admin-Freigabe, damit „DSGVO" nicht als Gratis-Weg für eine eigentlich kostenpflichtige Depublizierung zulässiger Firmeneinträge missbraucht wird.
|
||||
|
||||
### Spur A — Kleine Korrektur (kostenlos)
|
||||
|
||||
Feld- oder Textkorrektur **ohne neuen Inhalt** (Tippfehler, Kontaktdaten, Name einer natürlichen Person) → Pflicht-Vorschau/Diff → sofortiges Speichern. Rate-limited und auf geringe Änderungsdistanz begrenzt (Levenshtein), damit die Spur nicht zum kostenlosen Voll-Editor wird.
|
||||
|
||||
### Spur B — DSGVO-Anonymisierung (kostenlos, nie Credits)
|
||||
|
||||
Der Kern des Konzepts. Ablauf:
|
||||
|
||||
1. PM auswählen.
|
||||
2. **Erfassung:** Strukturierte Kontaktfelder werden automatisch zum Leeren angeboten; zusätzlich Freitextfelder, in die der Nutzer die zu schwärzenden Werte einträgt — **inklusive Varianten** (z. B. „Matvey Teplitsky", „Herr Teplitsky", „Teplitskys").
|
||||
3. **Pflicht-Vorschau/Diff:** System zeigt markiert, was ersetzt wird. Ersetzung über **Wortgrenzen** (`\b`), damit weder Teiltreffer stehenbleiben noch Wörter wie „maximal" bei Name „Max" zerlegt werden.
|
||||
4. **Admin-Freigabe:** Admin bestätigt und entscheidet auch **anonymisieren vs. depublizieren** — bei Vanity-/Personen-PMs bleibt nach Schwärzung nur ein Rumpf, dann ist Depublizierung das richtige Mittel.
|
||||
5. **Ausführung:** Text-Replace + separate Entfernung/Ersetzung betroffener **Bilder und Anhänge** (Klarname in Pressebild/PDF!) + Re-Index/Cache-Refresh.
|
||||
6. **Kein KI-Red-Flag-Gate** — Anonymisierung ist Compliance-Pflicht, darf nicht wie ein normaler Freigabelauf scheitern.
|
||||
|
||||
Umsetzung des Ersetzungswerts: neutraler Platzhalter oder Rolle („der Geschäftsführer"), kein Leerstring (zerstört Zitate grammatikalisch).
|
||||
|
||||
### Spur C — Depublizierung (35 €)
|
||||
|
||||
Checkout → Zahlung → Tombstone (offline + noindex, **umkehrbar** mit Reversibilitätsfenster). Auch Zielpunkt aus Spur B, wenn der Admin depublizieren statt anonymisieren wählt.
|
||||
|
||||
### Spur D — Änderung (kostenpflichtig)
|
||||
|
||||
Neuen Text eingeben → Preis beziffern + Checkout → Zahlung → **neuer Freigabelauf über die KI-Prüfung**: grün/gelb → live; rot → abgelehnt, kein Verbrauch. Aufwand entspricht faktisch einer Depublizierung (Eingriff + Freigabe), 35 € als plausibler Anker; reine Geschäftsentscheidung.
|
||||
|
||||
### 4. Gemeinsame Abschlussschritte
|
||||
|
||||
- **Re-Sync-Sperre setzen** (bei jeder Mutation zwingend) — sonst holt connektar beim nächsten Import den alten Stand / Klarnamen zurück.
|
||||
- **Nutzer-Benachrichtigung + Audit-Log.** Der Log protokolliert „Feld X anonymisiert durch User Y am Z", **nicht** den Alt-Wert im Klartext (sonst leben die Daten im Log weiter). Alt-Wert nur verschlüsselt + mit Löschfrist, falls für Reversibilität nötig.
|
||||
|
||||
---
|
||||
|
||||
## Querschnitts-Guardrails
|
||||
|
||||
- **DSGVO nie Credits** (Art. 12 Abs. 5).
|
||||
- **Scope-Grenze Spur B:** Das DSGVO-Formular fasst nur **personenbezogene Felder** einer natürlichen Person an. Realname → Rolle/Platzhalter = frei; Realname → anderer echter Name oder Textumbau = Spur D (kostenpflichtig).
|
||||
- **Begriff:** Was gebaut wird, ist **De-Identifikation / Schwärzung direkter Identifier**, keine „Anonymisierung" im DSGVO-Vollsinn — in der Kundenkommunikation nicht überversprechen.
|
||||
- **Formular-Rate-Limit** (pro URL/IP), damit der Magic-Link-Versand nicht zum Werkzeug wird, fremde Kontakt-Postfächer zu fluten.
|
||||
- **Re-Sync-Sperre** ist strukturell, nicht optional (connektar = write-once).
|
||||
|
||||
---
|
||||
|
||||
## Zu bestätigen
|
||||
|
||||
1. Spur B bleibt zunächst **Admin-Freigabe**, Automatisierung später bei guter Kalibrierung.
|
||||
2. Exakte Grenze Spur A ↔ D (Vorschlag: Feld-/Textkorrektur ohne neuen Inhalt = klein; neuer/umgeschriebener Fließtext, Bildtausch, Depublizierung = groß).
|
||||
3. Preis Spur D (Vorschlag: 35 € analog Depublizierung).
|
||||
4. Reversibilitätsfenster-Dauer für Depublizierung/Anonymisierung.
|
||||
|
|
@ -415,6 +415,40 @@ return [
|
|||
'throttle' => 'Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.',
|
||||
],
|
||||
|
||||
'pm_report' => [
|
||||
'title' => 'Pressemitteilung melden oder ändern',
|
||||
'concerns_title' => 'Anliegen zu Ihrer Pressemitteilung',
|
||||
'meta' => 'Änderungs-, Lösch- und Datenschutz-Anliegen zu einer veröffentlichten Pressemitteilung — verifiziert über die hinterlegte Pressekontaktadresse.',
|
||||
'breadcrumb' => 'PM melden/ändern',
|
||||
'intro' => 'Sie möchten eine veröffentlichte Pressemitteilung korrigieren, ändern, offline nehmen oder personenbezogene Daten entfernen lassen? Fügen Sie die URL der Mitteilung ein — wir senden einen Bestätigungslink an die hinterlegte Pressekontaktadresse. Die Kosten sehen Sie vorab:',
|
||||
'track_correction' => 'Kleine Korrektur (Tippfehler, Kontaktdaten)',
|
||||
'track_correction_desc' => 'Feld- oder Textkorrektur ohne neuen Inhalt.',
|
||||
'track_dsgvo' => 'Entfernung personenbezogener Daten (DSGVO)',
|
||||
'track_dsgvo_desc' => 'Schwärzung von Namen und Kontaktdaten natürlicher Personen; die Mitteilung bleibt online. Prüfung durch unser Team.',
|
||||
'track_depublication' => 'Depublizierung (Mitteilung offline nehmen)',
|
||||
'track_depublication_desc' => 'Die Mitteilung wird vollständig offline genommen (umkehrbar innerhalb der Widerruffrist).',
|
||||
'track_change' => 'Inhaltliche Änderung',
|
||||
'track_change_desc' => 'Neuer oder umgeschriebener Text, Bildtausch — mit erneutem Freigabelauf.',
|
||||
'cost_free' => 'kostenlos',
|
||||
'verification_note' => 'Der Bestätigungslink geht immer an die in der Veröffentlichung hinterlegte Pressekontaktadresse — nicht an eine hier eingegebene Adresse. So stellen wir sicher, dass nur der echte Kontakt Änderungen anstoßen kann.',
|
||||
'submit' => 'Bestätigungslink anfordern',
|
||||
'flash_link_sent' => 'Wir haben einen Bestätigungslink an :email gesendet. Bitte prüfen Sie das Postfach der hinterlegten Pressekontaktadresse.',
|
||||
'flash_link_invalid' => 'Der Bestätigungslink ist ungültig, abgelaufen oder wurde bereits verwendet. Bitte fordern Sie einen neuen Link an.',
|
||||
'flash_session_expired' => 'Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Bestätigungslink an.',
|
||||
'validation_not_found' => 'Unter dieser URL wurde keine veröffentlichte Pressemitteilung gefunden. Bitte prüfen Sie den Link zur Detailseite.',
|
||||
'validation_already_open' => 'Zu dieser Pressemitteilung liegt bereits eine offene Anfrage dieser Art vor. Sie erhalten eine Rückmeldung per E-Mail.',
|
||||
'throttle_release' => 'Für diese Pressemitteilung wurden heute bereits mehrere Bestätigungslinks angefordert. Bitte versuchen Sie es morgen erneut oder prüfen Sie das Postfach.',
|
||||
'manual_fallback' => 'Für diese Pressemitteilung ist keine erreichbare Pressekontaktadresse hinterlegt. Bitte nutzen Sie die manuelle Prüfung — unser Team verifiziert Ihre Berechtigung anhand eines Nachweises:',
|
||||
'manual_fallback_link' => 'Zum Formular für die manuelle Prüfung',
|
||||
'verified_as' => 'Verifiziert über die Pressekontaktadresse :email — Sie können jetzt Ihr Anliegen stellen.',
|
||||
'concern_legend' => 'Welches Anliegen möchten Sie stellen?',
|
||||
'label_message' => 'Beschreibung Ihres Anliegens',
|
||||
'placeholder_message' => 'z. B. „Bitte entfernen Sie meinen Namen Max Mustermann und die Telefonnummer aus dem Pressekontakt."',
|
||||
'concern_note' => 'Kostenlose Anliegen setzen wir nach Prüfung direkt um. Bei kostenpflichtigen Anliegen (Depublizierung, inhaltliche Änderung) erhalten Sie vor der Umsetzung eine Zahlungsaufforderung — es entstehen keine Kosten ohne Ihre Bestätigung.',
|
||||
'concern_submit' => 'Anliegen absenden',
|
||||
'flash_concern_received' => 'Vielen Dank. Ihr Anliegen ist unter der Vorgangsnummer :reference eingegangen — Sie erhalten eine Eingangsbestätigung per E-Mail an die Pressekontaktadresse.',
|
||||
],
|
||||
|
||||
'cancellation' => [
|
||||
'meta_title' => 'Verträge hier kündigen',
|
||||
'meta_description' => 'Kündigen Sie Ihren laufenden Vertrag online – ohne Login, über die gesetzliche Bestätigungsseite nach § 312k BGB.',
|
||||
|
|
|
|||
|
|
@ -414,6 +414,40 @@ return [
|
|||
'throttle' => 'Too many requests. Please try again in :minutes minutes.',
|
||||
],
|
||||
|
||||
'pm_report' => [
|
||||
'title' => 'Report or change a press release',
|
||||
'concerns_title' => 'Your request regarding this press release',
|
||||
'meta' => 'Change, removal and data-protection requests for a published press release — verified via the press contact address on file.',
|
||||
'breadcrumb' => 'Report/change release',
|
||||
'intro' => 'You want to correct or change a published press release, take it offline, or have personal data removed? Paste the URL of the release — we will send a confirmation link to the press contact address on file. Costs are shown upfront:',
|
||||
'track_correction' => 'Minor correction (typos, contact details)',
|
||||
'track_correction_desc' => 'Field or text correction without new content.',
|
||||
'track_dsgvo' => 'Removal of personal data (GDPR)',
|
||||
'track_dsgvo_desc' => 'Redaction of names and contact details of natural persons; the release stays online. Reviewed by our team.',
|
||||
'track_depublication' => 'Depublication (take the release offline)',
|
||||
'track_depublication_desc' => 'The release is taken offline completely (reversible within the revocation window).',
|
||||
'track_change' => 'Content change',
|
||||
'track_change_desc' => 'New or rewritten text, image replacement — with a new approval run.',
|
||||
'cost_free' => 'free of charge',
|
||||
'verification_note' => 'The confirmation link is always sent to the press contact address on file in the publication — never to an address entered here. This ensures only the genuine contact can initiate changes.',
|
||||
'submit' => 'Request confirmation link',
|
||||
'flash_link_sent' => 'We have sent a confirmation link to :email. Please check the mailbox of the press contact address on file.',
|
||||
'flash_link_invalid' => 'The confirmation link is invalid, expired or has already been used. Please request a new link.',
|
||||
'flash_session_expired' => 'Your session has expired. Please request a new confirmation link.',
|
||||
'validation_not_found' => 'No published press release was found at this URL. Please check the link to the detail page.',
|
||||
'validation_already_open' => 'An open request of this type already exists for this press release. You will receive feedback by email.',
|
||||
'throttle_release' => 'Several confirmation links have already been requested for this press release today. Please try again tomorrow or check the mailbox.',
|
||||
'manual_fallback' => 'No reachable press contact address is on file for this press release. Please use the manual review — our team will verify your authorisation based on proof:',
|
||||
'manual_fallback_link' => 'Go to the manual review form',
|
||||
'verified_as' => 'Verified via the press contact address :email — you can now submit your request.',
|
||||
'concern_legend' => 'Which request would you like to submit?',
|
||||
'label_message' => 'Description of your request',
|
||||
'placeholder_message' => 'e.g. "Please remove my name Max Mustermann and the phone number from the press contact."',
|
||||
'concern_note' => 'Free requests are implemented directly after review. For paid requests (depublication, content change) you will receive a payment request before implementation — no costs arise without your confirmation.',
|
||||
'concern_submit' => 'Submit request',
|
||||
'flash_concern_received' => 'Thank you. Your request has been received under case number :reference — you will receive a confirmation of receipt by email to the press contact address.',
|
||||
],
|
||||
|
||||
'cancellation' => [
|
||||
'meta_title' => 'Cancel contracts here',
|
||||
'meta_description' => 'Cancel your ongoing contract online – without signing in, via the statutory confirmation page pursuant to Section 312k of the German Civil Code (BGB).',
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Console\Commands\GenerateManualInvoices;
|
|||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
|
||||
use App\Console\Commands\PurgeMagicLinks;
|
||||
use App\Console\Commands\PurgeRedactionSnapshots;
|
||||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
||||
use App\Support\OperationalAlert;
|
||||
|
|
@ -38,6 +39,13 @@ Schedule::command(PurgeMagicLinks::class, ['--days=30'])
|
|||
->runInBackground()
|
||||
->onFailure($alertOnFailure('magic-links:purge'));
|
||||
|
||||
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
|
||||
Schedule::command(PurgeRedactionSnapshots::class)
|
||||
->dailyAt('03:10')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('snapshots:purge'));
|
||||
|
||||
// PM-Entwürfe: Zombie-Drafts nach 180 Tagen Inaktivität archivieren (wöchentlich)
|
||||
Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
|
||||
->weekly()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Http\Controllers\EmailUnsubscribeController;
|
|||
use App\Http\Controllers\LegacyInvoicePdfController;
|
||||
use App\Http\Controllers\ProofPdfController;
|
||||
use App\Http\Controllers\StripeInvoiceRedirectController;
|
||||
use App\Http\Controllers\Web\PressReleaseReportController;
|
||||
use App\Http\Middleware\EnsureUserIsCustomer;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -31,6 +32,12 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
|
|||
->where('id', '[0-9]+')
|
||||
->name('press-releases.proof');
|
||||
|
||||
// Meldeverfahren (Phase 3): Eigentümer-Einstieg ohne Magic-Link —
|
||||
// die Zuordnung der PM ersetzt die Verifikation (logged_in).
|
||||
Route::post('press-releases/{pressReleaseId}/melden', [PressReleaseReportController::class, 'startFromCustomer'])
|
||||
->where('pressReleaseId', '[0-9]+')
|
||||
->name('press-releases.report');
|
||||
|
||||
Volt::route('firmen', 'customer.press-kits.index')->name('press-kits.index');
|
||||
Volt::route('firmen/anlegen', 'customer.press-kits.create')->name('press-kits.create');
|
||||
Volt::route('firmen/{id}', 'customer.press-kits.show')->name('press-kits.show');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Http\Controllers\Web\NewsroomsController;
|
|||
use App\Http\Controllers\Web\PreiseController;
|
||||
use App\Http\Controllers\Web\PressReleaseAttachmentMediaController;
|
||||
use App\Http\Controllers\Web\PressReleaseImageMediaController;
|
||||
use App\Http\Controllers\Web\PressReleaseReportController;
|
||||
use App\Http\Controllers\Web\ReleaseDetailController;
|
||||
use App\Http\Controllers\Web\ReleaseShareTrackingController;
|
||||
use App\Http\Controllers\Web\ReportFormController;
|
||||
|
|
@ -272,6 +273,22 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
|
|||
->middleware('throttle:10,1')
|
||||
->whereIn('way', ReportFormController::WAYS)
|
||||
->name('melden.form.store');
|
||||
|
||||
// Meldeverfahren v2 (Phase 3): PM melden/ändern — URL einfügen →
|
||||
// Magic-Link an die hinterlegte Pressekontaktadresse → verifizierte
|
||||
// Anliegen-Auswahl (vier Spuren).
|
||||
Route::get('melden/pm', [PressReleaseReportController::class, 'showIntake'])->name('melden.pm');
|
||||
Route::post('melden/pm', [PressReleaseReportController::class, 'requestLink'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('melden.pm.store');
|
||||
Route::get('melden/pm/verifizieren/{token}', [PressReleaseReportController::class, 'verify'])
|
||||
->where('token', '[A-Za-z0-9]{40,}')
|
||||
->middleware('throttle:10,1')
|
||||
->name('melden.pm.verify');
|
||||
Route::get('melden/pm/anliegen', [PressReleaseReportController::class, 'showConcerns'])->name('melden.pm.anliegen');
|
||||
Route::post('melden/pm/anliegen', [PressReleaseReportController::class, 'storeConcern'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('melden.pm.anliegen.store');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
165
tests/Feature/Legal/AdminLegalRequestExecutionTest.php
Normal file
165
tests/Feature/Legal/AdminLegalRequestExecutionTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function adminUserForLegalQueue(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('an admin can preview and execute an anonymization from the queue', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(adminUserForLegalQueue());
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Matvey Teplitsky im Vorstand',
|
||||
]);
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->set('redactionTerms', "Matvey Teplitsky\nTeplitsky")
|
||||
->set('redactionReplacement', 'der Sprecher')
|
||||
->call('previewAnonymization')
|
||||
->assertHasNoErrors()
|
||||
->call('executeAnonymization')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pressRelease->refresh();
|
||||
$request->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('der Sprecher im Vorstand')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and($request->status)->toBe(LegalRequestStatus::Resolved)
|
||||
->and($request->executed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the panel prefills the replacement and shows the full content for copying terms', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(adminUserForLegalQueue());
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Matvey Teplitsky im Vorstand',
|
||||
'text' => '<p>Ansprechpartner ist Matvey Teplitsky, Telefon 0123.</p>',
|
||||
]);
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->assertSet('redactionReplacement', AnonymizationService::DEFAULT_REPLACEMENT)
|
||||
->assertSee('Ansprechpartner ist Matvey Teplitsky')
|
||||
->set('redactionTerms', 'Matvey Teplitsky')
|
||||
->call('previewAnonymization')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Ansprechpartner ist [entfernt]')
|
||||
->assertSee('Vorschau nach Schwärzung');
|
||||
|
||||
expect($pressRelease->fresh()->title)->toBe('Matvey Teplitsky im Vorstand');
|
||||
});
|
||||
|
||||
test('execute without a prior preview is refused', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(adminUserForLegalQueue());
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Original']);
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->set('redactionTerms', 'Original')
|
||||
->call('executeAnonymization')
|
||||
->assertHasErrors('redactionTerms');
|
||||
|
||||
expect($pressRelease->fresh()->title)->toBe('Original')
|
||||
->and($request->fresh()->executed_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('an admin can depublish instead of anonymizing', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(adminUserForLegalQueue());
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->call('depublishRelease')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pressRelease->refresh();
|
||||
$request->refresh();
|
||||
|
||||
expect($pressRelease->status)->toBe(PressReleaseStatus::Archived)
|
||||
->and($pressRelease->depublished_at)->not->toBeNull()
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and($request->status)->toBe(LegalRequestStatus::Resolved)
|
||||
->and($request->executed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an executed anonymization can be rolled back from the snapshot', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(adminUserForLegalQueue());
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Matvey Teplitsky im Vorstand',
|
||||
]);
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->set('redactionTerms', 'Matvey Teplitsky')
|
||||
->call('previewAnonymization')
|
||||
->call('executeAnonymization')
|
||||
->assertHasNoErrors()
|
||||
->call('restoreSnapshot')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pressRelease->refresh();
|
||||
$request->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('Matvey Teplitsky im Vorstand')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and($request->status)->toBe(LegalRequestStatus::InProgress)
|
||||
->and($request->executed_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('anonymization actions are forbidden without the compliance permission', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$this->actingAs($customer);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Original']);
|
||||
$request = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
Volt::test('admin.legal-requests.show', ['id' => $request->id])
|
||||
->set('redactionTerms', 'Original')
|
||||
->call('previewAnonymization')
|
||||
->assertForbidden();
|
||||
|
||||
expect($pressRelease->fresh()->title)->toBe('Original');
|
||||
});
|
||||
130
tests/Feature/Legal/AnonymizationServiceTest.php
Normal file
130
tests/Feature/Legal/AnonymizationServiceTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Contact;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('redaction replaces terms only at word boundaries, longest variant first', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Max Mustermann übernimmt die Leitung',
|
||||
'text' => '<p>Max ist maximal motiviert. Herr Mustermann bleibt. Mustermanns Rolle ändert sich.</p>',
|
||||
]);
|
||||
|
||||
app(AnonymizationService::class)->execute(
|
||||
$pressRelease,
|
||||
['Max Mustermann', 'Max', 'Mustermann'],
|
||||
'[entfernt]',
|
||||
detachContacts: false,
|
||||
);
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('[entfernt] übernimmt die Leitung')
|
||||
->and($pressRelease->text)->toContain('maximal') // "Max" zerlegt "maximal" nicht
|
||||
->and($pressRelease->text)->toContain('Mustermanns') // Genitiv nur über explizite Variante
|
||||
->and($pressRelease->text)->toContain('[entfernt] ist maximal')
|
||||
->and($pressRelease->text)->toContain('Herr [entfernt] bleibt')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue();
|
||||
});
|
||||
|
||||
test('preview reports hits per field without changing the press release', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Mariana Gleue informiert',
|
||||
'text' => '<p>Kontakt: Mariana Gleue, Telefon 0123.</p>',
|
||||
]);
|
||||
|
||||
$preview = app(AnonymizationService::class)->preview($pressRelease, ['Mariana Gleue']);
|
||||
|
||||
expect($preview['total_hits'])->toBe(2)
|
||||
->and($preview['fields']['title']['hits'])->toBe(1)
|
||||
->and($preview['fields']['text']['after'])->toContain('[entfernt]')
|
||||
->and($pressRelease->fresh()->title)->toBe('Mariana Gleue informiert')
|
||||
->and($pressRelease->fresh()->isSyncLocked())->toBeFalse();
|
||||
});
|
||||
|
||||
test('execute snapshots encrypted, detaches contacts and logs without cleartext', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Matvey Teplitsky spricht für die Firma',
|
||||
]);
|
||||
$contact = Contact::factory()->create();
|
||||
$pressRelease->contacts()->attach($contact->id);
|
||||
|
||||
$legalRequest = LegalRequest::factory()
|
||||
->type(LegalRequestType::Dsgvo)
|
||||
->create(['press_release_id' => $pressRelease->id]);
|
||||
|
||||
$snapshot = app(AnonymizationService::class)->execute(
|
||||
$pressRelease,
|
||||
['Matvey Teplitsky'],
|
||||
'der Sprecher',
|
||||
legalRequest: $legalRequest,
|
||||
);
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('der Sprecher spricht für die Firma')
|
||||
->and($pressRelease->contacts()->count())->toBe(0)
|
||||
->and($snapshot->legal_request_id)->toBe($legalRequest->id)
|
||||
->and($snapshot->encrypted_before['fields']['title'])->toContain('Matvey Teplitsky')
|
||||
->and($snapshot->encrypted_before['contact_ids'])->toBe([$contact->id]);
|
||||
|
||||
// Klartext weder im DB-Rohwert des Snapshots noch im Verlaufs-Eintrag.
|
||||
$raw = DB::table('press_release_redaction_snapshots')->where('id', $snapshot->id)->value('encrypted_before');
|
||||
expect($raw)->not->toContain('Teplitsky');
|
||||
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::Anonymization->value)->sole();
|
||||
expect($event->description)->not->toContain('Teplitsky')
|
||||
->and($event->meta['snapshot_id'])->toBe($snapshot->id);
|
||||
});
|
||||
|
||||
test('selected images are soft-deleted and their public files removed', function () {
|
||||
/** @var TestCase $this */
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('pr/foto.jpg', 'bild');
|
||||
Storage::disk('public')->put('pr/foto-thumb.jpg', 'thumb');
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
$image = PressReleaseImage::query()->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'disk' => 'public',
|
||||
'path' => 'pr/foto.jpg',
|
||||
'variants' => ['thumb' => 'pr/foto-thumb.jpg'],
|
||||
'title' => 'Pressefoto mit Klarname',
|
||||
]);
|
||||
|
||||
app(AnonymizationService::class)->execute(
|
||||
$pressRelease,
|
||||
[],
|
||||
detachContacts: false,
|
||||
removeImageIds: [$image->id],
|
||||
);
|
||||
|
||||
expect(PressReleaseImage::query()->find($image->id))->toBeNull()
|
||||
->and(PressReleaseImage::withTrashed()->find($image->id))->not->toBeNull();
|
||||
|
||||
Storage::disk('public')->assertMissing('pr/foto.jpg');
|
||||
Storage::disk('public')->assertMissing('pr/foto-thumb.jpg');
|
||||
});
|
||||
|
||||
test('the purge command removes only snapshots past their retention date', function () {
|
||||
/** @var TestCase $this */
|
||||
$due = PressReleaseRedactionSnapshot::factory()->dueForPurge()->create();
|
||||
$kept = PressReleaseRedactionSnapshot::factory()->create();
|
||||
|
||||
$this->artisan('snapshots:purge')->assertSuccessful();
|
||||
|
||||
expect(PressReleaseRedactionSnapshot::query()->find($due->id))->toBeNull()
|
||||
->and(PressReleaseRedactionSnapshot::query()->find($kept->id))->not->toBeNull();
|
||||
});
|
||||
149
tests/Feature/Legal/CorrectionServiceTest.php
Normal file
149
tests/Feature/Legal/CorrectionServiceTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\CreditTransaction;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\CorrectionRateLimitException;
|
||||
use App\Services\PressRelease\CorrectionService;
|
||||
use App\Services\PressRelease\CorrectionTooLargeException;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('a typo fix in a short title classifies as minor', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Neue Photovoltaikanlage in Betrieb genomen',
|
||||
]);
|
||||
|
||||
$classification = app(CorrectionService::class)->classify($pressRelease, [
|
||||
'title' => 'Neue Photovoltaikanlage in Betrieb genommen',
|
||||
]);
|
||||
|
||||
expect($classification['is_minor'])->toBeTrue()
|
||||
->and($classification['fields']['title']['distance_percent'])->toBeLessThan(15);
|
||||
});
|
||||
|
||||
test('a rewritten text classifies as major', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'text' => '<p>Die Firma eröffnet einen neuen Standort in Hamburg und schafft zwanzig Arbeitsplätze.</p>',
|
||||
]);
|
||||
|
||||
$classification = app(CorrectionService::class)->classify($pressRelease, [
|
||||
'text' => '<p>Völlig neuer Inhalt über ein ganz anderes Produkt mit komplett anderem Schwerpunkt und neuen Aussagen zur Marktlage.</p>',
|
||||
]);
|
||||
|
||||
expect($classification['is_minor'])->toBeFalse()
|
||||
->and($classification['fields']['text']['changed'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('markup-only changes count as no change', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'text' => '<p>Gleicher Inhalt in einem Absatz.</p>',
|
||||
]);
|
||||
|
||||
$classification = app(CorrectionService::class)->classify($pressRelease, [
|
||||
'text' => '<div>Gleicher Inhalt in einem Absatz.</div>',
|
||||
]);
|
||||
|
||||
expect($classification['has_changes'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('apply saves a minor correction immediately with snapshot, lock and log', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Betrieb genomen',
|
||||
]);
|
||||
|
||||
$legalRequest = app(CorrectionService::class)->apply(
|
||||
$pressRelease,
|
||||
['title' => 'Betrieb genommen'],
|
||||
'kunde@firma.example',
|
||||
LegalRequestVerificationMethod::LoggedIn,
|
||||
);
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('Betrieb genommen')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and($legalRequest->type)->toBe(LegalRequestType::Correction)
|
||||
->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved)
|
||||
->and($legalRequest->executed_at)->not->toBeNull()
|
||||
->and($legalRequest->redactionSnapshots()->count())->toBe(1)
|
||||
->and($legalRequest->redactionSnapshots()->first()->encrypted_before['fields']['title'])->toBe('Betrieb genomen');
|
||||
|
||||
ActivityEvent::query()->where('type', ActivityType::Correction->value)->sole();
|
||||
});
|
||||
|
||||
test('apply refuses a major change', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'title' => 'Kurzer Titel der Mitteilung',
|
||||
]);
|
||||
|
||||
app(CorrectionService::class)->apply(
|
||||
$pressRelease,
|
||||
['title' => 'Ein komplett anderer, deutlich längerer und inhaltlich neuer Titel'],
|
||||
'kunde@firma.example',
|
||||
LegalRequestVerificationMethod::LoggedIn,
|
||||
);
|
||||
})->throws(CorrectionTooLargeException::class);
|
||||
|
||||
test('the weekly correction brake blocks the third correction', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
config()->set('legal.corrections_per_week', 2);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Titel A1']);
|
||||
$service = app(CorrectionService::class);
|
||||
|
||||
$service->apply($pressRelease->refresh(), ['title' => 'Titel A2'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
|
||||
$service->apply($pressRelease->refresh(), ['title' => 'Titel A3'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
|
||||
|
||||
$service->apply($pressRelease->refresh(), ['title' => 'Titel A4'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
|
||||
})->throws(CorrectionRateLimitException::class);
|
||||
|
||||
test('restoreSnapshot brings back the previous field values and keeps the lock', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Original-Titel hier']);
|
||||
$service = app(CorrectionService::class);
|
||||
|
||||
$legalRequest = $service->apply(
|
||||
$pressRelease,
|
||||
['title' => 'Original-Titel hie'],
|
||||
'kunde@firma.example',
|
||||
LegalRequestVerificationMethod::LoggedIn,
|
||||
);
|
||||
|
||||
$service->restoreSnapshot($legalRequest->redactionSnapshots()->first());
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('Original-Titel hier')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a correction never touches credits', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create(['title' => 'Titel mit Tippfehlerr']);
|
||||
|
||||
app(CorrectionService::class)->apply(
|
||||
$pressRelease,
|
||||
['title' => 'Titel mit Tippfehler'],
|
||||
'kunde@firma.example',
|
||||
LegalRequestVerificationMethod::LoggedIn,
|
||||
);
|
||||
|
||||
expect(CreditTransaction::query()->count())->toBe(0);
|
||||
});
|
||||
95
tests/Feature/Legal/CustomerCorrectionPanelTest.php
Normal file
95
tests/Feature/Legal/CustomerCorrectionPanelTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function correctionPanelOwner(): User
|
||||
{
|
||||
$owner = User::factory()->create(['is_active' => true]);
|
||||
$owner->assignRole('customer');
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
test('an owner can apply a minor correction from the panel and it goes live instantly', function () {
|
||||
/** @var TestCase $this */
|
||||
Queue::fake();
|
||||
|
||||
$owner = correctionPanelOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Betrieb genomen',
|
||||
]);
|
||||
|
||||
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
|
||||
->call('startCorrection')
|
||||
->set('correctionValues.title', 'Betrieb genommen')
|
||||
->call('previewCorrection')
|
||||
->assertHasNoErrors()
|
||||
->call('applyCorrection')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('Betrieb genommen')
|
||||
->and($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and(LegalRequest::query()->where('type', LegalRequestType::Correction->value)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a major change from the panel becomes an open paid change request instead', function () {
|
||||
/** @var TestCase $this */
|
||||
$owner = correctionPanelOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Kurzer Titel der Mitteilung',
|
||||
]);
|
||||
|
||||
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
|
||||
->call('startCorrection')
|
||||
->set('correctionValues.title', 'Ein komplett anderer, deutlich längerer und inhaltlich neuer Titel')
|
||||
->call('previewCorrection')
|
||||
->assertHasNoErrors()
|
||||
->call('submitPaidChangeRequest')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$request = LegalRequest::query()->where('type', LegalRequestType::Change->value)->sole();
|
||||
|
||||
expect($request->status)->toBe(LegalRequestStatus::Open)
|
||||
->and($request->payload['new_values']['title'])->toContain('komplett anderer')
|
||||
->and($pressRelease->fresh()->title)->toBe('Kurzer Titel der Mitteilung');
|
||||
});
|
||||
|
||||
test('apply without preview is refused', function () {
|
||||
/** @var TestCase $this */
|
||||
$owner = correctionPanelOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Original',
|
||||
]);
|
||||
|
||||
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
|
||||
->call('startCorrection')
|
||||
->set('correctionValues.title', 'Origina')
|
||||
->call('applyCorrection')
|
||||
->assertHasErrors('correction');
|
||||
|
||||
expect($pressRelease->fresh()->title)->toBe('Original');
|
||||
});
|
||||
190
tests/Feature/Legal/PressReleaseReportFlowTest.php
Normal file
190
tests/Feature/Legal/PressReleaseReportFlowTest.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\LegalRequestVerificationMethod;
|
||||
use App\Mail\LegalRequestReceived;
|
||||
use App\Mail\PressReleaseReportLink;
|
||||
use App\Models\Contact;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('pm-report|127.0.0.1');
|
||||
});
|
||||
|
||||
function publishedReleaseWithContact(string $contactEmail = 'presse@firma.example'): PressRelease
|
||||
{
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
$contact = Contact::factory()->create(['email' => $contactEmail]);
|
||||
$pressRelease->contacts()->attach($contact->id);
|
||||
|
||||
return $pressRelease;
|
||||
}
|
||||
|
||||
function detailUrlFor(PressRelease $pressRelease): string
|
||||
{
|
||||
return 'https://presseecho.test/de/press-release/'.$pressRelease->slug;
|
||||
}
|
||||
|
||||
test('the intake sends a magic link to the stored press contact, never to a form address', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$pressRelease = publishedReleaseWithContact('presse@firma.example');
|
||||
|
||||
$response = $this->post('/de/melden/pm', [
|
||||
'reported_url' => detailUrlFor($pressRelease),
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('pm-report-status', fn (string $status) => str_contains($status, 'p***@firma.example'));
|
||||
|
||||
Mail::assertQueued(PressReleaseReportLink::class, fn (PressReleaseReportLink $mail) => $mail->hasTo('presse@firma.example'));
|
||||
|
||||
$link = MagicLink::query()->where('purpose', 'press_release_report')->sole();
|
||||
expect($link->payload['press_release_id'])->toBe($pressRelease->id)
|
||||
->and($link->payload['contact_email'])->toBe('presse@firma.example');
|
||||
});
|
||||
|
||||
test('an unknown url is rejected with a validation error', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$this->post('/de/melden/pm', ['reported_url' => 'https://presseecho.test/de/press-release/gibt-es-nicht'])
|
||||
->assertSessionHasErrors('reported_url');
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('a release without any contact email points to the manual fallback', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
$pressRelease->company()->withoutGlobalScopes()->update(['email' => null]);
|
||||
|
||||
$this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)])
|
||||
->assertSessionHas('pm-report-manual', true);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('link requests per release are rate limited', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
config()->set('legal.report_links_per_day', 1);
|
||||
|
||||
$pressRelease = publishedReleaseWithContact();
|
||||
|
||||
$this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)])
|
||||
->assertSessionHas('pm-report-status');
|
||||
|
||||
$this->post('/de/melden/pm', ['reported_url' => detailUrlFor($pressRelease)])
|
||||
->assertSessionHasErrors('reported_url');
|
||||
|
||||
Mail::assertQueuedCount(1);
|
||||
});
|
||||
|
||||
test('the magic link verifies once and opens the concern selection', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = publishedReleaseWithContact();
|
||||
$link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example');
|
||||
|
||||
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token'])
|
||||
->assertRedirect('/de/melden/pm/anliegen');
|
||||
|
||||
$this->get('/de/melden/pm/anliegen')
|
||||
->assertOk()
|
||||
->assertSee($pressRelease->title)
|
||||
->assertSee('p***@firma.example');
|
||||
|
||||
// Zweiter Klick: Link ist verbraucht.
|
||||
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token'])
|
||||
->assertRedirect('/de/melden/pm')
|
||||
->assertSessionHas('pm-report-error');
|
||||
});
|
||||
|
||||
test('a verified visitor can submit a concern which lands verified in the queue', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$pressRelease = publishedReleaseWithContact();
|
||||
$link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example');
|
||||
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token']);
|
||||
|
||||
$this->post('/de/melden/pm/anliegen', [
|
||||
'concern' => 'dsgvo',
|
||||
'message' => 'Bitte meinen Namen aus dem Pressekontakt entfernen.',
|
||||
])->assertSessionHas('pm-report-status');
|
||||
|
||||
$request = LegalRequest::query()->sole();
|
||||
|
||||
expect($request->type)->toBe(LegalRequestType::Dsgvo)
|
||||
->and($request->press_release_id)->toBe($pressRelease->id)
|
||||
->and($request->requester_email)->toBe('presse@firma.example')
|
||||
->and($request->verified_at)->not->toBeNull()
|
||||
->and($request->verification_method)->toBe(LegalRequestVerificationMethod::MagicLink)
|
||||
->and($request->status)->toBe(LegalRequestStatus::Open);
|
||||
|
||||
Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo('presse@firma.example'));
|
||||
});
|
||||
|
||||
test('a second open concern of the same type is blocked', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$pressRelease = publishedReleaseWithContact();
|
||||
$link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example');
|
||||
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token']);
|
||||
|
||||
LegalRequest::factory()->type(LegalRequestType::Correction)->create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'status' => LegalRequestStatus::Open,
|
||||
]);
|
||||
|
||||
$this->post('/de/melden/pm/anliegen', [
|
||||
'concern' => 'correction',
|
||||
'message' => 'Bitte Tippfehler im Titel korrigieren.',
|
||||
])->assertSessionHasErrors('concern');
|
||||
});
|
||||
|
||||
test('the concern page requires a verified session', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('/de/melden/pm/anliegen')
|
||||
->assertRedirect('/de/melden/pm')
|
||||
->assertSessionHas('pm-report-error');
|
||||
});
|
||||
|
||||
test('a logged-in owner skips verification and lands in the concern selection', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$owner = User::factory()->create(['is_active' => true]);
|
||||
$owner->assignRole('customer');
|
||||
$pressRelease = PressRelease::factory()->published()->create(['user_id' => $owner->id]);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->post('/admin/me/press-releases/'.$pressRelease->id.'/melden')
|
||||
->assertRedirect('/de/melden/pm/anliegen');
|
||||
|
||||
$this->get('/de/melden/pm/anliegen')->assertOk()->assertSee($pressRelease->title);
|
||||
});
|
||||
|
||||
test('a foreign user cannot start the report flow for someone elses release', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$stranger = User::factory()->create(['is_active' => true]);
|
||||
$stranger->assignRole('customer');
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
|
||||
$this->actingAs($stranger)
|
||||
->post('/admin/me/press-releases/'.$pressRelease->id.'/melden')
|
||||
->assertForbidden();
|
||||
});
|
||||
200
tests/Feature/Legal/SyncLockTest.php
Normal file
200
tests/Feature/Legal/SyncLockTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Category;
|
||||
use App\Models\LegacyImportMap;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Services\Import\ImportContext;
|
||||
use App\Services\Import\PressReleaseImporter;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Minimales Legacy-Schema + Import-Map (Kategorie/User), damit der Importer
|
||||
* eine PM auflösen kann — analog LegacyPressReleaseDateImportTest, eigener
|
||||
* Helper wegen globalem Pest-Funktionsnamensraum.
|
||||
*/
|
||||
function seedSyncLockLegacySchema(): void
|
||||
{
|
||||
Config::set('database.connections.mysql_presseecho', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => false,
|
||||
]);
|
||||
|
||||
DB::purge('mysql_presseecho');
|
||||
|
||||
Schema::connection('mysql_presseecho')->create('press_release', function (Blueprint $table): void {
|
||||
$table->integer('id')->primary();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('language')->nullable();
|
||||
$table->text('text')->nullable();
|
||||
$table->string('backlink_url')->nullable();
|
||||
$table->integer('category_id')->nullable();
|
||||
$table->string('keywords')->nullable();
|
||||
$table->integer('user_id')->nullable();
|
||||
$table->integer('company_id')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->integer('hits')->nullable();
|
||||
$table->integer('teaser_begin')->nullable();
|
||||
$table->integer('teaser_end')->nullable();
|
||||
$table->boolean('no_export')->nullable();
|
||||
$table->dateTime('created_at')->nullable();
|
||||
$table->dateTime('updated_at')->nullable();
|
||||
$table->string('slug')->nullable();
|
||||
});
|
||||
|
||||
Schema::connection('mysql_presseecho')->create('press_release_image', function (Blueprint $table): void {
|
||||
$table->integer('id')->primary();
|
||||
$table->integer('press_release_id');
|
||||
});
|
||||
|
||||
Schema::connection('mysql_presseecho')->create('press_release_contact', function (Blueprint $table): void {
|
||||
$table->integer('press_release_id');
|
||||
$table->integer('contact_id');
|
||||
});
|
||||
|
||||
$category = Category::factory()->withTranslations()->create();
|
||||
|
||||
LegacyImportMap::query()->create([
|
||||
'legacy_portal' => 'presseecho',
|
||||
'legacy_table' => 'category',
|
||||
'legacy_id' => 500,
|
||||
'target_table' => 'categories',
|
||||
'target_id' => $category->id,
|
||||
]);
|
||||
|
||||
$author = User::factory()->create();
|
||||
|
||||
LegacyImportMap::query()->create([
|
||||
'legacy_portal' => 'presseecho',
|
||||
'legacy_table' => 'sf_guard_user',
|
||||
'legacy_id' => 700,
|
||||
'target_table' => 'users',
|
||||
'target_id' => $author->id,
|
||||
]);
|
||||
}
|
||||
|
||||
test('lockSync stamps the press release, logs the event and is idempotent', function () {
|
||||
/** @var TestCase $this */
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
|
||||
$pressRelease->lockSync('DSGVO-Anonymisierung M-1');
|
||||
|
||||
expect($pressRelease->isSyncLocked())->toBeTrue()
|
||||
->and($pressRelease->sync_locked_at)->not->toBeNull()
|
||||
->and($pressRelease->sync_lock_reason)->toBe('DSGVO-Anonymisierung M-1');
|
||||
|
||||
$firstLockedAt = $pressRelease->sync_locked_at;
|
||||
|
||||
$this->travel(1)->hours();
|
||||
$pressRelease->lockSync('Zweiter Versuch');
|
||||
$this->travelBack();
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->sync_locked_at->equalTo($firstLockedAt))->toBeTrue()
|
||||
->and($pressRelease->sync_lock_reason)->toBe('DSGVO-Anonymisierung M-1');
|
||||
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::SyncLock->value)->sole();
|
||||
|
||||
expect($event->subject_id)->toBe($pressRelease->id)
|
||||
->and($event->description)->toContain('DSGVO-Anonymisierung M-1');
|
||||
});
|
||||
|
||||
test('the importer skips a sync-locked press release even with force', function () {
|
||||
/** @var TestCase $this */
|
||||
seedSyncLockLegacySchema();
|
||||
|
||||
DB::connection('mysql_presseecho')->table('press_release')->insert([
|
||||
'id' => 42,
|
||||
'title' => 'Originaltitel mit Klarname',
|
||||
'language' => 'de',
|
||||
'text' => 'Text mit Klarname',
|
||||
'category_id' => 500,
|
||||
'user_id' => 700,
|
||||
'status' => 'published',
|
||||
'no_export' => false,
|
||||
'created_at' => '2019-05-01 08:00:00',
|
||||
'updated_at' => '2019-05-01 08:00:00',
|
||||
'slug' => 'original',
|
||||
]);
|
||||
|
||||
// Erstimport, dann Mutation über das Meldeverfahren (Schwärzung) + Sperre.
|
||||
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScopes()
|
||||
->where('legacy_portal', 'presseecho')->where('legacy_id', 42)->firstOrFail();
|
||||
$pressRelease->forceFill(['title' => 'Geschwärzter Titel'])->save();
|
||||
$pressRelease->lockSync('DSGVO-Anonymisierung');
|
||||
|
||||
// Force-Re-Import darf die Schwärzung nicht überschreiben.
|
||||
$result = app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
expect($pressRelease->title)->toBe('Geschwärzter Titel')
|
||||
->and($result->skipped())->toBe(1)
|
||||
->and($result->updated())->toBe(0);
|
||||
});
|
||||
|
||||
test('an unlocked press release is still updated by a force re-import', function () {
|
||||
/** @var TestCase $this */
|
||||
seedSyncLockLegacySchema();
|
||||
|
||||
DB::connection('mysql_presseecho')->table('press_release')->insert([
|
||||
'id' => 43,
|
||||
'title' => 'Unveränderte Meldung',
|
||||
'language' => 'de',
|
||||
'text' => 'Inhalt',
|
||||
'category_id' => 500,
|
||||
'user_id' => 700,
|
||||
'status' => 'published',
|
||||
'no_export' => false,
|
||||
'created_at' => '2019-05-01 08:00:00',
|
||||
'updated_at' => '2019-05-01 08:00:00',
|
||||
'slug' => 'unveraendert',
|
||||
]);
|
||||
|
||||
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
|
||||
$result = app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
|
||||
|
||||
expect($result->updated())->toBe(1)
|
||||
->and($result->skipped())->toBe(0);
|
||||
});
|
||||
|
||||
test('a redaction snapshot encrypts the previous values and gets a purge date from config', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('legal.snapshot_retention_days', 30);
|
||||
|
||||
$pressRelease = PressRelease::factory()->published()->create();
|
||||
|
||||
$snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [
|
||||
'title' => 'Klarname Max Mustermann',
|
||||
]);
|
||||
|
||||
// Klartext darf nie unverschlüsselt in der Datenbank liegen.
|
||||
$raw = DB::table('press_release_redaction_snapshots')
|
||||
->where('id', $snapshot->id)
|
||||
->value('encrypted_before');
|
||||
|
||||
expect($raw)->not->toContain('Max Mustermann')
|
||||
->and($snapshot->encrypted_before['title'])->toBe('Klarname Max Mustermann')
|
||||
->and($snapshot->purge_after->toDateString())->toBe(now()->addDays(30)->toDateString());
|
||||
});
|
||||
|
||||
test('the due-for-purge scope only matches snapshots past their purge date', function () {
|
||||
/** @var TestCase $this */
|
||||
$due = PressReleaseRedactionSnapshot::factory()->dueForPurge()->create();
|
||||
PressReleaseRedactionSnapshot::factory()->create();
|
||||
|
||||
expect(PressReleaseRedactionSnapshot::query()->dueForPurge()->pluck('id')->all())
|
||||
->toBe([$due->id]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue