diff --git a/app/Enums/EmailNotificationType.php b/app/Enums/EmailNotificationType.php index a6cc9934..67bb225f 100644 --- a/app/Enums/EmailNotificationType.php +++ b/app/Enums/EmailNotificationType.php @@ -17,6 +17,7 @@ enum EmailNotificationType: string case PressReleasePublished = 'press_release_published'; case PressReleaseRejected = 'press_release_rejected'; case PressReleaseDepublished = 'press_release_depublished'; + case PressReleaseCorrected = 'press_release_corrected'; case Newsletter = 'newsletter'; public function label(): string @@ -25,6 +26,7 @@ enum EmailNotificationType: string self::PressReleasePublished => 'Pressemitteilung veröffentlicht', self::PressReleaseRejected => 'Pressemitteilung abgelehnt', self::PressReleaseDepublished => 'Pressemitteilung depubliziert', + self::PressReleaseCorrected => 'Pressemitteilung korrigiert/geändert', self::Newsletter => 'Newsletter & Plattform-Infos', }; } @@ -38,6 +40,7 @@ enum EmailNotificationType: string self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.', self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.', self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.', + self::PressReleaseCorrected => 'Bestätigung, wenn eine Korrektur oder kostenpflichtige Änderung an einer veröffentlichten Pressemitteilung übernommen wurde.', self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.', }; } @@ -51,6 +54,7 @@ enum EmailNotificationType: string self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.', self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.', self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.', + self::PressReleaseCorrected => 'Sie haben die Korrektur bzw. Änderung dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.', self::Newsletter => 'Sie haben ein Benutzerkonto bei pressekonto bzw. den Portalen presseecho und businessportal24 und erhalten daher gelegentlich Informationen zur Plattform.', }; } @@ -64,6 +68,7 @@ enum EmailNotificationType: string self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen', self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen', self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen', + self::PressReleaseCorrected => 'Bestätigungen über Korrekturen und Änderungen Ihrer Pressemitteilungen', self::Newsletter => 'Newsletter und Plattform-Informationen', }; } diff --git a/app/Enums/SinglePurchaseType.php b/app/Enums/SinglePurchaseType.php index 0973af39..05e105ed 100644 --- a/app/Enums/SinglePurchaseType.php +++ b/app/Enums/SinglePurchaseType.php @@ -9,6 +9,7 @@ enum SinglePurchaseType: string case Boost = 'boost'; case ProofPdf = 'proof_pdf'; case Depublication = 'depublication'; + case Change = 'press_release_change'; public function label(): string { @@ -18,6 +19,7 @@ enum SinglePurchaseType: string self::Boost => 'Boost / Platzierung', self::ProofPdf => 'Veröffentlichungsnachweis (PDF)', self::Depublication => 'Depublikation (Entfernung aus der öffentlichen Ansicht)', + self::Change => 'Änderung einer veröffentlichten Pressemitteilung', }; } @@ -34,6 +36,7 @@ enum SinglePurchaseType: string self::Boost => 'Boost', self::ProofPdf => 'Veröffentlichungsnachweis', self::Depublication => 'Depublikation', + self::Change => 'Änderung', }; } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 76b3e823..bc8bfc45 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -2,10 +2,12 @@ namespace App\Http\Controllers; +use App\Enums\LegalRequestType; use App\Enums\PressReleaseStatus; use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; use App\Models\BookingConfirmation; +use App\Models\LegalRequest; use App\Models\Plan; use App\Models\PressRelease; use App\Models\SinglePurchase; @@ -233,6 +235,80 @@ class CheckoutController extends Controller return $this->checkout->forDepublication($user, $purchase); } + /** + * Kostenpflichtige Änderung einer eigenen, veröffentlichten PM + * (Meldeverfahren v2, Spur D). Voraussetzung ist die im Panel bestandene + * KI-Vorprüfung: ohne offene Change-Anfrage mit gelb/grünem Vorprüf- + * Ergebnis gibt es keinen Checkout. Die Umsetzung übernimmt der Webhook + * nach Zahlungseingang (CorrectionService::applyPaid). + */ + public function change(Request $request, int $pressReleaseId): Checkout|RedirectResponse + { + $user = $request->user(); + $priceCents = (int) config('billing.change_price_cents'); + + if ($priceCents <= 0) { + return $this->backToBookings(__('Die kostenpflichtige Änderung ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); + } + + $pressRelease = PressRelease::query() + ->accessibleBy($user) + ->whereKey($pressReleaseId) + ->firstOrFail(); + + if ($pressRelease->status !== PressReleaseStatus::Published) { + return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können geändert werden.')); + } + + if (! $user->hasCompleteBillingAddress()) { + return $this->backToProfile(); + } + + if ($redirect = $this->requireEntrepreneurConfirmation($request)) { + return $redirect; + } + + $legalRequest = LegalRequest::query() + ->where('press_release_id', $pressRelease->id) + ->where('type', LegalRequestType::Change->value) + ->open() + ->latest('id') + ->first(); + + if (! $legalRequest || ! in_array($legalRequest->payload['ki_check']['classification'] ?? null, ['green', 'yellow'], true)) { + return $this->backToBookings(__('Für diese Pressemitteilung liegt keine freigegebene Änderungs-Anfrage vor. Bitte starten Sie die Änderung an der Pressemitteilung neu.')); + } + + // Doppelbuchungs-Bremse wie bei der Depublikation: nur ein BEZAHLTER + // Kauf blockiert, ein abgebrochener Pending-Checkout wird + // wiederverwendet. + $existing = SinglePurchase::query() + ->where('press_release_id', $pressRelease->id) + ->where('type', SinglePurchaseType::Change->value) + ->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value]) + ->latest('id') + ->first(); + + if ($existing && $existing->status !== SinglePurchaseStatus::Pending) { + return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein bezahlter Änderungs-Vorgang vor.')); + } + + $purchase = $existing ?? SinglePurchase::query()->create([ + 'user_id' => $user->id, + 'type' => SinglePurchaseType::Change->value, + 'status' => SinglePurchaseStatus::Pending->value, + 'price_cents' => $priceCents, + 'currency' => config('billing.default_currency'), + 'press_release_id' => $pressRelease->id, + ]); + + if ($legalRequest->single_purchase_id !== $purchase->id) { + $legalRequest->forceFill(['single_purchase_id' => $purchase->id])->save(); + } + + return $this->checkout->forChange($user, $purchase); + } + /** * Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode, * Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll. diff --git a/app/Http/Controllers/LegalRequestController.php b/app/Http/Controllers/LegalRequestController.php index 3e96084e..a6a105fd 100644 --- a/app/Http/Controllers/LegalRequestController.php +++ b/app/Http/Controllers/LegalRequestController.php @@ -31,10 +31,24 @@ class LegalRequestController extends Controller $type = LegalRequestType::tryFrom((string) $request->query('type')) ?? LegalRequestType::Report; + // Zweistufige Triage (Meldeverfahren v2): Absender-Anliegen + // (Korrektur/Änderung/Depublizierung) laufen über den Magic-Link-Weg, + // nicht über dieses Formular. + $initialConcern = match ($type) { + LegalRequestType::Dsgvo => 'dsgvo', + LegalRequestType::PersonalRights => 'personal_rights', + LegalRequestType::Correction, LegalRequestType::Depublication, LegalRequestType::Change => 'owner', + default => 'report', + }; + return view('web.legal-request', [ 'release' => $release, - 'type' => $type, - 'types' => LegalRequestType::cases(), + 'initialConcern' => $initialConcern, + // Passwortloser Portal-Login (Entscheidung 23.07.): Der Anmeldelink + // führt Pressekontakte direkt ins pressekonto — dort werden ihre + // PMs im Konto zusammengeführt und alle Selbstservice-Funktionen + // (Korrektur, Änderung, Depublizierung, Datenschutz) stehen bereit. + 'portalMagicLinkUrl' => rtrim(config('domains.domain_portal_url', config('app.url')), '/').'/anmeldelink', ]); } @@ -50,7 +64,14 @@ class LegalRequestController extends Controller } $validated = $request->validate([ - 'type' => ['required', Rule::enum(LegalRequestType::class)], + // Bewusst NICHT das volle Enum: Absender-Spuren (Korrektur, + // Änderung, Depublizierung) haben eigene verifizierte Flows und + // dürfen hier nicht als anonyme Queue-Anfrage einlaufen. + 'type' => ['required', Rule::in([ + LegalRequestType::Report->value, + LegalRequestType::Dsgvo->value, + LegalRequestType::PersonalRights->value, + ])], // E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei // einer Meldung optional. 'requester_email' => [ diff --git a/app/Http/Controllers/Web/PressReleaseReportController.php b/app/Http/Controllers/Web/PressReleaseReportController.php index 885a3551..95b49558 100644 --- a/app/Http/Controllers/Web/PressReleaseReportController.php +++ b/app/Http/Controllers/Web/PressReleaseReportController.php @@ -15,6 +15,7 @@ use App\Models\MagicLink; use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\Auth\MagicLinkGenerator; +use App\Services\PressRelease\AnonymizationService; use App\Support\WebPortalContext; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -161,7 +162,7 @@ class PressReleaseReportController extends Controller ]); } - public function storeConcern(Request $request, string $edition): RedirectResponse + public function storeConcern(Request $request, string $edition): View|RedirectResponse { $verified = $this->verifiedContext($request); @@ -174,6 +175,11 @@ class PressReleaseReportController extends Controller $validated = $request->validate([ 'concern' => ['required', 'string', 'in:'.implode(',', array_keys(self::CONCERNS))], 'message' => ['required', 'string', 'min:10', 'max:5000'], + // Spur B (Phase 5): strukturierter Schwärzungsauftrag. + 'redaction_terms' => ['required_if:concern,dsgvo', 'nullable', 'string', 'max:2000'], + 'detach_contacts' => ['nullable', 'boolean'], + 'remove_images' => ['nullable', 'boolean'], + 'confirmed' => ['nullable', 'boolean'], ]); $pressRelease = $verified['press_release']; @@ -192,6 +198,54 @@ class PressReleaseReportController extends Controller ]); } + $payload = [ + 'source' => 'self_service', + 'concern' => $validated['concern'], + ]; + + if ($validated['concern'] === 'dsgvo') { + $terms = $this->redactionTermList((string) ($validated['redaction_terms'] ?? '')); + $detachContacts = $request->boolean('detach_contacts'); + $removeImages = $request->boolean('remove_images'); + + if ($terms === [] && ! $detachContacts && ! $removeImages) { + throw ValidationException::withMessages([ + 'redaction_terms' => __('static.pm_report.dsgvo_validation_empty'), + ]); + } + + $preview = app(AnonymizationService::class)->preview($pressRelease, $terms); + + if ($terms !== [] && $preview['total_hits'] === 0) { + throw ValidationException::withMessages([ + 'redaction_terms' => __('static.pm_report.dsgvo_validation_no_hits'), + ]); + } + + // Pflicht-Diff (Konzept §Spur B): erst die Vorschau bestätigen, + // dann einreichen — ohne gesehenen Diff keine Anfrage. + if (! $request->boolean('confirmed')) { + return view('web.melden-pm-anliegen', [ + 'pressRelease' => $pressRelease, + 'contactEmail' => $this->maskEmail($verified['contact_email']), + 'dsgvoPreview' => $preview, + 'dsgvoInput' => [ + 'redaction_terms' => (string) ($validated['redaction_terms'] ?? ''), + 'detach_contacts' => $detachContacts, + 'remove_images' => $removeImages, + 'message' => $validated['message'], + ], + ]); + } + + $payload['redaction'] = [ + 'terms' => $terms, + 'detach_contacts' => $detachContacts, + 'remove_images_requested' => $removeImages, + 'preview_total_hits' => $preview['total_hits'], + ]; + } + $legalRequest = LegalRequest::query()->create([ 'press_release_id' => $pressRelease->id, 'portal' => $pressRelease->portal?->value, @@ -199,10 +253,7 @@ class PressReleaseReportController extends Controller 'status' => LegalRequestStatus::Open->value, 'requester_email' => $verified['contact_email'], 'message' => $validated['message'], - 'payload' => [ - 'source' => 'self_service', - 'concern' => $validated['concern'], - ], + 'payload' => $payload, 'requester_ip' => $request->ip(), 'verified_at' => now(), 'verification_method' => $verified['method'], @@ -222,6 +273,22 @@ class PressReleaseReportController extends Controller ])); } + /** + * Zeilen-Eingabe („ein Begriff pro Zeile") in eine bereinigte, + * eindeutige Begriffsliste wandeln. + * + * @return list + */ + private function redactionTermList(string $raw): array + { + return collect(preg_split('/\r\n|\r|\n/', $raw) ?: []) + ->map(fn (string $term): string => trim($term)) + ->filter() + ->unique() + ->values() + ->all(); + } + /** * Verifizierten Kontext aus der Session lesen — abgelaufene Marker und * verschwundene PMs beenden die Sitzung. diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 65e36093..effee61e 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -5,14 +5,17 @@ namespace App\Listeners; use App\Enums\ActivityType; use App\Enums\EmailNotificationType; use App\Enums\InvoiceStatus; +use App\Enums\LegalRequestType; use App\Enums\PressReleaseStatus; use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; use App\Mail\BookingPaymentConfirmed; +use App\Mail\PressReleaseChangeApplied; use App\Mail\PressReleaseDepublished; use App\Models\CreditTopup; use App\Models\Invoice; use App\Models\InvoiceBillingAddress; +use App\Models\LegalRequest; use App\Models\PressRelease; use App\Models\SinglePurchase; use App\Models\User; @@ -20,6 +23,8 @@ use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Billing\CreditTopupService; use App\Services\Billing\InvoiceNumberGenerator; +use App\Services\PressRelease\CorrectionService; +use App\Services\PressRelease\CorrectionTooLargeException; use App\Services\PressRelease\PressReleaseService; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Carbon; @@ -403,7 +408,7 @@ class ProcessStripeWebhook return; } - /** @var array{purchase: SinglePurchase, marked_paid: bool, depublished_release: PressRelease|null}|null $result */ + /** @var array{purchase: SinglePurchase, marked_paid: bool, depublished_release: PressRelease|null, changed_release: PressRelease|null}|null $result */ $result = DB::transaction(function () use ($purchaseId, $session): ?array { $purchase = SinglePurchase::query() ->whereKey((int) $purchaseId) @@ -425,7 +430,7 @@ class ProcessStripeWebhook ]); $markedPaid = true; } elseif ( - $purchase->type !== SinglePurchaseType::Depublication + ! in_array($purchase->type, [SinglePurchaseType::Depublication, SinglePurchaseType::Change], true) || $purchase->status !== SinglePurchaseStatus::Paid ) { return null; @@ -435,6 +440,7 @@ class ProcessStripeWebhook 'purchase' => $purchase, 'marked_paid' => $markedPaid, 'depublished_release' => $this->fulfillDepublication($purchase), + 'changed_release' => $this->fulfillChange($purchase), ]; }); @@ -448,6 +454,24 @@ class ProcessStripeWebhook Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); } + if (($changedRelease = $result['changed_release']) !== null) { + $buyer = $purchase->user; + $changeRequest = LegalRequest::query() + ->where('single_purchase_id', $purchase->id) + ->where('type', LegalRequestType::Change->value) + ->latest('id') + ->first(); + + if ($buyer && $buyer->email && $changeRequest && $buyer->wantsEmailNotification(EmailNotificationType::PressReleaseCorrected)) { + Mail::to($buyer->email)->queue(new PressReleaseChangeApplied($buyer, $changedRelease, $changeRequest)); + } + + Log::info('Pressemitteilung nach bezahlter Änderung aktualisiert.', [ + 'single_purchase_id' => $purchase->id, + 'press_release_id' => $changedRelease->id, + ]); + } + $pressRelease = $result['depublished_release']; if ($pressRelease === null) { @@ -501,4 +525,62 @@ class ProcessStripeWebhook return $pressRelease; } + + /** + * Spur D (Meldeverfahren v2): Nach bezahltem Änderungs-Kauf die bei der + * KI-Vorprüfung hinterlegten neuen Werte sofort live übernehmen. Der + * Kauf gilt mit der Ausführung als eingelöst; Replays fangen der + * Pending-Status und das executed_at der Change-Anfrage ab. + */ + private function fulfillChange(SinglePurchase $purchase): ?PressRelease + { + if ($purchase->type !== SinglePurchaseType::Change) { + return null; + } + + $legalRequest = LegalRequest::query() + ->where('single_purchase_id', $purchase->id) + ->where('type', LegalRequestType::Change->value) + ->latest('id') + ->first(); + + if (! $legalRequest || $legalRequest->executed_at !== null) { + return null; + } + + $pressRelease = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->whereKey($purchase->press_release_id) + ->lockForUpdate() + ->first(); + + if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) { + Log::warning('Änderung bezahlt, aber PM nicht (mehr) veröffentlicht.', [ + 'single_purchase_id' => $purchase->id, + 'press_release_id' => $purchase->press_release_id, + ]); + + return null; + } + + try { + $applied = app(CorrectionService::class)->applyPaid($pressRelease, $legalRequest); + } catch (CorrectionTooLargeException) { + // Kauf bleibt bezahlt/unkonsumiert — Klärung durch den Admin, + // statt eine Obergrenzen-Verletzung stillschweigend zu übernehmen. + Log::error('Bezahlte Änderung überschreitet die Obergrenze — nicht ausgeführt.', [ + 'single_purchase_id' => $purchase->id, + 'legal_request_id' => $legalRequest->id, + ]); + + return null; + } + + $purchase->update([ + 'status' => SinglePurchaseStatus::Consumed->value, + 'consumed_at' => now(), + ]); + + return $applied ? $pressRelease : null; + } } diff --git a/app/Mail/LegalRequestExecuted.php b/app/Mail/LegalRequestExecuted.php new file mode 100644 index 00000000..b8871832 --- /dev/null +++ b/app/Mail/LegalRequestExecuted.php @@ -0,0 +1,42 @@ +legalRequest->reference(), + ); + } + + public function content(): Content + { + return new Content(view: 'emails.legal.executed'); + } +} diff --git a/app/Mail/PressReleaseChangeApplied.php b/app/Mail/PressReleaseChangeApplied.php new file mode 100644 index 00000000..6b3910f4 --- /dev/null +++ b/app/Mail/PressReleaseChangeApplied.php @@ -0,0 +1,83 @@ + Deutsche Labels der geänderten Felder. */ + public array $changedFieldLabels; + + public function __construct( + public readonly User $user, + public readonly PressRelease $pressRelease, + public readonly LegalRequest $legalRequest, + ) { + $this->showUrl = $user->canAccessAdmin() + ? route('admin.press-releases.show', $pressRelease->id) + : route('me.press-releases.show', $pressRelease->id); + + $this->isPaidChange = $legalRequest->type === LegalRequestType::Change; + $this->changedFieldLabels = $this->resolveChangedFieldLabels($legalRequest); + + $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseCorrected); + } + + public function envelope(): Envelope + { + $prefix = $this->isPaidChange + ? 'Ihre Änderung ist live' + : 'Ihre Korrektur ist live'; + + return new Envelope( + subject: $prefix.': '.$this->pressRelease->title, + ); + } + + public function content(): Content + { + return new Content(view: 'emails.press-release.change-applied'); + } + + /** + * @return list + */ + private function resolveChangedFieldLabels(LegalRequest $legalRequest): array + { + $labels = [ + 'title' => 'Titel', + 'subtitle' => 'Untertitel', + 'text' => 'Text', + 'keywords' => 'Keywords', + ]; + + return collect((array) ($legalRequest->payload['fields'] ?? [])) + ->filter(fn (array $meta): bool => (bool) ($meta['changed'] ?? false)) + ->keys() + ->map(fn (string $field): string => $labels[$field] ?? $field) + ->values() + ->all(); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index eb11777e..71013393 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -181,6 +181,22 @@ class PressRelease extends Model return $contactEmail; } + // Legacy-Importe: Kontakte hängen an der Firma, nicht an der PM — + // ohne PM-Kontakt zählt der Firmen-Pressekontakt als Kanal. + if ($this->company_id) { + $companyContactEmail = Contact::query() + ->withoutGlobalScopes() + ->where('company_id', $this->company_id) + ->whereNotNull('email') + ->where('email', '!=', '') + ->orderBy('id') + ->value('email'); + + if (is_string($companyContactEmail) && $companyContactEmail !== '') { + return $companyContactEmail; + } + } + $companyEmail = $this->company()->withoutGlobalScopes()->value('email'); return is_string($companyEmail) && $companyEmail !== '' ? $companyEmail : null; diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index bfbe58db..bc37f199 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -469,6 +469,31 @@ class StripeCheckoutService return $checkout; } + /** + * Stripe-Checkout für eine kostenpflichtige Änderung (Meldeverfahren v2, + * Spur D). Ad-hoc-Charge wie die Depublikation; der Webhook übernimmt + * die Änderung nach Zahlungseingang (CorrectionService::applyPaid). + */ + public function forChange(User $user, SinglePurchase $purchase): Checkout + { + $this->syncCustomerFromBillingAddress($user); + + $checkout = $user->checkout( + [$this->adHocLineItem($purchase->price_cents, 'Änderung einer veröffentlichten Pressemitteilung')], + [ + ...$this->oneTimeSessionOptions($user), + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ); + + $this->recordCheckoutStarted($user, 'Änderung einer veröffentlichten Pressemitteilung', [ + 'kind' => 'press_release_change', + 'single_purchase_id' => $purchase->id, + ]); + + return $checkout; + } + /** * Stripe-Checkout für eine Wallet-Aufladung. Ad-hoc-Charge über den * Netto-Paketpreis (Stripe Tax ergänzt die USt.); die `credit_topup_id` diff --git a/app/Services/PressRelease/CorrectionRateLimitException.php b/app/Services/PressRelease/CorrectionRateLimitException.php index ea31ffb5..e6726b01 100644 --- a/app/Services/PressRelease/CorrectionRateLimitException.php +++ b/app/Services/PressRelease/CorrectionRateLimitException.php @@ -13,6 +13,12 @@ class CorrectionRateLimitException extends Exception { public function __construct() { - parent::__construct('Für diese Pressemitteilung wurden zuletzt bereits mehrere Korrekturen gespeichert. Bitte versuchen Sie es später erneut.'); + $limit = (int) config('legal.corrections_per_week'); + + parent::__construct(trans_choice( + 'Pro Woche ist höchstens eine kostenlose Korrektur je Pressemitteilung möglich — das Limit ist bereits erreicht. Bitte versuchen Sie es später erneut.|Pro Woche sind höchstens :count kostenlose Korrekturen je Pressemitteilung möglich — das Limit ist bereits erreicht. Bitte versuchen Sie es später erneut.', + $limit, + ['count' => $limit], + )); } } diff --git a/app/Services/PressRelease/CorrectionService.php b/app/Services/PressRelease/CorrectionService.php index 402f90ed..726648ff 100644 --- a/app/Services/PressRelease/CorrectionService.php +++ b/app/Services/PressRelease/CorrectionService.php @@ -11,8 +11,11 @@ use App\Models\PressRelease; use App\Models\PressReleaseRedactionSnapshot; use App\Models\User; use App\Services\ActivityLog; +use App\Services\PressRelease\Classification\ClassificationManager; +use App\Services\PressRelease\Classification\ClassificationResult; use App\Support\WebCaches; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Kleine Korrektur (Meldeverfahren v2, Spur A — Phase 4): Selbstbedienungs- @@ -30,13 +33,50 @@ class CorrectionService private readonly ActivityLog $activityLog, private readonly PressReleaseService $pressReleaseService, private readonly PressReleaseHtmlSanitizer $sanitizer, + private readonly ClassificationManager $classification, ) {} + /** + * KI-Vorprüfung des GEÄNDERTEN Inhalts (Spur D, vor der Zahlung): läuft + * auf einer nicht gespeicherten Kopie der PM mit den vorgeschlagenen + * Werten. Rot → keine Zahlung, Einreichung wird abgelehnt. Fällt der + * KI-Anbieter aus, greift der deterministische Fallback (Blacklist) — + * wie beim regulären Freigabelauf. + * + * @param array $newValues + */ + public function precheckProposedContent(PressRelease $pressRelease, array $newValues): ClassificationResult + { + $proposed = $pressRelease->replicate(); + + foreach (self::CORRECTABLE_FIELDS as $field) { + if (array_key_exists($field, $newValues)) { + $value = (string) ($newValues[$field] ?? ''); + $proposed->setAttribute($field, $value !== '' ? $value : null); + } + } + + try { + return $this->classification->driver()->classify($proposed); + } catch (\Throwable $e) { + Log::warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [ + 'press_release_id' => $pressRelease->id, + 'error' => $e->getMessage(), + ]); + + return $this->classification->driver('deterministic')->classify($proposed); + } + } + /** * Einstufung klein/groß je Feld — verändert nichts. * + * `exceeds_paid_limit`: oberhalb von legal.change_max_distance_percent + * ist auch die kostenpflichtige Änderung (Spur D) nicht mehr buchbar — + * sonst ließe sich der Text faktisch selbst depublizieren. + * * @param array $newValues - * @return array{fields: array, max_distance_percent: float, has_changes: bool, is_minor: bool} + * @return array{fields: array, max_distance_percent: float, has_changes: bool, is_minor: bool, exceeds_paid_limit: bool} */ public function classify(PressRelease $pressRelease, array $newValues): array { @@ -79,6 +119,7 @@ class CorrectionService 'max_distance_percent' => $maxPercent, 'has_changes' => $hasChanges, 'is_minor' => $hasChanges && $allMinor, + 'exceeds_paid_limit' => $maxPercent > (int) config('legal.change_max_distance_percent'), ]; } @@ -129,21 +170,7 @@ class CorrectionService '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(); + $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); $pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference()); return $legalRequest; @@ -176,6 +203,100 @@ class CorrectionService return $legalRequest; } + /** + * Führt eine BEZAHLTE Änderung (Spur D) nach Zahlungseingang aus: die + * neuen Werte kommen aus dem Payload der bei der Vorprüfung angelegten + * Change-Anfrage. Kein Minor-Gate und keine Wochen-Bremse — dafür die + * absolute Obergrenze (legal.change_max_distance_percent) und die + * bereits bestandene KI-Vorprüfung. Geht sofort live. + * + * @throws CorrectionTooLargeException wenn die Obergrenze doch reißt + */ + public function applyPaid(PressRelease $pressRelease, LegalRequest $legalRequest, ?User $actor = null): bool + { + /** @var array $newValues */ + $newValues = (array) ($legalRequest->payload['new_values'] ?? []); + $classification = $this->classify($pressRelease, $newValues); + + if ($classification['exceeds_paid_limit']) { + throw new CorrectionTooLargeException; + } + + if (! $classification['has_changes']) { + Log::warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [ + 'legal_request_id' => $legalRequest->id, + 'press_release_id' => $pressRelease->id, + ]); + + return false; + } + + DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): void { + $legalRequest->forceFill([ + 'status' => LegalRequestStatus::Resolved->value, + 'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]), + 'resolved_at' => now(), + 'executed_at' => now(), + ])->save(); + + PressReleaseRedactionSnapshot::capture($pressRelease, [ + 'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS), + ], $legalRequest); + + $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); + $pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference()); + }); + + WebCaches::forgetSitemaps(); + + $changedFields = collect($classification['fields']) + ->filter(fn (array $meta): bool => $meta['changed']) + ->keys() + ->implode(', '); + + $this->activityLog->record( + ActivityType::Correction, + sprintf( + 'Kostenpflichtige Änderung (%s) nach Zahlungseingang übernommen: Feld(er) %s, max. Distanz %.1f %%.', + $legalRequest->reference(), + $changedFields, + $classification['max_distance_percent'], + ), + user: $actor, + subject: $pressRelease, + meta: ['legal_request_id' => $legalRequest->id], + ); + + $this->pressReleaseService->reprocessAfterContentChange($pressRelease); + + return true; + } + + /** + * Schreibt die geänderten Felder (Text durch den Sanitizer) auf die PM. + * + * @param array $classifiedFields + * @param array $newValues + */ + private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void + { + foreach ($classifiedFields 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(); + } + /** * 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 @@ -249,13 +370,21 @@ class CorrectionService } /** - * Vergleichsbasis: HTML raus, Whitespace normalisiert — reine - * Markup-Änderungen zählen nicht als inhaltliche Distanz. + * Vergleichsbasis: HTML raus, Entities dekodiert, Whitespace normalisiert + * — reine Markup-Änderungen zählen nicht als inhaltliche Distanz. Wichtig, + * weil der Editor Bestands-HTML normalisiert (  → Leerzeichen, + * & → &,
→ Absätze): ohne Dekodierung zählte jedes betroffene + * Wort als Änderung und bläht die Distanz auf. */ private function comparableText(string $value): string { - $plain = strip_tags($value); - $collapsed = preg_replace('/\s+/u', ' ', $plain) ?? $plain; + // Blockgrenzen und
als Leerzeichen erhalten, damit strip_tags + // angrenzende Wörter nicht zu einem verklebt. + $spaced = preg_replace('#]*>|#i', ' ', $value) ?? $value; + $plain = strip_tags($spaced); + $decoded = html_entity_decode($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $normalized = str_replace(["\u{00A0}", "\u{202F}", "\u{2007}"], ' ', $decoded); + $collapsed = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized; return trim($collapsed); } diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index e466187d..d2e18b7f 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -21,6 +21,7 @@ use App\Models\User; use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Admin\AdminPerformanceCache; +use App\Support\WebCaches; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -391,7 +392,15 @@ class PressReleaseService * erhält den Depublikations-Marker — die öffentliche Detail-URL zeigt * fortan einen Tombstone (410). */ - public function depublish(PressRelease $pressRelease): void + /** + * Depubliziert eine veröffentlichte PM (Tombstone bleibt öffentlich). + * + * Setzt IMMER die Re-Sync-Sperre — sonst würde der nächste Importer-Lauf + * die Entfernung überschreiben (Meldeverfahren §7). Aufrufer können über + * $syncLockReason einen spezifischeren Grund (z. B. die Vorgangsnummer) + * ins Sperr-Protokoll geben. + */ + public function depublish(PressRelease $pressRelease, ?string $syncLockReason = null): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Published]); @@ -401,6 +410,8 @@ class PressReleaseService 'status' => PressReleaseStatus::Archived->value, 'depublished_at' => now(), ]); + $pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders'); + WebCaches::forgetSitemaps(); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication'); } diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 267c2ea5..950aa7b3 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -2,6 +2,7 @@ namespace App\Services\Web; +use App\Models\Contact; use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Support\WebPortalContext; @@ -62,6 +63,22 @@ class ReleaseDetailPageData abort(404); } + // Legacy-Importe kennen fast keine PM↔Kontakt-Verknüpfung — im + // Altsystem hingen Pressekontakte an der FIRMA. Ohne eigenen + // PM-Kontakt zeigt die Seite deshalb den Firmen-Kontakt (wie das + // Altsystem), statt den Block ganz wegzulassen. + if ($release->contacts->isEmpty() && $release->company_id) { + $companyContact = Contact::query() + ->withoutGlobalScopes() + ->where('company_id', $release->company_id) + ->orderBy('id') + ->first(); + + if ($companyContact) { + $release->setRelation('contacts', $release->contacts->push($companyContact)); + } + } + $releaseId = $release->getKey(); defer(static fn () => PressRelease::recordDeferredView($releaseId)); diff --git a/config/billing.php b/config/billing.php index 04db7479..08015491 100644 --- a/config/billing.php +++ b/config/billing.php @@ -70,6 +70,20 @@ return [ 'depublication_price_cents' => env('BILLING_DEPUBLICATION_PRICE_CENTS', 3500), + /* + |-------------------------------------------------------------------------- + | Kostenpflichtige Änderung (Meldeverfahren v2, Spur D) + |-------------------------------------------------------------------------- + | + | Netto-Preis einer inhaltlichen Änderung an einer veröffentlichten PM + | oberhalb der Spur-A-Grenze (legal.correction_max_distance_percent). + | 10 € netto laut User-Entscheidung 22.07.2026 (E1). Auf 0/leer setzen + | deaktiviert den Checkout. Ad-hoc-Charge wie die Depublikation. + | + */ + + 'change_price_cents' => env('BILLING_CHANGE_PRICE_CENTS', 1000), + /* |-------------------------------------------------------------------------- | USt-Behandlung (Entscheidung 12.06.2026) diff --git a/config/legal.php b/config/legal.php index 9a670341..f9098229 100644 --- a/config/legal.php +++ b/config/legal.php @@ -52,6 +52,20 @@ return [ 'snapshot_retention_days' => (int) env('LEGAL_SNAPSHOT_RETENTION_DAYS', 30), 'correction_max_distance_percent' => (int) env('LEGAL_CORRECTION_MAX_DISTANCE_PERCENT', 5), + /* + |-------------------------------------------------------------------------- + | Meldeverfahren: Spur D — Kostenpflichtige Änderung (Obergrenze) + |-------------------------------------------------------------------------- + | + | change_max_distance_percent: maximale relative Änderungsdistanz, bis zu + | der eine kostenpflichtige Änderung überhaupt buchbar ist. Darüber wäre + | die „Änderung" faktisch ein neuer Text bzw. eine verdeckte + | Depublizierung — dann bleibt nur Depublikation + Neuveröffentlichung. + | + */ + + 'change_max_distance_percent' => (int) env('LEGAL_CHANGE_MAX_DISTANCE_PERCENT', 50), + /* |-------------------------------------------------------------------------- | Meldeverfahren: Magic-Link-Verifikation (Phase 3, E7) diff --git a/database/migrations/2026_07_23_113508_add_change_to_single_purchases_type_enum.php b/database/migrations/2026_07_23_113508_add_change_to_single_purchases_type_enum.php new file mode 100644 index 00000000..c2d68af9 --- /dev/null +++ b/database/migrations/2026_07_23_113508_add_change_to_single_purchases_type_enum.php @@ -0,0 +1,43 @@ +getDriverName() !== 'mysql') { + return; + } + + DB::statement(sprintf( + 'ALTER TABLE single_purchases MODIFY COLUMN `type` ENUM(%s) NOT NULL', + $this->enumList(), + )); + } + + public function down(): void + { + // Kein Rückbau: ein verengtes ENUM würde vorhandene Änderungs-Käufe + // abschneiden. + } + + private function enumList(): string + { + $values = array_map( + static fn (SinglePurchaseType $type): string => $type->value, + SinglePurchaseType::cases(), + ); + + return "'".implode("','", $values)."'"; + } +}; diff --git a/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md b/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md index f4be3e85..48a7ddb9 100644 --- a/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md +++ b/docs/Decision-Update/Entwicklungsplan Meldeverfahren.md @@ -37,9 +37,9 @@ 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 5 Spur B — DSGVO-Einreichung (self-service → Admin-Gate) ✅ umgesetzt 23.07.2026 Phase 6 Spur C — Depublizierung (Gast-Checkout + Reversibilitätsfenster) -Phase 7 Spur D — Änderung (Gast-Checkout + KI-Freigabelauf) +Phase 7 Spur D — Änderung (Checkout + KI-Vorprüfung) ✅ umgesetzt 23.07.2026 Phase 8 Querschnitt-Abschluss: Kommunikation, Auslieferungs-Checks, Doku ``` @@ -49,13 +49,14 @@ Jede Phase ist eigenständig deploybar; nach Phase 2 ist der Interims-Triage-Pro **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. +1. ~~**Phase 5 — Spur B strukturiert**~~ ✅ **umgesetzt 23.07.** (Details im Phase-5-Abschnitt). +2. **Phase 6 — Spur C:** ✅ **Re-Sync-Sperre nachgerüstet (23.07.):** `PressReleaseService::depublish()` setzt die Sperre jetzt IMMER selbst (kein Aufrufer kann sie vergessen) und invalidiert die Sitemap-Caches; der Admin-Pfad gibt seine Vorgangsnummer als Sperrgrund mit. **Gast-Checkout zurückgestellt (Entscheidung 23.07.):** Magic-Link-Login ins Panel deckt Pressekontakte ab — dort existiert die bezahlte Depublikation bereits. Noch offen aus Phase 6: Reversibilitätsfenster-Hinweis („umkehrbar bis …", `legal.reversal_window_days`) in Panel/Mails. +3. ~~**Phase 7 — Spur D Checkout**~~ ✅ **Panel-Pfad am 23.07. umgesetzt** — mit geändertem Flow (E2 revidiert, siehe Phase-7-Abschnitt): KI-Vorprüfung **vor** der Zahlung im Modal, rot → Ablehnung ohne Zahlung, je PM nur einmal möglich; Obergrenze `legal.change_max_distance_percent` (50 %). Offen: Gast-Pfad für Magic-Link-Verifizierte, Bestätigungsmail nach Ausführung. +4. ~~**Spur A im Web-Pfad**~~ ✅ **erledigt per Entscheidung 23.07.:** Kein eigener Web-Korrektur-Pfad nötig — hinterlegte Pressekontakte loggen sich per bestehendem Magic-Link-Login ins Panel ein und nutzen dort Spur A/D. Bestätigungsmail umgesetzt: `PressReleaseChangeApplied` (Typ `press_release_corrected`, abbestellbar) nach Spur-A-Korrektur (sofort) und Spur-D-Änderung (nach Webhook-Ausführung). 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. +6. ~~**Ops:** Dev-DB-Migration~~ ✅ **erledigt 23.07.:** Das ALTER war serverseitig fertig (Migration manuell nachregistriert), alle restlichen Migrationen gelaufen — der komplette Flow ist auf pressekonto.test browser-testbar. +7. ✅ **Öffentliche Triage-Seite (23.07.):** `/release/{slug}/rechtliches` ist jetzt zweistufig — Stufe 1 wählt das Anliegen (Absender-Selbstservice / DSGVO / Persönlichkeitsrechte / Meldung), Stufe 2 leitet Absender-Anliegen und DSGVO/Persönlichkeitsrechte primär auf den Magic-Link-Weg (Deep-Link zum `/melden/pm`-Trichter mit vorbefüllter PM-URL); das Kontaktformular ist dort nur der Fallback mit Pflicht-Hinweis auf die persönliche Identifizierung. Meldungen rechtswidriger Inhalte gehen direkt ins Formular (DSA). Der Store-Endpoint akzeptiert nur noch `report`/`dsgvo`/`personal_rights` — Absender-Spuren können nicht mehr als anonyme Queue-Anfrage einlaufen. +8. **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. --- @@ -161,14 +162,15 @@ Link geht an hinterlegte, nie an Formular-Adresse (maskiert); unbekannte URL abg - ✅ Tests (11 neue, alle grün): Tippfehler klein / Umschreibung groß / Markup-only = keine Änderung, Sofort-Live mit Snapshot+Sperre+Verlauf, Zu-groß-Abwehr, Wochen-Bremse, Restore (Service + Admin-UI), kein Credit-Bezug, Panel-Happy-Paths, Vorschau-Pflicht. - ℹ️ **Noch offen aus Phase 4:** dieselben Formulare im verifizierten Web-Bereich (Magic-Link-Besucher) — aktuell erzeugt Spur A dort noch eine Queue-Anfrage; die `CorrectionService`-Engine ist dafür wiederverwendbar. Nutzer-Bestätigungsmail nach Korrektur bewusst weggelassen (Owner sieht das Ergebnis sofort im Panel), für den Web-Pfad nachrüsten. -## Phase 5 — Spur B: DSGVO-Einreichung self-service → Admin-Gate +## Phase 5 — Spur B: DSGVO-Einreichung self-service → Admin-Gate ✅ **umgesetzt (23.07.2026)** -- Formular: Strukturfelder zum Leeren angeboten + Freitext-Schwärzwerte inkl. Varianten → Pflicht-Diff (Preview aus `AnonymizationService::preview()`) → **Einreichung** (nicht Ausführung): `LegalRequest` `type=dsgvo`, payload = Schwärzungsauftrag, Status `open`. -- Admin-Queue zeigt den vorbereiteten Diff (Phase-2-Editor, vorbefüllt) → Admin bestätigt **anonymisieren** oder wählt **depublizieren** → `AnonymizationService::execute()` bzw. `depublish()` + Snapshot. -- Bestätigungsmail an Antragsteller bei Einreichung (Fristbeginn dokumentiert, Vorgangsnummer `M-{id}`) und bei Ausführung. -- **Wording:** In der UI „Schwärzung/Entfernung personenbezogener Daten", nicht „Anonymisierung im DSGVO-Vollsinn" (Guardrail — nicht überversprechen). -- Bremse: 1 offene Anfrage pro PM/Typ (Logik existiert konzeptionell in `LegalRequestStatus::isOpen()` — beim Store durchsetzen). -- Tests: Einreichung erzeugt keinen Datenbank-Write an der PM; Ausführung nur durch Admin; nie Credits. +- ✅ **Strukturformular** im verifizierten Web-Bereich (`melden-pm-anliegen`, Alpine-Toggle bei Spur-Auswahl „DSGVO"): Schwärzbegriffe zeilenweise (Hinweis auf Varianten/Genitiv), Checkboxen „Pressekontakt-Daten entfernen" und „Bilder mit Personenbezug entfernen" (Bildauswahl trifft der Admin), Freitext-Begründung bleibt Pflicht. +- ✅ **Pflicht-Diff:** Erster POST rendert die Vorschau aus `AnonymizationService::preview()` (Treffer je Feld, geschwärzte Fassung als Klartext) — erst „Verbindlich einreichen" erzeugt die Anfrage. Begriffe ohne Treffer werden mit Schreibweisen-Hinweis abgewiesen; leeres Formular (keine Begriffe, keine Option) ebenso. +- ✅ **Einreichung ≠ Ausführung:** `LegalRequest` `type=dsgvo`, Status `open`, `payload.redaction` = {terms, detach_contacts, remove_images_requested, preview_total_hits}; an der PM ändert sich nichts, keine Credits. Bremse 1 offene Anfrage pro PM/Typ greift weiter. +- ✅ **Admin-Editor vorbefüllt:** `admin.legal-requests.show` übernimmt Begriffe + Kontakt-Option aus `payload.redaction` und zeigt einen Hinweis-Kasten (inkl. Bild-Entfernungswunsch). Admin prüft, ergänzt, führt aus oder depubliziert. +- ✅ **Mails:** Eingangsbestätigung existierte (Phase 3); neu die **Umsetzungsbestätigung** `LegalRequestExecuted` (transaktional, nicht abbestellbar) an die Antragsteller-Adresse nach `executeAnonymization` („anonymized") und `depublishRelease` („depublished"). +- ✅ **Wording-Guardrail** eingehalten: überall „Schwärzung/Entfernung", inkl. Hinweis auf Suchmaschinen-/Drittarchiv-Kopien. +- ✅ Tests: Pflicht-Vorschau ohne DB-Write, Treffer-Validierung, bestätigte Einreichung mit strukturiertem Payload, Admin-Prefill, Umsetzungs-Mail. ## Phase 6 — Spur C: Depublizierung (35 €, Gast-Checkout, umkehrbar) @@ -179,15 +181,20 @@ Link geht an hinterlegte, nie an Formular-Adresse (maskiert); unbekannte URL abg - Preis: 35 € live via `config('billing.depublication_price_cents')` (existiert). Die systemische 25-Credit-Variante für eingeloggte Kunden bleibt unberührt — aber Test sichert ab, dass der **DSGVO-Pfad (Spur B) nie** in diesen Checkout mündet. - Tests: Gast-Checkout-Fulfillment, Reversibilitätsfenster, Tombstone + Sitemap-Ausschluss. -## Phase 7 — Spur D: Änderung (10 €, KI-Freigabelauf) +## Phase 7 — Spur D: Änderung (10 €, KI-Vorprüfung) ✅ **Panel-Pfad umgesetzt (23.07.2026)** -- Preis: **10 €** (E1), neuer Config-Wert `billing.change_price_cents` = 1000. -- Formular: neuen Text/Feldwerte eingeben (Voll-Editor light, analog `customer/press-releases/edit`-Formularregeln aus `ProvidesPressReleaseFormRules`) → Preis anzeigen → Gast-Checkout (`SinglePurchaseType`-Case `Change`, `adHocLineItem`-Muster). -- Nach Zahlung: Änderung in **Entwurfs-Kopie/Pending-Payload** halten (nicht direkt live), dann `ClassifyPressRelease` auf den neuen Inhalt: - - **grün/gelb** → Änderung anwenden, PM bleibt/wird published, `lockSync()`, Kauf auf `Consumed`, Mail „live". - - **rot** → abgelehnt, Änderung verworfen, **kein Verbrauch** (E2): Kauf bleibt un-konsumiert, Antragsteller kann mit überarbeitetem Text erneut einreichen ohne erneut zu zahlen; Mail mit Ablehnungsgrund. -- KiAudit-Trail entsteht automatisch über den bestehenden Job. -- Tests: rot verwirft ohne PM-Änderung; grün wendet an; Webhook-Idempotenz. +**Flow-Entscheidung 23.07. (revidiert E2):** Die KI-Prüfung läuft **VOR der Zahlung** als einmaliger Zwischenschritt im Modal — nicht mehr nach Zahlung auf einem Pending-Payload. Rot → Einreichung wird direkt abgelehnt, es kommt gar nicht erst zur Zahlung (damit entfällt der „kein Verbrauch"-Fall aus E2). Gelb/Grün → Checkbox (§ 14 BGB/AGB, wie Depublikation) + direkter Stripe-Checkout; nach Zahlungseingang geht die Änderung **sofort live**. Der Vorgang ist **je PM nur einmal möglich** (auch nach Ablehnung kein zweiter Anlauf). + +- ✅ **Obergrenze:** `legal.change_max_distance_percent` = **50 %** (env `LEGAL_CHANGE_MAX_DISTANCE_PERCENT`) — darüber ist auch die kostenpflichtige Änderung nicht buchbar (sonst verdeckte Selbst-Depublizierung); UI verweist auf Depublikation + Neuveröffentlichung. `CorrectionService::classify()` liefert dafür `exceeds_paid_limit`. +- ✅ **Preis:** `billing.change_price_cents` = 1000 (E1); 0/leer deaktiviert den Weg (wie Depublikation). +- ✅ **Vorprüfung:** `CorrectionService::precheckProposedContent()` klassifiziert eine NICHT gespeicherte PM-Kopie mit den neuen Werten über den `ClassificationManager` (KI-Provider, Fallback deterministisch/Blacklist). Ergebnis + Provider landen in `payload.ki_check` der Change-`LegalRequest` (rot → Status `Rejected`, sonst `Open`). +- ✅ **Checkout:** `CheckoutController::change()` (Route `me.checkout.change`, `checkout/aenderung/{id}`) verlangt die offene Change-Anfrage mit gelb/grünem `ki_check` als Nachweis; `SinglePurchaseType::Change` (`press_release_change`, Enum-Migration), Pending-Wiederverwendung wie Depublikation; Anfrage wird über `single_purchase_id` verknüpft. `StripeCheckoutService::forChange()` (adHoc-Muster). +- ✅ **Fulfillment:** `ProcessStripeWebhook::fulfillChange()` → `CorrectionService::applyPaid()`: Snapshot, Felder schreiben (Text durch Sanitizer), `lockSync()`, Verlauf, Re-Score; Anfrage `Resolved`/`executed_at`, Kauf `Consumed`. Reißt die Obergrenze doch (Payload-Manipulation), bleibt der Kauf unkonsumiert zur Admin-Klärung. +- ✅ **Panel-UI:** Modal `paid-change` mit Zwischenerklärung (einmalige Prüfung, rot/gelb-grün-Folgen), Loading-State, Ablehnungsansicht mit Gründen, Bestätigungs-Switch (`BookingConfirmation` Typ `press_release_change`) + Stripe-Button. Abgebrochene Zahlung: Modal springt direkt wieder zum Zahlungs-Schritt der bereits geprüften Fassung. +- ✅ **Tests:** Panel (grün → Zahlungs-Schritt, rot → Ablehnung + Einmal-Regel, Obergrenze), Service (`applyPaid`, `precheck`, `exceeds_paid_limit`), Checkout (Nachweis-Pflicht, Pending-Reuse, rot blockiert), Webhook (Ausführung + Replay-Idempotenz). +- ✅ **Bestätigungsmail:** `PressReleaseChangeApplied` an den Auftraggeber nach Spur-A-Korrektur und Spur-D-Ausführung (neuer `EmailNotificationType::PressReleaseCorrected`, abbestellbar, erscheint automatisch in den Mail-Präferenzen). +- **Entscheidung 23.07.:** Kein separater Gast-Pfad — hinterlegte Pressekontakte nutzen den bestehenden Magic-Link-Login ins Panel und bearbeiten dort. Der `/melden/pm`-Trichter bleibt für Dritte/DSGVO/Depublikations-Anliegen. +- **Kleinigkeit offen:** KiAudit-Eintrag für die Vorprüfung (Ergebnis liegt bisher nur in `payload.ki_check` der Anfrage). ## Phase 8 — Querschnitt-Abschluss @@ -204,7 +211,7 @@ Link geht an hinterlegte, nie an Formular-Adresse (maskiert); unbekannte URL abg | # | Entscheidung | Umsetzung im Plan | |---|---|---| | **E1** | Zwei getrennte Preise: **35 €** = komplette Depublizierung einer PM (Spur C), **10 €** = Änderung einer PM (Spur D) | Spur C: `billing.depublication_price_cents` (existiert); Spur D: neuer Config-Wert `billing.change_price_cents` = 1000 → Phase 7 | -| **E2** | Bei KI-„rot" **kein Verbrauch** — es wird nichts abgezogen | Kauf bleibt un-konsumiert (Status nicht `Consumed`); der Antragsteller kann mit überarbeitetem Text erneut in den Freigabelauf, ohne erneut zu zahlen → Phase 7 | +| **E2** | ~~Bei KI-„rot" kein Verbrauch~~ **Revidiert 23.07.:** KI-Prüfung läuft **vor** der Zahlung (einmaliger Zwischenschritt im Modal). Rot → Ablehnung ohne Zahlung; Gelb/Grün → Zahlung, danach sofort live. Je PM **nur einmal möglich**. | Umgesetzt in Phase 7 (`precheckProposedContent`, Modal `paid-change`); der „kein Verbrauch"-Fall entfällt, weil vor Zahlung geprüft wird | | **E3** | Reversibilitätsfenster als **Config-Variable, Standard 14 Tage** | `legal.reversal_window_days` = 14 → Phase 6 | | **E4** | Snapshot-Löschfrist als **Config-Variable, Standard 30 Tage** | `legal.snapshot_retention_days` = 30; bestimmt `purge_after` → Phasen 1.1 / 2.3 | | **E5** | Levenshtein-Schwellwert Spur A als Config-Variable; Start 15 %, am 22.07. abends auf **5 %** verschärft (+ Mini-Edit-Ausnahme ≤ 2 Zeichen) | `legal.correction_max_distance_percent` = 5, `legal.corrections_per_week` = 1 → Phase 4 | @@ -236,3 +243,11 @@ Link geht an hinterlegte, nie an Formular-Adresse (maskiert); unbekannte URL abg | 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 | +| 23.07.2026 (rev. 8) | Phase 7 Panel-Pfad umgesetzt mit revidiertem E2 (KI-Vorprüfung VOR Zahlung, rot → Ablehnung ohne Zahlung, nur einmal je PM; Obergrenze 50 %). Außerdem: Vergleichsbasis entity-fest (Editor-Normalisierung ≠ Änderung), `toEditorHtml()` in Admin-Edit + Korrektur-Panel (Legacy-Plaintext-Umbrüche), Wochenlimit-Hinweis in Panel-Text und Bremsen-Meldung, Dev-DB vollständig migriert | +| 23.07.2026 (rev. 9) | Entscheidung: kein separater Web-Korrektur-Pfad — Magic-Link-Login ins Panel deckt Pressekontakte ab. Bestätigungsmail `PressReleaseChangeApplied` (Spur A sofort, Spur D nach Webhook) mit neuem abbestellbarem Typ `press_release_corrected`; Phase 7 damit vollständig | +| 23.07.2026 (rev. 10) | Phase 5 umgesetzt: strukturiertes DSGVO-Schwärzungsformular mit Pflicht-Diff im verifizierten Web-Bereich, Admin-Editor-Prefill aus `payload.redaction`, Umsetzungsbestätigung `LegalRequestExecuted` an Antragsteller | +| 23.07.2026 (rev. 11) | Gast-Checkout zurückgestellt (Magic-Link-Login deckt Pressekontakte ab). Re-Sync-Sperre + Sitemap-Invalidierung fest in `depublish()` verankert — schließt die Lücke im bezahlten Webhook-Pfad; Vollsuite 1418 Tests grün | +| 23.07.2026 (rev. 12) | Öffentliche „Rechtliches"-Seite an der PM zur zweistufigen Triage umgebaut: Anliegen-Karten, Magic-Link-Deep-Link mit vorbefüllter URL, Formular nur als Fallback (mit Identifizierungs-Hinweis bei DSGVO/Persönlichkeitsrechten), Meldung direkt; Store-Endpoint auf die drei öffentlichen Typen gehärtet | +| 23.07.2026 (rev. 13) | Pressekontakt-Lücke der Legacy-Bestände geschlossen: 30.422 von 30.433 veröffentlichten PMs haben keine PM↔Kontakt-Verknüpfung (im Altsystem hingen Kontakte an der FIRMA). Detailseite zeigt jetzt Fallback Firmen-Kontakt → Firmendaten; `pressContactEmail()` (Magic-Link-Kanal) nutzt dieselbe Kette PM-Kontakt → Firmen-Kontakt → Firmen-E-Mail | +| 23.07.2026 (rev. 14) | Entscheidung: Die Triage-Buttons führen NICHT in den Web-Melde-Trichter (`/melden/pm`), sondern zum passwortlosen **Portal-Anmeldelink** (`pressekonto /anmeldelink`) — dort werden die PMs der Kontaktadresse im Konto zusammengeführt und alle Selbstservice-Funktionen stehen bereit. Der Web-Trichter bleibt als eigenständiger Weg bestehen (u. a. verifizierte DSGVO-Struktureinreichung) | +| 23.07.2026 (rev. 15) | Panel: „Ändern/Melden"-Button leitet nicht mehr in den Web-Trichter, sondern öffnet ein Anliegen-Modal (4 Spuren). Korrektur/Änderung und Depublizierung springen zu den Panel-Abschnitten; DSGVO hat jetzt das strukturierte Schwärzungsformular (Pflicht-Vorschau, Einreichung ≠ Ausführung, Payload wie Web-Formular → Admin-Editor-Prefill) direkt als Modal im Panel | diff --git a/lang/de/static.php b/lang/de/static.php index 58e5438b..6bafe4a5 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -447,6 +447,28 @@ return [ '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.', + 'dsgvo_terms_label' => 'Zu entfernende Angaben (ein Begriff pro Zeile)', + 'dsgvo_terms_placeholder' => "Max Mustermann\nMustermann\nmax@firma.example\n+49 123 456789", + 'dsgvo_terms_hint' => 'Bitte auch Schreibvarianten angeben (z. B. Nachname allein, Genitiv „Mustermanns"). Ersetzt wird nur ganzwörtlich.', + 'dsgvo_detach_contacts' => 'Hinterlegte Pressekontakt-Daten von dieser Mitteilung entfernen', + 'dsgvo_remove_images' => 'Bilder mit Personenbezug entfernen (unser Team wählt die betroffenen Bilder aus)', + 'dsgvo_note' => 'Es handelt sich um eine Schwärzung/Entfernung in dieser Veröffentlichung. Die Umsetzung erfolgt nach Prüfung durch unser Team; Kopien in Suchmaschinen oder Drittarchiven können zeitweise weiter verfügbar sein.', + 'dsgvo_validation_empty' => 'Bitte mindestens einen Begriff angeben oder eine der Entfernungs-Optionen auswählen.', + 'dsgvo_validation_no_hits' => 'Die angegebenen Begriffe kommen in der Mitteilung nicht vor. Bitte prüfen Sie die Schreibweise (Begriffe werden ganzwörtlich gesucht).', + 'dsgvo_preview_button' => 'Vorschau der Schwärzung anzeigen', + 'dsgvo_preview_title' => 'Vorschau: So sieht die Mitteilung nach der Schwärzung aus', + 'dsgvo_preview_hits' => 'Eine Stelle wird ersetzt.|:hits Stellen werden ersetzt.', + 'dsgvo_preview_field_hits' => 'eine Ersetzung|:hits Ersetzungen', + 'dsgvo_preview_no_text_hits' => 'Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.', + 'dsgvo_preview_contacts' => 'Pressekontakt-Daten werden entfernt.', + 'dsgvo_preview_images' => 'Bilder mit Personenbezug werden nach Prüfung entfernt.', + 'dsgvo_preview_confirm' => 'Verbindlich einreichen', + 'dsgvo_preview_edit_hint' => 'Nicht korrekt? Passen Sie die Begriffe unten an und rufen Sie die Vorschau erneut auf.', + 'dsgvo_field_title' => 'Titel', + 'dsgvo_field_subtitle' => 'Untertitel', + 'dsgvo_field_text' => 'Text', + 'dsgvo_field_boilerplate' => 'Unternehmens-Boilerplate', + 'dsgvo_field_keywords' => 'Keywords', ], 'cancellation' => [ diff --git a/lang/en/static.php b/lang/en/static.php index 556bdcae..91db08b2 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -446,6 +446,28 @@ return [ '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.', + 'dsgvo_terms_label' => 'Details to be removed (one term per line)', + 'dsgvo_terms_placeholder' => "Max Mustermann\nMustermann\nmax@company.example\n+49 123 456789", + 'dsgvo_terms_hint' => 'Please also include spelling variants (e.g. surname alone, genitive forms). Replacement is whole-word only.', + 'dsgvo_detach_contacts' => 'Remove the stored press contact details from this release', + 'dsgvo_remove_images' => 'Remove images containing personal data (our team selects the affected images)', + 'dsgvo_note' => 'This is a redaction/removal within this publication. Implementation follows a review by our team; copies in search engines or third-party archives may remain available for some time.', + 'dsgvo_validation_empty' => 'Please provide at least one term or select one of the removal options.', + 'dsgvo_validation_no_hits' => 'The terms you provided do not appear in the release. Please check the spelling (terms are matched whole-word).', + 'dsgvo_preview_button' => 'Show redaction preview', + 'dsgvo_preview_title' => 'Preview: how the release will look after redaction', + 'dsgvo_preview_hits' => 'One occurrence will be replaced.|:hits occurrences will be replaced.', + 'dsgvo_preview_field_hits' => 'one replacement|:hits replacements', + 'dsgvo_preview_no_text_hits' => 'No text replacements — your request only concerns contact details or images.', + 'dsgvo_preview_contacts' => 'Press contact details will be removed.', + 'dsgvo_preview_images' => 'Images containing personal data will be removed after review.', + 'dsgvo_preview_confirm' => 'Submit bindingly', + 'dsgvo_preview_edit_hint' => 'Not correct? Adjust the terms below and open the preview again.', + 'dsgvo_field_title' => 'Title', + 'dsgvo_field_subtitle' => 'Subtitle', + 'dsgvo_field_text' => 'Text', + 'dsgvo_field_boilerplate' => 'Company boilerplate', + 'dsgvo_field_keywords' => 'Keywords', ], 'cancellation' => [ diff --git a/resources/views/emails/legal/executed.blade.php b/resources/views/emails/legal/executed.blade.php new file mode 100644 index 00000000..194a61aa --- /dev/null +++ b/resources/views/emails/legal/executed.blade.php @@ -0,0 +1,38 @@ + +

Guten Tag{{ $legalRequest->requesterName() ? ' '.$legalRequest->requesterName() : '' }},

+ + @if ($action === 'depublished') +

+ Ihr Anliegen wurde umgesetzt: Die betroffene Pressemitteilung wurde aus der + öffentlichen Ansicht entfernt. Unter der bisherigen Adresse erscheint nur noch + der Hinweis, dass die Mitteilung entfernt wurde. +

+ @else +

+ Ihr Anliegen wurde umgesetzt: Die beantragten personenbezogenen Angaben wurden + in der betroffenen Pressemitteilung geschwärzt bzw. entfernt. Die Mitteilung + bleibt im Übrigen online. +

+ @endif + +
+
Vorgangsnummer
+
{{ $legalRequest->reference() }}
+ @if ($legalRequest->pressRelease) +
Betroffene Pressemitteilung
+
{{ $legalRequest->pressRelease->title }}
+ @endif + @if ($legalRequest->executed_at) +
Umgesetzt am
+
{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr
+ @endif +
+ +
+

+ Bitte beachten Sie: Kopien in Suchmaschinen oder Drittarchiven können + zeitweise weiter verfügbar sein, bis deren Zwischenspeicher aktualisiert + sind. Bei Rückfragen geben Sie bitte die Vorgangsnummer an. +

+
+
diff --git a/resources/views/emails/press-release/change-applied.blade.php b/resources/views/emails/press-release/change-applied.blade.php new file mode 100644 index 00000000..ed09b4a6 --- /dev/null +++ b/resources/views/emails/press-release/change-applied.blade.php @@ -0,0 +1,35 @@ + +

Hallo {{ $user->name }},

+ + @if ($isPaidChange) +

Ihre kostenpflichtige Änderung wurde nach Zahlungseingang übernommen. Die folgende Pressemitteilung ist ab sofort in der geänderten Fassung öffentlich sichtbar:

+ @else +

Ihre Korrektur wurde übernommen. Die folgende Pressemitteilung ist ab sofort in der korrigierten Fassung öffentlich sichtbar:

+ @endif + +

{{ $pressRelease->title }}

+ + Pressemitteilung im Konto ansehen + +
+
Vorgang
+
{{ $legalRequest->reference() }}
+ @if ($changedFieldLabels !== []) +
Geänderte Felder
+
{{ implode(', ', $changedFieldLabels) }}
+ @endif + @if ($legalRequest->executed_at) +
Übernommen am
+
{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr
+ @endif +
+ +
+ @if ($isPaidChange) +

Die Rechnung zu diesem Auftrag finden Sie in Ihrem Konto unter Rechnungen.

+ @endif +

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+
+ + +
diff --git a/resources/views/livewire/admin/legal-requests/show.blade.php b/resources/views/livewire/admin/legal-requests/show.blade.php index b0aace93..2fbebe01 100644 --- a/resources/views/livewire/admin/legal-requests/show.blade.php +++ b/resources/views/livewire/admin/legal-requests/show.blade.php @@ -3,9 +3,11 @@ use App\Enums\LegalRequestStatus; use App\Enums\LegalRequestType; use App\Enums\PressReleaseStatus; +use App\Mail\LegalRequestExecuted; use App\Models\LegalRequest; use App\Services\PressRelease\AnonymizationService; use App\Services\PressRelease\PressReleaseService; +use Illuminate\Support\Facades\Mail; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -38,6 +40,16 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] $this->id = $id; $this->adminNote = (string) ($this->request()->admin_note ?? ''); $this->redactionReplacement = AnonymizationService::DEFAULT_REPLACEMENT; + + // Spur B (Phase 5): strukturierter Schwärzungsauftrag aus dem + // verifizierten Web-Formular befüllt den Editor vor — der Admin + // prüft, ergänzt und führt aus. + $redaction = (array) ($this->request()->payload['redaction'] ?? []); + + if ($redaction !== []) { + $this->redactionTerms = implode("\n", (array) ($redaction['terms'] ?? [])); + $this->detachContacts = (bool) ($redaction['detach_contacts'] ?? true); + } } /** @@ -94,6 +106,8 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] 'executed_at' => now(), ]); + $this->sendExecutedMail($request, 'anonymized'); + $this->redactionPreview = null; $this->notification = __('Anonymisierung ausgeführt — PM geschwärzt, Re-Sync-Sperre gesetzt.'); } @@ -117,8 +131,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] return; } - $service->depublish($pressRelease); - $pressRelease->lockSync('Depublizierung im Meldeverfahren '.$request->reference()); + $service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference()); $request->update([ 'status' => LegalRequestStatus::Resolved->value, @@ -128,9 +141,26 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] 'executed_at' => now(), ]); + $this->sendExecutedMail($request, 'depublished'); + $this->notification = __('PM depubliziert (Tombstone, umkehrbar) — Re-Sync-Sperre gesetzt.'); } + /** + * Umsetzungs-Bestätigung an die meldende Person (Phase 5) — nur wenn + * eine Antragsteller-Adresse vorliegt (interne Vorgänge haben keine). + * + * @param 'anonymized'|'depublished' $action + */ + private function sendExecutedMail(LegalRequest $request, string $action): void + { + if (! $request->requester_email) { + return; + } + + Mail::to($request->requester_email)->queue(new LegalRequestExecuted($request->fresh(), $action)); + } + /** * Rückgängig machen: spielt den verschlüsselten Vorher-Snapshot einer * ausgeführten Korrektur/Anonymisierung zurück (bis zur Löschfrist). @@ -370,6 +400,15 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')] {{ __('Schwärzt personenbezogene Daten über Wortgrenzen (inkl. Varianten wie „Herr Teplitsky", „Teplitskys"). Kostenlos, ohne KI-Gate; Alt-Werte werden verschlüsselt gesichert und die Re-Sync-Sperre gesetzt.') }}

+ @if ((array) ($request->payload['redaction'] ?? []) !== []) +
+ {{ __('Strukturierter Schwärzungsauftrag aus dem Web-Formular — Begriffe und Optionen sind unten vorbefüllt (:hits Treffer laut Antragsteller-Vorschau).', ['hits' => (int) ($request->payload['redaction']['preview_total_hits'] ?? 0)]) }} + @if ($request->payload['redaction']['remove_images_requested'] ?? false) + {{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }} + @endif +
+ @endif + categoryId = $pr->category_id; $this->title = $pr->title; $this->subtitle = $pr->subtitle ?? ''; - $this->text = $pr->text; + $this->text = app(PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text); $this->keywords = $pr->keywords ?? ''; $this->backlinkUrl = $pr->backlink_url ?? ''; $this->boilerplateOverride = $pr->boilerplate_override ?? ''; diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index e2409be4..c6b08a45 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -47,9 +47,33 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends /** @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 */ + /** @var array{fields: array, max_distance_percent: float, has_changes: bool, is_minor: bool, exceeds_paid_limit: bool}|null */ public ?array $correctionPreview = null; + /** + * Spur D (Phase 7): Ergebnis der einmaligen KI-Vorprüfung im + * Änderungs-Modal — rot stoppt vor der Zahlung. + * + * @var array{classification: string, reasons: list}|null + */ + public ?array $paidChangePrecheck = null; + + /** Spur D: Unternehmer-/AGB-Bestätigung im Änderungs-Modal. */ + public bool $changeConfirmed = false; + + /** Spur B (DSGVO-Modal): Schwärzbegriffe, ein Begriff pro Zeile. */ + public string $dsgvoTerms = ''; + + public bool $dsgvoDetachContacts = false; + + public bool $dsgvoRemoveImages = false; + + /** Spur B: Begründung des Anliegens (Pflicht). */ + public string $dsgvoMessage = ''; + + /** @var array{fields: array, total_hits: int}|null */ + public ?array $dsgvoPreview = null; + /** * Kontext fürs „Nicht genügend Credits"-Modal (QA-Befund 07.07.): * ['label' => Add-on-Name, 'required' => int, 'available' => int]. @@ -315,7 +339,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $this->correctionValues = [ 'title' => (string) ($pr->title ?? ''), 'subtitle' => (string) ($pr->subtitle ?? ''), - 'text' => (string) ($pr->text ?? ''), + // Legacy-Plaintext für den HTML-Editor in

/
wandeln — + // sonst kollabieren die Umbrüche und gingen beim Speichern verloren. + 'text' => app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->toEditorHtml($pr->text), 'keywords' => (string) ($pr->keywords ?? ''), ]; $this->correctionOpen = true; @@ -373,17 +399,25 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends return; } + $user = auth()->user(); + + if ($user->email && $user->wantsEmailNotification(\App\Enums\EmailNotificationType::PressReleaseCorrected)) { + \Illuminate\Support\Facades\Mail::to($user->email) + ->queue(new \App\Mail\PressReleaseChangeApplied($user, $pr, $legalRequest)); + } + $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. + * Spur D Schritt 1 (Phase 7): Modal mit der Zwischenerklärung öffnen — + * die kostenpflichtige Änderung läuft über eine einmalige KI-Vorprüfung + * VOR der Zahlung. Je PM ist der Vorgang nur einmal möglich; ein + * abgebrochener Stripe-Checkout darf aber wieder aufgenommen werden. */ - public function submitPaidChangeRequest(): void + public function openPaidChangeModal(): void { $pr = $this->getMyPR(); $this->authorize('view', $pr); @@ -394,14 +428,229 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends return; } - $alreadyOpen = \App\Models\LegalRequest::query() + if ((int) config('billing.change_price_cents') <= 0) { + $this->addError('correction', __('Die kostenpflichtige Änderung ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); + + return; + } + + if ($this->correctionPreview['exceeds_paid_limit'] ?? false) { + $this->addError('correction', __('Die Änderung ist zu umfangreich (über :limit % Distanz) und kann nicht als Änderung gebucht werden.', ['limit' => (int) config('legal.change_max_distance_percent')])); + + return; + } + + $existing = $this->latestChangeRequest($pr); + + if ($existing !== null) { + $check = $existing->payload['ki_check']['classification'] ?? null; + + if ($existing->status === \App\Enums\LegalRequestStatus::Open && in_array($check, ['green', 'yellow'], true)) { + // Abgebrochene Zahlung: direkt wieder zum Zahlungs-Schritt — + // es gilt die bereits geprüfte Fassung aus der Anfrage. + $this->paidChangePrecheck = [ + 'classification' => (string) $check, + 'reasons' => (array) ($existing->payload['ki_check']['reasons'] ?? []), + ]; + Flux::modal('paid-change')->show(); + + return; + } + + $this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.')); + + return; + } + + $this->paidChangePrecheck = null; + $this->changeConfirmed = false; + Flux::modal('paid-change')->show(); + } + + /** + * Spur D Schritt 2: einmalige KI-Vorprüfung der geänderten Fassung. + * Rot → Change-Anfrage wird direkt abgelehnt, keine Zahlung. Gelb/Grün → + * die Anfrage bleibt offen und der Zahlungs-Schritt wird freigeschaltet. + * Der Checkout-Controller verlangt genau diese Anfrage als Nachweis. + */ + public function runPaidChangePrecheck(\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.')); + Flux::modal('paid-change')->close(); + + return; + } + + if ($this->latestChangeRequest($pr) !== null) { + $this->addError('correction', __('Die kostenpflichtige Änderung ist je Pressemitteilung nur einmal möglich — für diese Mitteilung wurde sie bereits genutzt oder abgelehnt.')); + Flux::modal('paid-change')->close(); + + return; + } + + $result = $correctionService->precheckProposedContent($pr, $this->correctionValues); + $isRed = $result->classification === \App\Enums\PressReleaseClassification::Red; + + \App\Models\LegalRequest::query()->create([ + 'press_release_id' => $pr->id, + 'portal' => $pr->portal?->value, + 'type' => \App\Enums\LegalRequestType::Change->value, + 'status' => $isRed + ? \App\Enums\LegalRequestStatus::Rejected->value + : \App\Enums\LegalRequestStatus::Open->value, + 'requester_email' => (string) auth()->user()->email, + 'message' => __('Kostenpflichtige Änderung (Spur D) über das Kunden-Panel eingereicht.'), + 'payload' => [ + 'source' => 'self_service', + 'new_values' => $this->correctionValues, + 'fields' => $this->correctionPreview['fields'], + 'ki_check' => [ + 'classification' => $result->classification->value, + 'reasons' => $result->reasons, + 'provider' => $result->provider, + 'model' => $result->model, + 'checked_at' => now()->toIso8601String(), + ], + ], + 'requester_ip' => request()->ip(), + 'verified_at' => now(), + 'verification_method' => \App\Enums\LegalRequestVerificationMethod::LoggedIn->value, + 'resolved_at' => $isRed ? now() : null, + ]); + + $this->paidChangePrecheck = [ + 'classification' => $result->classification->value, + 'reasons' => $result->reasons, + ]; + } + + /** + * Unternehmer-Pflicht-Checkbox im Änderungs-Modal (Spur D) — + * protokolliert wie bei der Depublikation; der Checkout-Controller + * verlangt die zeitnahe Bestätigung zusätzlich serverseitig. + */ + public function updatedChangeConfirmed(bool $value): void + { + if (! $value) { + return; + } + + $agb = LegalDocument::current(LegalDocument::TYPE_AGB); + + BookingConfirmation::query()->create([ + 'user_id' => auth()->id(), + 'booking_type' => 'press_release_change', + 'booking_ref' => (string) $this->id, + 'legal_document_id' => $agb?->id, + 'agb_version' => $agb?->version ?? (string) config('legal.agb_version'), + 'ip' => request()->ip(), + 'confirmed_at' => now(), + ]); + } + + /** + * Jüngste Änderungs-Anfrage (Spur D) dieser PM — Grundlage der + * „nur einmal möglich"-Regel. + */ + private function latestChangeRequest(PressRelease $pr): ?\App\Models\LegalRequest + { + return \App\Models\LegalRequest::query() ->where('press_release_id', $pr->id) ->where('type', \App\Enums\LegalRequestType::Change->value) + ->latest('id') + ->first(); + } + + /** + * Anliegen-Modal → Korrektur/Änderung: Formular öffnen und zum + * Panel-Abschnitt scrollen. + */ + public function goToCorrection(): void + { + $this->startCorrection(); + Flux::modal('pm-concerns')->close(); + $this->js("document.getElementById('korrektur-panel')?.scrollIntoView({behavior: 'smooth'})"); + } + + /** + * Anliegen-Modal → Depublizierung: zum Panel-Abschnitt scrollen. + */ + public function goToDepublication(): void + { + Flux::modal('pm-concerns')->close(); + $this->js("document.getElementById('depublikation-panel')?.scrollIntoView({behavior: 'smooth'})"); + } + + public function openDsgvoModal(): void + { + Flux::modal('pm-concerns')->close(); + Flux::modal('pm-dsgvo')->show(); + } + + /** + * Spur B (strukturierter Schwärzungsauftrag, wie im Web-Formular): + * Pflicht-Vorschau vor der Einreichung — verändert nichts an der PM. + */ + public function previewDsgvo(\App\Services\PressRelease\AnonymizationService $anonymizationService): void + { + $pr = $this->getMyPR(); + $this->authorize('view', $pr); + + $terms = $this->dsgvoTermList(); + + if ($terms === [] && ! $this->dsgvoDetachContacts && ! $this->dsgvoRemoveImages) { + $this->addError('dsgvo', __('Bitte mindestens einen Begriff angeben oder eine der Entfernungs-Optionen auswählen.')); + $this->dsgvoPreview = null; + + return; + } + + $preview = $anonymizationService->preview($pr, $terms); + + if ($terms !== [] && $preview['total_hits'] === 0) { + $this->addError('dsgvo', __('Die angegebenen Begriffe kommen in der Mitteilung nicht vor. Bitte prüfen Sie die Schreibweise (ganzwörtliche Suche).')); + $this->dsgvoPreview = null; + + return; + } + + $this->resetErrorBag('dsgvo'); + $this->dsgvoPreview = $preview; + } + + /** + * Einreichung ≠ Ausführung: erzeugt die verifizierte DSGVO-Anfrage für + * die Admin-Queue (Spur B bleibt admin-gegated), die PM bleibt unberührt. + */ + public function submitDsgvo(): void + { + $pr = $this->getMyPR(); + $this->authorize('view', $pr); + + if ($this->dsgvoPreview === null) { + $this->addError('dsgvo', __('Bitte zuerst die Vorschau prüfen.')); + + return; + } + + if (mb_strlen(trim($this->dsgvoMessage)) < 10) { + $this->addError('dsgvo', __('Bitte begründen Sie Ihr Anliegen kurz (mindestens 10 Zeichen).')); + + return; + } + + $alreadyOpen = \App\Models\LegalRequest::query() + ->where('press_release_id', $pr->id) + ->where('type', \App\Enums\LegalRequestType::Dsgvo->value) ->open() ->exists(); if ($alreadyOpen) { - $this->addError('correction', __('Für diese Pressemitteilung liegt bereits eine offene Änderungs-Anfrage vor.')); + $this->addError('dsgvo', __('Für diese Pressemitteilung liegt bereits eine offene Datenschutz-Anfrage vor.')); return; } @@ -409,22 +658,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $legalRequest = \App\Models\LegalRequest::query()->create([ 'press_release_id' => $pr->id, 'portal' => $pr->portal?->value, - 'type' => \App\Enums\LegalRequestType::Change->value, + 'type' => \App\Enums\LegalRequestType::Dsgvo->value, 'status' => \App\Enums\LegalRequestStatus::Open->value, 'requester_email' => (string) auth()->user()->email, - 'message' => __('Kostenpflichtige Änderung (10 €) über das Kunden-Panel eingereicht.'), + 'message' => trim($this->dsgvoMessage), 'payload' => [ 'source' => 'self_service', - 'new_values' => $this->correctionValues, + 'concern' => 'dsgvo', + 'redaction' => [ + 'terms' => $this->dsgvoTermList(), + 'detach_contacts' => $this->dsgvoDetachContacts, + 'remove_images_requested' => $this->dsgvoRemoveImages, + 'preview_total_hits' => (int) ($this->dsgvoPreview['total_hits'] ?? 0), + ], ], '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()])); + \Illuminate\Support\Facades\Mail::to($legalRequest->requester_email)->queue( + (new \App\Mail\LegalRequestReceived($legalRequest))->afterCommit() + ); + \Illuminate\Support\Facades\Mail::to((string) config('legal.contact_email'))->queue( + (new \App\Mail\LegalRequestSubmitted($legalRequest))->afterCommit() + ); + + $this->reset('dsgvoTerms', 'dsgvoDetachContacts', 'dsgvoRemoveImages', 'dsgvoMessage', 'dsgvoPreview'); + Flux::modal('pm-dsgvo')->close(); + Flux::toast(__('Datenschutz-Anfrage :reference eingereicht — unser Team prüft und setzt die Schwärzung um.', ['reference' => $legalRequest->reference()])); + } + + /** + * @return list + */ + private function dsgvoTermList(): array + { + return collect(preg_split('/\r\n|\r|\n/', $this->dsgvoTerms) ?: []) + ->map(fn (string $term): string => trim($term)) + ->filter() + ->unique() + ->values() + ->all(); } /** @@ -691,14 +966,14 @@ 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 - + {{-- Meldeverfahren: Anliegen-Auswahl im Modal — Korrektur/Änderung + und Depublizierung springen zu den Panel-Abschnitten, DSGVO + öffnet das strukturierte Schwärzungsformular. --}} + + {{ __('Ändern/Melden') }} - + @endif {{ __('Zurück') }} @@ -1289,7 +1564,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Kleine Korrektur (Levenshtein-Gate) kostenlos + sofort live; größere Änderung wird automatisch als Spur D (10 €) eingestuft. --}} @if ($pr->status === PressReleaseStatus::Published) -
+
{{ __('Änderung & Korrektur') }}
@@ -1301,7 +1576,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends

{{ __('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')]) }} + {{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (Distanz über :percent %) kosten :price € zzgl. USt., werden vor der Zahlung einmalig automatisch geprüft und sind je Mitteilung nur einmal möglich. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent'), 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }} + {{ trans_choice('Pro Mitteilung ist höchstens eine kostenlose Korrektur pro Woche möglich.|Pro Mitteilung sind höchstens :count kostenlose Korrekturen pro Woche möglich.', (int) config('legal.corrections_per_week'), ['count' => (int) config('legal.corrections_per_week')]) }}

@@ -1319,7 +1595,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends - + {{ __('Text') }} + @@ -1336,9 +1616,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends wire:confirm="{{ __('Korrektur jetzt speichern? Die Änderung ist sofort öffentlich sichtbar.') }}"> {{ __('Kostenlos speichern — sofort live') }} - @elseif ($correctionPreview !== null) - - {{ __('Als kostenpflichtige Änderung einreichen (10 €)') }} + @elseif ($correctionPreview !== null && ! ($correctionPreview['exceeds_paid_limit'] ?? false)) + + {{ __('Als kostenpflichtige Änderung einreichen (:price € zzgl. USt.)', ['price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }} @endif @@ -1349,9 +1629,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @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']]) }} + @if ($correctionPreview['is_minor']) + {{ __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.') }} + @elseif ($correctionPreview['exceeds_paid_limit'] ?? false) + {{ __('Einstufung: Zu umfangreich (max. Distanz :percent %, Grenze :limit %) — eine so weitgehende Änderung ist nicht buchbar. Für einen neuen Text nutzen Sie bitte die Depublikation und eine Neuveröffentlichung.', ['percent' => $correctionPreview['max_distance_percent'], 'limit' => (int) config('legal.change_max_distance_percent')]) }} + @else + {{ __('Einstufung: Größere Änderung (max. Distanz :percent %) — kostenpflichtig (:price € zzgl. USt.) nach einmaliger automatischer Prüfung.', ['percent' => $correctionPreview['max_distance_percent'], 'price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }} + @endif

@foreach ($correctionPreview['fields'] as $field => $meta) @continue(! $meta['changed']) @@ -1375,7 +1659,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $depubNet = number_format($depublicationPriceCents / 100, 2, ',', '.'); $depubGross = number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.'); @endphp -
+
{{ __('Depublikation (Service)') }}
@@ -1424,6 +1708,196 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif + {{-- ============== MODAL: ANLIEGEN-AUSWAHL (Ändern/Melden) ============== + Vier Wege: Korrektur & Änderung springen zum Panel-Abschnitt, + Depublizierung ebenso, DSGVO öffnet das Schwärzungsformular. --}} + +
+
+ {{ __('Ändern/Melden') }} + {{ __('Was möchten Sie tun?') }} +
+ +
+ + + + +
+ + + {{ __('Abbrechen') }} + +
+
+ + {{-- ============== MODAL: DSGVO-SCHWÄRZUNG (Spur B, strukturiert) ============== + Wie das Web-Formular (Phase 5): Pflicht-Vorschau, Einreichung ≠ + Ausführung — die Umsetzung bleibt beim Admin. --}} + +
+
+ {{ __('Datenschutz (DSGVO)') }} + {{ __('Persönliche Daten schwärzen lassen') }} +
+ +

+ {{ __('Geben Sie an, welche Angaben entfernt werden sollen. Die Umsetzung erfolgt nach Prüfung durch unser Team; die Mitteilung bleibt im Übrigen online.') }} +

+ + + + + + + + + + + + + @if ($dsgvoPreview !== null) +
+

+ {{ trans_choice('Vorschau: Eine Stelle wird ersetzt.|Vorschau: :hits Stellen werden ersetzt.', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }} +

+ @php + $dsgvoFieldLabels = ['title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords')]; + @endphp + @forelse ($dsgvoPreview['fields'] as $field => $meta) +
+

{{ $dsgvoFieldLabels[$field] ?? $field }} · {{ $meta['hits'] }}×

+

{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 400) }}

+
+ @empty +

{{ __('Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.') }}

+ @endforelse +
+ @endif + +
+ + {{ $dsgvoPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }} + + @if ($dsgvoPreview !== null) + + {{ __('Verbindlich einreichen') }} + + @endif + + {{ __('Abbrechen') }} + +
+
+
+ + {{-- ============== MODAL: KOSTENPFLICHTIGE ÄNDERUNG (Spur D, Phase 7) ============== + Zwischenschritt vor der Zahlung: einmalige KI-Vorprüfung der neuen + Fassung. Rot → Ablehnung ohne Zahlung; Gelb/Grün → Checkbox + Stripe. --}} + + @php + $paidChangeNet = number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.'); + $paidChangeGross = number_format((int) config('billing.change_price_cents') * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.'); + @endphp +
+
+ {{ __('Kostenpflichtige Änderung') }} + {{ __('Prüfung & Zahlung') }} +
+ + @if ($paidChangePrecheck === null) +
+

+ {{ __('Ihre geänderte Fassung wird jetzt einmalig automatisch geprüft — wie beim Freigabelauf einer neuen Pressemitteilung.') }} +

+
    +
  • {{ __('Ergebnis Rot: Die Einreichung wird direkt abgelehnt, es entstehen keine Kosten.') }}
  • +
  • {{ __('Ergebnis Gelb oder Grün: Sie gelangen weiter zur Zahlung (:net € zzgl. USt., :gross € inkl. 19 % USt.). Nach Zahlungseingang geht die Änderung sofort live.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}
  • +
+

+ {{ __('Dieser Vorgang ist je Pressemitteilung nur einmal möglich.') }} +

+
+
+ + {{ __('Prüfung jetzt starten') }} + {{ __('Prüfung läuft …') }} + + + {{ __('Abbrechen') }} + +
+ @elseif ($paidChangePrecheck['classification'] === 'red') +
+

{{ __('Prüfung nicht bestanden — die Einreichung wurde abgelehnt.') }}

+

+ {{ __('Es entstehen keine Kosten. Die geänderte Fassung verstößt gegen unsere Inhalts-Richtlinien und kann nicht veröffentlicht werden.') }} +

+ @if ($paidChangePrecheck['reasons'] !== []) +
    + @foreach ($paidChangePrecheck['reasons'] as $reason) +
  • {{ $reason }}
  • + @endforeach +
+ @endif +
+ + {{ __('Schließen') }} + + @else +
+

{{ __('Prüfung bestanden.') }}

+

+ {{ __('Nach Zahlungseingang (:net € zzgl. USt., :gross € inkl. 19 % USt.) wird die geprüfte Fassung sofort übernommen und ist öffentlich sichtbar.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }} +

+
+ + @if ($changeConfirmed) + + {{ __('Kostenpflichtig ändern') }} + + @else + + {{ __('Kostenpflichtig ändern') }} + + @endif + @endif +
+
+ {{-- ============== MODAL: ADD-ON-KAUF BESTÄTIGEN ============== Vorgeschaltete Bestätigung (Entscheidung 10.07.): kein Add-on-Klick bucht direkt Credits ab — erst „Kostenpflichtig buchen" kauft. --}} diff --git a/resources/views/web/legal-request.blade.php b/resources/views/web/legal-request.blade.php index 51c7a714..3e4bbe4b 100644 --- a/resources/views/web/legal-request.blade.php +++ b/resources/views/web/legal-request.blade.php @@ -5,16 +5,29 @@ @section('content') {{-- - Hinweistexte je Anfragetyp (R2.5, final 10.07.2026): Wortlaut folgt dem am - 06.07. inhaltlich freigegebenen Meldeverfahren v0.1 (/melden bzw. - lang/de/static.php static.report.*) und dem tatsächlichen Flow - (Vorgangsnummer M-{id}, unverzügliche Eingangsbestätigung nach DSA Art. 16 - Abs. 4 sofern E-Mail angegeben, kostenlose manuelle Prüfung). Die noch - ausstehende anwaltliche Freigabe des Rechtstexte-Pakets [EXTERN] umfasst - auch diese Texte. + Zweistufige Triage (Meldeverfahren v2, 23.07.2026): Stufe 1 wählt das + Anliegen, Stufe 2 zeigt den passenden Weg. Absender-Anliegen und + DSGVO/Persönlichkeitsrechte werden primär auf den passwortlosen + Anmeldelink ins Portal (pressekonto /anmeldelink) geleitet — dort werden + die PMs der Kontaktadresse im Konto zusammengeführt und alle + Selbstservice-Funktionen stehen bereit. Das Kontaktformular ist nur der + Fallback und weist auf die notwendige persönliche Identifizierung hin. + Meldungen rechtswidriger Inhalte gehen direkt ins Formular (DSA: jede + dritte Person darf melden). + Hinweistexte folgen dem am 06.07. freigegebenen Meldeverfahren v0.1; + die anwaltliche Freigabe des Rechtstexte-Pakets [EXTERN] umfasst auch + diese Texte. --}} +
{{ $release->title }}

- Datenschutz (DSGVO), Persönlichkeitsrechte und Meldungen werden von unserem - Team manuell geprüft. Bitte wählen Sie die Art Ihrer Anfrage – dazu erscheint - ein Hinweis mit den genauen Regeln. + Bitte wählen Sie zuerst Ihr Anliegen — dazu erscheint der passende Weg. + Viele Anliegen können Sie nach einer kurzen Verifizierung direkt selbst + anstoßen; alle Prüfungen sind kostenlos.

@if (session('legal-status')) @@ -39,63 +52,141 @@
@endif -
- @csrf - -
- - - @error('type') -

{{ $message }}

- @enderror + {{-- ===== Stufe 1: Anliegen wählen ===== --}} +
+ Was möchten Sie tun? +
+ @foreach ([ + 'owner' => ['label' => 'Korrektur, Änderung oder Depublizierung', 'desc' => 'Sie sind Absender oder hinterlegter Pressekontakt und möchten Inhalte korrigieren, ändern oder die Mitteilung offline nehmen.', 'badge' => 'Selbstservice'], + 'dsgvo' => ['label' => 'Entfernung persönlicher Daten (DSGVO)', 'desc' => 'Ihr Name oder Ihre Kontaktdaten erscheinen in dieser Mitteilung und sollen entfernt werden (Art. 17 DSGVO).', 'badge' => 'kostenlos'], + 'personal_rights' => ['label' => 'Verletzung von Persönlichkeitsrechten', 'desc' => 'Unwahre Tatsachenbehauptungen, Bild- oder Namensrechte — die Mitteilung betrifft Sie persönlich.', 'badge' => 'kostenlos'], + 'report' => ['label' => 'Rechtswidrige Inhalte melden', 'desc' => 'Urheberrechts- oder Markenverletzung, irreführende Inhalte, Spam — Meldung als dritte Person, auch ohne eigene Betroffenheit.', 'badge' => 'kostenlos'], + ] as $value => $card) + + @endforeach
+
- {{-- Hinweistext je Anfragetyp (Wortlaut: siehe Kommentar am Seitenanfang). --}} -
- Meldung: - Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten - oder sie Ihre Rechte verletzt — z. B. unwahre Tatsachenbehauptungen, - Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam. - Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum. - Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist - kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich - eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere - Entscheidung mit Begründung. + {{-- ===== Stufe 2a: Absender-Anliegen → passwortloser Portal-Login ===== --}} +
+
+ So funktioniert es: + Fordern Sie im Portal einen passwortlosen Anmeldelink an Ihre hinterlegte + Pressekontakt- oder Firmenadresse an. Nach dem Klick sind Sie angemeldet und + Ihre Pressemitteilungen werden in Ihrem Konto zusammengeführt — dort können + Sie kleine Korrekturen kostenlos und sofort umsetzen, größere Änderungen + (10 €) und die Depublizierung (35 €) direkt beauftragen.
-
+ + Anmeldelink im Portal anfordern + +

+ Keine erreichbare Pressekontaktadresse mehr? Dann nutzen Sie bitte das + Formular unter „Entfernung persönlicher Daten" bzw. die manuelle Prüfung + über das Meldeverfahren — + wir verifizieren Ihre Berechtigung anhand eines Nachweises. +

+
+ + {{-- ===== Stufe 2b: DSGVO ===== --}} +
+
Datenschutz / DSGVO: Als natürliche Person können Sie die Entfernung, Ersetzung oder Anonymisierung Ihrer persönlichen Daten in dieser Pressemitteilung verlangen (Art. 17 DSGVO) — zum Beispiel Name, Telefonnummer oder - E-Mail-Adresse. Bitte geben Sie an, welche Angaben Sie betreffen. Die - Prüfung und Bearbeitung ist für Sie kostenlos. Zur Prüfung Ihrer - Berechtigung benötigen wir Ihre E-Mail-Adresse; in der Regel senden wir - einen Bestätigungslink an die betroffene Adresse, andernfalls können wir - einen geeigneten Identitäts- oder Berechtigungsnachweis verlangen. Sie - erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer. Die + E-Mail-Adresse. Die Prüfung und Bearbeitung ist für Sie kostenlos. Die Pressemitteilung selbst bleibt nach der Entfernung Ihrer Daten grundsätzlich bestehen.
-
+
+ Empfohlener Weg — passwortloser Anmeldelink ins Portal: + Sind Sie die in der Veröffentlichung hinterlegte Kontaktperson (oder haben + Zugriff auf deren Postfach), melden Sie sich per Anmeldelink im Portal an — + Ihre Mitteilungen werden dort in Ihrem Konto zusammengeführt und Sie geben + die zu entfernenden Angaben direkt mit Vorschau an. Der schnellste Weg. +
+ + Anmeldelink im Portal anfordern + +

+ +

+
+ + {{-- ===== Stufe 2c: Persönlichkeitsrechte ===== --}} +
+
Persönlichkeitsrecht: Wenn diese Mitteilung Ihre Persönlichkeitsrechte verletzt — z. B. durch unwahre Tatsachenbehauptungen oder die Verletzung von Bild- oder - Namensrechten —, schildern Sie bitte konkret, welche Inhalte betroffen - sind und warum. Für die Eingangsbestätigung mit Vorgangsnummer und - Rückfragen benötigen wir Ihre E-Mail-Adresse. Wir prüfen Ihre Anfrage - sorgfältig und zeitnah und teilen Ihnen unsere Entscheidung mit - Begründung mit; bei berechtigten Beanstandungen wird der Inhalt - angepasst, gesperrt oder entfernt. Die Prüfung ist kostenlos. + Namensrechten —, prüfen wir Ihre Anfrage sorgfältig und zeitnah und teilen + Ihnen unsere Entscheidung mit Begründung mit; bei berechtigten + Beanstandungen wird der Inhalt angepasst, gesperrt oder entfernt. Die + Prüfung ist kostenlos. +
+
+ Schnellster Weg — passwortloser Anmeldelink ins Portal: + Sind Sie zugleich die hinterlegte Kontaktperson dieser Veröffentlichung, + melden Sie sich per Anmeldelink im Portal an und stoßen die Anpassung + direkt in Ihrem Konto an. +
+ + Anmeldelink im Portal anfordern + +

+ +

+
+ + {{-- ===== Stufe 2d: Meldung → direkt zum Formular ===== --}} +
+ Meldung: + Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten + oder sie Ihre Rechte verletzt — z. B. unwahre Tatsachenbehauptungen, + Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam. + Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum. + Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist + kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich + eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere + Entscheidung mit Begründung. +
+ + {{-- ===== Formular: direkt (Meldung) bzw. Fallback (DSGVO/Persönlichkeitsrecht) ===== --}} + + @csrf + + + {{-- Identifizierungs-Hinweis nur im Fallback-Weg (DSGVO/Persönlichkeitsrecht). --}} +
+ Wichtig — persönliche Identifizierung erforderlich: + Da personenbezogene Daten bzw. Persönlichkeitsrechte betroffen sind, müssen + wir Ihre Berechtigung prüfen. In der Regel senden wir einen Bestätigungslink + an die betroffene Adresse; andernfalls können wir einen geeigneten + Identitäts- oder Berechtigungsnachweis verlangen. Sie erhalten unverzüglich + eine Eingangsbestätigung mit Vorgangsnummer.
@@ -128,8 +219,6 @@ verwirft der Controller die Anfrage still. Die Namen ('website', 'homepage') sind bewusst „attraktiv" für Auto-Filler. Zusätzlich greift serverseitig ein Rate-Limit pro IP/PM. - Mögliche Erweiterung später: zeitbasierte Falle (signierter Render- - Zeitstempel, Ablehnung bei Absenden < 2s) oder Captcha bei Missbrauch. --}}