recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal — rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout (SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre, Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent (50 %) verhindert die verdeckte Selbst-Depublizierung. Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D, neuer abbestellbarer Typ press_release_corrected). Außerdem: - Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert (Lücke im bezahlten Webhook-Pfad geschlossen) - Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis; Store auf report/dsgvo/personal_rights gehärtet - Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt des Web-Formulars - Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung): Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt → Firmendaten - Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel, Wochenlimit-Hinweis in Text und Bremsen-Meldung Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
04b49c567d
commit
e6eca9c85f
40 changed files with 2507 additions and 169 deletions
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
app/Mail/LegalRequestExecuted.php
Normal file
42
app/Mail/LegalRequestExecuted.php
Normal 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');
|
||||
}
|
||||
}
|
||||
83
app/Mail/PressReleaseChangeApplied.php
Normal file
83
app/Mail/PressReleaseChangeApplied.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ( → Leerzeichen,
|
||||
* & → &, <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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)."'";
|
||||
}
|
||||
};
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
38
resources/views/emails/legal/executed.blade.php
Normal file
38
resources/views/emails/legal/executed.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)')"
|
||||
|
|
|
|||
|
|
@ -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 ?? '';
|
||||
|
|
|
|||
|
|
@ -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 Mustermanns 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. --}}
|
||||
|
|
|
|||
|
|
@ -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. 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 €) und die Depublizierung (35 €) 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. 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. 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
195
tests/Feature/Billing/ChangeCheckoutTest.php
Normal file
195
tests/Feature/Billing/ChangeCheckoutTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 & Leistung stimmen.<br />Zeile zwei mit "Zitat".</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 und & 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();
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
137
tests/Feature/Legal/CustomerDsgvoPanelTest.php
Normal file
137
tests/Feature/Legal/CustomerDsgvoPanelTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue