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:
parent
a69b02193c
commit
85b039e739
6 changed files with 253 additions and 2 deletions
53
app/Services/Company/CompanyProfileCheck.php
Normal file
53
app/Services/Company/CompanyProfileCheck.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
96
tests/Feature/NewsroomProfileTest.php
Normal file
96
tests/Feature/NewsroomProfileTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue