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

@ -2,6 +2,7 @@
namespace App\Services\Customer;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -78,6 +79,30 @@ class CustomerCompanyContext
->get();
}
/**
* @return Collection<int, Company>
*/
public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection
{
$term = Portal::stripTrailingAbbreviation($term);
$limit = max(1, $limit);
if ($term === '') {
return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit);
}
return $this->companyOptionQuery($user)
->where(function (Builder $query) use ($term): void {
$query->where('companies.name', 'like', '%'.$term.'%')
->orWhere('companies.slug', 'like', '%'.$term.'%')
->orWhere('companies.email', 'like', '%'.$term.'%');
})
->orderBy('companies.name')
->orderBy('companies.id')
->limit($limit)
->get();
}
public function companyCountFor(User $user): int
{
return $this->accessibleCompanyQuery($user)->count();
@ -181,6 +206,41 @@ class CustomerCompanyContext
]);
}
/**
* @return Builder<Company>
*/
private function companyOptionQuery(User $user): Builder
{
return $this->accessibleCompanyQuery($user)
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']);
}
/**
* @return Collection<int, Company>
*/
private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection
{
$selectedCompany = $selectedCompanyId
? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first()
: null;
$companies = $this->companyOptionQuery($user)
->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id))
->latest('companies.created_at')
->latest('companies.id')
->limit($selectedCompany ? max(0, $limit - 1) : $limit)
->get();
if (! $selectedCompany) {
return $companies;
}
return $companies
->prepend($selectedCompany)
->unique('id')
->values();
}
private function userCanAccessCompany(User $user, int $companyId): bool
{
return $this->accessibleCompanyQuery($user)

View file

@ -43,6 +43,8 @@ class ImageService
'thumb' => ['width' => 320, 'height' => 240],
'medium' => ['width' => 800, 'height' => 600],
'large' => ['width' => 1600, 'height' => 1200],
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
'cover' => ['width' => 1280, 'height' => 580],
];
public const ALLOWED_LOGO_MIME_TYPES = [
@ -60,7 +62,7 @@ class ImageService
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
public function __construct(private readonly string $disk = 'public') {}
@ -99,8 +101,9 @@ class ImageService
}
/**
* Persists a freshly uploaded press release image and generates all
* variants. Original is stored under `press-releases/{id}/images`.
* Persists a freshly uploaded press release image, generates all variants
* and discards the original upload. The canonical stored path points to
* the cover variant to keep storage usage predictable.
*
* @return array{
* path: string,
@ -122,9 +125,6 @@ class ImageService
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$absolute = $disk->path($relativePath);
$size = @getimagesize($absolute) ?: [null, null];
$variants = $this->generateVariants(
$disk,
$relativePath,
@ -133,11 +133,19 @@ class ImageService
cover: true,
);
$coverPath = $variants['cover'] ?? $relativePath;
$coverAbsolute = $disk->path($coverPath);
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
$disk->delete($relativePath);
}
return [
'path' => $relativePath,
'path' => $coverPath,
'variants' => $variants,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
'mime' => $upload->getMimeType(),
];
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\DeterministicClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\OpenAiClassificationDriver;
use Illuminate\Support\Manager;
/**
* Löst den aktiven Klassifikations-Treiber anhand von
* config('scoring.classification.provider') auf (Laravel-Manager-Pattern).
*
* Weitere Anbieter (Anthropic, Gemini) werden später als zusätzliche
* create*Driver()-Methoden ergänzt.
*/
class ClassificationManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) ($this->config->get('scoring.classification.provider') ?: 'deterministic');
}
public function createDeterministicDriver(): ClassificationDriver
{
return $this->container->make(DeterministicClassificationDriver::class);
}
public function createOpenaiDriver(): ClassificationDriver
{
return $this->container->make(OpenAiClassificationDriver::class);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Enums\PressReleaseClassification;
/**
* Ergebnis einer KI-Klassifikation (Red Flag, Konzept §15.1).
*
* Provider-neutral: jeder Treiber liefert dasselbe Schema zurück, damit der
* Job das Ergebnis einheitlich persistieren und auditieren kann.
*/
final class ClassificationResult
{
/**
* @param list<string> $reasons Begründungen der KI (kann leer sein)
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
*/
public function __construct(
public readonly PressReleaseClassification $classification,
public readonly array $reasons,
public readonly string $provider,
public readonly ?string $model,
public readonly array $rawResponse,
) {}
public function reasonText(): ?string
{
return $this->reasons === [] ? null : implode('; ', $this->reasons);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\PressRelease\Classification\Contracts;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationResult;
/**
* Vertrag für austauschbare Klassifikations-Treiber (OpenAI, Anthropic,
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
*/
interface ClassificationDriver
{
/**
* Klassifiziert eine Pressemitteilung (green/yellow/red).
*
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job
* weicht dann auf den deterministischen Fallback aus.
*/
public function classify(PressRelease $pressRelease): ClassificationResult;
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistService;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
/**
* Regelbasierter Fallback ohne externe API.
*
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt
* (Timeout, Rate-Limit, Fehler). Bewertet ausschließlich über die
* Wort-Blacklist: Treffer Rot, sonst Grün. Liefert nie Gelb.
*/
class DeterministicClassificationDriver implements ClassificationDriver
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function classify(PressRelease $pressRelease): ClassificationResult
{
$word = $this->blacklist->findInPressRelease($pressRelease);
if ($word !== null) {
return new ClassificationResult(
classification: PressReleaseClassification::Red,
reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => $word],
);
}
return new ClassificationResult(
classification: PressReleaseClassification::Green,
reasons: [],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => null],
);
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Klassifikations-Treiber auf Basis der OpenAI Chat-Completions-API.
*
* Liest Zugangsdaten/Modell aus config/services.openai. Verlangt eine
* strukturierte JSON-Antwort `{classification, reasons[]}`. Bei fehlendem
* Key, Transport-/HTTP-Fehlern oder ungültiger Antwort wird eine Exception
* geworfen, sodass der Job auf den deterministischen Treiber ausweicht.
*/
class OpenAiClassificationDriver implements ClassificationDriver
{
public function classify(PressRelease $pressRelease): ClassificationResult
{
$config = config('services.openai');
$apiKey = $config['api_key'] ?? null;
if (blank($apiKey)) {
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
}
$model = config('scoring.classification.model') ?: ($config['model'] ?? 'gpt-5.4-mini');
$timeout = (int) (config('scoring.classification.timeout') ?: ($config['timeout'] ?? 30));
$response = Http::withToken($apiKey)
->timeout($timeout)
->acceptJson()
->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [
'model' => $model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
],
]);
if ($response->failed()) {
throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()}).");
}
$payload = $response->json();
$content = data_get($payload, 'choices.0.message.content');
if (! is_string($content) || trim($content) === '') {
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
}
$parsed = json_decode($content, true);
if (! is_array($parsed) || ! isset($parsed['classification'])) {
throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.');
}
$classification = PressReleaseClassification::tryFrom((string) $parsed['classification']);
if ($classification === null) {
throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.');
}
$reasons = array_values(array_filter(array_map(
static fn ($reason): string => (string) $reason,
is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [],
)));
return new ClassificationResult(
classification: $classification,
reasons: $reasons,
provider: 'openai',
model: $model,
rawResponse: is_array($payload) ? $payload : [],
);
}
private function systemPrompt(): string
{
return <<<'PROMPT'
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal.
Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf.
Klassifiziere genau eine der drei Stufen:
- "green": unauffällig, kann veröffentlicht werden.
- "yellow": grenzwertig/unklar, sollte manuell geprüft werden.
- "red": unzulässig, darf nicht veröffentlicht werden.
Prüfe insbesondere diese Faktoren (Red Flags):
- reine Werbung statt journalistischer Pressemitteilung
- beleidigende, diskriminierende oder hetzerische Inhalte
- rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen,
Verleumdung, Aufruf zu Straftaten)
- Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links)
- unseriöse oder offensichtlich falsche Versprechen
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]}
Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch.
PROMPT;
}
private function userPrompt(PressRelease $pressRelease): string
{
$title = (string) $pressRelease->title;
$text = trim(strip_tags((string) $pressRelease->text));
return "Titel:\n{$title}\n\nText:\n{$text}";
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Services\PressRelease\ContentScore;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
use App\Services\PressRelease\ContentScore\Drivers\DeterministicContentScoreDriver;
use App\Services\PressRelease\ContentScore\Drivers\OpenAiContentScoreDriver;
use Illuminate\Support\Manager;
/**
* Löst den aktiven Content-Score-Treiber anhand von
* config('scoring.content_score.provider') auf (Laravel-Manager-Pattern).
*/
class ContentScoreManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) ($this->config->get('scoring.content_score.provider') ?: 'deterministic');
}
public function createDeterministicDriver(): ContentScoreDriver
{
return $this->container->make(DeterministicContentScoreDriver::class);
}
public function createOpenaiDriver(): ContentScoreDriver
{
return $this->container->make(OpenAiContentScoreDriver::class);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Services\PressRelease\ContentScore;
/**
* Ergebnis einer Content-Score-Berechnung (Qualität, Konzept §15.2).
*
* Provider-neutral: jeder Treiber liefert einen 0100-Score plus optionalen
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
*/
final class ContentScoreResult
{
/**
* @param int $score 0100
* @param array<string, mixed> $breakdown Faktor-Aufschlüsselung (optional)
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
*/
public function __construct(
public readonly int $score,
public readonly array $breakdown,
public readonly string $provider,
public readonly ?string $model,
public readonly array $rawResponse,
) {}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\PressRelease\ContentScore\Contracts;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
/**
* Vertrag für austauschbare Content-Score-Treiber (OpenAI, später Anthropic/
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
*/
interface ContentScoreDriver
{
/**
* Berechnet den Content-Score (0100) einer Pressemitteilung.
*
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job weicht
* dann auf den deterministischen Fallback aus.
*/
public function score(PressRelease $pressRelease): ContentScoreResult;
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Services\PressRelease\ContentScore\Drivers;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
/**
* Regelbasierter Fallback ohne externe API.
*
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt. Bewertet
* messbare, formale Faktoren (Textlänge, Bild, Quelle, Headline, Vollständigkeit)
* zu einem groben 0100-Score. Ersetzt keine inhaltliche KI-Bewertung, hält den
* Funnel aber lauffähig.
*/
class DeterministicContentScoreDriver implements ContentScoreDriver
{
public function score(PressRelease $pressRelease): ContentScoreResult
{
$breakdown = [];
$score = 40; // Basiswert für eine formal vorhandene PM
$breakdown['base'] = 40;
$textLength = $pressRelease->plainTextLength();
$lengthPoints = match (true) {
$textLength >= 1500 => 20,
$textLength >= 800 => 12,
$textLength >= 300 => 6,
default => 0,
};
$score += $lengthPoints;
$breakdown['length'] = $lengthPoints;
$subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0;
$score += $subtitlePoints;
$breakdown['subtitle'] = $subtitlePoints;
$imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0;
$score += $imagePoints;
$breakdown['image'] = $imagePoints;
$sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0;
$score += $sourcePoints;
$breakdown['source'] = $sourcePoints;
$titleLength = mb_strlen((string) $pressRelease->title);
$headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0;
$score += $headlinePoints;
$breakdown['headline'] = $headlinePoints;
$keywordPoints = filled($pressRelease->keywords) ? 4 : 0;
$score += $keywordPoints;
$breakdown['keywords'] = $keywordPoints;
$contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0;
$score += $contactPoints;
$breakdown['contact'] = $contactPoints;
$score = max(0, min(100, $score));
return new ContentScoreResult(
score: $score,
breakdown: $breakdown,
provider: 'deterministic',
model: null,
rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength],
);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Services\PressRelease\ContentScore\Drivers;
use App\Models\PressRelease;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Content-Score-Treiber auf Basis der OpenAI Chat-Completions-API.
*
* Liest Zugangsdaten aus config/services.openai, Modell/Timeout aus
* config/scoring.content_score. Verlangt strukturiertes JSON
* `{score, breakdown}`. Bei fehlendem Key, HTTP-/Transportfehlern oder
* ungültiger Antwort wird eine Exception geworfen (Job Fallback).
*/
class OpenAiContentScoreDriver implements ContentScoreDriver
{
public function score(PressRelease $pressRelease): ContentScoreResult
{
$openai = config('services.openai');
$apiKey = $openai['api_key'] ?? null;
if (blank($apiKey)) {
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
}
$model = config('scoring.content_score.model') ?: ($openai['model'] ?? 'gpt-5.4-mini');
$timeout = (int) (config('scoring.content_score.timeout') ?: 30);
$response = Http::withToken($apiKey)
->timeout($timeout)
->acceptJson()
->post($openai['url'] ?? 'https://api.openai.com/v1/chat/completions', [
'model' => $model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
],
]);
if ($response->failed()) {
throw new RuntimeException("OpenAI-Content-Score fehlgeschlagen (HTTP {$response->status()}).");
}
$payload = $response->json();
$content = data_get($payload, 'choices.0.message.content');
if (! is_string($content) || trim($content) === '') {
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
}
$parsed = json_decode($content, true);
if (! is_array($parsed) || ! isset($parsed['score']) || ! is_numeric($parsed['score'])) {
throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.');
}
$score = (int) max(0, min(100, (int) round((float) $parsed['score'])));
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
return new ContentScoreResult(
score: $score,
breakdown: $breakdown,
provider: 'openai',
model: $model,
rawResponse: is_array($payload) ? $payload : [],
);
}
private function systemPrompt(): string
{
return <<<'PROMPT'
Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung
auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die
Qualität, nicht die Zulässigkeit.
Berücksichtige diese gewichteten Kategorien:
- Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate
- Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau
- Lesbarkeit (10%): Satzlängen, angemessene Fachsprache
- Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region
- Bildmaterial (10%): Bild vorhanden/erwähnt
- Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen
- Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz
- Originalität (10%): kein Boilerplate, individueller Ton
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15,
"lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10,
"quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}}
PROMPT;
}
private function userPrompt(PressRelease $pressRelease): string
{
$title = (string) $pressRelease->title;
$text = trim(strip_tags((string) $pressRelease->text));
$hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein';
$source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—';
return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}";
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleasePlaceholder;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
/**
* Auflösung des Titelbildes (Hero/Thumb) einer Pressemitteilung.
*
* Liefert immer ein darstellbares Bild: entweder das echte Vorschaubild
* (bevorzugt das als `is_preview` markierte, sonst das erste) oder den
* deterministischen SVG-Platzhalter aus dem Set.
*/
class PressReleaseCoverImage
{
/**
* Liefert die URL zum Titelbild. Fällt auf den SVG-Platzhalter zurück.
*
* Die Variante wird über eine Fallback-Kette aufgelöst, damit auch
* Altbestände ohne `cover`-Variante (1280x580) sauber rendern.
*
* @param string $variant Bevorzugte Bild-Variante (cover|large|medium|thumb).
*/
public function coverUrl(PressRelease $pressRelease, string $variant = 'cover'): string
{
$image = $this->coverImage($pressRelease);
if (! $image) {
return $this->placeholderUrl($pressRelease);
}
foreach ($this->fallbackChain($variant) as $key) {
$url = $image->variantUrl($key);
if ($url !== null) {
return $url;
}
}
return $image->url() ?? $this->placeholderUrl($pressRelease);
}
/**
* Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten.
*
* @return list<string>
*/
private function fallbackChain(string $preferred): array
{
$chain = [$preferred, 'cover', 'large', 'medium', 'thumb'];
return array_values(array_unique($chain));
}
/**
* Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt.
*/
public function coverIsPlaceholder(PressRelease $pressRelease): bool
{
return $this->coverImage($pressRelease) === null;
}
/**
* Die Platzhalter-Variante dieser PM (mit Default-Fallback).
*/
public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder
{
$variant = $pressRelease->placeholder_variant;
if ($variant instanceof PressReleasePlaceholder) {
return $variant;
}
return PressReleasePlaceholder::fromValueOrDefault($variant);
}
private function placeholderUrl(PressRelease $pressRelease): string
{
return asset($this->placeholder($pressRelease)->path());
}
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
{
$images = $pressRelease->relationLoaded('images')
? $pressRelease->images
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get();
return $images->firstWhere('is_preview', true) ?? $images->first();
}
}

View file

@ -2,7 +2,10 @@
namespace App\Services\PressRelease;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
@ -43,9 +46,101 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
// Quota-Stub: zählt den Monatsverbrauch des Autors hoch. Wird vom
// echten Tarif-Modul später abgelöst (Schnittstelle bleibt stabil).
$pressRelease->user?->increment('press_release_quota_used_this_month');
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
}
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
/**
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
*
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
* aktualisiert, der Status bleibt unverändert. So führt das bloße
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
* oder Ablehnung die Entscheidung bleibt beim regulären Workflow/Admin.
*/
public function reclassifyIfClassified(PressRelease $pressRelease): void
{
if ($pressRelease->classification === null) {
return;
}
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
}
/**
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
*/
public function rescoreIfScored(PressRelease $pressRelease): void
{
if ($pressRelease->content_score === null) {
return;
}
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
}
/**
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
* (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen.
*
* - Rot Ablehnung mit Begründung an den Autor
* - Gelb bleibt in der manuellen Review-Queue
* - Grün automatische Veröffentlichung (sofort bzw. zum geplanten Termin)
*
* Greift nur, solange die PM noch im Status `review` steht; manuelle
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
*/
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
{
if ($pressRelease->status !== PressReleaseStatus::Review) {
return;
}
if ($classification === PressReleaseClassification::Red) {
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
return;
}
if ($classification === PressReleaseClassification::Green) {
$this->autoPublishGreen($pressRelease);
}
// Gelb: keine Aktion bleibt zur manuellen Prüfung im Status „review".
}
/**
* Veröffentlicht eine grün klassifizierte PM automatisch.
*
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
* publiziert optional mit einem Sicherheitsfenster
* (scoring.classification.green_delay_minutes).
*/
private function autoPublishGreen(PressRelease $pressRelease): void
{
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
return;
}
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
$this->publish($pressRelease, 'ki', $publishedAtOverride);
}
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
@ -63,7 +158,7 @@ class PressReleaseService
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease),
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
@ -83,14 +178,18 @@ class PressReleaseService
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
* öffentlichen Listing.
*
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
* ist ein geplanter Termin hat stets Vorrang.
*/
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
{
if ($pressRelease->published_at) {
return $pressRelease->published_at;
}
$base = $pressRelease->scheduled_at ?: now();
$base = $pressRelease->scheduled_at ?: ($override ?? now());
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
return $pressRelease->embargo_at;
@ -99,7 +198,7 @@ class PressReleaseService
return $base;
}
public function reject(PressRelease $pressRelease, ?string $reason = null): void
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
@ -107,7 +206,7 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
$this->notifyAuthor($pressRelease, 'rejected', $reason);
}