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('# ]*>|(p|div|h[1-6]|li|blockquote|tr|td|th)>#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). --}}
-
+
@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?') }}
+
+
+
+
+ {{ __('Kleine Korrektur') }} · {{ __('kostenlos') }}
+ {{ __('Tippfehler, Kontaktdaten — ohne neuen Inhalt, sofort live.') }}
+
+
+ {{ __('Inhaltliche Änderung') }} · {{ number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}
+ {{ __('Neuer oder umgeschriebener Text — mit einmaliger automatischer Prüfung vor der Zahlung.') }}
+
+
+ {{ __('Entfernung persönlicher Daten (DSGVO)') }} · {{ __('kostenlos') }}
+ {{ __('Namen und Kontaktdaten natürlicher Personen schwärzen lassen — Prüfung durch unser Team.') }}
+
+
+ {{ __('Depublizierung') }} · {{ number_format((int) config('billing.depublication_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}
+ {{ __('Mitteilung vollständig aus der öffentlichen Ansicht nehmen (Tombstone bleibt).') }}
+
+
+
+
+ {{ __('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.
--}}
+
@endif
-