recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

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 04b49c567d
commit e6eca9c85f
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));

View file

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

View file

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

View file

@ -0,0 +1,43 @@
<?php
use App\Enums\SinglePurchaseType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Gleicht das type-ENUM von single_purchases auf den aktuellen Case-Stand
* ab (neu: press_release_change, Meldeverfahren v2 Spur D). Gleiches
* Muster wie die Depublication-Erweiterung vom 07.07.: Bestands-MySQL
* kennt den neuen Wert sonst nicht ("Data truncated for column 'type'");
* frische Datenbanken erhalten die volle Liste über die create-Migration.
*/
return new class extends Migration
{
public function up(): void
{
if (DB::connection()->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)."'";
}
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
<x-emails.layout title="Umsetzungsbestätigung zu Ihrer Meldung" preheader="Ihr Anliegen wurde umgesetzt.">
<p>Guten Tag{{ $legalRequest->requesterName() ? ' '.$legalRequest->requesterName() : '' }},</p>
@if ($action === 'depublished')
<p>
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.
</p>
@else
<p>
Ihr Anliegen wurde umgesetzt: Die beantragten personenbezogenen Angaben wurden
in der betroffenen Pressemitteilung geschwärzt bzw. entfernt. Die Mitteilung
bleibt im Übrigen online.
</p>
@endif
<dl class="meta">
<dt>Vorgangsnummer</dt>
<dd>{{ $legalRequest->reference() }}</dd>
@if ($legalRequest->pressRelease)
<dt>Betroffene Pressemitteilung</dt>
<dd>{{ $legalRequest->pressRelease->title }}</dd>
@endif
@if ($legalRequest->executed_at)
<dt>Umgesetzt am</dt>
<dd>{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr</dd>
@endif
</dl>
<div class="note">
<p>
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.
</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,35 @@
<x-emails.layout :title="$isPaidChange ? 'Ihre Änderung ist live' : 'Ihre Korrektur ist live'" preheader="Die beauftragte Anpassung wurde übernommen und ist öffentlich sichtbar.">
<p>Hallo {{ $user->name }},</p>
@if ($isPaidChange)
<p>Ihre kostenpflichtige Änderung wurde nach Zahlungseingang übernommen. Die folgende Pressemitteilung ist ab sofort in der geänderten Fassung öffentlich sichtbar:</p>
@else
<p>Ihre Korrektur wurde übernommen. Die folgende Pressemitteilung ist ab sofort in der korrigierten Fassung öffentlich sichtbar:</p>
@endif
<p class="title">{{ $pressRelease->title }}</p>
<x-emails.button :url="$showUrl">Pressemitteilung im Konto ansehen</x-emails.button>
<dl class="meta">
<dt>Vorgang</dt>
<dd>{{ $legalRequest->reference() }}</dd>
@if ($changedFieldLabels !== [])
<dt>Geänderte Felder</dt>
<dd>{{ implode(', ', $changedFieldLabels) }}</dd>
@endif
@if ($legalRequest->executed_at)
<dt>Übernommen am</dt>
<dd>{{ $legalRequest->executed_at->format('d.m.Y H:i') }} Uhr</dd>
@endif
</dl>
<div class="note">
@if ($isPaidChange)
<p>Die Rechnung zu diesem Auftrag finden Sie in Ihrem Konto unter Rechnungen.</p>
@endif
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -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.') }}
</p>
@if ((array) ($request->payload['redaction'] ?? []) !== [])
<div class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-ink)]">
{{ __('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)
<span class="font-semibold">{{ __('Antragsteller bittet zusätzlich um Entfernung von Bildern mit Personenbezug — unten auswählen.') }}</span>
@endif
</div>
@endif
<flux:field>
<flux:textarea wire:model="redactionTerms" rows="4"
:label="__('Schwärzbegriffe (ein Begriff pro Zeile, Varianten einzeln)')"

View file

@ -100,7 +100,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->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 ?? '';

View file

@ -47,9 +47,33 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
/** @var array<string, string> Bearbeitbare Feld-Werte der Korrektur. */
public array $correctionValues = [];
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, max_distance_percent: float, has_changes: bool, is_minor: bool}|null */
/** @var array{fields: array<string, array{distance_percent: float, changed: bool}>, 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<string>}|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<string, array{hits: int, after: string}>, 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 <p>/<br> 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<string>
*/
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') }}
</flux:button>
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
{{-- Meldeverfahren (Phase 3): Eigentümer überspringt die
Magic-Link-Verifikation (Zuordnung = Verifikation). --}}
<form method="POST" action="{{ route('me.press-releases.report', ['pressReleaseId' => $pr->id]) }}">
@csrf
<flux:button type="submit" variant="filled" icon="flag">
{{-- Meldeverfahren: Anliegen-Auswahl im Modal — Korrektur/Änderung
und Depublizierung springen zu den Panel-Abschnitten, DSGVO
öffnet das strukturierte Schwärzungsformular. --}}
<flux:modal.trigger name="pm-concerns">
<flux:button variant="filled" icon="flag">
{{ __('Ändern/Melden') }}
</flux:button>
</form>
</flux:modal.trigger>
@endif
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('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)
<article class="panel">
<article class="panel" id="korrektur-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Änderung & Korrektur') }}</span>
</div>
@ -1301,7 +1576,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Veröffentlichte Mitteilung korrigieren oder ändern') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Kleine Korrekturen (Tippfehler, Kontaktdaten — ohne neuen Inhalt) sind kostenlos und sofort live. Größere Änderungen (neuer Text, max. Distanz über :percent %) kosten 10 € und durchlaufen erneut die Freigabe. Die Einstufung übernimmt das System anhand der Vorschau.', ['percent' => (int) config('legal.correction_max_distance_percent')]) }}
{{ __('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')]) }}
</p>
</div>
</div>
@ -1319,7 +1595,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:input wire:model="correctionValues.subtitle" :label="__('Untertitel')" />
</flux:field>
<flux:field>
<flux:textarea wire:model="correctionValues.text" rows="10" :label="__('Text')" />
<flux:label>{{ __('Text') }}</flux:label>
<flux:editor
wire:model="correctionValues.text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
/>
</flux:field>
<flux:field>
<flux:input wire:model="correctionValues.keywords" :label="__('Keywords')" />
@ -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') }}
</flux:button>
@elseif ($correctionPreview !== null)
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="submitPaidChangeRequest">
{{ __('Als kostenpflichtige Änderung einreichen (10 €)') }}
@elseif ($correctionPreview !== null && ! ($correctionPreview['exceeds_paid_limit'] ?? false))
<flux:button size="sm" variant="primary" icon="credit-card" wire:click="openPaidChangeModal">
{{ __('Als kostenpflichtige Änderung einreichen (:price € zzgl. USt.)', ['price' => number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.')]) }}
</flux:button>
@endif
<flux:button size="sm" variant="ghost" wire:click="$set('correctionOpen', false)">
@ -1349,9 +1629,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($correctionPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-1">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
{{ $correctionPreview['is_minor']
? __('Einstufung: Kleine Korrektur — kostenlos, wird sofort übernommen.')
: __('Einstufung: Größere Änderung (max. Distanz :percent %) — 10 € mit erneutem Freigabelauf.', ['percent' => $correctionPreview['max_distance_percent']]) }}
@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
</p>
@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
<article class="panel">
<article class="panel" id="depublikation-panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Depublikation (Service)') }}</span>
</div>
@ -1424,6 +1708,196 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== MODAL: ANLIEGEN-AUSWAHL (Ändern/Melden) ==============
Vier Wege: Korrektur & Änderung springen zum Panel-Abschnitt,
Depublizierung ebenso, DSGVO öffnet das Schwärzungsformular. --}}
<flux:modal name="pm-concerns" class="w-full max-w-md">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Ändern/Melden') }}</flux:text>
<flux:heading size="lg">{{ __('Was möchten Sie tun?') }}</flux:heading>
</div>
<div class="space-y-2">
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Kleine Korrektur') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Tippfehler, Kontaktdaten — ohne neuen Inhalt, sofort live.') }}</span>
</button>
<button type="button" wire:click="goToCorrection"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Inhaltliche Änderung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.change_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Neuer oder umgeschriebener Text — mit einmaliger automatischer Prüfung vor der Zahlung.') }}</span>
</button>
<button type="button" wire:click="openDsgvoModal"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Entfernung persönlicher Daten (DSGVO)') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ __('kostenlos') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Namen und Kontaktdaten natürlicher Personen schwärzen lassen — Prüfung durch unser Team.') }}</span>
</button>
<button type="button" wire:click="goToDepublication"
class="w-full text-left rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3 hover:bg-[color:var(--color-bg-elev)] transition-colors">
<span class="block text-[13px] font-bold text-[color:var(--color-ink)]">{{ __('Depublizierung') }} <span class="font-normal text-[color:var(--color-ink-3)]">· {{ number_format((int) config('billing.depublication_price_cents') / 100, 2, ',', '.') }} € {{ __('zzgl. USt.') }}</span></span>
<span class="block text-[12.5px] text-[color:var(--color-ink-2)] mt-0.5">{{ __('Mitteilung vollständig aus der öffentlichen Ansicht nehmen (Tombstone bleibt).') }}</span>
</button>
</div>
<flux:modal.close>
<flux:button variant="ghost" size="sm">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</flux:modal>
{{-- ============== MODAL: DSGVO-SCHWÄRZUNG (Spur B, strukturiert) ==============
Wie das Web-Formular (Phase 5): Pflicht-Vorschau, Einreichung ≠
Ausführung — die Umsetzung bleibt beim Admin. --}}
<flux:modal name="pm-dsgvo" class="w-full max-w-lg">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Datenschutz (DSGVO)') }}</flux:text>
<flux:heading size="lg">{{ __('Persönliche Daten schwärzen lassen') }}</flux:heading>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Geben Sie an, welche Angaben entfernt werden sollen. Die Umsetzung erfolgt nach Prüfung durch unser Team; die Mitteilung bleibt im Übrigen online.') }}
</p>
<flux:field>
<flux:textarea wire:model="dsgvoTerms" rows="4"
:label="__('Zu entfernende Angaben (ein Begriff pro Zeile, Varianten einzeln)')"
:placeholder="'Max Mustermann&#10;Mustermanns&#10;max@firma.example'" />
</flux:field>
<flux:checkbox wire:model="dsgvoDetachContacts"
:label="__('Hinterlegte Pressekontakt-Daten von dieser Mitteilung entfernen')" />
<flux:checkbox wire:model="dsgvoRemoveImages"
:label="__('Bilder mit Personenbezug entfernen (Auswahl trifft unser Team)')" />
<flux:field>
<flux:textarea wire:model="dsgvoMessage" rows="3"
:label="__('Begründung (Pflicht)')"
:placeholder="__('z. B. Ich bin die betroffene Person und wünsche die Entfernung meines Namens.')" />
</flux:field>
<flux:error name="dsgvo" />
@if ($dsgvoPreview !== null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2.5 text-[12.5px] space-y-2 max-h-64 overflow-y-auto">
<p class="font-semibold text-[color:var(--color-ink)] m-0">
{{ trans_choice('Vorschau: Eine Stelle wird ersetzt.|Vorschau: :hits Stellen werden ersetzt.', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}
</p>
@php
$dsgvoFieldLabels = ['title' => __('Titel'), 'subtitle' => __('Untertitel'), 'text' => __('Text'), 'boilerplate_override' => __('Boilerplate'), 'keywords' => __('Keywords')];
@endphp
@forelse ($dsgvoPreview['fields'] as $field => $meta)
<div>
<p class="m-0 text-[12px] font-semibold text-[color:var(--color-ink)]">{{ $dsgvoFieldLabels[$field] ?? $field }} · {{ $meta['hits'] }}×</p>
<p class="m-0 text-[color:var(--color-ink-2)] whitespace-pre-line">{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 400) }}</p>
</div>
@empty
<p class="m-0 text-[color:var(--color-ink-2)]">{{ __('Keine Text-Ersetzungen — Ihre Anfrage betrifft nur Kontaktdaten bzw. Bilder.') }}</p>
@endforelse
</div>
@endif
<div class="flex flex-wrap gap-2">
<flux:button size="sm" variant="filled" icon="eye" wire:click="previewDsgvo">
{{ $dsgvoPreview === null ? __('Vorschau prüfen (Pflicht)') : __('Vorschau aktualisieren') }}
</flux:button>
@if ($dsgvoPreview !== null)
<flux:button size="sm" variant="primary" icon="check" wire:click="submitDsgvo">
{{ __('Verbindlich einreichen') }}
</flux:button>
@endif
<flux:modal.close>
<flux:button size="sm" variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
{{-- ============== 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. --}}
<flux:modal name="paid-change" class="w-full max-w-md">
@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
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('Kostenpflichtige Änderung') }}</flux:text>
<flux:heading size="lg">{{ __('Prüfung & Zahlung') }}</flux:heading>
</div>
@if ($paidChangePrecheck === null)
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)] space-y-2">
<p class="m-0">
{{ __('Ihre geänderte Fassung wird jetzt einmalig automatisch geprüft — wie beim Freigabelauf einer neuen Pressemitteilung.') }}
</p>
<ul class="m-0 pl-4 list-disc space-y-1">
<li>{{ __('Ergebnis Rot: Die Einreichung wird direkt abgelehnt, es entstehen keine Kosten.') }}</li>
<li>{{ __('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]) }}</li>
</ul>
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
{{ __('Dieser Vorgang ist je Pressemitteilung nur einmal möglich.') }}
</p>
</div>
<div class="flex gap-2">
<flux:button variant="primary" icon="shield-check" wire:click="runPaidChangePrecheck">
<span wire:loading.remove wire:target="runPaidChangePrecheck">{{ __('Prüfung jetzt starten') }}</span>
<span wire:loading wire:target="runPaidChangePrecheck">{{ __('Prüfung läuft …') }}</span>
</flux:button>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
</div>
@elseif ($paidChangePrecheck['classification'] === 'red')
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-err)] bg-[color:var(--color-err-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung nicht bestanden — die Einreichung wurde abgelehnt.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Es entstehen keine Kosten. Die geänderte Fassung verstößt gegen unsere Inhalts-Richtlinien und kann nicht veröffentlicht werden.') }}
</p>
@if ($paidChangePrecheck['reasons'] !== [])
<ul class="m-0 mt-2 pl-4 list-disc space-y-1 text-[color:var(--color-ink-2)]">
@foreach ($paidChangePrecheck['reasons'] as $reason)
<li>{{ $reason }}</li>
@endforeach
</ul>
@endif
</div>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
@else
<div class="rounded-[6px] border-l-[3px] border border-[color:var(--color-ok)] bg-[color:var(--color-ok-soft)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink)]">
<p class="m-0 font-bold">{{ __('Prüfung bestanden.') }}</p>
<p class="m-0 mt-1 text-[color:var(--color-ink-2)]">
{{ __('Nach Zahlungseingang (:net € zzgl. USt., :gross € inkl. 19 % USt.) wird die geprüfte Fassung sofort übernommen und ist öffentlich sichtbar.', ['net' => $paidChangeNet, 'gross' => $paidChangeGross]) }}
</p>
</div>
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="changeConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a>
</p>
</div>
@if ($changeConfirmed)
<flux:button variant="primary" icon="credit-card" href="{{ route('me.checkout.change', $pr->id) }}">
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@else
<flux:button variant="primary" icon="credit-card" disabled>
{{ __('Kostenpflichtig ändern') }}
</flux:button>
@endif
@endif
</div>
</flux:modal>
{{-- ============== MODAL: ADD-ON-KAUF BESTÄTIGEN ==============
Vorgeschaltete Bestätigung (Entscheidung 10.07.): kein Add-on-Klick
bucht direkt Credits ab — erst „Kostenpflichtig buchen" kauft. --}}

View file

@ -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.
--}}
<style>[x-cloak]{display:none!important}</style>
<section
x-data="{ type: @js(old('type', $type->value)), emailRequired() { return ['dsgvo', 'personal_rights'].includes(this.type); } }"
x-data="{
concern: @js(old('type', $initialConcern)),
showFallback: @js(in_array(old('type'), ['dsgvo', 'personal_rights'], true)),
pick(value) { this.concern = value; this.showFallback = false; },
needsFallbackToggle() { return ['dsgvo', 'personal_rights'].includes(this.concern); },
formVisible() { return this.concern === 'report' || (this.needsFallbackToggle() && this.showFallback); },
emailRequired() { return ['dsgvo', 'personal_rights'].includes(this.concern); }
}"
class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-10"
>
<a href="{{ route('release.detail', ['slug' => $release->slug]) }}"
@ -28,9 +41,9 @@
Betrifft: <span class="text-ink-2 font-medium">{{ $release->title }}</span>
</p>
<p class="text-[12.5px] text-ink-3 leading-relaxed mb-6">
Datenschutz (DSGVO), Persönlichkeitsrechte und Meldungen werden von unserem
Team manuell geprüft. Bitte wählen Sie die Art Ihrer Anfrage – dazu erscheint
ein Hinweis mit den genauen Regeln.
Bitte wählen Sie zuerst Ihr Anliegen — dazu erscheint der passende Weg.
Viele Anliegen können Sie nach einer kurzen Verifizierung direkt selbst
anstoßen; alle Prüfungen sind kostenlos.
</p>
@if (session('legal-status'))
@ -39,63 +52,141 @@
</div>
@endif
<form method="POST" action="{{ route('legal-request.store', ['slug' => $release->slug]) }}" class="space-y-5">
@csrf
<div>
<label for="type" class="block text-[12px] font-semibold text-ink mb-1.5">Art der Anfrage</label>
<select id="type" name="type" x-model="type"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">
@foreach ($types as $case)
<option value="{{ $case->value }}" @selected(old('type', $type->value) === $case->value)>
{{ $case->publicLabel() }}
</option>
@endforeach
</select>
@error('type')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
{{-- ===== Stufe 1: Anliegen wählen ===== --}}
<fieldset class="mb-6">
<legend class="block text-[12px] font-semibold text-ink mb-2">Was möchten Sie tun?</legend>
<div class="space-y-2">
@foreach ([
'owner' => ['label' => 'Korrektur, Änderung oder Depublizierung', 'desc' => 'Sie sind Absender oder hinterlegter Pressekontakt und möchten Inhalte korrigieren, ändern oder die Mitteilung offline nehmen.', 'badge' => 'Selbstservice'],
'dsgvo' => ['label' => 'Entfernung persönlicher Daten (DSGVO)', 'desc' => 'Ihr Name oder Ihre Kontaktdaten erscheinen in dieser Mitteilung und sollen entfernt werden (Art. 17 DSGVO).', 'badge' => 'kostenlos'],
'personal_rights' => ['label' => 'Verletzung von Persönlichkeitsrechten', 'desc' => 'Unwahre Tatsachenbehauptungen, Bild- oder Namensrechte — die Mitteilung betrifft Sie persönlich.', 'badge' => 'kostenlos'],
'report' => ['label' => 'Rechtswidrige Inhalte melden', 'desc' => 'Urheberrechts- oder Markenverletzung, irreführende Inhalte, Spam — Meldung als dritte Person, auch ohne eigene Betroffenheit.', 'badge' => 'kostenlos'],
] as $value => $card)
<label class="flex items-start gap-3 border border-bg-rule px-3 py-2.5 cursor-pointer hover:bg-bg-elev"
:class="concern === '{{ $value }}' ? 'border-brand bg-bg-elev' : ''">
<input type="radio" name="concern_choice" value="{{ $value }}" class="mt-0.5"
@change="pick('{{ $value }}')"
:checked="concern === '{{ $value }}'" />
<span class="text-[13px] text-ink">
<span class="font-semibold">{{ $card['label'] }}</span>
<span class="text-ink-3">· {{ $card['badge'] }}</span>
<span class="block text-[12.5px] text-ink-2">{{ $card['desc'] }}</span>
</span>
</label>
@endforeach
</div>
</fieldset>
{{-- Hinweistext je Anfragetyp (Wortlaut: siehe Kommentar am Seitenanfang). --}}
<div x-cloak x-show="type === 'report'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Meldung:</strong>
Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten
oder sie Ihre Rechte verletzt — z.&nbsp;B. unwahre Tatsachenbehauptungen,
Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam.
Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum.
Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist
kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere
Entscheidung mit Begründung.
{{-- ===== Stufe 2a: Absender-Anliegen → passwortloser Portal-Login ===== --}}
<div x-cloak x-show="concern === 'owner'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">So funktioniert es:</strong>
Fordern Sie im Portal einen passwortlosen Anmeldelink an Ihre hinterlegte
Pressekontakt- oder Firmenadresse an. Nach dem Klick sind Sie angemeldet und
Ihre Pressemitteilungen werden in Ihrem Konto zusammengeführt — dort können
Sie kleine Korrekturen kostenlos und sofort umsetzen, größere Änderungen
(10&nbsp;€) und die Depublizierung (35&nbsp;€) direkt beauftragen.
</div>
<div x-cloak x-show="type === 'dsgvo'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p class="text-[12px] text-ink-3 leading-relaxed">
Keine erreichbare Pressekontaktadresse mehr? Dann nutzen Sie bitte das
Formular unter „Entfernung persönlicher Daten" bzw. die manuelle Prüfung
über das <a href="{{ route('melden') }}" class="underline hover:text-ink">Meldeverfahren</a> —
wir verifizieren Ihre Berechtigung anhand eines Nachweises.
</p>
</div>
{{-- ===== Stufe 2b: DSGVO ===== --}}
<div x-cloak x-show="concern === 'dsgvo'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Datenschutz / DSGVO:</strong>
Als natürliche Person können Sie die Entfernung, Ersetzung oder
Anonymisierung Ihrer persönlichen Daten in dieser Pressemitteilung
verlangen (Art. 17 DSGVO) — zum Beispiel Name, Telefonnummer oder
E-Mail-Adresse. Bitte geben Sie an, welche Angaben Sie betreffen. Die
Prüfung und Bearbeitung ist für Sie kostenlos. Zur Prüfung Ihrer
Berechtigung benötigen wir Ihre E-Mail-Adresse; in der Regel senden wir
einen Bestätigungslink an die betroffene Adresse, andernfalls können wir
einen geeigneten Identitäts- oder Berechtigungsnachweis verlangen. Sie
erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer. Die
E-Mail-Adresse. Die Prüfung und Bearbeitung ist für Sie kostenlos. Die
Pressemitteilung selbst bleibt nach der Entfernung Ihrer Daten
grundsätzlich bestehen.
</div>
<div x-cloak x-show="type === 'personal_rights'"
class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<div class="px-4 py-3 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Empfohlener Weg — passwortloser Anmeldelink ins Portal:</strong>
Sind Sie die in der Veröffentlichung hinterlegte Kontaktperson (oder haben
Zugriff auf deren Postfach), melden Sie sich per Anmeldelink im Portal an —
Ihre Mitteilungen werden dort in Ihrem Konto zusammengeführt und Sie geben
die zu entfernenden Angaben direkt mit Vorschau an. Der schnellste Weg.
</div>
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p>
<button type="button" @click="showFallback = true" x-show="!showFallback"
class="text-[12.5px] text-ink-2 underline underline-offset-2 hover:text-ink">
Ich bin nicht der hinterlegte Pressekontakt oder habe keinen Zugriff auf das Postfach
</button>
</p>
</div>
{{-- ===== Stufe 2c: Persönlichkeitsrechte ===== --}}
<div x-cloak x-show="concern === 'personal_rights'" class="space-y-4">
<div class="px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Persönlichkeitsrecht:</strong>
Wenn diese Mitteilung Ihre Persönlichkeitsrechte verletzt — z.&nbsp;B.
durch unwahre Tatsachenbehauptungen oder die Verletzung von Bild- oder
Namensrechten —, schildern Sie bitte konkret, welche Inhalte betroffen
sind und warum. Für die Eingangsbestätigung mit Vorgangsnummer und
Rückfragen benötigen wir Ihre E-Mail-Adresse. Wir prüfen Ihre Anfrage
sorgfältig und zeitnah und teilen Ihnen unsere Entscheidung mit
Begründung mit; bei berechtigten Beanstandungen wird der Inhalt
angepasst, gesperrt oder entfernt. Die Prüfung ist kostenlos.
Namensrechten —, prüfen wir Ihre Anfrage sorgfältig und zeitnah und teilen
Ihnen unsere Entscheidung mit Begründung mit; bei berechtigten
Beanstandungen wird der Inhalt angepasst, gesperrt oder entfernt. Die
Prüfung ist kostenlos.
</div>
<div class="px-4 py-3 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Schnellster Weg — passwortloser Anmeldelink ins Portal:</strong>
Sind Sie zugleich die hinterlegte Kontaktperson dieser Veröffentlichung,
melden Sie sich per Anmeldelink im Portal an und stoßen die Anpassung
direkt in Ihrem Konto an.
</div>
<a href="{{ $portalMagicLinkUrl }}"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
Anmeldelink im Portal anfordern
</a>
<p>
<button type="button" @click="showFallback = true" x-show="!showFallback"
class="text-[12.5px] text-ink-2 underline underline-offset-2 hover:text-ink">
Ich bin nicht der hinterlegte Pressekontakt — Anfrage per Formular stellen
</button>
</p>
</div>
{{-- ===== Stufe 2d: Meldung → direkt zum Formular ===== --}}
<div x-cloak x-show="concern === 'report'"
class="mb-4 px-4 py-3 border border-bg-rule bg-bg-elev text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Meldung:</strong>
Melden Sie diese Pressemitteilung, wenn Sie sie für rechtswidrig halten
oder sie Ihre Rechte verletzt — z.&nbsp;B. unwahre Tatsachenbehauptungen,
Urheberrechts- oder Markenverletzungen, irreführende Inhalte oder Spam.
Bitte benennen Sie konkret, welche Inhalte betroffen sind und warum.
Jede Meldung wird von unserem Team manuell geprüft; die Meldung ist
kostenlos. Wenn Sie eine E-Mail-Adresse angeben, erhalten Sie unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere
Entscheidung mit Begründung.
</div>
{{-- ===== Formular: direkt (Meldung) bzw. Fallback (DSGVO/Persönlichkeitsrecht) ===== --}}
<form x-cloak x-show="formVisible()" method="POST"
action="{{ route('legal-request.store', ['slug' => $release->slug]) }}" class="space-y-5 mt-2">
@csrf
<input type="hidden" name="type" :value="concern" />
{{-- Identifizierungs-Hinweis nur im Fallback-Weg (DSGVO/Persönlichkeitsrecht). --}}
<div x-cloak x-show="needsFallbackToggle()"
class="px-4 py-3 border-l-2 border border-bg-rule text-[12.5px] text-ink-2 leading-relaxed">
<strong class="text-ink">Wichtig — persönliche Identifizierung erforderlich:</strong>
Da personenbezogene Daten bzw. Persönlichkeitsrechte betroffen sind, müssen
wir Ihre Berechtigung prüfen. In der Regel senden wir einen Bestätigungslink
an die betroffene Adresse; andernfalls können wir einen geeigneten
Identitäts- oder Berechtigungsnachweis verlangen. Sie erhalten unverzüglich
eine Eingangsbestätigung mit Vorgangsnummer.
</div>
<div>
@ -128,8 +219,6 @@
verwirft der Controller die Anfrage still. Die Namen ('website',
'homepage') sind bewusst „attraktiv" für Auto-Filler. Zusätzlich greift
serverseitig ein Rate-Limit pro IP/PM.
Mögliche Erweiterung später: zeitbasierte Falle (signierter Render-
Zeitstempel, Ablehnung bei Absenden < 2s) oder Captcha bei Missbrauch.
--}}
<div class="hidden" aria-hidden="true">
<label for="website">Website</label>

