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:
parent
7043ea8ad5
commit
0b35c3b04d
19 changed files with 536 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue