press-releases: Publish-Rechte, Quota-Races & Slug vereinheitlichen

- Publish/Reject/Archive und changeStatus erzwingen konsistent
  press-releases:publish; changeStatus schloss die Lücke, über die das
  Status-Modal die Prüfung umging (Fix#1/H2).
- publish()/changeStatusFromAdmin sperren PM- und User-Zeile
  transaktional; QuotaExceededException wird in Admin-Publish und im
  Auto-Publish-Job abgefangen (Warnung statt 500 / bleibt in Review, Fix#3/M2).
- Slug-Erzeugung nur noch über HasUniqueSlug (generateUniqueSlug +
  saveWithUniqueSlugRetry), redundanter Controller-Helper entfernt (M3).
- API-/Legacy-Text wird vor der Persistenz sanitisiert (M6);
  Scheduler/Legacy-Tracking ignorieren soft-gelöschte PMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:47:51 +00:00
parent 7043ea8ad5
commit 0b35c3b04d
19 changed files with 536 additions and 91 deletions

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Console\Command;
@ -47,7 +48,7 @@ class PublishScheduledPressReleases extends Command
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScopes()
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,

View file

@ -3,15 +3,16 @@
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
@ -19,6 +20,8 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use function Illuminate\Support\defer;
/**
* Alt-Vertrag POST/GET /service/pressreleases (Paritäts-Doku §1/§4):
*
@ -37,8 +40,13 @@ class LegacyPressReleaseController extends Controller
public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
public function store(Request $request, PressReleaseService $service): JsonResponse
{
public function store(
Request $request,
PressReleaseService $service,
PressReleaseHtmlSanitizer $sanitizer,
): JsonResponse {
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
try {
$validated = $request->validate([
'press_release' => ['required', 'array'],
@ -77,13 +85,16 @@ class LegacyPressReleaseController extends Controller
}
$language = strtolower(substr((string) ($validated['language'] ?? 'de'), 0, 2)) ?: 'de';
$text = $sanitizer->isHtml($validated['text'])
? $sanitizer->clean($validated['text'])
: $validated['text'];
$pressRelease = new PressRelease([
'company_id' => $company->id,
'category_id' => $category->id,
'language' => $language,
'title' => $validated['title'],
'text' => $validated['text'],
'text' => $text,
'backlink_url' => $validated['backlink_url'] ?? null,
'keywords' => $validated['keywords'] ?? null,
'status' => PressReleaseStatus::Draft->value,
@ -99,7 +110,10 @@ class LegacyPressReleaseController extends Controller
'portal' => $pressRelease->portal,
'language' => $language,
]);
$pressRelease->save();
$pressRelease->saveWithUniqueSlugRetry($pressRelease->title, [
'portal' => $pressRelease->portal,
'language' => $language,
]);
if (! empty($validated['contacts_list'])) {
$ownContactIds = $company->contacts()->whereIn('contacts.id', $validated['contacts_list'])->pluck('contacts.id');
@ -137,7 +151,7 @@ class LegacyPressReleaseController extends Controller
public function show(Request $request, int $pressRelease): JsonResponse
{
$pressRelease = PressRelease::withoutGlobalScopes()
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease)
->with(['company'])
->first();
@ -150,11 +164,10 @@ class LegacyPressReleaseController extends Controller
return $this->legacyError(404, ['Not found.']);
}
// Hit-Zählung wie im Altsystem (dort auch für API-Abrufe).
PressRelease::withoutGlobalScopes()
->whereKey($pressRelease->getKey())
->update(['hits' => ($pressRelease->hits ?? 0) + 1]);
$pressRelease->events()->create(['type' => PressReleaseEventType::View]);
// Hit-Zählung wie im Altsystem (dort auch für API-Abrufe), aber erst
// nach der Antwort und atomar, damit parallele Polls keine Hits verlieren.
$pressReleaseId = $pressRelease->getKey();
defer(static fn () => PressRelease::recordDeferredView($pressReleaseId));
$spokeUrl = $this->spokeBaseUrl($pressRelease->portal?->value);

View file

@ -13,6 +13,7 @@ use App\Models\PressRelease;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
@ -40,20 +41,15 @@ class PressReleaseController extends Controller
public function store(StorePressReleaseRequest $request): JsonResponse
{
$validated = $request->validated();
$validated = $this->sanitizeHtmlText($request->validated());
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
abort_unless($company !== null, 403);
$pressRelease = PressRelease::withoutGlobalScopes()->create([
$pressRelease = new PressRelease([
...$validated,
'uuid' => (string) Str::uuid(),
'user_id' => $request->user()->id,
'portal' => $company->portal->value,
'slug' => $this->uniqueSlug(
Str::slug($validated['title']),
$company->portal->value,
$validated['language'],
),
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
// `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
@ -68,6 +64,14 @@ class PressReleaseController extends Controller
'ai_declaration_confirmed_at' => now(),
] : []),
]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
'portal' => $company->portal->value,
'language' => $validated['language'],
]);
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
'portal' => $company->portal->value,
'language' => $validated['language'],
]);
return PressReleaseResource::make(
$pressRelease->load($this->apiResourceRelations())
@ -96,7 +100,7 @@ class PressReleaseController extends Controller
], 409);
}
$validated = $request->validated();
$validated = $this->sanitizeHtmlText($request->validated());
$company = isset($validated['company_id'])
? $this->findOwnedCompany((int) $validated['company_id'], $request)
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
@ -116,10 +120,17 @@ class PressReleaseController extends Controller
|| array_key_exists('language', $validated)
|| array_key_exists('company_id', $validated)
) {
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
// Selbst-Ausschluss übernimmt der Trait automatisch ($self->exists).
$pressRelease->slug = $pressRelease->generateUniqueSlug($title, [
'portal' => $portal,
'language' => $language,
]);
}
$pressRelease->save();
$pressRelease->saveWithUniqueSlugRetry($title, [
'portal' => $portal,
'language' => $language,
]);
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
if ($pressRelease->wasChanged(['title', 'text'])) {
@ -247,24 +258,24 @@ class PressReleaseController extends Controller
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
}
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
/**
* Säubert einen per API übergebenen HTML-Text vor der Persistenz — analog
* zum Web-Formular, das immer über den Sanitizer speichert. Nur HTML wird
* durch HTMLPurifier gejagt; Legacy-Plaintext bleibt unverändert (wird beim
* Rendern ohnehin escaped), damit `<`/`&` in Klartext-Einreichungen nicht
* zerstört werden.
*
* @param array<string, mixed> $validated
* @return array<string, mixed>
*/
private function sanitizeHtmlText(array $validated): array
{
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
$candidate = $slug;
$suffix = 2;
$sanitizer = app(PressReleaseHtmlSanitizer::class);
while (
PressRelease::withoutGlobalScopes()
->where('portal', $portal)
->where('language', $language)
->where('slug', $candidate)
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
->exists()
) {
$candidate = "{$slug}-{$suffix}";
$suffix++;
if (array_key_exists('text', $validated) && $sanitizer->isHtml($validated['text'])) {
$validated['text'] = $sanitizer->clean($validated['text']);
}
return $candidate;
return $validated;
}
}

View file

@ -8,6 +8,7 @@ use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
@ -80,6 +81,13 @@ class ClassifyPressRelease implements ShouldQueue
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
} catch (BlacklistViolationException) {
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
} catch (QuotaExceededException $exception) {
// Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM
// bleibt in Review, damit Autor/Admin bewusst entscheiden — kein
// Job-Retry, der die Klassifikation sinnlos wiederholt.
Log::info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Support\Str;
/**
@ -49,6 +50,37 @@ trait HasUniqueSlug
return $slug;
}
/**
* Persist the model and recover from a concurrent slug insert by
* regenerating the next available suffix.
*
* @param array<string, mixed> $scope
* @param array<string, mixed> $options
*/
public function saveWithUniqueSlugRetry(
string $source,
array $scope = [],
array $options = [],
int $maxAttempts = 3,
): bool {
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $this->save($options);
} catch (QueryException $exception) {
if ($attempt === $maxAttempts || ! $this->isSlugUniqueConstraintViolation($exception)) {
throw $exception;
}
$this->setAttribute(
$this->slugColumn(),
$this->generateUniqueSlug($source, $scope),
);
}
}
return false;
}
/**
* @param array<string, mixed> $scope
*/
@ -98,6 +130,15 @@ trait HasUniqueSlug
return 'item';
}
private function isSlugUniqueConstraintViolation(QueryException $exception): bool
{
$message = Str::lower($exception->getMessage());
$isUniqueViolation = in_array((string) $exception->getCode(), ['23000', '23505'], true)
|| Str::contains($message, ['unique constraint', 'duplicate entry', 'duplicate key']);
return $isUniqueViolation && Str::contains($message, $this->slugColumn());
}
/**
* Hook for models to add additional WHERE constraints (e.g. soft-deleted
* records). Default: no additional constraints.

View file

@ -7,6 +7,7 @@ use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Http\Middleware\SetEdition;
@ -264,6 +265,25 @@ class PressRelease extends Model
return $this->hasMany(PressReleaseEvent::class);
}
/**
* Zählt einen Aufruf atomar (increment `hits`) und schreibt ein View-Event.
* Zentral, weil sowohl die Web-Detailseite als auch die Legacy-API dies
* per `defer()` nach der Response ausführen. Das Event wird direkt über die
* bekannte ID angelegt (kein erneutes Laden der PM).
*/
public static function recordDeferredView(int $pressReleaseId): void
{
static::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->increment('hits');
PressReleaseEvent::create([
'press_release_id' => $pressReleaseId,
'type' => PressReleaseEventType::View,
]);
}
/**
* Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) —
* jeder Submit erzeugt einen neuen, unveränderlichen Eintrag.
@ -373,6 +393,18 @@ class PressRelease extends Model
return $query->published()->where('published_at', '<=', now());
}
/**
* Instanz-Pendant zu scopeVisibleInArchive(): wertet die öffentliche
* Sichtbarkeit aus den bereits geladenen Attributen aus, ohne erneute
* DB-Abfrage (für die hochfrequenten Medienrouten).
*/
public function isVisibleInArchive(): bool
{
return $this->status === PressReleaseStatus::Published
&& $this->published_at !== null
&& $this->published_at->lessThanOrEqualTo(now());
}
/**
* @param list<string> $portalValues
*/