View file

@ -4,6 +4,7 @@
@section('meta_description', __('static.pm_report.meta'))
@section('content')
<style>[x-cloak]{display:none!important}</style>
<x-web.static-page
:eyebrow="__('static.legal.eyebrow')"
:title="__('static.pm_report.concerns_title')"
@ -21,7 +22,73 @@
</div>
@endif
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5">
@if (($dsgvoPreview ?? null) !== null)
{{-- Pflicht-Diff (Spur B): Vorschau der Schwärzung bestätigen, bevor
die Anfrage entsteht. Ausführung bleibt beim Admin. --}}
@php
$fieldLabels = [
'title' => __('static.pm_report.dsgvo_field_title'),
'subtitle' => __('static.pm_report.dsgvo_field_subtitle'),
'text' => __('static.pm_report.dsgvo_field_text'),
'boilerplate_override' => __('static.pm_report.dsgvo_field_boilerplate'),
'keywords' => __('static.pm_report.dsgvo_field_keywords'),
];
@endphp
<div class="mb-6 border border-bg-rule">
<div class="px-4 py-3 bg-bg-elev border-b border-bg-rule">
<p class="text-[13px] font-semibold text-ink">{{ __('static.pm_report.dsgvo_preview_title') }}</p>
<p class="text-[12.5px] text-ink-2 mt-0.5">
{{ trans_choice('static.pm_report.dsgvo_preview_hits', (int) $dsgvoPreview['total_hits'], ['hits' => (int) $dsgvoPreview['total_hits']]) }}
</p>
</div>
<div class="px-4 py-3 space-y-4 text-[13px]">
@forelse ($dsgvoPreview['fields'] as $field => $meta)
<div>
<p class="text-[12px] font-semibold text-ink mb-1">
{{ $fieldLabels[$field] ?? $field }}
<span class="font-normal text-ink-3">· {{ trans_choice('static.pm_report.dsgvo_preview_field_hits', (int) $meta['hits'], ['hits' => (int) $meta['hits']]) }}</span>
</p>
<p class="text-ink-2 leading-relaxed whitespace-pre-line border-l-2 border-bg-rule pl-3">{{ \Illuminate\Support\Str::limit(trim(strip_tags((string) $meta['after'])), 600) }}</p>
</div>
@empty
<p class="text-ink-2">{{ __('static.pm_report.dsgvo_preview_no_text_hits') }}</p>
@endforelse
@if ($dsgvoInput['detach_contacts'])
<p class="text-ink-2">✓ {{ __('static.pm_report.dsgvo_preview_contacts') }}</p>
@endif
@if ($dsgvoInput['remove_images'])
<p class="text-ink-2">✓ {{ __('static.pm_report.dsgvo_preview_images') }}</p>
@endif
</div>
<div class="px-4 py-3 border-t border-bg-rule bg-bg-elev">
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="flex flex-wrap items-center gap-3">
@csrf
<input type="hidden" name="concern" value="dsgvo" />
<input type="hidden" name="confirmed" value="1" />
<input type="hidden" name="redaction_terms" value="{{ $dsgvoInput['redaction_terms'] }}" />
<input type="hidden" name="detach_contacts" value="{{ $dsgvoInput['detach_contacts'] ? 1 : 0 }}" />
<input type="hidden" name="remove_images" value="{{ $dsgvoInput['remove_images'] ? 1 : 0 }}" />
<input type="hidden" name="message" value="{{ $dsgvoInput['message'] }}" />
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
{{ __('static.pm_report.dsgvo_preview_confirm') }}
</button>
<span class="text-[12.5px] text-ink-3">{{ __('static.pm_report.dsgvo_preview_edit_hint') }}</span>
</form>
</div>
</div>
@endif
@php
$initialConcern = old('concern', isset($dsgvoInput) ? 'dsgvo' : '');
$prefilledTerms = old('redaction_terms', $dsgvoInput['redaction_terms'] ?? '');
$prefilledMessage = old('message', $dsgvoInput['message'] ?? '');
$prefilledDetach = old('detach_contacts', ($dsgvoInput['detach_contacts'] ?? false) ? '1' : null);
$prefilledRemoveImages = old('remove_images', ($dsgvoInput['remove_images'] ?? false) ? '1' : null);
@endphp
<form method="POST" action="{{ route('melden.pm.anliegen.store') }}" class="space-y-5"
x-data="{ concern: '{{ $initialConcern }}' }">
@csrf
<fieldset>
@ -35,7 +102,8 @@
] as $value => $track)
<label class="flex items-start gap-3 border border-bg-rule px-3 py-2.5 cursor-pointer hover:bg-bg-elev">
<input type="radio" name="concern" value="{{ $value }}" required class="mt-0.5"
@checked(old('concern') === $value) />
x-model="concern"
@checked($initialConcern === $value) />
<span class="text-[13px] text-ink">
<span class="font-semibold">{{ $track['label'] }}</span>
<span class="text-ink-3">· {{ $track['cost'] }}</span>
@ -49,11 +117,35 @@
@enderror
</fieldset>
{{-- Spur B (Phase 5): strukturierter Schwärzungsauftrag statt nur Freitext.
Wording bewusst „Schwärzung/Entfernung", nicht „Anonymisierung". --}}
<div x-cloak x-show="concern === 'dsgvo'" class="space-y-4 border border-bg-rule bg-bg-elev px-4 py-4">
<div>
<label for="redaction_terms" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.pm_report.dsgvo_terms_label') }}</label>
<textarea id="redaction_terms" name="redaction_terms" rows="4" maxlength="2000"
placeholder="{{ __('static.pm_report.dsgvo_terms_placeholder') }}"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ $prefilledTerms }}</textarea>
<p class="mt-1 text-[12px] text-ink-3">{{ __('static.pm_report.dsgvo_terms_hint') }}</p>
@error('redaction_terms')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<label class="flex items-start gap-2.5 text-[13px] text-ink cursor-pointer">
<input type="checkbox" name="detach_contacts" value="1" class="mt-0.5" @checked($prefilledDetach) />
<span>{{ __('static.pm_report.dsgvo_detach_contacts') }}</span>
</label>
<label class="flex items-start gap-2.5 text-[13px] text-ink cursor-pointer">
<input type="checkbox" name="remove_images" value="1" class="mt-0.5" @checked($prefilledRemoveImages) />
<span>{{ __('static.pm_report.dsgvo_remove_images') }}</span>
</label>
<p class="text-[12px] text-ink-3 leading-relaxed">{{ __('static.pm_report.dsgvo_note') }}</p>
</div>
<div>
<label for="message" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.pm_report.label_message') }}</label>
<textarea id="message" name="message" rows="6" required minlength="10" maxlength="5000"
placeholder="{{ __('static.pm_report.placeholder_message') }}"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ old('message') }}</textarea>
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand">{{ $prefilledMessage }}</textarea>
@error('message')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
@ -65,7 +157,8 @@
<button type="submit"
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
{{ __('static.pm_report.concern_submit') }}
<span x-show="concern === 'dsgvo'" x-cloak>{{ __('static.pm_report.dsgvo_preview_button') }}</span>
<span x-show="concern !== 'dsgvo'">{{ __('static.pm_report.concern_submit') }}</span>
</button>
</form>
</div>

