Ablehnungskategorien live aus der DB (rejection_categories)

/richtlinien rendert jetzt alle aktiven Kategorien aus der neuen
rejection_categories-Tabelle (stabiler code als Anker, public_label/desc,
optionale Beispiele) statt der statischen K1-K6-Liste. Kategorien werden
nie geloescht, nur deaktiviert; neue Kategorien erscheinen ohne
Textaenderung und ohne neue AGB-Version (AGB par. 7 Abs. 3 verweist nicht
abschliessend). Seeder legt K1-K6 idempotent an; Versionszeile der Seite
kommt aus legal_documents. Go-Live-Checkliste um beide Seeder ergaenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 09:25:16 +00:00
parent 7510e7633e
commit 0bec71ffa5
8 changed files with 213 additions and 18 deletions

View file

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Database\Factories\RejectionCategoryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Ablehnungskategorie der Inhaltsprüfung (Richtlinien v0.1, AGB § 7 Abs. 3).
*
* Der `code` (K1, K2, …) ist stabil und wird in Ablehnungs-Begründungen
* referenziert; die öffentliche Richtlinien-Seite rendert alle aktiven
* Kategorien als Anker-Abschnitte. Kategorien werden nie gelöscht, nur
* deaktiviert (`active = false`).
*/
class RejectionCategory extends Model
{
/** @use HasFactory<RejectionCategoryFactory> */
use HasFactory;
protected $fillable = [
'code',
'public_label',
'public_desc',
'examples',
'severity',
'rubric_ref',
'active',
'sort_order',
];
protected function casts(): array
{
return [
'active' => 'boolean',
'sort_order' => 'integer',
];
}
/**
* @param Builder<RejectionCategory> $query
* @return Builder<RejectionCategory>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
/**
* @param Builder<RejectionCategory> $query
* @return Builder<RejectionCategory>
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('code');
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\RejectionCategory;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<RejectionCategory>
*/
class RejectionCategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'code' => 'K'.fake()->unique()->numberBetween(1, 999),
'public_label' => fake()->sentence(3),
'public_desc' => fake()->sentence(12),
'examples' => null,
'severity' => 'rot',
'rubric_ref' => null,
'active' => true,
'sort_order' => fake()->numberBetween(0, 100),
];
}
public function inactive(): static
{
return $this->state(['active' => false]);
}
}

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ablehnungskategorien (Rechtstexte-Architektur E3, Richtlinien v0.1):
* Die öffentliche Richtlinien-Seite rendert alle aktiven Kategorien live;
* Ablehnungs-Mails referenzieren den stabilen `code` (AGB § 7 Abs. 3).
* Kategorien werden nie gelöscht, nur deaktiviert — Ablehnungen der
* Vergangenheit müssen referenzierbar bleiben.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('rejection_categories', function (Blueprint $table) {
$table->id();
$table->string('code', 20)->unique(); // z. B. "K1", stabil
$table->string('public_label');
$table->text('public_desc');
$table->text('examples')->nullable(); // optional, öffentlich
$table->string('severity', 20)->default('rot'); // rot | gelb_hinweis
$table->string('rubric_ref', 80)->nullable(); // interne Rubrik, nie öffentlich
$table->boolean('active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['active', 'sort_order']);
});
}
public function down(): void
{
Schema::dropIfExists('rejection_categories');
}
};

View file

@ -22,6 +22,7 @@ class DatabaseSeeder extends Seeder
PlanSeeder::class,
PaymentOptionSeeder::class,
LegalDocumentSeeder::class,
RejectionCategorySeeder::class,
// CategorySeeder::class,
]);

View file

