12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,106 @@
<?php
namespace App\Services\PressRelease;
use App\Models\PressRelease;
/**
* Wortbasierte Blacklist-Prüfung für Pressemitteilungen.
*
* Die Liste kommt aktuell aus config/blacklist.php; ein Wechsel auf
* eine Datenbank-Tabelle (Admin-UI) ist später ohne API-Bruch möglich.
*/
class BlacklistService
{
/**
* @var list<string>|null
*/
private ?array $words = null;
/**
* @param array{words?: list<string>}|null $config
*/
public function __construct(?array $config = null)
{
if ($config !== null) {
$this->words = $this->normalizeList($config['words'] ?? []);
}
}
/**
* Findet das erste Blacklist-Wort, das in Titel oder Text vorkommt.
*/
public function findInPressRelease(PressRelease $pressRelease): ?string
{
$haystack = $pressRelease->title.' '.$pressRelease->text;
return $this->find($haystack);
}
/**
* Findet das erste Blacklist-Wort in einem beliebigen String.
* Vergleich ist case-insensitiv und ganzwörtlich.
*/
public function find(string $text): ?string
{
$words = $this->words();
if ($words === []) {
return null;
}
$needle = $this->normalizeText($text);
foreach ($words as $word) {
$padded = ' '.trim($word).' ';
if (trim($padded) === '') {
continue;
}
if (str_contains($needle, $padded)) {
return trim($padded);
}
}
return null;
}
public function matches(string $text): bool
{
return $this->find($text) !== null;
}
/**
* @return list<string>
*/
private function words(): array
{
if ($this->words === null) {
$this->words = $this->normalizeList((array) config('blacklist.words', []));
}
return $this->words;
}
/**
* @param array<int, mixed> $words
* @return list<string>
*/
private function normalizeList(array $words): array
{
return array_values(array_filter(array_map(
fn ($word): string => $this->normalizeText((string) $word),
$words,
), fn (string $word): bool => $word !== ''));
}
private function normalizeText(string $text): string
{
$text = mb_strtolower($text, 'UTF-8');
$text = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
return ' '.trim($text).' ';
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
class BlacklistViolationException extends RuntimeException
{
public function __construct(string $message, public readonly string $word)
{
parent::__construct($message);
}
}

View file

@ -0,0 +1,196 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
/**
* Kapselt Statusübergänge für Pressemitteilungen inkl. Benachrichtigungs-Mails.
*
* Jede Methode verändert exklusiv den Status und versendet optional eine Mail
* an den Autor (User). Die Caller-Schicht (Volt, Command, API) muss nur noch
* diese Methoden aufrufen keine Mail-Logik außerhalb dieser Klasse.
*/
class PressReleaseService
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function submitForReview(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
throw new BlacklistViolationException($reason, $word);
}
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
}
public function publish(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
throw new BlacklistViolationException($reason, $word);
}
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $pressRelease->published_at ?? now(),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, 'admin');
$this->notifyAuthor($pressRelease, 'published');
}
public function reject(PressRelease $pressRelease, ?string $reason = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
$this->notifyAuthor($pressRelease, 'rejected', $reason);
}
public function backToDraft(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review, PressReleaseStatus::Rejected]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Draft->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Draft, null, 'admin');
}
public function archive(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
}
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;
$pressRelease->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published
? ($pressRelease->published_at ?? now())
: $pressRelease->published_at,
]);
if ($previous !== $status) {
$this->logStatusChange($pressRelease, $previous, $status, $reason, 'admin');
}
}
public function deleteFromAdmin(PressRelease $pressRelease): void
{
if ($pressRelease->status === PressReleaseStatus::Published) {
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'text' => AdminPreset::activeValue(
AdminPreset::PRESS_RELEASE_DELETED_PUBLISHED_TEXT,
"Diese Pressemitteilung wurde entfernt.\n\nDer Inhalt ist nicht mehr verfuegbar."
),
'keywords' => null,
'backlink_url' => null,
'no_export' => true,
]);
return;
}
$pressRelease->delete();
}
/**
* @param PressReleaseStatus[] $allowed
*/
private function assertStatus(PressRelease $pressRelease, array $allowed): void
{
if (! in_array($pressRelease->status, $allowed, true)) {
$allowedValues = implode(', ', array_map(fn ($s) => $s->value, $allowed));
$currentStatus = $pressRelease->status instanceof PressReleaseStatus
? $pressRelease->status->value
: (string) $pressRelease->status;
throw new \LogicException(
"Statusübergang nicht erlaubt. Aktueller Status: {$currentStatus}, erwartet: {$allowedValues}"
);
}
}
private function logStatusChange(
PressRelease $pressRelease,
?PressReleaseStatus $from,
PressReleaseStatus $to,
?string $reason,
string $source,
): void {
PressReleaseStatusLog::query()->create([
'press_release_id' => $pressRelease->id,
'changed_by_user_id' => Auth::id(),
'from_status' => $from?->value,
'to_status' => $to->value,
'reason' => $reason,
'source' => $source,
'created_at' => now(),
]);
Cache::forget(AdminPerformanceCache::PressReleaseStats);
Cache::forget(AdminPerformanceCache::PressReleaseReviewCount);
}
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void
{
$user = $pressRelease->user;
if (! $user || ! $user->email) {
return;
}
$mailable = match ($event) {
'published' => new PressReleasePublished($user, $pressRelease),
'rejected' => new PressReleaseRejected($user, $pressRelease, $reason),
default => null,
};
if ($mailable) {
Mail::to($user->email)->queue($mailable);
}
}
}