View file

@ -65,7 +65,7 @@
<div>
<label for="reported_url" class="block text-[12px] font-semibold text-ink mb-1.5">{{ __('static.report_form.label_url') }}</label>
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url') }}" required
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url', request('reported_url')) }}" required
placeholder="https://…"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
@error('reported_url')

View file

@ -70,6 +70,10 @@
$contact = $release->contacts->first();
$contactName = $contact ? trim(implode(' ', array_filter([$contact->title, $contact->first_name, $contact->last_name]))) : null;
// Legacy-Bestände ohne Kontakt-Datensatz: der Block erscheint trotzdem,
// solange die Firma erreichbare Angaben hat (wie im Altsystem).
$contactEmail = $contact?->email ?: $company?->email;
$hasContactBlock = $contact || filled($contactEmail) || filled($company?->address) || filled($company?->website);
// Attributions-Zeile wie im Legacy-System: Urheber (Firma der PM) und
// Übermittler (Profilfeld agency_name des einliefernden Accounts, z. B.
@ -440,8 +444,8 @@
<x-web.ad-slot variant="inline" />
@endunless
{{-- Pressekontakt --}}
@if ($contact)
{{-- Pressekontakt (Fallback ohne Kontakt-Datensatz: Firmendaten) --}}
@if ($hasContactBlock)
<div class="mt-8 px-5 sm:px-7 py-6 bg-white border border-bg-rule-strong max-w-[680px]">
<div class="eyebrow mb-3 text-[10.5px]">{{ __('web.detail.press_contact') }}</div>
@ -456,9 +460,9 @@
</span>
@endif
</div>
@if ($contact->responsibility || $companyName)
@if ($contact?->responsibility || $companyName)
<div class="text-[12.5px] text-ink-3 mb-3.5 leading-[1.5]">
@if ($contact->responsibility){{ $contact->responsibility }}<br />@endif
@if ($contact?->responsibility){{ $contact->responsibility }}<br />@endif
{{ $companyName }}
</div>
@endif
@ -470,16 +474,16 @@
<span class="text-ink-2">{{ $company->address }}</span>
</span>
@endif
@if ($contact->phone)
@if ($contact?->phone)
<span class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" class="text-ink-3 flex-shrink-0" aria-hidden="true"><path d="M2 3l3-1 1.5 3-1.5 1c.5 2 2 3.5 4 4l1-1.5L13 10l-1 3c-5 0-10-5-10-10z" fill="currentColor" /></svg>
<a href="tel:{{ preg_replace('/[^0-9+]/', '', $contact->phone) }}" class="text-ink-2 hover:text-ink transition-colors">{{ $contact->phone }}</a>
</span>
@endif
@if ($contact->email)
@if ($contactEmail)
<span class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" class="text-ink-3 flex-shrink-0" aria-hidden="true"><path d="M1 3h12v8H1zM1 3l6 4 6-4" stroke="currentColor" stroke-width="1.2" fill="none" /></svg>
<a href="mailto:{{ $contact->email }}" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ $contact->email }}</a>
<a href="mailto:{{ $contactEmail }}" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ $contactEmail }}</a>
</span>
@endif
@if ($company?->website)

