diff --git a/app/Console/Commands/PurgeRedactionSnapshots.php b/app/Console/Commands/PurgeRedactionSnapshots.php new file mode 100644 index 00000000..6101bf02 --- /dev/null +++ b/app/Console/Commands/PurgeRedactionSnapshots.php @@ -0,0 +1,30 @@ +dueForPurge() + ->delete(); + + $this->info("Schwärzungs-Snapshots bereinigt: {$deleted} Einträge gelöscht."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 10db2a37..381a9be3 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -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', }; } } diff --git a/app/Enums/LegalRequestType.php b/app/Enums/LegalRequestType.php index 0158a9ec..0bd2b138 100644 --- a/app/Enums/LegalRequestType.php +++ b/app/Enums/LegalRequestType.php @@ -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', }; } } diff --git a/app/Enums/LegalRequestVerificationMethod.php b/app/Enums/LegalRequestVerificationMethod.php new file mode 100644 index 00000000..60afb39d --- /dev/null +++ b/app/Enums/LegalRequestVerificationMethod.php @@ -0,0 +1,25 @@ + 'Magic-Link', + self::Manual => 'Manuell geprüft', + self::LoggedIn => 'Eingeloggter Creator', + }; + } +} diff --git a/app/Http/Controllers/Web/PressReleaseReportController.php b/app/Http/Controllers/Web/PressReleaseReportController.php new file mode 100644 index 00000000..885a3551 --- /dev/null +++ b/app/Http/Controllers/Web/PressReleaseReportController.php @@ -0,0 +1,339 @@ + */ + 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']); + } +} diff --git a/app/Mail/PressReleaseReportLink.php b/app/Mail/PressReleaseReportLink.php new file mode 100644 index 00000000..f548851f --- /dev/null +++ b/app/Mail/PressReleaseReportLink.php @@ -0,0 +1,40 @@ + 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 $query * @return Builder diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 5e678566..eb11777e 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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). */ diff --git a/app/Models/PressReleaseRedactionSnapshot.php b/app/Models/PressReleaseRedactionSnapshot.php new file mode 100644 index 00000000..e20e9fe4 --- /dev/null +++ b/app/Models/PressReleaseRedactionSnapshot.php @@ -0,0 +1,73 @@ + */ + 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 $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 $query + * @return Builder + */ + public function scopeDueForPurge(Builder $query): Builder + { + return $query->whereDate('purge_after', '<', now()->toDateString()); + } +} diff --git a/app/Services/Auth/MagicLinkGenerator.php b/app/Services/Auth/MagicLinkGenerator.php index a7f735b5..b081bb23 100644 --- a/app/Services/Auth/MagicLinkGenerator.php +++ b/app/Services/Auth/MagicLinkGenerator.php @@ -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 diff --git a/app/Services/Import/PressReleaseImporter.php b/app/Services/Import/PressReleaseImporter.php index de099eea..7463e798 100644 --- a/app/Services/Import/PressReleaseImporter.php +++ b/app/Services/Import/PressReleaseImporter.php @@ -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; diff --git a/app/Services/PressRelease/AnonymizationService.php b/app/Services/PressRelease/AnonymizationService.php new file mode 100644 index 00000000..e762d3ff --- /dev/null +++ b/app/Services/PressRelease/AnonymizationService.php @@ -0,0 +1,230 @@ + */ + 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 $terms + * @return array{fields: array, 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 $terms + * @param list $removeImageIds + * @param list $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 $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 = '/(? $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); + } + } + } +} diff --git a/app/Services/PressRelease/CorrectionRateLimitException.php b/app/Services/PressRelease/CorrectionRateLimitException.php new file mode 100644 index 00000000..ea31ffb5 --- /dev/null +++ b/app/Services/PressRelease/CorrectionRateLimitException.php @@ -0,0 +1,18 @@ + */ + 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 $newValues + * @return array{fields: array, 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 $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 $a + * @param list $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]; + } +} diff --git a/app/Services/PressRelease/CorrectionTooLargeException.php b/app/Services/PressRelease/CorrectionTooLargeException.php new file mode 100644 index 00000000..70edd3ea --- /dev/null +++ b/app/Services/PressRelease/CorrectionTooLargeException.php @@ -0,0 +1,17 @@ + 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), + ]; diff --git a/database/factories/PressReleaseRedactionSnapshotFactory.php b/database/factories/PressReleaseRedactionSnapshotFactory.php new file mode 100644 index 00000000..53f0f59d --- /dev/null +++ b/database/factories/PressReleaseRedactionSnapshotFactory.php @@ -0,0 +1,34 @@ + + */ +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(), + ]); + } +} diff --git a/database/migrations/2026_07_22_151506_add_sync_lock_to_press_releases_table.php b/database/migrations/2026_07_22_151506_add_sync_lock_to_press_releases_table.php new file mode 100644 index 00000000..4a00b704 --- /dev/null +++ b/database/migrations/2026_07_22_151506_add_sync_lock_to_press_releases_table.php @@ -0,0 +1,45 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_22_151507_add_meldeverfahren_fields_to_legal_requests_table.php b/database/migrations/2026_07_22_151507_add_meldeverfahren_fields_to_legal_requests_table.php new file mode 100644 index 00000000..f657d253 --- /dev/null +++ b/database/migrations/2026_07_22_151507_add_meldeverfahren_fields_to_legal_requests_table.php @@ -0,0 +1,31 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_22_151507_create_press_release_redaction_snapshots_table.php b/database/migrations/2026_07_22_151507_create_press_release_redaction_snapshots_table.php new file mode 100644 index 00000000..e72502e0 --- /dev/null +++ b/database/migrations/2026_07_22_151507_create_press_release_redaction_snapshots_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_22_171134_add_report_purpose_to_magic_links_table.php b/database/migrations/2026_07_22_171134_add_report_purpose_to_magic_links_table.php new file mode 100644 index 00000000..b037ec82 --- /dev/null +++ b/database/migrations/2026_07_22_171134_add_report_purpose_to_magic_links_table.php @@ -0,0 +1,39 @@ +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(); + }); + } +}; diff --git a/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md b/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md new file mode 100644 index 00000000..f4be3e85 --- /dev/null +++ b/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md @@ -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** (`(?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 | diff --git a/docs/Decision-Update/Lösch- und Änderungsanfragen.md b/docs/Decision-Update/Lösch- und Änderungsanfragen.md new file mode 100644 index 00000000..8c269b4c --- /dev/null +++ b/docs/Decision-Update/Lösch- und Änderungsanfragen.md @@ -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| \ No newline at end of file diff --git a/docs/Decision-Update/Pressemitteilung melden.md b/docs/Decision-Update/Pressemitteilung melden.md new file mode 100644 index 00000000..d6c8c520 --- /dev/null +++ b/docs/Decision-Update/Pressemitteilung melden.md @@ -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. \ No newline at end of file diff --git a/lang/de/static.php b/lang/de/static.php index b6f40adc..58e5438b 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -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.', diff --git a/lang/en/static.php b/lang/en/static.php index 91073097..556bdcae 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -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).', diff --git a/resources/views/emails/legal/report-link.blade.php b/resources/views/emails/legal/report-link.blade.php new file mode 100644 index 00000000..fd024903 --- /dev/null +++ b/resources/views/emails/legal/report-link.blade.php @@ -0,0 +1,36 @@ + +

Guten Tag,

+ +

+ für die folgende Pressemitteilung wurde über unser Meldeverfahren ein + Änderungs- oder Lösch-Anliegen gestartet: +

+ +
+
Pressemitteilung
+
{{ $pressRelease->title }}
+
+ +

+ 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: +

+ +

+ {{ $verificationUrl }} +

+ +

+ Der Link ist gültig bis {{ $expiresAt->format('d.m.Y H:i') }} Uhr + und kann nur einmal verwendet werden. +

+ +
+

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

+
+
diff --git a/resources/views/livewire/admin/legal-requests/show.blade.php b/resources/views/livewire/admin/legal-requests/show.blade.php index 2bdca3cf..b0aace93 100644 --- a/resources/views/livewire/admin/legal-requests/show.blade.php +++ b/resources/views/livewire/admin/legal-requests/show.blade.php @@ -1,7 +1,11 @@ */ + public array $removeImageIds = []; + + /** @var array{fields: array, total_hits: int}|null */ + public ?array $redactionPreview = null; + public function mount(int $id): void { $this->id = $id; $this->adminNote = (string) ($this->request()->admin_note ?? ''); + $this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT; + } + + /** + * 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 + */ + private function redactionTermList(): array + { + return collect(preg_split('/\r\n|\r|\n/', $this->redactionTerms) ?: []) + ->map(fn (string $term): string => trim($term)) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function replacementValue(): string + { + return trim($this->redactionReplacement) !== '' + ? trim($this->redactionReplacement) + : AnonymizationService::DEFAULT_REPLACEMENT; + } + + public function canRedact(LegalRequest $request): bool + { + return $request->pressRelease !== null + && in_array($request->type, [LegalRequestType::Dsgvo, LegalRequestType::PersonalRights], true); + } + + /** + * HTML-Inhalt lesbar und kopierbar machen: Block-Enden zu Zeilenumbrüchen, + * Tags raus — der Admin kopiert Begriffe direkt aus dieser Ansicht. + */ + public function displayValue(?string $value): string + { + // Ohne "?"+">" im Pattern — der Volt-Compiler würde die Sequenz als + // PHP-Schließtag lesen und die Klasse zerschneiden. + $withBreaks = preg_replace('#]*>|#i', "\n", (string) $value) ?? ''; + + return trim(strip_tags($withBreaks)); + } + + /** + * @return array + */ + public function redactableFieldLabels(): array + { + return [ + 'title' => __('Titel'), + 'subtitle' => __('Untertitel'), + 'text' => __('Text'), + 'boilerplate_override' => __('Boilerplate'), + 'keywords' => __('Keywords'), + ]; } public function markInProgress(): void @@ -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')] + {{-- ============== ANONYMISIERUNG (Spur B) ============== --}} + @if ($this->canRedact($request) && $request->status->isOpen()) +
+
+ {{ __('Anonymisierung (Schwärzung)') }} +
+
+

+ {{ __('Schwärzt personenbezogene Daten über Wortgrenzen (inkl. Varianten wie „Herr Teplitsky", „Teplitskys"). Kostenlos, ohne KI-Gate; Alt-Werte werden verschlüsselt gesichert und die Re-Sync-Sperre gesetzt.') }} +

+ + + + + + +
+ + + +
+ +
+
+ + @if ($request->pressRelease->images->isNotEmpty()) + + {{ __('Bilder entfernen (Klarname in Pressebild!)') }} +
+ @foreach ($request->pressRelease->images as $image) + + @endforeach +
+
+ @endif + +
+ + {{ $redactionPreview === null ? __('Vorschau erstellen (Pflicht)') : __('Vorschau aktualisieren') }} + + @if ($redactionPreview !== null) + + {{ __('Anonymisieren ausführen') }} + + @endif + @if ($request->pressRelease->status === \App\Enums\PressReleaseStatus::Published) + + {{ __('Stattdessen depublizieren') }} + + @endif +
+ + {{-- Kompletter PM-Inhalt: vor der Vorschau das Original (Begriffe + von hier kopieren), danach der Stand nach Schwärzung. --}} +
+
+

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

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

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

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

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

+ @endif +
+
+
+ @endif + {{-- ============== BEARBEITUNG ============== --}}
@@ -195,6 +480,31 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]

@endif +
+
+
{{ __('Verifikation') }}
+
+ {{ $request->verification_method?->label() ?? __('nicht verifiziert') }} + @if ($request->verified_at) + · {{ $request->verified_at->format('d.m.Y H:i') }} + @endif +
+
+ @if ($request->executed_at) +
+
{{ __('Ausgeführt') }}
+
{{ $request->executed_at->format('d.m.Y H:i') }}
+
+ @endif +
+ + @if ($request->executed_at && $request->redactionSnapshots()->exists()) + + {{ __('Rückgängig machen (Snapshot)') }} + + @endif +
@if ($request->status->isOpen()) @if ($request->status !== \App\Enums\LegalRequestStatus::InProgress) diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 7fc3e7aa..e2409be4 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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 Bearbeitbare Feld-Werte der Korrektur. */ + public array $correctionValues = []; + + /** @var array{fields: array, 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 {{ __('Vorschau-Link') }} + @if ($pr->status === \App\Enums\PressReleaseStatus::Published) + {{-- Meldeverfahren (Phase 3): Eigentümer überspringt die + Magic-Link-Verifikation (Zuordnung = Verifikation). --}} +
+ @csrf + + {{ __('Ändern/Melden') }} + +
+ @endif {{ __('Zurück') }} @@ -1142,6 +1285,88 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@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) +
+
+ {{ __('Änderung & Korrektur') }} +
+
+
+
+ +
+
+

{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}

+

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

+
+
+ + @if (! $correctionOpen) + + {{ __('Korrektur/Änderung starten') }} + + @else +
+ + + + + + + + + + + + + + + +
+ + {{ $correctionPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }} + + @if ($correctionPreview !== null && $correctionPreview['is_minor']) + + {{ __('Kostenlos speichern — sofort live') }} + + @elseif ($correctionPreview !== null) + + {{ __('Als kostenpflichtige Änderung einreichen (10 €)') }} + + @endif + + {{ __('Abbrechen') }} + +
+ + @if ($correctionPreview !== null) +
+

+ {{ $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']]) }} +

+ @foreach ($correctionPreview['fields'] as $field => $meta) + @continue(! $meta['changed']) +

+ {{ $field }}: {{ __('Änderungsdistanz :percent %', ['percent' => $meta['distance_percent']]) }} +

+ @endforeach +
+ @endif +
+ @endif +
+
+ @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. --}} diff --git a/resources/views/web/melden-pm-anliegen.blade.php b/resources/views/web/melden-pm-anliegen.blade.php new file mode 100644 index 00000000..15f64235 --- /dev/null +++ b/resources/views/web/melden-pm-anliegen.blade.php @@ -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') + +
+
+

{{ $pressRelease->title }}

+

{{ __('static.pm_report.verified_as', ['email' => $contactEmail]) }}

+
+ + @if (session('pm-report-status')) +
+ {{ session('pm-report-status') }} +
+ @endif + +
+ @csrf + +
+ {{ __('static.pm_report.concern_legend') }} +
+ @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) + + @endforeach +
+ @error('concern') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('message') +

{{ $message }}

+ @enderror +
+ +

+ {{ __('static.pm_report.concern_note') }} +

+ + +
+
+
+@endsection diff --git a/resources/views/web/melden-pm.blade.php b/resources/views/web/melden-pm.blade.php new file mode 100644 index 00000000..c443afeb --- /dev/null +++ b/resources/views/web/melden-pm.blade.php @@ -0,0 +1,87 @@ +@extends('web.layouts.web-master') + +@section('title', __('static.pm_report.title')) +@section('meta_description', __('static.pm_report.meta')) + +@section('content') + +
+

+ {{ __('static.pm_report.intro') }} +

+ + {{-- Transparenz vorab: kostenlos vs. kostenpflichtig (Anti-Zombie). --}} +
+ + + @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) + + + + + @endforeach + +
{{ $track['label'] }}{{ $track['cost'] }}
+
+ + @if (session('pm-report-status')) +
+ {{ session('pm-report-status') }} +
+ @endif + + @if (session('pm-report-error')) + + @endif + + @if (session('pm-report-manual')) +
+ {{ __('static.pm_report.manual_fallback') }} + + {{ __('static.pm_report.manual_fallback_link') }} + +
+ @endif + +
+ @csrf + + {{-- Honeypot --}} + + +
+ + + @error('reported_url') +

{{ $message }}

+ @enderror +
+ +

+ {{ __('static.pm_report.verification_note') }} +

+ + +
+
+
+@endsection diff --git a/resources/views/web/melden.blade.php b/resources/views/web/melden.blade.php index f89417f5..f702abc6 100644 --- a/resources/views/web/melden.blade.php +++ b/resources/views/web/melden.blade.php @@ -34,6 +34,17 @@

{!! __('static.report.intro_direct') !!}

{!! __('static.report.intro_free') !!}

+ {{-- Meldeverfahren v2 (Phase 3): Self-Service-Einstieg über die + hinterlegte Pressekontaktadresse. --}} +
+

{{ __('static.pm_report.title') }}

+

{{ __('static.pm_report.verification_note') }}

+ + {{ __('static.pm_report.breadcrumb') }} + +
+

{{ __('static.report.way1_heading') }}

{{ __('static.report.way1_intro') }}

    diff --git a/routes/console.php b/routes/console.php index 169d21eb..4591022d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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() diff --git a/routes/customer.php b/routes/customer.php index c32398ad..900f5238 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -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'); diff --git a/routes/web.php b/routes/web.php index f7d37b6d..25d3b39f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); // --------------------------------------------------------------------------- diff --git a/tests/Feature/Legal/AdminLegalRequestExecutionTest.php b/tests/Feature/Legal/AdminLegalRequestExecutionTest.php new file mode 100644 index 00000000..54366086 --- /dev/null +++ b/tests/Feature/Legal/AdminLegalRequestExecutionTest.php @@ -0,0 +1,165 @@ +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' => '

    Ansprechpartner ist Matvey Teplitsky, Telefon 0123.

    ', + ]); + $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'); +}); diff --git a/tests/Feature/Legal/AnonymizationServiceTest.php b/tests/Feature/Legal/AnonymizationServiceTest.php new file mode 100644 index 00000000..b5b1f7b7 --- /dev/null +++ b/tests/Feature/Legal/AnonymizationServiceTest.php @@ -0,0 +1,130 @@ +published()->create([ + 'title' => 'Max Mustermann übernimmt die Leitung', + 'text' => '

    Max ist maximal motiviert. Herr Mustermann bleibt. Mustermanns Rolle ändert sich.

    ', + ]); + + 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' => '

    Kontakt: Mariana Gleue, Telefon 0123.

    ', + ]); + + $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(); +}); diff --git a/tests/Feature/Legal/CorrectionServiceTest.php b/tests/Feature/Legal/CorrectionServiceTest.php new file mode 100644 index 00000000..a90c1d79 --- /dev/null +++ b/tests/Feature/Legal/CorrectionServiceTest.php @@ -0,0 +1,149 @@ +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' => '

    Die Firma eröffnet einen neuen Standort in Hamburg und schafft zwanzig Arbeitsplätze.

    ', + ]); + + $classification = app(CorrectionService::class)->classify($pressRelease, [ + 'text' => '

    Völlig neuer Inhalt über ein ganz anderes Produkt mit komplett anderem Schwerpunkt und neuen Aussagen zur Marktlage.

    ', + ]); + + 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' => '

    Gleicher Inhalt in einem Absatz.

    ', + ]); + + $classification = app(CorrectionService::class)->classify($pressRelease, [ + 'text' => '
    Gleicher Inhalt in einem Absatz.
    ', + ]); + + 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); +}); diff --git a/tests/Feature/Legal/CustomerCorrectionPanelTest.php b/tests/Feature/Legal/CustomerCorrectionPanelTest.php new file mode 100644 index 00000000..f50db4af --- /dev/null +++ b/tests/Feature/Legal/CustomerCorrectionPanelTest.php @@ -0,0 +1,95 @@ +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'); +}); diff --git a/tests/Feature/Legal/PressReleaseReportFlowTest.php b/tests/Feature/Legal/PressReleaseReportFlowTest.php new file mode 100644 index 00000000..6e84e537 --- /dev/null +++ b/tests/Feature/Legal/PressReleaseReportFlowTest.php @@ -0,0 +1,190 @@ +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(); +}); diff --git a/tests/Feature/Legal/SyncLockTest.php b/tests/Feature/Legal/SyncLockTest.php new file mode 100644 index 00000000..5b5e128d --- /dev/null +++ b/tests/Feature/Legal/SyncLockTest.php @@ -0,0 +1,200 @@ + '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]); +});