@ -0,0 +1,39 @@
<?php
namespace Database\Seeders;
use App\Models\RejectionCategory;
use Illuminate\Database\Seeder;
/**
* Start-Kategorien K1–K6 (Richtlinien v0.1, docs/legal). Idempotent über
* den stabilen `code`; bestehende Kategorien werden aktualisiert, nie
* gelöscht. Neue Kategorien sind neue Zeilen ohne AGB-Änderung.
*/
class RejectionCategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['K1', 'Rechtswidrige Inhalte', 'Inhalte, die gegen geltendes Recht verstoßen — etwa strafbare Inhalte, Verstöße gegen das Wettbewerbs- oder Heilmittelwerberecht, Angebote verbotener Produkte oder Dienstleistungen.'],
['K2', 'Explizite oder jugendgefährdende Inhalte', 'Pornografische, gewaltverherrlichende oder anderweitig jugendgefährdende Darstellungen in Text oder Bild.'],
['K3', 'Hass und Diskriminierung', 'Inhalte, die Menschen wegen ihrer Herkunft, Religion, Geschlechtsidentität, sexuellen Orientierung, Behinderung oder vergleichbarer Merkmale herabwürdigen oder zu Hass aufrufen.'],
['K4', 'Irreführung und Falschbehauptung', 'Nachweislich falsche Tatsachenbehauptungen, erfundene Studien oder Zitate, Täuschung über den Absender sowie gesundheits- oder finanzbezogene Heilsversprechen ohne Grundlage.'],
['K5', 'Rechte Dritter', 'Verletzung von Urheber-, Marken- oder Persönlichkeitsrechten — etwa fremde Texte oder Bilder ohne Lizenz, unbefugte Namensnennung, herabsetzende Aussagen über Dritte.'],
['K6', 'Kein Pressecharakter / Spam', 'Reine Werbe- oder Verkaufstexte ohne Nachrichtenwert, Keyword-Spam, automatisch generierte Masseninhalte ohne inhaltliche Substanz, Duplikate bereits veröffentlichter Mitteilungen.'],
];
foreach ($categories as $index => [$code, $label, $desc]) {
RejectionCategory::query()->updateOrCreate(
['code' => $code],
[
'public_label' => $label,
'public_desc' => $desc,
'severity' => 'rot',
'active' => true,
'sort_order' => ($index + 1) * 10,
],
);
}
}
}

View file