View file

@ -59,6 +59,9 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
Route::get('checkout/depublikation/{pressReleaseId}', [CheckoutController::class, 'depublication'])
->where('pressReleaseId', '[0-9]+')
->name('checkout.depublication');
Route::get('checkout/aenderung/{pressReleaseId}', [CheckoutController::class, 'change'])
->where('pressReleaseId', '[0-9]+')
->name('checkout.change');
Route::get('checkout/abo-verwalten', [CheckoutController::class, 'billingPortal'])
->name('checkout.billing-portal');
Volt::route('invoices', 'customer.invoices')->name('invoices.index');

View file

@ -151,6 +151,26 @@ test('admin edit hydratisiert subtitle, boilerplate_override und Kontakt', funct
->assertSet('contactId', $contact->id);
});
test('admin edit wandelt Legacy-Plaintext für den Editor in Absätze', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();
$company = Company::factory()->presseecho()->create();
$pr = PressRelease::factory()->create([
'company_id' => $company->id,
'text' => "Erster Absatz der Meldung.\r\n\r\nZweiter Absatz nach Leerzeile.\r\nDritte Zeile direkt darunter.",
]);
$this->actingAs($admin);
$component = LivewireVolt::test('admin.press-releases.edit', ['id' => $pr->id]);
$editorText = $component->get('text');
expect($editorText)->toContain('<p>Erster Absatz der Meldung.</p>')
->and($editorText)->toContain('<p>Zweiter Absatz nach Leerzeile.<br>')
->and($editorText)->toContain('Dritte Zeile direkt darunter.');
});
test('admin edit speichert subtitle, boilerplate_override und Kontakt', function () {
/** @var TestCase $this */
$admin = makeFieldsAdmin();

View file

@ -0,0 +1,195 @@
<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Enums\Portal;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Listeners\ProcessStripeWebhook;
use App\Mail\PressReleaseChangeApplied;
use App\Models\BillingAddress;
use App\Models\BookingConfirmation;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Events\WebhookReceived;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.change_price_cents', 1000);
});
function changeCustomer(): User
{
$user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
$user->assignRole('customer');
BillingAddress::factory()->create(['user_id' => $user->id]);
// B2B-Gate: zeitnahe Unternehmer-Bestätigung (wie im Änderungs-Modal).
BookingConfirmation::query()->create([
'user_id' => $user->id,
'booking_type' => 'press_release_change',
'agb_version' => (string) config('legal.agb_version'),
'confirmed_at' => now(),
]);
return $user;
}
function publishedChangeReleaseFor(User $user): PressRelease
{
return PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create([
'user_id' => $user->id,
'published_at' => now()->subDay(),
'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb',
]);
}
function approvedChangeRequestFor(PressRelease $release, User $user): LegalRequest
{
return LegalRequest::query()->create([
'press_release_id' => $release->id,
'portal' => $release->portal?->value,
'type' => LegalRequestType::Change->value,
'status' => LegalRequestStatus::Open->value,
'requester_email' => (string) $user->email,
'message' => 'Kostenpflichtige Änderung (Spur D) über das Kunden-Panel eingereicht.',
'payload' => [
'source' => 'self_service',
'new_values' => ['title' => 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb'],
'ki_check' => ['classification' => 'green', 'reasons' => []],
],
'verified_at' => now(),
'verification_method' => LegalRequestVerificationMethod::LoggedIn->value,
]);
}
test('the change checkout refuses without an approved precheck request', function () {
/** @var TestCase $this */
$user = changeCustomer();
$release = publishedChangeReleaseFor($user);
$this->actingAs($user)
->get(route('me.checkout.change', $release->id))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
expect(SinglePurchase::count())->toBe(0);
});
test('a red-rejected precheck request does not unlock the checkout', function () {
/** @var TestCase $this */
$user = changeCustomer();
$release = publishedChangeReleaseFor($user);
$request = approvedChangeRequestFor($release, $user);
$request->forceFill([
'status' => LegalRequestStatus::Rejected->value,
'payload' => array_merge($request->payload, ['ki_check' => ['classification' => 'red', 'reasons' => ['Unzulässiges Wort.']]]),
])->save();
$this->actingAs($user)
->get(route('me.checkout.change', $release->id))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
expect(SinglePurchase::count())->toBe(0);
});
test('the change checkout creates a pending purchase, links the request and reuses pendings', function () {
/** @var TestCase $this */
$user = changeCustomer();
$release = publishedChangeReleaseFor($user);
$legalRequest = approvedChangeRequestFor($release, $user);
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test'));
$this->mock(StripeCheckoutService::class, function ($mock) use ($checkout) {
// Zweimal: der zweite Aufruf verwendet den offenen Kauf wieder.
$mock->shouldReceive('forChange')->twice()->andReturn($checkout);
});
$this->actingAs($user)
->get(route('me.checkout.change', $release->id))
->assertRedirect('https://checkout.stripe.com/c/pay/test');
$purchase = SinglePurchase::sole();
expect($purchase->type)->toBe(SinglePurchaseType::Change);
expect($purchase->status)->toBe(SinglePurchaseStatus::Pending);
expect($purchase->price_cents)->toBe(1000);
expect($purchase->press_release_id)->toBe($release->id);
expect($legalRequest->fresh()->single_purchase_id)->toBe($purchase->id);
// Abgebrochener Checkout blockiert nicht: erneuter Aufruf nutzt den
// Pending-Kauf wieder statt einen zweiten anzulegen.
$this->actingAs($user)
->get(route('me.checkout.change', $release->id))
->assertRedirect('https://checkout.stripe.com/c/pay/test');
expect(SinglePurchase::count())->toBe(1);
});
test('the paid webhook applies the change immediately with snapshot, lock and consumed purchase', function () {
/** @var TestCase $this */
Mail::fake();
Queue::fake();
$user = changeCustomer();
$release = publishedChangeReleaseFor($user);
$legalRequest = approvedChangeRequestFor($release, $user);
$purchase = SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Change->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => 1000,
'currency' => 'EUR',
'press_release_id' => $release->id,
]);
$legalRequest->forceFill(['single_purchase_id' => $purchase->id])->save();
$event = new WebhookReceived([
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_test_change',
'payment_intent' => 'pi_test_change',
'payment_status' => 'paid',
'metadata' => ['single_purchase_id' => (string) $purchase->id],
]],
]);
app(ProcessStripeWebhook::class)->handle($event);
app(ProcessStripeWebhook::class)->handle($event); // Replay bleibt folgenlos.
$release->refresh();
$legalRequest->refresh();
expect($release->title)->toBe('Solaranlage in Muenchen eroeffnet und in Vollbetrieb')
->and($release->isSyncLocked())->toBeTrue()
->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved)
->and($legalRequest->executed_at)->not->toBeNull()
->and($legalRequest->redactionSnapshots()->count())->toBe(1)
->and($legalRequest->redactionSnapshots()->first()->encrypted_before['fields']['title'])->toBe('Solaranlage in Hamburg eroeffnet und in Betrieb');
expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);
expect($purchase->fresh()->consumed_at)->not->toBeNull();
// Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus).
Mail::assertQueued(PressReleaseChangeApplied::class, function (PressReleaseChangeApplied $mail) use ($user, $release) {
return $mail->hasTo($user->email) && $mail->pressRelease->is($release) && $mail->isPaidChange;
});
Mail::assertQueuedCount(1);
});

View file

@ -224,6 +224,9 @@ test('the paid webhook depublishes the release and the public page shows a tombs
$release->refresh();
expect($release->status)->toBe(PressReleaseStatus::Archived);
expect($release->depublished_at)->not->toBeNull();
// Re-Sync-Sperre (Meldeverfahren §7): ohne sie würde der nächste
// Importer-Lauf die bezahlte Entfernung überschreiben.
expect($release->isSyncLocked())->toBeTrue();
// Eingelöst = Status Consumed — nur so erscheint der Kauf unter
// „Eingelöste Käufe" auf der Buchungs-Seite.
expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);

View file

@ -3,11 +3,13 @@
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestExecuted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\AnonymizationService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;
use Tests\TestCase;
@ -76,6 +78,60 @@ test('the panel prefills the replacement and shows the full content for copying
expect($pressRelease->fresh()->title)->toBe('Matvey Teplitsky im Vorstand');
});
test('the editor is prefilled from a structured web redaction order', function () {
/** @var TestCase $this */
$this->actingAs(adminUserForLegalQueue());
$pressRelease = PressRelease::factory()->published()->create();
$request = LegalRequest::factory()
->type(LegalRequestType::Dsgvo)
->create([
'press_release_id' => $pressRelease->id,
'payload' => [
'source' => 'self_service',
'concern' => 'dsgvo',
'redaction' => [
'terms' => ['Max Mustermann', 'Mustermanns'],
'detach_contacts' => false,
'remove_images_requested' => true,
'preview_total_hits' => 3,
],
],
]);
Volt::test('admin.legal-requests.show', ['id' => $request->id])
->assertSet('redactionTerms', "Max Mustermann\nMustermanns")
->assertSet('detachContacts', false)
->assertSee('Strukturierter Schwärzungsauftrag')
->assertSee('Entfernung von Bildern mit Personenbezug');
});
test('executing sends an execution confirmation to the requester', function () {
/** @var TestCase $this */
Mail::fake();
$this->actingAs(adminUserForLegalQueue());
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Matvey Teplitsky im Vorstand',
]);
$request = LegalRequest::factory()
->type(LegalRequestType::Dsgvo)
->create([
'press_release_id' => $pressRelease->id,
'requester_email' => 'presse@firma.example',
]);
Volt::test('admin.legal-requests.show', ['id' => $request->id])
->set('redactionTerms', 'Matvey Teplitsky')
->call('previewAnonymization')
->call('executeAnonymization')
->assertHasNoErrors();
Mail::assertQueued(LegalRequestExecuted::class, function (LegalRequestExecuted $mail) {
return $mail->hasTo('presse@firma.example') && $mail->action === 'anonymized';
});
});
test('execute without a prior preview is refused', function () {
/** @var TestCase $this */
$this->actingAs(adminUserForLegalQueue());

View file

@ -4,9 +4,12 @@ use App\Enums\ActivityType;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Enums\PressReleaseClassification;
use App\Models\ActivityEvent;
use App\Models\CreditTransaction;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistService;
use App\Services\PressRelease\CorrectionRateLimitException;
use App\Services\PressRelease\CorrectionService;
use App\Services\PressRelease\CorrectionTooLargeException;
@ -54,6 +57,37 @@ test('markup-only changes count as no change', function () {
expect($classification['has_changes'])->toBeFalse();
});
test('editor normalization of entities and line breaks counts as no change', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'text' => '<p>Preis &amp; Leistung&nbsp;stimmen.<br />Zeile zwei mit &quot;Zitat&quot;.</p>',
]);
// So liefert der Rich-Text-Editor denselben Inhalt zurück: Entities
// dekodiert, <br> als eigener Absatz.
$classification = app(CorrectionService::class)->classify($pressRelease, [
'text' => "<p>Preis & Leistung\u{00A0}stimmen.</p><p>Zeile zwei mit \"Zitat\".</p>",
]);
expect($classification['has_changes'])->toBeFalse();
});
test('one added letter in an entity-laden text stays near zero distance', function () {
/** @var TestCase $this */
$words = implode(' ', array_fill(0, 60, 'Wort&nbsp;und &amp; mehr'));
$pressRelease = PressRelease::factory()->published()->create([
'text' => '<p>'.$words.' Betrieb genomen.</p>',
]);
$decoded = implode(' ', array_fill(0, 60, "Wort\u{00A0}und & mehr"));
$classification = app(CorrectionService::class)->classify($pressRelease, [
'text' => '<p>'.$decoded.' Betrieb genommen.</p>',
]);
expect($classification['is_minor'])->toBeTrue()
->and($classification['fields']['text']['distance_percent'])->toBeLessThan(1.0);
});
test('apply saves a minor correction immediately with snapshot, lock and log', function () {
/** @var TestCase $this */
Queue::fake();
@ -110,6 +144,84 @@ test('the weekly correction brake blocks the third correction', function () {
$service->apply($pressRelease->refresh(), ['title' => 'Titel A4'], 'k@f.example', LegalRequestVerificationMethod::LoggedIn);
})->throws(CorrectionRateLimitException::class);
test('classify flags changes above the paid distance limit', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Kurzer Titel der Mitteilung',
]);
$classification = app(CorrectionService::class)->classify($pressRelease, [
'title' => 'Ein voellig anderer, deutlich laengerer und inhaltlich komplett neuer Titel',
]);
expect($classification['exceeds_paid_limit'])->toBeTrue()
->and($classification['is_minor'])->toBeFalse();
});
test('applyPaid executes a paid change with snapshot, lock and resolved request', function () {
/** @var TestCase $this */
Queue::fake();
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb',
]);
$legalRequest = LegalRequest::factory()
->type(LegalRequestType::Change)
->create([
'press_release_id' => $pressRelease->id,
'payload' => ['new_values' => ['title' => 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb']],
]);
$applied = app(CorrectionService::class)->applyPaid($pressRelease, $legalRequest);
$pressRelease->refresh();
$legalRequest->refresh();
expect($applied)->toBeTrue()
->and($pressRelease->title)->toBe('Solaranlage in Muenchen eroeffnet und in Vollbetrieb')
->and($pressRelease->isSyncLocked())->toBeTrue()
->and($legalRequest->status)->toBe(LegalRequestStatus::Resolved)
->and($legalRequest->executed_at)->not->toBeNull()
->and($legalRequest->redactionSnapshots()->count())->toBe(1);
});
test('applyPaid refuses a change above the paid distance limit', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Kurzer Titel der Mitteilung',
]);
$legalRequest = LegalRequest::factory()
->type(LegalRequestType::Change)
->create([
'press_release_id' => $pressRelease->id,
'payload' => ['new_values' => ['title' => 'Ein voellig anderer, deutlich laengerer und inhaltlich komplett neuer Titel']],
]);
app(CorrectionService::class)->applyPaid($pressRelease, $legalRequest);
})->throws(CorrectionTooLargeException::class);
test('the precheck classifies the proposed content instead of the stored one', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', ['skandalwort']);
app()->forgetInstance(BlacklistService::class);
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Harmloser Titel der Mitteilung',
]);
$service = app(CorrectionService::class);
$red = $service->precheckProposedContent($pressRelease, ['title' => 'Titel mit Skandalwort darin']);
$green = $service->precheckProposedContent($pressRelease, ['title' => 'Harmlose neue Fassung des Titels']);
expect($red->classification)->toBe(PressReleaseClassification::Red)
->and($green->classification)->toBe(PressReleaseClassification::Green)
->and($pressRelease->fresh()->title)->toBe('Harmloser Titel der Mitteilung');
});
test('restoreSnapshot brings back the previous field values and keeps the lock', function () {
/** @var TestCase $this */
Queue::fake();

View file

@ -2,10 +2,13 @@
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Mail\PressReleaseChangeApplied;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\BlacklistService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt;
use Tests\TestCase;
@ -26,6 +29,7 @@ function correctionPanelOwner(): User
test('an owner can apply a minor correction from the panel and it goes live instantly', function () {
/** @var TestCase $this */
Queue::fake();
Mail::fake();
$owner = correctionPanelOwner();
$this->actingAs($owner);
@ -48,9 +52,90 @@ test('an owner can apply a minor correction from the panel and it goes live inst
expect($pressRelease->title)->toBe('Betrieb genommen')
->and($pressRelease->isSyncLocked())->toBeTrue()
->and(LegalRequest::query()->where('type', LegalRequestType::Correction->value)->count())->toBe(1);
// Bestätigungsmail an den Auftraggeber (abbestellbar über die Präferenzen).
Mail::assertQueued(PressReleaseChangeApplied::class, function (PressReleaseChangeApplied $mail) use ($owner) {
return $mail->hasTo($owner->email) && ! $mail->isPaidChange;
});
});
test('a major change from the panel becomes an open paid change request instead', function () {
test('a major change passes the green precheck and unlocks the payment step', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', []);
app()->forgetInstance(BlacklistService::class);
$owner = correctionPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb',
]);
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->call('startCorrection')
->set('correctionValues.title', 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb')
->call('previewCorrection')
->assertHasNoErrors()
->call('openPaidChangeModal')
->assertHasNoErrors()
->call('runPaidChangePrecheck')
->assertHasNoErrors()
->assertSet('paidChangePrecheck.classification', 'green')
->assertSee('Prüfung bestanden');
$request = LegalRequest::query()->where('type', LegalRequestType::Change->value)->sole();
expect($request->status)->toBe(LegalRequestStatus::Open)
->and($request->payload['ki_check']['classification'])->toBe('green')
->and($request->payload['new_values']['title'])->toContain('Muenchen')
->and($request->executed_at)->toBeNull()
->and($pressRelease->fresh()->title)->toBe('Solaranlage in Hamburg eroeffnet und in Betrieb');
});
test('a red precheck rejects the change before payment and the process is one-time', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', ['skandalwort']);
app()->forgetInstance(BlacklistService::class);
$owner = correctionPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Solaranlage in Hamburg eroeffnet und in Betrieb',
]);
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->call('startCorrection')
->set('correctionValues.title', 'Solaranlage in Hamburg eroeffnet mit Skandalwort dabei')
->call('previewCorrection')
->call('openPaidChangeModal')
->assertHasNoErrors()
->call('runPaidChangePrecheck')
->assertSet('paidChangePrecheck.classification', 'red')
->assertSee('Prüfung nicht bestanden');
$request = LegalRequest::query()->where('type', LegalRequestType::Change->value)->sole();
expect($request->status)->toBe(LegalRequestStatus::Rejected)
->and($request->payload['ki_check']['classification'])->toBe('red')
->and($pressRelease->fresh()->title)->toBe('Solaranlage in Hamburg eroeffnet und in Betrieb');
// Nur einmal möglich: nach der Ablehnung gibt es keinen zweiten Anlauf.
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->call('startCorrection')
->set('correctionValues.title', 'Solaranlage in Muenchen eroeffnet und in Vollbetrieb')
->call('previewCorrection')
->call('openPaidChangeModal')
->assertHasErrors('correction');
expect(LegalRequest::query()->where('type', LegalRequestType::Change->value)->count())->toBe(1);
});
test('a change above the paid distance limit cannot be booked at all', function () {
/** @var TestCase $this */
$owner = correctionPanelOwner();
$this->actingAs($owner);
@ -62,17 +147,68 @@ test('a major change from the panel becomes an open paid change request instead'
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->call('startCorrection')
->set('correctionValues.title', 'Ein komplett anderer, deutlich längerer und inhaltlich neuer Titel')
->set('correctionValues.title', 'Ein voellig anderer, deutlich laengerer und inhaltlich komplett neuer Titel')
->call('previewCorrection')
->assertHasNoErrors()
->call('submitPaidChangeRequest')
->assertSee('Zu umfangreich')
->call('openPaidChangeModal')
->assertHasErrors('correction');
expect(LegalRequest::query()->count())->toBe(0);
});
test('a legacy plain-text release is prefilled as paragraphs and counts as unchanged', function () {
/** @var TestCase $this */
$owner = correctionPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'text' => "Erster Absatz.\r\n\r\nZweiter Absatz mit Inhalt.",
]);
$component = Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->call('startCorrection');
expect($component->get('correctionValues')['text'])->toContain('<p>Erster Absatz.</p>');
// Unverändert abschicken: die Plaintext→HTML-Wandlung darf nicht als
// inhaltliche Änderung zählen.
$component->call('previewCorrection')
->assertHasErrors('correction');
expect($pressRelease->fresh()->text)->toContain("Erster Absatz.\r\n");
});
test('the panel names the weekly limit and the brake message states it too', function () {
/** @var TestCase $this */
Queue::fake();
config()->set('legal.corrections_per_week', 1);
$owner = correctionPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Titel B1',
]);
$component = Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->assertSee('höchstens eine kostenlose Korrektur pro Woche')
->call('startCorrection')
->set('correctionValues.title', 'Titel B2')
->call('previewCorrection')
->call('applyCorrection')
->assertHasNoErrors();
$request = LegalRequest::query()->where('type', LegalRequestType::Change->value)->sole();
$component
->call('startCorrection')
->set('correctionValues.title', 'Titel B3')
->call('previewCorrection')
->call('applyCorrection')
->assertHasErrors('correction')
->assertSee('das Limit ist bereits erreicht');
expect($request->status)->toBe(LegalRequestStatus::Open)
->and($request->payload['new_values']['title'])->toContain('komplett anderer')
->and($pressRelease->fresh()->title)->toBe('Kurzer Titel der Mitteilung');
expect($pressRelease->fresh()->title)->toBe('Titel B2');
});
test('apply without preview is refused', function () {

View file

@ -0,0 +1,137 @@
<?php
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;
use Tests\TestCase;
beforeEach(function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
});
function dsgvoPanelOwner(): User
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
return $owner;
}
test('the concerns modal offers all four tracks', function () {
/** @var TestCase $this */
$owner = dsgvoPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create(['user_id' => $owner->id]);
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->assertSee('Was möchten Sie tun?')
->assertSee('Kleine Korrektur')
->assertSee('Inhaltliche Änderung')
->assertSee('Entfernung persönlicher Daten (DSGVO)')
->assertSee('Depublizierung');
});
test('the dsgvo preview validates input and never touches the release', function () {
/** @var TestCase $this */
$owner = dsgvoPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Max Mustermann übernimmt die Leitung',
]);
$component = Volt::test('customer.press-releases.show', ['id' => $pressRelease->id]);
// Leeres Formular → Fehler.
$component->call('previewDsgvo')->assertHasErrors('dsgvo');
// Begriffe ohne Treffer → Fehler.
$component->set('dsgvoTerms', 'Kommtnichtvor')
->call('previewDsgvo')
->assertHasErrors('dsgvo');
// Treffer → Vorschau, PM unverändert.
$component->set('dsgvoTerms', 'Max Mustermann')
->call('previewDsgvo')
->assertHasNoErrors()
->assertSee('[entfernt] übernimmt die Leitung');
expect($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung')
->and(LegalRequest::query()->count())->toBe(0);
});
test('the confirmed dsgvo submission creates a verified open request for the admin queue', function () {
/** @var TestCase $this */
Mail::fake();
$owner = dsgvoPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Max Mustermann übernimmt die Leitung',
]);
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->set('dsgvoTerms', "Max Mustermann\nMustermanns")
->set('dsgvoDetachContacts', true)
->set('dsgvoMessage', 'Ich bin die betroffene Person und wünsche die Entfernung.')
->call('submitDsgvo')
->assertHasErrors('dsgvo') // ohne Vorschau keine Einreichung
->call('previewDsgvo')
->assertHasNoErrors()
->call('submitDsgvo')
->assertHasNoErrors();
$request = LegalRequest::query()->sole();
expect($request->type)->toBe(LegalRequestType::Dsgvo)
->and($request->status)->toBe(LegalRequestStatus::Open)
->and($request->verification_method)->toBe(LegalRequestVerificationMethod::LoggedIn)
->and($request->requester_email)->toBe($owner->email)
->and($request->payload['redaction']['terms'])->toBe(['Max Mustermann', 'Mustermanns'])
->and($request->payload['redaction']['detach_contacts'])->toBeTrue()
->and($request->executed_at)->toBeNull()
->and($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung');
Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo($owner->email));
Mail::assertQueued(LegalRequestSubmitted::class);
});
test('a second open dsgvo request for the same release is blocked', function () {
/** @var TestCase $this */
Mail::fake();
$owner = dsgvoPanelOwner();
$this->actingAs($owner);
$pressRelease = PressRelease::factory()->published()->create([
'user_id' => $owner->id,
'title' => 'Max Mustermann übernimmt die Leitung',
]);
LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create([
'press_release_id' => $pressRelease->id,
'status' => LegalRequestStatus::Open,
]);
Volt::test('customer.press-releases.show', ['id' => $pressRelease->id])
->set('dsgvoTerms', 'Max Mustermann')
->set('dsgvoMessage', 'Bitte meinen Namen entfernen, danke.')
->call('previewDsgvo')
->call('submitDsgvo')
->assertHasErrors('dsgvo');
expect(LegalRequest::query()->count())->toBe(1);
});

