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:
Kevin Adametz 2026-07-22 15:56:34 +00:00
parent 58c0cc3f23
commit b4b2e013d6
42 changed files with 3868 additions and 1 deletions

View 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;
}
}

View file

@ -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',
};
}
}

View file

@ -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',
};
}
}

View 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',
};
}
}

View 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']);
}
}

View 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');
}
}

View file

@ -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>

View file

@ -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).
*/

View 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());
}
}

View file

@ -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

View file

@ -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;

View 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);
}
}
}
}

View 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.');
}
}

View 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];
}
}

View 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
View 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);
}
}
}
}

View file

@ -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),
];

View 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(),
]);
}
}

View file

@ -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']);
});
}
};

View file

@ -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']);
});
}
};

View file

@ -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');
}
};

View file

@ -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();
});
}
};

View 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 |

View 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|

View 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.

View file

@ -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.',

View file

@ -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).',

View 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>

View file

@ -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&#10;Herr Teplitsky&#10;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)

View file

@ -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. --}}

View 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

View 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

View file

@ -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>

View file

@ -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()

View file

@ -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');

View file

@ -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');
});
// ---------------------------------------------------------------------------

View 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');
});

View 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();
});

View 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);
});

View 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');
});

View 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();
});

View 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]);
});