@ -125,6 +125,11 @@ php artisan legacy:grandfather-subscriptions
# Favicons für alle Domains generieren
php artisan domains:generate-favicons
# Rechtstext-Versionen (E3) + Ablehnungskategorien K1–K6 anlegen — ohne sie
# fehlen AGB-Versionsangabe/Protokoll-Referenz und die /richtlinien-Kategorien.
php artisan db:seed --class=LegalDocumentSeeder --force
php artisan db:seed --class=RejectionCategorySeeder --force
```
---

View file

@ -4,22 +4,20 @@
@section('meta_description', 'Veröffentlichungsrichtlinien für Pressemitteilungen auf presseecho.de und businessportal24.com: Prüfverfahren, Anforderungen und Ablehnungskategorien.')
@php
// Rechtstexte-Architektur E3: Rahmentext v0.1 (03.07.2026). Die
// Ablehnungskategorien sind hier zum Launch statisch (K1–K6); das
// rejection_categories-System (live aus der DB) ist R3.1-Backlog.
// Rechtstexte-Architektur E3: statischer Rahmentext + dynamische
// Kategorienliste aus rejection_categories — neue/angepasste Kategorien
// erscheinen ohne Textänderung. Version/Stand aus legal_documents.
$host = request()->getHost();
$isHub = str_contains($host, 'pressekonto');
$brandKey = $isHub ? 'pressekonto' : (str_contains($host, 'presseecho') ? 'presseecho' : 'businessportal24');
$contactEmail = (string) config('legal.contact_email');
$rejectionCategories = [
['code' => 'K1', 'label' => 'Rechtswidrige Inhalte', 'desc' => 'Inhalte, die gegen geltendes Recht verstoßen — etwa strafbare Inhalte, Verstöße gegen das Wettbewerbs- oder Heilmittelwerberecht, Angebote verbotener Produkte oder Dienstleistungen.'],
['code' => 'K2', 'label' => 'Explizite oder jugendgefährdende Inhalte', 'desc' => 'Pornografische, gewaltverherrlichende oder anderweitig jugendgefährdende Darstellungen in Text oder Bild.'],
['code' => 'K3', 'label' => 'Hass und Diskriminierung', 'desc' => 'Inhalte, die Menschen wegen ihrer Herkunft, Religion, Geschlechtsidentität, sexuellen Orientierung, Behinderung oder vergleichbarer Merkmale herabwürdigen oder zu Hass aufrufen.'],
['code' => 'K4', 'label' => 'Irreführung und Falschbehauptung', 'desc' => 'Nachweislich falsche Tatsachenbehauptungen, erfundene Studien oder Zitate, Täuschung über den Absender sowie gesundheits- oder finanzbezogene Heilsversprechen ohne Grundlage.'],
['code' => 'K5', 'label' => 'Rechte Dritter', 'desc' => 'Verletzung von Urheber-, Marken- oder Persönlichkeitsrechten — etwa fremde Texte oder Bilder ohne Lizenz, unbefugte Namensnennung, herabsetzende Aussagen über Dritte.'],
['code' => 'K6', 'label' => 'Kein Pressecharakter / Spam', 'desc' => 'Reine Werbe- oder Verkaufstexte ohne Nachrichtenwert, Keyword-Spam, automatisch generierte Masseninhalte ohne inhaltliche Substanz, Duplikate bereits veröffentlichter Mitteilungen.'],
];
$rejectionCategories = \App\Models\RejectionCategory::query()->active()->ordered()->get();
$guidelinesDocument = \App\Models\LegalDocument::current(\App\Models\LegalDocument::TYPE_RICHTLINIEN);
$guidelinesStand = $guidelinesDocument
? 'Stand: '.$guidelinesDocument->effectiveFromFormatted().' · Version '.$guidelinesDocument->version
: 'Stand: 3. Juli 2026 · Version 0.1';
@endphp
@section('content')
@ -27,7 +25,7 @@
eyebrow="Rechtliches"
title="Veröffentlichungsrichtlinien"
breadcrumb="Richtlinien"
updated="Stand: 3. Juli 2026 · Version 0.1"
updated="{{ $guidelinesStand }}"
>
<div class="pm-body max-w-[760px]">
<p>
@ -69,12 +67,15 @@
<div class="max-w-[760px] mt-4 border border-bg-rule divide-y divide-bg-rule">
@foreach ($rejectionCategories as $category)
<div id="{{ $category['code'] }}" class="px-5 py-4 scroll-mt-6">
<div id="{{ $category->code }}" class="px-5 py-4 scroll-mt-6">
<div class="flex items-baseline gap-3">
<span class="font-mono text-[12px] font-bold text-ink-3 flex-shrink-0">{{ $category['code'] }}</span>
<span class="font-mono text-[12px] font-bold text-ink-3 flex-shrink-0">{{ $category->code }}</span>
<div>
<h3 class="text-[14.5px] font-semibold text-ink m-0">{{ $category['label'] }}</h3>
<p class="text-[13px] leading-[1.6] text-ink-2 mt-1.5 mb-0">{{ $category['desc'] }}</p>
<h3 class="text-[14.5px] font-semibold text-ink m-0">{{ $category->public_label }}</h3>
<p class="text-[13px] leading-[1.6] text-ink-2 mt-1.5 mb-0">{{ $category->public_desc }}</p>
@if ($category->examples)
<p class="text-[12px] leading-[1.6] text-ink-3 mt-1.5 mb-0">Beispiele: {{ $category->examples }}</p>
@endif
</div>
</div>
</div>

View file

@ -1,5 +1,7 @@
<?php
use App\Models\RejectionCategory;
use Database\Seeders\RejectionCategorySeeder;
use Tests\TestCase;
dataset('static pages', [
@ -75,12 +77,25 @@ test('the terms render the b2b version with cancellation and archive rules', fun
test('the guidelines list the rejection categories with anchors', function () {
/** @var TestCase $this */
// Kategorien kommen live aus rejection_categories (E3) — neue Zeilen
// erscheinen ohne Textänderung, deaktivierte verschwinden.
$this->seed(RejectionCategorySeeder::class);
RejectionCategory::query()->where('code', 'K6')->update(['active' => false]);
RejectionCategory::factory()->create([
'code' => 'K7',
'public_label' => 'Testkategorie aus der Datenbank',
'sort_order' => 70,
]);
$this->get('https://presseecho.test/de/richtlinien')
->assertSuccessful()
->assertSeeText('So funktioniert die Prüfung')
->assertSeeText('Kein Pressecharakter / Spam')
->assertSeeText('Rechtswidrige Inhalte')
->assertSeeText('Testkategorie aus der Datenbank')
->assertSee('id="K1"', false)
->assertSee('id="K6"', false);
->assertSee('id="K7"', false)
// Deaktivierte Kategorien werden nicht mehr gerendert.
->assertDontSeeText('Kein Pressecharakter / Spam');
});
test('the reporting page separates free legal paths from the paid takedown service', function () {