View file

@ -17,10 +17,11 @@ use App\Models\PressReleaseAiDeclaration;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
@ -32,7 +33,10 @@ use Illuminate\Support\Facades\Mail;
*/
class PressReleaseService
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function __construct(
private readonly BlacklistService $blacklist,
private readonly AdminPerformanceCache $adminCache,
) {}
/**
* @param string $source Einlieferungsweg fuers Status-Log ('customer'
@ -203,28 +207,48 @@ class PressReleaseService
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
$blacklistViolation = DB::transaction(function () use ($pressRelease, $source, $publishedAtOverride): ?array {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
->lockForUpdate()
->firstOrFail();
$previous = $pressRelease->status;
$this->assertStatus($locked, [PressReleaseStatus::Review]);
$previous = $locked->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
if ($word = $this->blacklist->findInPressRelease($locked)) {
$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);
$locked->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
throw new BlacklistViolationException($reason, $word);
return ['reason' => $reason, 'word' => $word];
}
$this->consumePublishSlot($locked);
$locked->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride),
]);
$this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source);
return null;
});
$pressRelease->refresh();
if ($blacklistViolation !== null) {
$this->notifyAuthor($pressRelease, 'rejected', $blacklistViolation['reason']);
throw new BlacklistViolationException(
$blacklistViolation['reason'],
$blacklistViolation['word'],
);
}
$this->consumePublishSlot($pressRelease);
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
$this->notifyAuthor($pressRelease, 'published');
}
@ -251,7 +275,10 @@ class PressReleaseService
return;
}
$user = $pressRelease->user;
$user = User::query()
->whereKey($pressRelease->user_id)
->lockForUpdate()
->first();
if (! $user || $user->hasUnlimitedPressReleaseQuota()) {
return;
@ -265,15 +292,21 @@ class PressReleaseService
return;
}
$user->singlePurchases()
$purchase = $user->singlePurchases()
->grantingSubmission()
->oldest('paid_at')
->first()
?->update([
'status' => SinglePurchaseStatus::Consumed->value,
'consumed_at' => now(),
'press_release_id' => $pressRelease->id,
]);
->lockForUpdate()
->first();
if ($purchase === null) {
throw new QuotaExceededException;
}
$purchase->update([
'status' => SinglePurchaseStatus::Consumed->value,
'consumed_at' => now(),
'press_release_id' => $pressRelease->id,
]);
}
/**
@ -311,13 +344,21 @@ class PressReleaseService
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
DB::transaction(function () use ($pressRelease, $reason, $source): void {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
->lockForUpdate()
->firstOrFail();
$previous = $pressRelease->status;
$this->assertStatus($locked, [PressReleaseStatus::Review]);
$previous = $locked->status;
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
$locked->update(['status' => PressReleaseStatus::Rejected->value]);
$this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, $source);
});
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
$pressRelease->refresh();
$this->notifyAuthor($pressRelease, 'rejected', $reason);
}
@ -362,22 +403,31 @@ class PressReleaseService
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;
DB::transaction(function () use ($pressRelease, $status, $reason): void {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
->lockForUpdate()
->firstOrFail();
$previous = $locked->status;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($pressRelease);
}
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($locked);
}
$pressRelease->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published
? ($pressRelease->published_at ?? now())
: $pressRelease->published_at,
]);
$locked->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published
? ($locked->published_at ?? now())
: $locked->published_at,
]);
if ($previous !== $status) {
$this->logStatusChange($pressRelease, $previous, $status, $reason, 'admin');
}
if ($previous !== $status) {
$this->logStatusChange($locked, $previous, $status, $reason, 'admin');
}
});
$pressRelease->refresh();
}
public function deleteFromAdmin(PressRelease $pressRelease): void
@ -480,8 +530,7 @@ class PressReleaseService
'created_at' => now(),
]);
Cache::forget(AdminPerformanceCache::PressReleaseStats);
Cache::forget(AdminPerformanceCache::PressReleaseReviewCount);
$this->adminCache->forgetPressReleaseStats();
}
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void