From 4419d9ff43b53e14aea8c6f520cdf0183da31254 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 09:47:06 +0000 Subject: [PATCH] Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9A — Gelb geht direkt live (Entscheidung 12.06.2026): - routeByClassification(): Gelb durchlaeuft denselben Auto-Publish-Pfad wie Gruen (autoPublishApproved); nur Rot wird abgelehnt - Scheduler publiziert faellige gelbe + gruene PMs; unklassifizierte bleiben als Fallback in der manuellen Queue 9B — Slot-Verbrauch bei Veroeffentlichung (Decision-Update 3.2): - Increment aus submitForReview() entfernt; publish() und changeStatusFromAdmin() zaehlen idempotent beim ersten published-Uebergang (Pruefung ueber Status-Logs); Rot kostet nichts - Submit-Guard: Einreichen erfordert freien Slot (QuotaExceededException, API 422) 9C — Submit-Gate vorbereitet (Decision-Update 5.1): - User::hasActiveBooking()-Stub hinter config/billing.php (enforce_booking, Default aus); Tarif-Modul ersetzt nur den Rumpf - Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis; Server-Guard (BookingRequiredException), API antwortet 402 - Fix: Customer-Create legte PMs bei "Zur Pruefung senden" direkt mit Status review an (vorbei an Blacklist/Quota/KI/Status-Log) — laeuft jetzt immer ueber submitForReview() Suite: 451 passed, 4 skipped (9 neue Tests). Pint clean. Plan: docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md (Block 2 nach Review-Stopp). Co-Authored-By: Claude Fable 5 --- .../PublishScheduledPressReleases.php | 8 +- .../Api/V1/PressReleaseController.php | 10 ++ app/Models/User.php | 18 +++ .../PressRelease/BookingRequiredException.php | 17 ++ .../PressRelease/PressReleaseService.php | 68 ++++++-- .../PressRelease/QuotaExceededException.php | 17 ++ config/billing.php | 19 +++ dev/frontend/hub-flux/PROGRESS.md | 42 +++++ docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md | 10 +- docs/STATUS-ABGLEICH-USER-PANEL.md | 6 +- docs/user-admin/checkliste-user-backend.md | 9 +- .../press-release-submit-modal.blade.php | 145 +++++++++++------- .../customer/press-releases/create.blade.php | 60 ++++++-- .../customer/press-releases/edit.blade.php | 11 ++ .../customer/press-releases/index.blade.php | 4 + .../customer/press-releases/show.blade.php | 13 ++ .../Api/V1/PressReleaseSubmitApiTest.php | 46 +++++- .../PressReleaseClassificationJobTest.php | 27 +++- .../PressReleasePublishModalPhase8iTest.php | 17 +- tests/Feature/PressReleaseQuotaTest.php | 98 ++++++++++-- tests/Feature/PressReleaseSchedulingTest.php | 20 ++- 21 files changed, 551 insertions(+), 114 deletions(-) create mode 100644 app/Services/PressRelease/BookingRequiredException.php create mode 100644 app/Services/PressRelease/QuotaExceededException.php create mode 100644 config/billing.php diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index 26480ac..5b01b8e 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -44,9 +44,15 @@ class PublishScheduledPressReleases extends Command $now = now(); + // 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() ->where('status', PressReleaseStatus::Review->value) - ->where('classification', PressReleaseClassification::Green->value) + ->whereIn('classification', [ + PressReleaseClassification::Green->value, + PressReleaseClassification::Yellow->value, + ]) ->whereNotNull('scheduled_at') ->where('scheduled_at', '<=', $now) ->orderBy('scheduled_at') diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 30baf83..ce561f7 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -10,7 +10,9 @@ use App\Http\Resources\PressReleaseResource; use App\Models\Company; use App\Models\PressRelease; use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\QuotaExceededException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -141,6 +143,14 @@ class PressReleaseController extends Controller try { $service->submitForReview($pressRelease); + } catch (BookingRequiredException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 402); + } catch (QuotaExceededException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); } catch (BlacklistViolationException $exception) { return response()->json([ 'message' => $exception->getMessage(), diff --git a/app/Models/User.php b/app/Models/User.php index a07089f..e155365 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -92,6 +92,24 @@ class User extends Authenticatable return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month); } + /** + * Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung + * erfordert eine aktive Buchung. + * + * Stub bis zum Tarif-Modul (Phase 9D/9E): solange + * `billing.enforce_booking` deaktiviert ist (Default), gilt jede:r als + * gebucht. Das Tarif-Modul ersetzt den Rumpf durch die echte + * Subscription-/Einzelkauf-Prüfung — die Schnittstelle bleibt stabil. + */ + public function hasActiveBooking(): bool + { + if (! config('billing.enforce_booking')) { + return true; + } + + return false; + } + /** * Get the user's initials */ diff --git a/app/Services/PressRelease/BookingRequiredException.php b/app/Services/PressRelease/BookingRequiredException.php new file mode 100644 index 0000000..ddf097f --- /dev/null +++ b/app/Services/PressRelease/BookingRequiredException.php @@ -0,0 +1,17 @@ +assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]); + $user = $pressRelease->user; + + // Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive + // Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub. + if ($user && ! $user->hasActiveBooking()) { + throw new BookingRequiredException; + } + + // Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht + // (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn + // noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne + // verfügbares Kontingent automatisch veröffentlicht. + if ($user && $user->pressReleaseQuotaRemaining() <= 0) { + throw new QuotaExceededException; + } + $previous = $pressRelease->status; if ($word = $this->blacklist->findInPressRelease($pressRelease)) { @@ -47,10 +63,6 @@ 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'); @@ -92,11 +104,14 @@ class PressReleaseService /** * Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses - * (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen. + * (Decision-Update §5.0, Entscheidung 12.06.2026). 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) + * - Rot → Ablehnung mit Begründung an den Autor + * - Gelb/Grün → automatische Veröffentlichung (sofort bzw. zum Termin); + * Gelb bleibt als interne Markierung erhalten (nicht + * boostbar, Admin-Signal), löst aber keine manuelle + * Prüfung aus * * Greift nur, solange die PM noch im Status `review` steht; manuelle * Admin-Eingriffe in der Zwischenzeit haben damit Vorrang. @@ -113,22 +128,18 @@ class PressReleaseService return; } - if ($classification === PressReleaseClassification::Green) { - $this->autoPublishGreen($pressRelease); - } - - // Gelb: keine Aktion – bleibt zur manuellen Prüfung im Status „review". + $this->autoPublishApproved($pressRelease); } /** - * Veröffentlicht eine grün klassifizierte PM automatisch. + * Veröffentlicht eine als Gelb oder 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 + private function autoPublishApproved(PressRelease $pressRelease): void { if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) { return; @@ -156,6 +167,8 @@ class PressReleaseService throw new BlacklistViolationException($reason, $word); } + $this->consumePublishSlot($pressRelease); + $pressRelease->update([ 'status' => PressReleaseStatus::Published->value, 'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride), @@ -165,6 +178,27 @@ class PressReleaseService $this->notifyAuthor($pressRelease, 'published'); } + /** + * Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers + * (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte + * PMs kosten nichts). Erneutes Publizieren — etwa nach Archivierung — + * zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem + * Schreiben des neuen Status-Logs aufgerufen werden. + */ + private function consumePublishSlot(PressRelease $pressRelease): void + { + $alreadyPublishedOnce = PressReleaseStatusLog::query() + ->where('press_release_id', $pressRelease->id) + ->where('to_status', PressReleaseStatus::Published->value) + ->exists(); + + if ($alreadyPublishedOnce) { + return; + } + + $pressRelease->user?->increment('press_release_quota_used_this_month'); + } + /** * Bestimmt das wirksame `published_at` einer PM. * @@ -234,6 +268,10 @@ class PressReleaseService { $previous = $pressRelease->status; + if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) { + $this->consumePublishSlot($pressRelease); + } + $pressRelease->update([ 'status' => $status->value, 'published_at' => $status === PressReleaseStatus::Published diff --git a/app/Services/PressRelease/QuotaExceededException.php b/app/Services/PressRelease/QuotaExceededException.php new file mode 100644 index 0000000..bdf63bc --- /dev/null +++ b/app/Services/PressRelease/QuotaExceededException.php @@ -0,0 +1,17 @@ + env('BILLING_ENFORCE_BOOKING', false), + +]; diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index b654dc1..7966ad0 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,48 @@ --- +## 2026-06-12 · Phase 9 · Veröffentlichungs-Flow Block 1 (9A–9C) ✅ + +Plan-Doc: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`. Grundlage: +`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md` +(+ Entscheidung 12.06.: Gelb geht direkt live). + +Vorab Block 0: Repo aufgeräumt (3 Artefakt-Dateien im Root entfernt), +der unkommittierte Stand vom 29.05.–11.06. in drei Commits gesichert +(Vite/Multi-Domain-Infra, User-Panel/KI-Pipeline, Doku-Sync). + +**9A — Gelb-Routing Direkt-Live** +- `routeByClassification()`: Gelb durchläuft denselben Auto-Publish-Pfad + wie Grün (`autoPublishApproved()`); nur Rot wird abgelehnt. +- Scheduler publiziert fällige gelbe + grüne PMs; unklassifizierte + bleiben als Fallback in der manuellen Queue. + +**9B — Slot-Verbrauch bei Veröffentlichung** +- Increment aus `submitForReview()` entfernt; `publish()` und + `changeStatusFromAdmin()` zählen idempotent beim ersten + `published`-Übergang (Prüfung über Status-Logs). Rot kostet nichts. +- Submit-Guard: Einreichen erfordert freien Slot + (`QuotaExceededException`, API 422). + +**9C — Submit-Gate + Funnel-Fix** +- `User::hasActiveBooking()`-Stub hinter `config/billing.php` + (`enforce_booking`, Default aus) — Tarif-Modul ersetzt nur den Rumpf. +- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis mit + CTA zur Buchungs-Seite; Server-Guard (`BookingRequiredException`), + API antwortet 402. +- **Befund + Fix**: Customer-Create legte PMs bei „Zur Prüfung senden" + direkt mit Status `review` an — vorbei an Blacklist, Quota, KI und + Status-Log. Jetzt: immer Draft anlegen, dann `submitForReview()`. + +**Verifikation**: Suite 451 passed / 4 skipped (9 neue Tests: +Quota-Semantik, Gelb-Routing, Gate via Service/API/Modal). Pint clean. + +Nächster Schritt: Review-Stopp, dann Block 2 (9D–9J: Tarif-Datenmodell, +Stripe/Cashier — Dependency-Freigabe nötig, Tarif-UI, Tageslimit, +Einzel-PM, Launch-Credits). + +--- + ## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F–8K) ✅ Abschluss von Phase 8. Die erste Hälfte (8A–8E: Show-Page-Lücken, diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index cbb5600..7e9f50c 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -1,7 +1,7 @@ # Phase 9 · Veröffentlichungs-Flow (Launch) & Tarif-Modul -Stand: 2026-06-12 — **in Umsetzung** (Block 1: 9A–9C zuerst, dann Review-Stopp, -dann Block 2: 9D–9J). +Stand: 2026-06-12 — **Block 1 (9A–9C) abgeschlossen**; Review-Stopp vor +Block 2 (9D–9J, Tarif-Modul). Suite nach Block 1: 451 passed, 4 skipped. Vorgänger: Phase 8 (User-Panel-Konsolidierung) + KI-Prüf-Pipeline (beide abgeschlossen). Verbindliche Entscheidungen: [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) Abgleich-Doku: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md) @@ -36,9 +36,9 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: | ID | Thema | Größe | Risiko | |---|---|---|---| -| **9A** | Gelb-Routing auf Direkt-Live umstellen (Routing, Scheduler, Tests) | S | gering | -| **9B** | Slot-Verbrauch von Einreichung auf Veröffentlichung umstellen (Rot = kein Slot) | M | mittel (Idempotenz) | -| **9C** | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) | M | gering | +| **9A** ✅ | Gelb-Routing auf Direkt-Live umstellen (Routing, Scheduler, Tests) | S | gering | +| **9B** ✅ | Slot-Verbrauch von Einreichung auf Veröffentlichung umstellen (Rot = kein Slot) | M | mittel (Idempotenz) | +| **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering | | — | **Review-Stopp mit User** | | | | **9D** | Tarif-Datenmodell: Pläne, Subscriptions, Einzel-PM-Käufe; Quota-Stub ablösen | L | hoch (Datenmodell) | | **9E** | Stripe-Anbindung (Laravel Cashier — **Dependency-Freigabe nötig**) | L | mittel | diff --git a/docs/STATUS-ABGLEICH-USER-PANEL.md b/docs/STATUS-ABGLEICH-USER-PANEL.md index ecb6eb4..2966a8f 100644 --- a/docs/STATUS-ABGLEICH-USER-PANEL.md +++ b/docs/STATUS-ABGLEICH-USER-PANEL.md @@ -140,7 +140,7 @@ eingearbeitet. Preise & Veröffentlichungs-Flow: siehe | Punkt | Code-Stand | |---|---| | KI-Prüfung mit JSON-Antwort | **umgesetzt** — `ClassifyPressRelease`-Job (Queue `classification`), OpenAI-Treiber + deterministischer Fallback, provider-agnostische Architektur unter `app/Services/PressRelease/Classification/` | -| Drei-Stufen-Ergebnis grün/gelb/rot | **umgesetzt** — `press_releases.classification`; Routing aktuell: Rot → `rejected` + Mail, Gelb → manuelle Admin-Queue, Grün → Auto-Publish. ⚠️ **Entscheidung 12.06.2026**: Gelb geht zum Launch **direkt live** wie Grün (Umstellung in Phase 9A) | +| Drei-Stufen-Ergebnis grün/gelb/rot | **umgesetzt** — `press_releases.classification`; Routing (seit Phase 9A, Entscheidung 12.06.2026): Rot → `rejected` + Mail, **Gelb/Grün → Auto-Publish** (sofort/zum Termin); unklassifizierte PMs bleiben als Fallback in der manuellen Queue | | Logging der KI-Antworten | **umgesetzt** — `ki_audits`-Tabelle (append-only, inkl. Provider/Modell/Begründung/Raw-Response) | | Content-Score 0–100 → Stufe | **umgesetzt** — `content_score`/`content_tier` (`ScorePressRelease`-Job), Editor-Panel, Admin-Badges, öffentliches Stufen-Badge in Customer-Show | | Re-Klassifikation bei Änderung | **umgesetzt** — `reclassifyIfClassified()`/`rescoreIfScored()` bei Titel-/Text-Änderung (Customer, Admin, API) | @@ -209,8 +209,8 @@ im öffentlichen Web-Frontend. | Tarif-Raster Starter/Business/Pro/Agency (29/49/99/199 €, 3/10/25/60 PMs) | **nicht im Datenmodell** | | Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** | | Zahlung/Checkout (Stripe) | **fehlt** | -| Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | ⚠️ **abweichend** — Quota-Stub zählt aktuell beim **Einreichen** (`submitForReview`); muss auf Veröffentlichung umgestellt werden | -| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **fehlt** — Einreichen ist aktuell frei (Quota-Stub 3/Monat) | +| Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | **umgesetzt** (Phase 9B) — zählt idempotent beim ersten `published`-Übergang; Einreichen erfordert freien Slot, verbraucht aber keinen | +| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung kommt mit 9D/9E | | Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** | | Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** | | Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI | diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index 96e6f2c..6c91f65 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -118,10 +118,11 @@ Details: `docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroe Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`. Umsetzungsplan: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`. -- [ ] Gelb-Routing auf Direkt-Live umstellen (Entscheidung 12.06.: Gelb geht wie Gruen online, keine manuelle Queue; nur Rot wird abgelehnt). -- [ ] Tarif-Datenmodell + Checkout/Zahlung (Starter/Business/Pro/Agency, Einzel-PM 19 €, Jahrespreis „2 Monate gratis"). -- [ ] Submit-Gate: „Speichern & zur Pruefung einreichen" hinter aktiver Buchung; „Speichern" bleibt immer frei. -- [ ] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch); Quota-Stub abloesen. +- [x] Gelb-Routing auf Direkt-Live umstellen (Entscheidung 12.06.: Gelb geht wie Gruen online, keine manuelle Queue; nur Rot wird abgelehnt) — Phase 9A. +- [x] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch, idempotent ueber Status-Logs; Submit-Guard bei 0 Rest-Slots) — Phase 9B. +- [x] Submit-Gate vorbereitet: `User::hasActiveBooking()`-Stub (`billing.enforce_booking`, Default aus), Buchungs-Hinweis im Modal, Server-Guard + API 402 — Phase 9C. Echte Buchungs-Pruefung kommt mit dem Tarif-Modul. +- [x] Funnel-Luecke geschlossen: Create-Form legte PMs direkt mit Status `review` an (ohne Blacklist/Quota/KI/Status-Log) — laeuft jetzt ueber `submitForReview` (9C). +- [ ] Tarif-Datenmodell + Checkout/Zahlung (Starter/Business/Pro/Agency, Einzel-PM 19 €, Jahrespreis „2 Monate gratis"); Quota-Stub abloesen. - [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. - [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 99c06f2..959640e 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -12,66 +12,99 @@ `action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`, `save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind. + + Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal + statt des Prüf-Flows einen Buchungs-Hinweis — der Button konvertiert, + er verschwindet nicht. Serverseitig sichert submitForReview() das Gate ab. --}} +@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true)) + -
-
- {{ __('Veröffentlichung') }} - {{ __('Pressemitteilung zur Prüfung einreichen') }} -
- - {{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}} -
-