View file

@ -54,6 +54,27 @@ test('the intake sends a magic link to the stored press contact, never to a form
->and($link->payload['contact_email'])->toBe('presse@firma.example');
});
test('the intake falls back to the company contact when the release has none', function () {
/** @var TestCase $this */
Mail::fake();
// Legacy-Import-Fall: keine PM↔Kontakt-Verknüpfung, aber die Firma hat
// einen Pressekontakt — der zählt als verifizierbarer Kanal.
$pressRelease = PressRelease::factory()->published()->create();
Contact::factory()->create([
'company_id' => $pressRelease->company_id,
'email' => 'firmenkontakt@firma.example',
]);
expect($pressRelease->contacts()->count())->toBe(0);
$this->post('/de/melden/pm', [
'reported_url' => detailUrlFor($pressRelease),
])->assertRedirect();
Mail::assertQueued(PressReleaseReportLink::class, fn (PressReleaseReportLink $mail) => $mail->hasTo('firmenkontakt@firma.example'));
});
test('an unknown url is rejected with a validation error', function () {
/** @var TestCase $this */
Mail::fake();
@ -121,13 +142,13 @@ test('a verified visitor can submit a concern which lands verified in the queue'
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token']);
$this->post('/de/melden/pm/anliegen', [
'concern' => 'dsgvo',
'message' => 'Bitte meinen Namen aus dem Pressekontakt entfernen.',
'concern' => 'correction',
'message' => 'Bitte den Tippfehler im Firmennamen korrigieren.',
])->assertSessionHas('pm-report-status');
$request = LegalRequest::query()->sole();
expect($request->type)->toBe(LegalRequestType::Dsgvo)
expect($request->type)->toBe(LegalRequestType::Correction)
->and($request->press_release_id)->toBe($pressRelease->id)
->and($request->requester_email)->toBe('presse@firma.example')
->and($request->verified_at)->not->toBeNull()
@ -137,6 +158,76 @@ test('a verified visitor can submit a concern which lands verified in the queue'
Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo('presse@firma.example'));
});
test('the dsgvo concern requires terms and enforces the mandatory preview', function () {
/** @var TestCase $this */
Mail::fake();
$pressRelease = publishedReleaseWithContact();
$pressRelease->forceFill(['title' => 'Max Mustermann übernimmt die Leitung'])->save();
$link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example');
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token']);
// Ohne Begriffe und ohne Entfernungs-Option → Fehler.
$this->post('/de/melden/pm/anliegen', [
'concern' => 'dsgvo',
'message' => 'Bitte meinen Namen entfernen, danke.',
])->assertSessionHasErrors('redaction_terms');
// Begriffe ohne Treffer → Fehler mit Hinweis auf die Schreibweise.
$this->post('/de/melden/pm/anliegen', [
'concern' => 'dsgvo',
'message' => 'Bitte meinen Namen entfernen, danke.',
'redaction_terms' => 'Kommtnichtvor',
])->assertSessionHasErrors('redaction_terms');
// Mit Treffern → Pflicht-Vorschau (kein Redirect), noch KEINE Anfrage.
$this->post('/de/melden/pm/anliegen', [
'concern' => 'dsgvo',
'message' => 'Bitte meinen Namen entfernen, danke.',
'redaction_terms' => 'Max Mustermann',
])->assertOk()
->assertSee('Vorschau: So sieht die Mitteilung nach der Schwärzung aus')
->assertSee('[entfernt] übernimmt die Leitung')
->assertSee('Verbindlich einreichen');
expect(LegalRequest::query()->count())->toBe(0)
->and($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung');
});
test('the confirmed dsgvo submission stores a structured redaction order without touching the release', function () {
/** @var TestCase $this */
Mail::fake();
$pressRelease = publishedReleaseWithContact();
$pressRelease->forceFill(['title' => 'Max Mustermann übernimmt die Leitung'])->save();
$link = app(MagicLinkGenerator::class)->createReportLink($pressRelease, 'presse@firma.example');
$this->get('/de/melden/pm/verifizieren/'.$link['plain_token']);
$this->post('/de/melden/pm/anliegen', [
'concern' => 'dsgvo',
'message' => 'Bitte meinen Namen entfernen, danke.',
'redaction_terms' => "Max Mustermann\nMustermanns",
'detach_contacts' => '1',
'remove_images' => '1',
'confirmed' => '1',
])->assertSessionHas('pm-report-status');
$request = LegalRequest::query()->sole();
expect($request->type)->toBe(LegalRequestType::Dsgvo)
->and($request->status)->toBe(LegalRequestStatus::Open)
->and($request->payload['redaction']['terms'])->toBe(['Max Mustermann', 'Mustermanns'])
->and($request->payload['redaction']['detach_contacts'])->toBeTrue()
->and($request->payload['redaction']['remove_images_requested'])->toBeTrue()
->and($request->payload['redaction']['preview_total_hits'])->toBeGreaterThan(0)
->and($request->executed_at)->toBeNull()
// Einreichung ≠ Ausführung: an der PM ändert sich nichts.
->and($pressRelease->fresh()->title)->toBe('Max Mustermann übernimmt die Leitung')
->and($pressRelease->fresh()->contacts()->count())->toBe(1);
Mail::assertQueued(LegalRequestReceived::class, fn (LegalRequestReceived $mail) => $mail->hasTo('presse@firma.example'));
});
test('a second open concern of the same type is blocked', function () {
/** @var TestCase $this */
Mail::fake();

View file

@ -49,6 +49,56 @@ test('the legal request form renders with per-type hints and hidden honeypots',
->assertSee('name="homepage"', false);
});
test('the triage offers the portal magic-link path', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'dsgvo']))
->assertOk()
// Stufe 1: Anliegen-Karten inkl. Absender-Selbstservice.
->assertSee('Korrektur, Änderung oder Depublizierung')
->assertSee('Rechtswidrige Inhalte melden')
// Passwortloser Anmeldelink ins Portal (PMs werden im Konto zusammengeführt).
->assertSee('Anmeldelink im Portal anfordern')
->assertSee(rtrim(config('domains.domain_portal_url', config('app.url')), '/').'/anmeldelink', false)
// Fallback nur mit Identifizierungs-Hinweis.
->assertSee('persönliche Identifizierung erforderlich')
// Vorauswahl aus dem Query-Parameter.
->assertSee("concern: 'dsgvo'", false);
});
test('owner concerns preselect the self-service card', function () {
/** @var TestCase $this */
$release = publishedRelease();
$this->get(route('legal-request.create', ['slug' => $release->slug, 'type' => 'depublication']))
->assertOk()
->assertSee("concern: 'owner'", false);
});
test('owner track types are rejected by the public store endpoint', function () {
/** @var TestCase $this */
Mail::fake();
$release = publishedRelease();
$this->from(route('legal-request.create', ['slug' => $release->slug]))
->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => 'correction',
'message' => 'Bitte den Titel dieser Mitteilung komplett austauschen.',
])->assertSessionHasErrors('type');
expect(LegalRequest::query()->count())->toBe(0);
});
test('the report intake prefills the reported url from the query string', function () {
/** @var TestCase $this */
$detailUrl = 'https://presseecho.test/de/press-release/beispiel-pm';
$this->get(route('melden.pm', ['edition' => 'de', 'reported_url' => $detailUrl]))
->assertOk()
->assertSee('value="'.$detailUrl.'"', false);
});
test('the form 404s for a non-published release', function () {
/** @var TestCase $this */
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create();

View file

@ -80,6 +80,56 @@ test('businessportal24 release detail renders the editorial shell with real data
->assertSee(rtrim(config('domains.domain_portal_url'), '/').'/anmeldelink', false);
});
test('release detail falls back to the company contact when the release has none', function () {
/** @var TestCase $this */
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
// Legacy-Import-Fall: PM ohne eigene Kontakt-Verknüpfung — im Altsystem
// hingen Pressekontakte an der Firma.
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(['published_at' => now()->subDay()]);
Contact::factory()->create([
'portal' => Portal::Businessportal24,
'company_id' => $company->id,
'first_name' => 'Jonas',
'last_name' => 'Feldkamp',
'email' => 'presse@muster-energie.de',
]);
expect($release->contacts()->count())->toBe(0);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
->assertSuccessful()
->assertSeeText('Pressekontakt')
->assertSeeText('Jonas Feldkamp');
// Der Fallback ist reine Anzeige — es entsteht keine Pivot-Verknüpfung.
expect($release->contacts()->count())->toBe(0);
});
test('release detail shows company data as press contact when no contact record exists at all', function () {
/** @var TestCase $this */
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
$company->update(['email' => 'kontakt@muster-energie.de']);
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(['published_at' => now()->subDay()]);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
->assertSuccessful()
->assertSeeText('Pressekontakt')
->assertSeeText('kontakt@muster-energie.de');
});
test('release detail without image embeds the headline in the generated hero', function () {
/** @var TestCase $this */
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);