press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)

- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent d6e49c11c3
commit c82f279fef
20 changed files with 927 additions and 89 deletions

View file

@ -5,6 +5,7 @@ use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use App\Services\Newsletter\NewsletterSendingDisabledException;
use Flux\Flux;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
@ -104,7 +105,14 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
{
$campaign = $this->persist();
$total = app(CampaignSender::class)->start($campaign);
try {
$total = app(CampaignSender::class)->start($campaign);
} catch (NewsletterSendingDisabledException $e) {
Flux::toast(heading: __('Versand gesperrt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-campaign-send')->close();
return;
}
if ($total === 0) {
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
@ -241,12 +249,23 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
{{ __('Test-Mail an mich') }}
</flux:button>
<flux:spacer />
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
@if (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
@else
<flux:button variant="primary" icon="paper-airplane" disabled>
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
@endif
</div>
@unless (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:callout variant="warning" icon="exclamation-triangle" class="mt-3"
:heading="__('Der Newsletter-Versand ist derzeit gesperrt')"
:text="__('Kampagnen lassen sich als Entwurf vorbereiten und als Test-Mail prüfen. Der Versand wird erst mit NEWSLETTER_SENDING_ENABLED freigeschaltet.')" />
@endunless
</div>
</article>

View file

@ -15,6 +15,7 @@ use App\Models\PressReleaseSearchIndex;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
@ -184,11 +185,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}
/**
* Manuelle KI-Prüfung aus der Liste — bewusst ohne Routing: bewertet
* und protokolliert, ändert aber nie den Status (Admin entscheidet).
* Manuelle KI-Prüfung aus der Liste — ohne Routing: bewertet und
* protokolliert, gibt aber nie frei. Ausnahme (Audit U03): Liefert der
* Lauf Rot und wartet die Meldung noch in `review`, lehnt der Job sie ab
* (inkl. Autorenmail). Deshalb dieselbe Berechtigung wie Freigeben und
* Ablehnen (Audit K-06).
*/
public function runAiCheck(?int $id = null): void
{
$this->authorize('press-releases:publish');
$id ??= $this->aiCheckId;
if ($id === null) {
@ -241,6 +247,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
} catch (QuotaExceededException $e) {
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
return;
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
return;
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');