{{ __('Mit dem Einreichen versichern Sie:') }}

-
    -
  • {{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}
  • -
  • {{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}
  • -
  • {{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}
  • -
  • {{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}
  • -
-

- {{ __('Sie stellen die Plattform von Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung von Inhalten resultieren. Die endgültige Veröffentlichung erfolgt nach redaktioneller Prüfung.') }} -

-
- - {{-- Kontingent (optional) --}} - @if (! is_null($quotaRemaining) && ! is_null($quotaTotal)) -
-
-
{{ __('PM-Kontingent diesen Monat') }}
-
{{ __('Verbleibend nach diesem Versand wird angerechnet.') }}
-
- 0 ? 'ok' : 'warn'])> - {{ $quotaRemaining }} / {{ $quotaTotal }} - + @if ($bookingRequired) +
+
+ {{ __('Veröffentlichung') }} + {{ __('Buchung erforderlich') }}
- @endif - {{-- Bestätigungen --}} -
- - - -
+
+

+ {{ __('Zum Einreichen einer Pressemitteilung wird eine aktive Buchung benötigt. Ihre Entwürfe bleiben gespeichert und können jederzeit weiter bearbeitet werden.') }} +

+

+ {{ __('Nach der Buchung reichen Sie die Pressemitteilung mit einem Klick zur Prüfung ein.') }} +

+
-
- - {{ __('Abbrechen') }} - - - {{ $confirmLabel ?? __('Veröffentlichung anfordern') }} - +
+ + {{ __('Später') }} + + + {{ __('Buchung auswählen') }} + +
-
+ @else +
+
+ {{ __('Veröffentlichung') }} + {{ __('Pressemitteilung zur Prüfung einreichen') }} +
+ + {{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}} +
+

{{ __('Mit dem Einreichen versichern Sie:') }}

+
    +
  • {{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}
  • +
  • {{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}
  • +
  • {{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}
  • +
  • {{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}
  • +
+

+ {{ __('Sie stellen die Plattform von Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung von Inhalten resultieren. Die endgültige Veröffentlichung erfolgt nach redaktioneller Prüfung.') }} +

+
+ + {{-- Kontingent (optional) --}} + @if (! is_null($quotaRemaining) && ! is_null($quotaTotal)) +
+
+
{{ __('PM-Kontingent diesen Monat') }}
+
{{ __('Wird erst bei Veröffentlichung verbraucht — abgelehnte Pressemitteilungen kosten keinen Slot.') }}
+
+ 0 ? 'ok' : 'warn'])> + {{ $quotaRemaining }} / {{ $quotaTotal }} + +
+ @endif + + {{-- Bestätigungen --}} +
+ + + +
+ +
+ + {{ __('Abbrechen') }} + + + {{ $confirmLabel ?? __('Veröffentlichung anfordern') }} + +
+
+ @endif diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index bd51e6a..b555fc5 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -8,7 +8,11 @@ use App\Models\Company; use App\Models\Contact; use App\Models\PressRelease; use App\Services\Customer\CustomerCompanyContext; +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 Flux\Flux; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Str; @@ -423,17 +427,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $this->portal = $company->portal?->value ?? Portal::Presseecho->value; - $status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft; - $slug = (new PressRelease)->generateUniqueSlug($this->title, [ 'portal' => $this->portal, 'language' => $this->language, ]); + // Immer als Entwurf anlegen — der Weg nach "review" führt ausschließlich + // über submitForReview() (Blacklist, Gate, Quota, Status-Log, KI). $pr = PressRelease::query()->create([ 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, - 'status' => $status->value, + 'status' => PressReleaseStatus::Draft->value, ...$this->pressReleaseAttributes($slug), ]); @@ -441,15 +445,47 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex $pr->contacts()->sync([$contact->id]); } - Flux::toast( - heading: $status === PressReleaseStatus::Review - ? __('Eingereicht') - : __('Entwurf gespeichert'), - text: $status === PressReleaseStatus::Review - ? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.') - : __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'), - variant: 'success', - ); + if ($submitStatus === 'review') { + $this->authorize('submitForReview', $pr); + + try { + app(PressReleaseService::class)->submitForReview($pr); + } catch (BlacklistViolationException $e) { + Flux::toast( + heading: __('Automatisch abgelehnt'), + text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]), + variant: 'danger', + duration: 8000, + ); + + $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); + + return; + } catch (BookingRequiredException|QuotaExceededException $e) { + Flux::toast( + heading: __('Als Entwurf gespeichert'), + text: $e->getMessage(), + variant: 'warning', + duration: 8000, + ); + + $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); + + return; + } + + Flux::toast( + heading: __('Eingereicht'), + text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'), + variant: 'success', + ); + } else { + Flux::toast( + heading: __('Entwurf gespeichert'), + text: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'), + variant: 'success', + ); + } $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); } diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 6d70f24..e8cb440 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -9,9 +9,11 @@ use App\Models\Contact; use App\Models\PressRelease; use App\Services\Customer\CustomerCompanyContext; use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseHtmlSanitizer; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\QuotaExceededException; use Flux\Flux; use Illuminate\Database\Eloquent\Collection; use Illuminate\Validation\Rule; @@ -448,6 +450,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl $this->redirect(route('me.press-releases.show', $pr->id), navigate: true); + return; + } catch (BookingRequiredException|QuotaExceededException $e) { + Flux::toast( + heading: __('Gespeichert, aber nicht eingereicht'), + text: $e->getMessage(), + variant: 'warning', + duration: 8000, + ); + return; } diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 980812d..037b8cb 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -5,7 +5,9 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\Customer\CustomerCompanyContext; use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\QuotaExceededException; use Flux\Flux; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; @@ -99,6 +101,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class variant: 'danger', duration: 8000, ); + } catch (BookingRequiredException|QuotaExceededException $e) { + Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000); } catch (\LogicException $e) { Flux::toast(text: $e->getMessage(), variant: 'danger'); } diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 21b0e26..e214f1e 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -4,8 +4,10 @@ use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\Auth\MagicLinkGenerator; use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\QuotaExceededException; use Flux\Flux; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; @@ -45,6 +47,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends duration: 8000, ); + return; + } catch (BookingRequiredException|QuotaExceededException $e) { + Flux::modal('confirm-submit-review')->close(); + + Flux::toast( + heading: __('Nicht eingereicht'), + text: $e->getMessage(), + variant: 'warning', + duration: 8000, + ); + return; } diff --git a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php index 3068de9..d65ca16 100644 --- a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php @@ -31,7 +31,7 @@ test('api create always produces a draft and ignores any status input', function ->assertJsonPath('data.status', PressReleaseStatus::Draft->value); }); -test('api submit route raises a draft to review and counts quota and writes a log', function () { +test('api submit route raises a draft to review without consuming quota and writes a log', function () { /** @var TestCase $this */ Queue::fake(); // Klassifikations-Routing separat getestet; hier nur der Submit-Übergang. @@ -50,12 +50,54 @@ test('api submit route raises a draft to review and counts quota and writes a lo ->assertJsonPath('data.status', PressReleaseStatus::Review->value); expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); - expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); + // Slot-Verbrauch erst bei Veröffentlichung (Decision-Update §3.2). + expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); expect(PressReleaseStatusLog::where('press_release_id', $pressRelease->id) ->where('to_status', PressReleaseStatus::Review->value) ->exists())->toBeTrue(); }); +test('api submit responds 402 when the booking gate is enforced', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Saubere Pressemitteilung', + 'text' => 'Inhalt', + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertStatus(402); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + +test('api submit responds 422 when the monthly quota is exhausted', function () { + /** @var TestCase $this */ + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 3, + ]); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + 'title' => 'Saubere Pressemitteilung', + 'text' => 'Inhalt', + ]); + + Sanctum::actingAs($user, ['press-releases:write']); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") + ->assertStatus(422); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + test('api submit auto-rejects a press release containing a banned word', function () { /** @var TestCase $this */ config()->set('blacklist.words', ['penis']); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index 363ae25..fd754e4 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -166,11 +166,36 @@ test('classify job leaves a green scheduled press release in review for the sche expect($fresh->status)->toBe(PressReleaseStatus::Review); }); -test('classify job keeps a yellow classification in the manual review queue', function () { +test('classify job auto-publishes a yellow classification like green', function () { + // Entscheidung 12.06.2026 (Decision-Update §5.0): Gelb geht direkt live, + // es gibt keine manuelle Prüf-Queue — nur Rot wird abgelehnt. + Mail::fake(); fakeOpenAiClassification('yellow', ['grenzwertig']); $pressRelease = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Review->value, + 'scheduled_at' => null, + 'title' => 'Grenzfall', + 'text' => 'Inhalt', + ]); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Yellow); + expect($fresh->status)->toBe(PressReleaseStatus::Published); + Mail::assertQueued(PressReleasePublished::class); +}); + +test('classify job leaves a yellow scheduled press release in review for the scheduler', function () { + fakeOpenAiClassification('yellow', ['grenzwertig']); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'scheduled_at' => now()->addWeek(), 'title' => 'Grenzfall', 'text' => 'Inhalt', ]); diff --git a/tests/Feature/PressReleasePublishModalPhase8iTest.php b/tests/Feature/PressReleasePublishModalPhase8iTest.php index 3748ba1..fec2ea1 100644 --- a/tests/Feature/PressReleasePublishModalPhase8iTest.php +++ b/tests/Feature/PressReleasePublishModalPhase8iTest.php @@ -28,7 +28,7 @@ test('customer show renders the publish confirmation modal with legal note and q ->assertSee('2 / 3'); }); -test('submitting from the show modal moves the draft into review and counts quota', function () { +test('submitting from the show modal moves the draft into review without consuming quota', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); $customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]); @@ -39,5 +39,18 @@ test('submitting from the show modal moves the draft into review and counts quot ->assertHasNoErrors(); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); - expect($customer->fresh()->press_release_quota_used_this_month)->toBe(1); + // Slot-Verbrauch erst bei Veröffentlichung (Decision-Update §3.2). + expect($customer->fresh()->press_release_quota_used_this_month)->toBe(0); +}); + +test('the modal shows a booking notice instead of the submit flow when the gate is enforced', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + + ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); + $this->actingAs($customer); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Buchung erforderlich') + ->assertDontSee('Mit dem Einreichen versichern Sie:'); }); diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php index ddbb2df..b84152d 100644 --- a/tests/Feature/PressReleaseQuotaTest.php +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -1,12 +1,15 @@ seed(RolesAndPermissionsSeeder::class); }); +function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease +{ + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + + return PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => $status, + ]); +} + test('remaining quota reflects the used counter', function () { $user = User::factory()->create([ 'press_release_quota' => 3, @@ -23,29 +40,88 @@ test('remaining quota reflects the used counter', function () { expect($user->pressReleaseQuotaRemaining())->toBe(2); }); -test('submitting a press release for review increments the monthly quota usage', function () { +test('submitting a press release does not consume a quota slot', function () { + // Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter. + Queue::fake(); + $user = User::factory()->create([ 'press_release_quota' => 3, 'press_release_quota_used_this_month' => 0, ]); $user->assignRole('customer'); - $company = Company::factory()->presseecho()->create(); - $user->companies()->attach($company->id, ['role' => 'owner']); - - $pr = PressRelease::factory()->create([ - 'user_id' => $user->id, - 'company_id' => $company->id, - 'category_id' => Category::factory()->create()->id, - 'portal' => $company->portal->value, - 'status' => 'draft', - ]); + $pr = quotaTestPressRelease($user); app(PressReleaseService::class)->submitForReview($pr); + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); + expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); +}); + +test('publishing consumes exactly one quota slot', function () { + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 0, + ]); + $user->assignRole('customer'); + + $pr = quotaTestPressRelease($user, 'review'); + + app(PressReleaseService::class)->publish($pr); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); +test('re-publishing after archive does not consume a second slot', function () { + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 0, + ]); + $user->assignRole('customer'); + + $pr = quotaTestPressRelease($user, 'review'); + $service = app(PressReleaseService::class); + + $service->publish($pr); + $service->archive($pr->fresh()); + $service->changeStatusFromAdmin($pr->fresh(), PressReleaseStatus::Published); + + expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); +}); + +test('a rejected press release does not consume a quota slot', function () { + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 0, + ]); + $user->assignRole('customer'); + + $pr = quotaTestPressRelease($user, 'review'); + + app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki'); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected); + expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); +}); + +test('submitting with an exhausted quota is blocked', function () { + Queue::fake(); + + $user = User::factory()->create([ + 'press_release_quota' => 3, + 'press_release_quota_used_this_month' => 3, + ]); + $user->assignRole('customer'); + + $pr = quotaTestPressRelease($user); + + expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) + ->toThrow(QuotaExceededException::class); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + test('monthly reset command zeroes the used counter', function () { User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); diff --git a/tests/Feature/PressReleaseSchedulingTest.php b/tests/Feature/PressReleaseSchedulingTest.php index c112cd7..4a75cb1 100644 --- a/tests/Feature/PressReleaseSchedulingTest.php +++ b/tests/Feature/PressReleaseSchedulingTest.php @@ -131,7 +131,7 @@ test('Command publisht fällige Review-PMs mit scheduled_at <= now', function () expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00'); }); -test('Command ignoriert fällige gelbe PMs (manuelle Prüfung)', function () { +test('Command publisht fällige gelbe PMs wie grüne (Direkt-Live)', function () { /** @var TestCase $this */ Carbon::setTestNow('2026-06-01 12:00:00'); @@ -144,7 +144,23 @@ test('Command ignoriert fällige gelbe PMs (manuelle Prüfung)', function () { Artisan::call(PublishScheduledPressReleases::class); - expect($yellow->fresh()->status)->toBe(PressReleaseStatus::Review); + expect($yellow->fresh()->status)->toBe(PressReleaseStatus::Published); +}); + +test('Command ignoriert fällige unklassifizierte PMs (Fallback: manuelle Queue)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-06-01 12:00:00'); + + $unclassified = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => null, + 'scheduled_at' => '2026-06-01 11:55:00', + 'published_at' => null, + ]); + + Artisan::call(PublishScheduledPressReleases::class); + + expect($unclassified->fresh()->status)->toBe(PressReleaseStatus::Review); }); test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {