From 85b039e739dd5f5486987508680140f17c655933 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 15 Sep 2026 13:04:12 +0000 Subject: [PATCH] =?UTF-8?q?newsroom:=20Beschreibung=20pflegen,=20durch=20d?= =?UTF-8?q?ie=20Rot-Ebene=20gepr=C3=BCft=20(Roadmap=201b.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Firmenseite im Kundenbereich bekommt den Beschreibungstext (max. 2 000 Zeichen, reiner Text). Er läuft durch dieselbe Klassifikation wie eine Meldung (CompanyProfileCheck, KI mit deterministischem Fallback); Rot wird abgewiesen. Links im Profiltext gibt es nicht — die Website ist das eine Link-Feld. Bilder zurückgestellt (BALD-Platzhalter), bis die Bild-Kennzeichnung angepasst ist. Stufe 3 der Konversionsleiter (newsroom_edited) wird hier geschrieben, einmal je Firma, mit der Herkunft des zuletzt eingelösten Magic Links. Co-Authored-By: Claude Opus 5 (1M context) --- app/Services/Company/CompanyProfileCheck.php | 53 ++++++++++ .../Conversion/ConversionRecorder.php | 25 +++++ dev/2026-09-01/roadmap-2026.md | 2 +- dev/2026-09-01/umsetzung-protokoll.md | 27 ++++++ .../customer/press-kits/show.blade.php | 52 +++++++++- tests/Feature/NewsroomProfileTest.php | 96 +++++++++++++++++++ 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 app/Services/Company/CompanyProfileCheck.php create mode 100644 tests/Feature/NewsroomProfileTest.php diff --git a/app/Services/Company/CompanyProfileCheck.php b/app/Services/Company/CompanyProfileCheck.php new file mode 100644 index 00000000..e407264d --- /dev/null +++ b/app/Services/Company/CompanyProfileCheck.php @@ -0,0 +1,53 @@ +check($company, $description)->classification === PressReleaseClassification::Red; + } + + public function check(Company $company, string $description): ClassificationResult + { + $proposed = new PressRelease([ + 'title' => (string) $company->name, + 'text' => $description, + ]); + + try { + return $this->classification->driver()->classify($proposed); + } catch (\Throwable $e) { + Log::warning('Profilprüfung (Rot-Ebene) fehlgeschlagen — deterministischer Fallback.', [ + 'company_id' => $company->getKey(), + 'error' => $e->getMessage(), + ]); + + return $this->classification->driver('deterministic')->classify($proposed); + } + } +} diff --git a/app/Services/Conversion/ConversionRecorder.php b/app/Services/Conversion/ConversionRecorder.php index b85ab49d..b81ca76c 100644 --- a/app/Services/Conversion/ConversionRecorder.php +++ b/app/Services/Conversion/ConversionRecorder.php @@ -128,6 +128,31 @@ class ConversionRecorder $this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company); } + /** + * Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3). + * + * Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic + * Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail + * kam und dann die Beschreibung schreibt, zählt für diese Mail. Ohne + * eingelösten Link (Passwort-Login) bleibt es `organic`. + */ + public function recordNewsroomEdited(Company $company, ?User $user): void + { + try { + $source = $user === null ? null : MagicLink::query() + ->where('user_id', $user->getKey()) + ->where('purpose', 'login') + ->whereNotNull('consumed_at') + ->latest('consumed_at') + ->value('source'); + } catch (\Throwable $e) { + report($e); + $source = null; + } + + $this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company); + } + private function alreadyRecorded(ConversionEventType $type, Company $company): bool { return ConversionEvent::query() diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 02ad137e..d8144028 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -120,7 +120,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die |---|---|---|---| | ~~1b.1~~ | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz — **bestand; 15.09.2026 Rückbau im Cache vervollständigt** | S | keine | | ~~1b.2~~ | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit — **gebaut 15.09.2026**: `/newsroom/{slug}/verwalten`, Link an die hinterlegte Firmen-E-Mail (maskiert), Herkunft `profile_claim`, Landung auf der Firmenseite; noch unverlinkt (→ 1b.4) | M | 1a.4 | -| 1b.3 | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte | M | 1b.2 | +| ~~1b.3~~ | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte — **15.09.2026**: Beschreibung durch die Rot-Ebene, reiner Text ohne Links, Stufe 3 der Leiter wird hier geschrieben; **Bilder zurückgestellt** (BALD-Platzhalter) bis die Bild-Kennzeichnung angepasst ist | M | 1b.2 | | ~~1b.4~~ | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail — **gebaut 15.09.2026** (E4: Hinweis unten in der Mail; Herkunft `mail_publish` läuft bis auf das Token durch) | S | 1b.2 | | ~~1b.5~~ | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit — **15.09.2026**: alle Admin-Wege (archivieren, Status ändern, löschen) leeren jetzt den Seiten-Cache | S | 1b.1 | | 1b.6 | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen | S | Bounce-Suppression live | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 3506b629..adbbc958 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -977,3 +977,30 @@ keinen Platz in dieser Mail (ein Element pro Mail). Mail beansprucht" von „auf der Seite entdeckt". Tests: drei weitere in `NewsroomClaimTest` (Seite, Mail, Herkunft). + +## 1b.3 — Kostenlose Profilpflege (Entscheidung 15.09.2026: Bilder zurückgestellt) + +Die Firmenseite im Kundenbereich konnte Name, Adresse, Kontakt, Website, +Land, Logo und Pressekontakte. Neu: + +- **Beschreibung** (`companies.boilerplate`, max. 2 000 Zeichen) als + Textarea. Erscheint im Newsroom und unter jeder Meldung als „Über das + Unternehmen". Beim Speichern wird der Seiten-Cache geleert. +- **Rot-Ebene:** Der Text läuft durch dieselbe Klassifikation wie eine + Meldung (`CompanyProfileCheck`: KI-Treiber, bei Ausfall deterministische + Blacklist — das Muster der Spur-D-Vorprüfung). Rot → Validierungsfehler, + nichts wird gespeichert. Gelb ist für ein Firmenprofil kein Grund. +- **Leitplanke Links:** Reiner Text. `strip_tags` beim Speichern, escaped + beim Rendern — es gibt im Profiltext keine Links, die man auszeichnen + müsste. Die Website ist das eine Link-Feld und trägt bereits `nofollow`. + Damit ist die Leitplanke durch Weglassen erfüllt statt durch eine Policy. +- **Bilder:** zurückgestellt, bis die Bild-Kennzeichnung angepasst ist. + Im Formular als „Bilder im Newsroom — BALD — Funktion folgt" sichtbar, + nicht weggelassen (Roadmap-Leitplanke). +- **Stufe 3 der Konversionsleiter** (`newsroom_edited`) wird hier + geschrieben — die letzte offene Schreibstelle aus Block 0. Einmal je + Firma; Herkunft ist die des zuletzt eingelösten Magic Links des Kontos + (so zählt „aus der Mail beansprucht, dann Beschreibung geschrieben" für + die Mail), sonst `organic`. + +Tests: `tests/Feature/NewsroomProfileTest.php` (4). diff --git a/resources/views/livewire/customer/press-kits/show.blade.php b/resources/views/livewire/customer/press-kits/show.blade.php index bea80360..7f1b8192 100644 --- a/resources/views/livewire/customer/press-kits/show.blade.php +++ b/resources/views/livewire/customer/press-kits/show.blade.php @@ -3,8 +3,11 @@ use App\Models\Company; use App\Models\Contact; use App\Models\PressRelease; +use App\Services\Company\CompanyProfileCheck; +use App\Services\Conversion\ConversionRecorder; use App\Services\Customer\CustomerCompanyContext; use App\Services\Image\ImageService; +use App\Support\WebCaches; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; @@ -33,6 +36,8 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component public string $companyCountryCode = 'DE'; + public string $companyBoilerplate = ''; + public bool $companyDisableFooterCode = false; public $companyLogo = null; @@ -76,6 +81,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component $this->companyPhone = (string) ($company->phone ?? ''); $this->companyWebsite = (string) ($company->website ?? ''); $this->companyCountryCode = (string) ($company->country_code ?? 'DE'); + $this->companyBoilerplate = (string) ($company->boilerplate ?? ''); $this->companyDisableFooterCode = (bool) $company->disable_footer_code; $this->companyLogo = null; $this->removeCompanyLogo = false; @@ -87,7 +93,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component $this->resetCompanyForm(); } - public function saveCompany(ImageService $imageService): void + public function saveCompany(ImageService $imageService, CompanyProfileCheck $profileCheck, ConversionRecorder $conversions): void { $company = $this->company(); $this->authorize('update', $company); @@ -99,9 +105,22 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component 'companyPhone' => ['nullable', 'string', 'max:40'], 'companyWebsite' => ['nullable', 'url', 'max:190'], 'companyCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))], + // Reiner Text (Roadmap 1b.3): Links im Profiltext sind nicht + // vorgesehen — sonst wird das Profil ein Backlink-Automat. Die + // Website ist das eine, systemseitig ausgezeichnete Link-Feld. + 'companyBoilerplate' => ['nullable', 'string', 'max:2000'], 'companyLogo' => ['nullable', 'image', 'max:' . (int) (ImageService::MAX_LOGO_BYTES / 1024)], ]); + $boilerplate = trim(strip_tags((string) $validated['companyBoilerplate'])); + + // Rot-Ebene der bestehenden Prüfung — dieselbe wie für eine Meldung. + if ($boilerplate !== '' && $boilerplate !== trim((string) $company->boilerplate) && $profileCheck->isRed($company, $boilerplate)) { + throw ValidationException::withMessages([ + 'companyBoilerplate' => __('Die Beschreibung enthält unzulässige Inhalte und kann so nicht gespeichert werden.'), + ]); + } + $company->fill([ 'name' => $validated['companyName'], 'address' => $validated['companyAddress'] ?: null, @@ -109,6 +128,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component 'phone' => $validated['companyPhone'] ?: null, 'website' => $validated['companyWebsite'] ?: null, 'country_code' => $validated['companyCountryCode'] ?: null, + 'boilerplate' => $boilerplate !== '' ? $boilerplate : null, 'disable_footer_code' => $this->companyDisableFooterCode, ]); @@ -130,6 +150,13 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component $company->save(); $this->resetCompanyForm(); + // Stufe 3 der Konversionsleiter: Newsroom gepflegt (einmal je Firma). + $conversions->recordNewsroomEdited($company, auth()->user()); + + // Der öffentliche Newsroom zeigt Name, Logo und Beschreibung — die + // Änderung soll nicht erst nach Ablauf des Seiten-Caches sichtbar sein. + WebCaches::forgetPublicPages(); + session()->flash('company-status', __('Stammdaten wurden gespeichert.')); } @@ -269,6 +296,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component $this->companyPhone = ''; $this->companyWebsite = ''; $this->companyCountryCode = 'DE'; + $this->companyBoilerplate = ''; $this->companyDisableFooterCode = false; $this->companyLogo = null; $this->removeCompanyLogo = false; @@ -416,6 +444,11 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component + + + + @foreach ($countries as $code => $name) @@ -471,6 +504,19 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component + {{-- Bilder im Newsroom: zurückgestellt, bis die Bild-Kennzeichnung + angepasst ist (Entscheidung 15.09.2026). Sichtbar als BALD, + nicht weggelassen — Leitplanke der Roadmap. --}} +
+
+ {{ __('Bilder im Newsroom') }} + {{ __('BALD') }} +
+

+ {{ __('Funktion folgt. Bilder im Newsroom unterliegen derselben KI-Kennzeichnung wie Bilder in Meldungen; die wird gerade überarbeitet.') }} +

+
+
{{ __('Abbrechen') }} @@ -510,6 +556,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ __('Adresse') }}
{{ $company->address ?: '–' }}
+
+
{{ __('Beschreibung') }}
+
{{ $company->boilerplate ?: '–' }}
+
diff --git a/tests/Feature/NewsroomProfileTest.php b/tests/Feature/NewsroomProfileTest.php new file mode 100644 index 00000000..f6c3c6e2 --- /dev/null +++ b/tests/Feature/NewsroomProfileTest.php @@ -0,0 +1,96 @@ +set('blacklist.words', ['spam-keyword']); + config()->set('scoring.classification.provider', 'deterministic'); + + $customer = User::factory()->create(['is_active' => true]); + $company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH', 'slug' => 'muster-gmbh', 'boilerplate' => null]); + $customer->companies()->attach($company->id, ['role' => 'responsible']); + test()->actingAs($customer); + + return [$customer, $company]; +} + +test('a customer can write the newsroom description and it shows on the public newsroom', function () { + /** @var TestCase $this */ + [$customer, $company] = profileCustomer(); + PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'language' => 'de']); + + // Seiten-Cache warm — die Änderung muss trotzdem sofort sichtbar sein. + $this->get('https://businessportal24.test/de/newsroom/muster-gmbh')->assertOk()->assertDontSeeText('führender Anbieter'); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('startEditCompany') + ->set('companyBoilerplate', "Muster ist ein führender Anbieter für Musterlösungen.\nLink") + ->call('saveCompany') + ->assertHasNoErrors(); + + // Reiner Text: Tags fallen weg, Links im Profiltext gibt es nicht. + expect($company->fresh()->boilerplate)->toBe("Muster ist ein führender Anbieter für Musterlösungen.\nLink"); + + $this->get('https://businessportal24.test/de/newsroom/muster-gmbh') + ->assertOk() + ->assertSeeText('führender Anbieter') + ->assertDontSee('spam.example', false); +}); + +test('a description that hits the red level is rejected and nothing is saved', function () { + /** @var TestCase $this */ + [$customer, $company] = profileCustomer(); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('startEditCompany') + ->set('companyName', 'Muster GmbH neu') + ->set('companyBoilerplate', 'Wir sind ein spam-keyword Anbieter.') + ->call('saveCompany') + ->assertHasErrors(['companyBoilerplate']); + + $company->refresh(); + expect($company->boilerplate)->toBeNull() + ->and($company->name)->toBe('Muster GmbH'); +}); + +test('saving the profile records step three once, with the origin of the last magic link', function () { + /** @var TestCase $this */ + [$customer, $company] = profileCustomer(); + $link = app(MagicLinkGenerator::class)->createLoginLink($customer, null, 15, ConversionSource::ProfileClaim); + $link['magic_link']->update(['consumed_at' => now()]); + + $component = LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]); + $component->call('startEditCompany')->set('companyBoilerplate', 'Erste Fassung.')->call('saveCompany')->assertHasNoErrors(); + $component->call('startEditCompany')->set('companyBoilerplate', 'Zweite Fassung.')->call('saveCompany')->assertHasNoErrors(); + + $events = ConversionEvent::query()->where('type', ConversionEventType::NewsroomEdited->value)->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->company_id)->toBe($company->id) + ->and($events->first()->source)->toBe(ConversionSource::ProfileClaim); +}); + +test('image upload is shown as a placeholder, not offered', function () { + /** @var TestCase $this */ + [$customer, $company] = profileCustomer(); + + LivewireVolt::test('customer.press-kits.show', ['id' => $company->id]) + ->call('startEditCompany') + ->assertSee('Bilder im Newsroom') + ->assertSee('BALD') + ->assertSee('Funktion folgt'); +});