newsroom: Beschreibung pflegen, durch die Rot-Ebene geprüft (Roadmap 1b.3)

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) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 13:04:12 +00:00
parent 079477a423
commit 87f95fb7a7
6 changed files with 253 additions and 2 deletions

View file

@ -0,0 +1,53 @@
<?php
namespace App\Services\Company;
use App\Enums\PressReleaseClassification;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use Illuminate\Support\Facades\Log;
/**
* Prüfung des Beschreibungstexts eines Newsrooms (Roadmap 1b.3, Leitplanke
* „mindestens durch die Rot-Ebene der bestehenden Prüfung").
*
* Kein eigenes Regelwerk: Der Text läuft durch dieselbe Klassifikation wie
* eine Meldung — KI-Treiber, bei Ausfall die deterministische Blacklist,
* genau wie die Vorprüfung einer kostenpflichtigen Änderung (Spur D). Nur
* das Ergebnis Rot zählt hier; Gelb ist für ein Firmenprofil kein Grund,
* den Text abzuweisen.
*
* Geprüft wird auf einer NICHT gespeicherten Meldung mit Firmenname als
* Titel und Beschreibung als Text — die Treiber kennen nur Meldungen, und
* mehr als Titel und Text lesen sie nicht.
*/
class CompanyProfileCheck
{
public function __construct(private readonly ClassificationManager $classification) {}
public function isRed(Company $company, string $description): bool
{
return $this->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);
}
}
}

View file

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

View file

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

View file

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

View file

@ -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
<flux:textarea wire:model="companyAddress" :label="__('Adresse')" rows="3" />
<flux:error name="companyAddress" />
</flux:field>
<flux:field class="sm:col-span-2">
<flux:textarea wire:model="companyBoilerplate" :label="__('Beschreibung')" rows="5" maxlength="2000"
:description="__('Kurzporträt des Unternehmens — erscheint im Newsroom und unter jeder Meldung als „Über das Unternehmen“. Reiner Text, keine Links; die Website steht im eigenen Feld.')" />
<flux:error name="companyBoilerplate" />
</flux:field>
<flux:field>
<flux:select wire:model="companyCountryCode" :label="__('Land')">
@foreach ($countries as $code => $name)
@ -471,6 +504,19 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</flux:field>
</div>
{{-- Bilder im Newsroom: zurückgestellt, bis die Bild-Kennzeichnung
angepasst ist (Entscheidung 15.09.2026). Sichtbar als BALD,
nicht weggelassen — Leitplanke der Roadmap. --}}
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-2">
<div class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Bilder im Newsroom') }}</span>
<flux:badge size="sm" color="zinc">{{ __('BALD') }}</flux:badge>
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Funktion folgt. Bilder im Newsroom unterliegen derselben KI-Kennzeichnung wie Bilder in Meldungen; die wird gerade überarbeitet.') }}
</p>
</div>
<div class="mt-4 pt-4 border-t border-[color:var(--color-bg-rule)] flex justify-end gap-2">
<flux:button type="button" variant="filled" wire:click="cancelCompanyForm">
{{ __('Abbrechen') }}
@ -510,6 +556,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Adresse') }}</dt>
<dd class="text-[color:var(--color-ink)] whitespace-pre-line">{{ $company->address ?: '–' }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Beschreibung') }}</dt>
<dd class="text-[color:var(--color-ink)] whitespace-pre-line">{{ $company->boilerplate ?: '–' }}</dd>
</div>
</dl>
</article>

View file

@ -0,0 +1,96 @@
<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\ConversionEvent;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Kostenlose Profilpflege des Newsrooms (Roadmap 1b.3): Beschreibungstext
* durch die Rot-Ebene, reiner Text ohne Links, Bilder als BALD-Platzhalter.
*/
function profileCustomer(): array
{
config()->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 <b>führender Anbieter</b> für Musterlösungen.\n<a href=\"https://spam.example\">Link</a>")
->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');
});