User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline

Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 08:30:13 +00:00
parent 0efabaf446
commit a000238ca8
141 changed files with 5922 additions and 1001 deletions

View file

@ -0,0 +1,67 @@
<?php
namespace App\Enums;
/**
* Lizenz-/Herkunftstyp eines Pressemitteilungs-Bildes.
*
* Dient der rechtssicheren Erfassung von Bildrechten beim Upload.
*/
enum ImageLicenseType: string
{
case Own = 'own';
case CreativeCommons = 'cc';
case Commercial = 'commercial';
case Consent = 'consent';
case PressPr = 'press_pr';
case PublicDomain = 'public_domain';
case Other = 'other';
/**
* Lesbares Label für die UI.
*/
public function label(): string
{
return match ($this) {
self::Own => 'Eigene Aufnahme',
self::Consent => 'Vom Urheber / Fotografen freigegeben',
self::Commercial => 'Agentur-/Stockbild-Lizenz',
self::CreativeCommons => 'Creative-Commons-Lizenz',
self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe',
self::PublicDomain => 'Gemeinfrei / Public Domain / CC0',
self::Other => 'Sonstige Lizenz / Sondervereinbarung',
};
}
/**
* Ob für diesen Typ eine Lizenz-URL Pflicht ist.
*/
public function requiresLicenseUrl(): bool
{
return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true);
}
/**
* Ob zusaetzliche Lizenzdetails verpflichtend sind.
*/
public function requiresLicenseDetail(): bool
{
return in_array($this, [self::CreativeCommons, self::Other], true);
}
/**
* Optionsliste für Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
/**
* Klassifikations-Score („Red Flag") laut Konzept §15.1.
*
* Entscheidet, ob eine Pressemitteilung überhaupt veröffentlicht wird:
* - Green unauffällig, Veröffentlichungspfad
* - Yellow grenzwertig, manuelle Review-Queue
* - Red unzulässig, zurück an den Autor
*/
enum PressReleaseClassification: string
{
case Green = 'green';
case Yellow = 'yellow';
case Red = 'red';
public function label(): string
{
return match ($this) {
self::Green => 'Grün',
self::Yellow => 'Gelb',
self::Red => 'Rot',
};
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Enums;
/**
* Content-Score-Stufe (Außenkommunikation, Konzept Update 2).
*
* Der numerische Score (0100) bleibt plattform-intern; nach außen wird er auf
* drei Stufen gemappt. Schwellen sind über config/scoring.php kalibrierbar.
*/
enum PressReleaseContentTier: string
{
case Standard = 'standard';
case Geprueft = 'gepruft';
case Hochwertig = 'hochwertig';
/**
* Leitet die Stufe aus dem numerischen Score ab (kalibrierbar über
* config('scoring.content_score.tiers')).
*/
public static function fromScore(int $score): self
{
$tiers = config('scoring.content_score.tiers', []);
$hochwertig = (int) ($tiers['hochwertig'] ?? 80);
$gepruft = (int) ($tiers['gepruft'] ?? 60);
return match (true) {
$score >= $hochwertig => self::Hochwertig,
$score >= $gepruft => self::Geprueft,
default => self::Standard,
};
}
public function label(): string
{
return match ($this) {
self::Standard => 'Standard',
self::Geprueft => 'Geprüft',
self::Hochwertig => 'Hochwertig',
};
}
/**
* Ob die Stufe öffentlich als Vertrauensindikator gezeigt wird. Standard
* wird laut Update 2 bewusst nicht beworben (kein Badge/Label).
*/
public function isPubliclyBadged(): bool
{
return $this !== self::Standard;
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Enums;
/**
* Wiederverwendbares Set farbiger SVG-Titelbild-Platzhalter für
* Pressemitteilungen ohne eigenes Bild.
*
* Die Werte entsprechen den Dateinamen in
* `public/images/press-release-placeholders/<value>.svg`.
*/
enum PressReleasePlaceholder: string
{
case GridBlue = '01-grid-blue';
case GridGreen = '02-grid-green';
case GridAmber = '03-grid-amber';
case LinesBlue = '04-lines-blue';
case LinesGreen = '05-lines-green';
case LinesAmber = '06-lines-amber';
case DotsBlue = '07-dots-blue';
case DotsGreen = '08-dots-green';
case DotsAmber = '09-dots-amber';
case WavesBlue = '10-waves-blue';
case WavesGreen = '11-waves-green';
case WavesAmber = '12-waves-amber';
case EditorialBlue = '13-editorial-blue';
case EditorialGreen = '14-editorial-green';
case EditorialAmber = '15-editorial-amber';
case SignalBlue = '16-signal-blue';
case SignalGreen = '17-signal-green';
case SignalAmber = '18-signal-amber';
/**
* Default-Variante, wenn nichts gesetzt ist.
*/
public static function default(): self
{
return self::GridBlue;
}
/**
* Liefert die Variante zu einem Roh-Wert oder den Default-Fallback.
*/
public static function fromValueOrDefault(?string $value): self
{
return self::tryFrom((string) $value) ?? self::default();
}
/**
* Deterministische Variante aus einem Seed (z. B. PM-ID/Titel), damit
* dieselbe PM immer denselben Platzhalter bekommt.
*/
public static function fromSeed(int|string $seed): self
{
$cases = self::cases();
return $cases[abs(crc32((string) $seed)) % count($cases)];
}
/**
* Öffentlicher Asset-Pfad relativ zu `public/`.
*/
public function path(): string
{
return 'images/press-release-placeholders/'.$this->value.'.svg';
}
/**
* Lesbares Label für die UI (Picker-Tooltips etc.).
*/
public function label(): string
{
$pattern = match (true) {
str_contains($this->value, 'grid') => 'Raster',
str_contains($this->value, 'lines') => 'Linien',
str_contains($this->value, 'dots') => 'Punkte',
str_contains($this->value, 'waves') => 'Wellen',
str_contains($this->value, 'editorial') => 'Editorial',
default => 'Signal',
};
$color = match (true) {
str_contains($this->value, 'blue') => 'Blau',
str_contains($this->value, 'green') => 'Grün',
default => 'Bernstein',
};
return $pattern.' · '.$color;
}
}