recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal

Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal —
rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout
(SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt
der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre,
Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent
(50 %) verhindert die verdeckte Selbst-Depublizierung.

Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau
im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor
wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted
an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D,
neuer abbestellbarer Typ press_release_corrected).

Außerdem:
- Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert
  (Lücke im bezahlten Webhook-Pfad geschlossen)
- Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der
  Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis;
  Store auf report/dsgvo/personal_rights gehärtet
- Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt
  des Web-Formulars
- Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung):
  Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt →
  Firmendaten
- Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als
  Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel,
  Wochenlimit-Hinweis in Text und Bremsen-Meldung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-23 13:02:57 +00:00
parent b4b2e013d6
commit 9afbedddeb
40 changed files with 2507 additions and 169 deletions

View file

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

View file

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

View file

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

View file

@ -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' => [

View file

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

View file

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

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\LegalRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Umsetzungs-Bestätigung an die meldende Person (Meldeverfahren v2,
* Phase 5): Die beantragte Schwärzung wurde ausgeführt bzw. die PM wurde
* depubliziert. Transaktionsmail (nicht abbestellbar) — der Empfänger ist
* i. d. R. ein Pressekontakt ohne Benutzerkonto.
*/
class LegalRequestExecuted extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* @param 'anonymized'|'depublished' $action
*/
public function __construct(
public readonly LegalRequest $legalRequest,
public readonly string $action,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Umsetzungsbestätigung: Ihre Meldung '.$this->legalRequest->reference(),
);
}
public function content(): Content
{
return new Content(view: 'emails.legal.executed');
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Mail;
use App\Enums\EmailNotificationType;
use App\Enums\LegalRequestType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Bestätigung an den Auftraggeber, dass eine Korrektur (Spur A, kostenlos)
* oder eine bezahlte Änderung (Spur D) an seiner veröffentlichten PM
* übernommen wurde und sofort öffentlich sichtbar ist.
*/
class PressReleaseChangeApplied extends Mailable
{
use Queueable, SerializesModels, Unsubscribable;
public string $showUrl;
public bool $isPaidChange;
/** @var list<string> 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<string>
*/
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();
}
}

View file

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

View file

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

View file

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

View file

@ -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<string, string|null> $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<string, string|null> $newValues
* @return array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool}
* @return array{fields: array<string, array{distance_percent: float, changed: bool}>, 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<string, string|null> $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<string, array{distance_percent: float, changed: bool}> $classifiedFields
* @param array<string, string|null> $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 (&nbsp; → Leerzeichen,
* &amp; → &, <br> → 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 <br> als Leerzeichen erhalten, damit strip_tags
// angrenzende Wörter nicht zu einem verklebt.
$spaced = preg_replace('#<br[^>]*>|</(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);
}

View file

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

View file

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