Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 09:47:06 +00:00
parent 8d8d957884
commit 4419d9ff43
21 changed files with 551 additions and 114 deletions

View file

@ -44,9 +44,15 @@ class PublishScheduledPressReleases extends Command
$now = now(); $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() $candidates = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Review->value) ->where('status', PressReleaseStatus::Review->value)
->where('classification', PressReleaseClassification::Green->value) ->whereIn('classification', [
PressReleaseClassification::Green->value,
PressReleaseClassification::Yellow->value,
])
->whereNotNull('scheduled_at') ->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now) ->where('scheduled_at', '<=', $now)
->orderBy('scheduled_at') ->orderBy('scheduled_at')

View file

@ -10,7 +10,9 @@ use App\Http\Resources\PressReleaseResource;
use App\Models\Company; use App\Models\Company;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -141,6 +143,14 @@ class PressReleaseController extends Controller
try { try {
$service->submitForReview($pressRelease); $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) { } catch (BlacklistViolationException $exception) {
return response()->json([ return response()->json([
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),

View file

@ -92,6 +92,24 @@ class User extends Authenticatable
return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month); 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 * Get the user's initials
*/ */

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Submit-Gate (Decision-Update §5.1): Einreichen zur Prüfung erfordert eine
* aktive Buchung. Speichern als Entwurf bleibt immer frei.
*/
class BookingRequiredException extends RuntimeException
{
public function __construct(string $message = 'Für das Einreichen zur Prüfung wird eine aktive Buchung benötigt.')
{
parent::__construct($message);
}
}

View file

@ -32,6 +32,22 @@ class PressReleaseService
{ {
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]); $this->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; $previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) { if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
@ -47,10 +63,6 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Review->value]); $pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer'); $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 // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification(). // des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
@ -92,11 +104,14 @@ class PressReleaseService
/** /**
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses * 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 * - Rot Ablehnung mit Begründung an den Autor
* - Gelb bleibt in der manuellen Review-Queue * - Gelb/Grün automatische Veröffentlichung (sofort bzw. zum Termin);
* - Grün automatische Veröffentlichung (sofort bzw. zum geplanten 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 * Greift nur, solange die PM noch im Status `review` steht; manuelle
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang. * Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
@ -113,22 +128,18 @@ class PressReleaseService
return; return;
} }
if ($classification === PressReleaseClassification::Green) { $this->autoPublishApproved($pressRelease);
$this->autoPublishGreen($pressRelease);
}
// Gelb: keine Aktion bleibt zur manuellen Prüfung im Status „review".
} }
/** /**
* 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 * Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
* Scheduler die Publikation zum Termin. Andernfalls wird sofort * Scheduler die Publikation zum Termin. Andernfalls wird sofort
* publiziert optional mit einem Sicherheitsfenster * publiziert optional mit einem Sicherheitsfenster
* (scoring.classification.green_delay_minutes). * (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()) { if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
return; return;
@ -156,6 +167,8 @@ class PressReleaseService
throw new BlacklistViolationException($reason, $word); throw new BlacklistViolationException($reason, $word);
} }
$this->consumePublishSlot($pressRelease);
$pressRelease->update([ $pressRelease->update([
'status' => PressReleaseStatus::Published->value, 'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride), 'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
@ -165,6 +178,27 @@ class PressReleaseService
$this->notifyAuthor($pressRelease, 'published'); $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. * Bestimmt das wirksame `published_at` einer PM.
* *
@ -234,6 +268,10 @@ class PressReleaseService
{ {
$previous = $pressRelease->status; $previous = $pressRelease->status;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($pressRelease);
}
$pressRelease->update([ $pressRelease->update([
'status' => $status->value, 'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published 'published_at' => $status === PressReleaseStatus::Published

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Das monatliche PM-Kontingent des Autors ist aufgebraucht Einreichen ist
* erst nach Reset/Upgrade (später: Extra-PM) wieder möglich.
*/
class QuotaExceededException extends RuntimeException
{
public function __construct(string $message = 'Das monatliche PM-Kontingent ist aufgebraucht.')
{
parent::__construct($message);
}
}

19
config/billing.php Normal file
View file

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Submit-Gate (Decision-Update §5.1)
|--------------------------------------------------------------------------
|
| "Speichern" ist immer frei; "Speichern & zur Prüfung einreichen" ist
| hinter eine aktive Buchung gegated. Bis das Tarif-Modul (Phase 9D/9E)
| die echte Buchungs-Prüfung liefert, bleibt das Gate deaktiviert
| User::hasActiveBooking() gibt dann für alle true zurück.
|
*/
'enforce_booking' => env('BILLING_ENFORCE_BOOKING', false),
];

View file

@ -5,6 +5,48 @@
--- ---
## 2026-06-12 · Phase 9 · Veröffentlichungs-Flow Block 1 (9A9C) ✅
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 (9D9J: Tarif-Datenmodell,
Stripe/Cashier — Dependency-Freigabe nötig, Tarif-UI, Tageslimit,
Einzel-PM, Launch-Credits).
---
## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F8K) ✅ ## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F8K) ✅
Abschluss von Phase 8. Die erste Hälfte (8A8E: Show-Page-Lücken, Abschluss von Phase 8. Die erste Hälfte (8A8E: Show-Page-Lücken,

View file

@ -1,7 +1,7 @@
# Phase 9 · Veröffentlichungs-Flow (Launch) & Tarif-Modul # Phase 9 · Veröffentlichungs-Flow (Launch) & Tarif-Modul
Stand: 2026-06-12 — **in Umsetzung** (Block 1: 9A9C zuerst, dann Review-Stopp, Stand: 2026-06-12 — **Block 1 (9A9C) abgeschlossen**; Review-Stopp vor
dann Block 2: 9D9J). Block 2 (9D9J, Tarif-Modul). Suite nach Block 1: 451 passed, 4 skipped.
Vorgänger: Phase 8 (User-Panel-Konsolidierung) + KI-Prüf-Pipeline (beide abgeschlossen). 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) 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) 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 | | ID | Thema | Größe | Risiko |
|---|---|---|---| |---|---|---|---|
| **9A** | Gelb-Routing auf Direkt-Live umstellen (Routing, Scheduler, Tests) | S | 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) | | **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 | | **9C** | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering |
| — | **Review-Stopp mit User** | | | | — | **Review-Stopp mit User** | | |
| **9D** | Tarif-Datenmodell: Pläne, Subscriptions, Einzel-PM-Käufe; Quota-Stub ablösen | L | hoch (Datenmodell) | | **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 | | **9E** | Stripe-Anbindung (Laravel Cashier — **Dependency-Freigabe nötig**) | L | mittel |

View file

@ -140,7 +140,7 @@ eingearbeitet. Preise & Veröffentlichungs-Flow: siehe
| Punkt | Code-Stand | | 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/` | | 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) | | Logging der KI-Antworten | **umgesetzt**`ki_audits`-Tabelle (append-only, inkl. Provider/Modell/Begründung/Raw-Response) |
| Content-Score 0100 → Stufe | **umgesetzt**`content_score`/`content_tier` (`ScorePressRelease`-Job), Editor-Panel, Admin-Badges, öffentliches Stufen-Badge in Customer-Show | | Content-Score 0100 → 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) | | 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** | | 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** | | Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** |
| Zahlung/Checkout (Stripe) | **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 | | 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 | **fehlt** — Einreichen ist aktuell frei (Quota-Stub 3/Monat) | | 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** | | Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** |
| Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** | | Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** |
| Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI | | Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI |

View file

@ -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`. 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). - [x] Gelb-Routing auf Direkt-Live umstellen (Entscheidung 12.06.: Gelb geht wie Gruen online, keine manuelle Queue; nur Rot wird abgelehnt) — Phase 9A.
- [ ] Tarif-Datenmodell + Checkout/Zahlung (Starter/Business/Pro/Agency, Einzel-PM 19 €, Jahrespreis „2 Monate gratis"). - [x] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch, idempotent ueber Status-Logs; Submit-Guard bei 0 Rest-Slots) — Phase 9B.
- [ ] Submit-Gate: „Speichern & zur Pruefung einreichen" hinter aktiver Buchung; „Speichern" bleibt immer frei. - [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.
- [ ] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch); Quota-Stub abloesen. - [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. - [ ] 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 €. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). - [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).

View file

@ -12,66 +12,99 @@
`action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim `action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim
Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`, Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`,
`save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind. `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))
<flux:modal :name="$name" class="w-full max-w-xl"> <flux:modal :name="$name" class="w-full max-w-xl">
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false }"> @if ($bookingRequired)
<div> <div class="space-y-5">
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text> <div>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading> <flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
</div> <flux:heading size="lg">{{ __('Buchung erforderlich') }}</flux:heading>
{{-- Rechtliche Hinweise (Platzhalter vor Go-Live anwaltlich prüfen) --}}
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit dem Einreichen versichern Sie:') }}</p>
<ul class="m-0 list-disc space-y-1 ps-5">
<li>{{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}</li>
<li>{{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}</li>
<li>{{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}</li>
<li>{{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}</li>
</ul>
<p class="m-0 mt-2 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('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.') }}
</p>
</div>
{{-- Kontingent (optional) --}}
@if (! is_null($quotaRemaining) && ! is_null($quotaTotal))
<div class="flex items-center justify-between rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3">
<div class="text-[12.5px] text-[color:var(--color-ink-2)]">
<div class="font-semibold text-[color:var(--color-ink)]">{{ __('PM-Kontingent diesen Monat') }}</div>
<div class="text-[color:var(--color-ink-3)]">{{ __('Verbleibend nach diesem Versand wird angerechnet.') }}</div>
</div>
<span @class(['badge', $quotaRemaining > 0 ? 'ok' : 'warn'])>
{{ $quotaRemaining }} / {{ $quotaTotal }}
</span>
</div> </div>
@endif
{{-- Bestätigungen --}} <div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<div class="space-y-2"> <p class="m-0 mb-2">
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]"> {{ __('Zum Einreichen einer Pressemitteilung wird eine aktive Buchung benötigt. Ihre Entwürfe bleiben gespeichert und können jederzeit weiter bearbeitet werden.') }}
<input type="checkbox" x-model="agb" class="mt-0.5" /> </p>
<span>{{ __('Der Inhalt entspricht den AGB und gesetzlichen Vorgaben.') }}</span> <p class="m-0 text-[11.5px] text-[color:var(--color-ink-3)]">
</label> {{ __('Nach der Buchung reichen Sie die Pressemitteilung mit einem Klick zur Prüfung ein.') }}
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]"> </p>
<input type="checkbox" x-model="images" class="mt-0.5" /> </div>
<span>{{ __('Alle Bildrechte sind geklärt.') }}</span>
</label>
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
<input type="checkbox" x-model="contact" class="mt-0.5" />
<span>{{ __('Die Angaben zum Pressekontakt sind korrekt.') }}</span>
</label>
</div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<flux:modal.close> <flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button> <flux:button variant="filled">{{ __('Später') }}</flux:button>
</flux:modal.close> </flux:modal.close>
<flux:button variant="primary" wire:click="{{ $action }}" <flux:button variant="primary" :href="route('me.bookings.index')" wire:navigate>
wire:loading.attr="disabled" {{ __('Buchung auswählen') }}
x-bind:disabled="! (agb && images && contact)"> </flux:button>
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }} </div>
</flux:button>
</div> </div>
</div> @else
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
</div>
{{-- Rechtliche Hinweise (Platzhalter vor Go-Live anwaltlich prüfen) --}}
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit dem Einreichen versichern Sie:') }}</p>
<ul class="m-0 list-disc space-y-1 ps-5">
<li>{{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}</li>
<li>{{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}</li>
<li>{{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}</li>
<li>{{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}</li>
</ul>
<p class="m-0 mt-2 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('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.') }}
</p>
</div>
{{-- Kontingent (optional) --}}
@if (! is_null($quotaRemaining) && ! is_null($quotaTotal))
<div class="flex items-center justify-between rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3">
<div class="text-[12.5px] text-[color:var(--color-ink-2)]">
<div class="font-semibold text-[color:var(--color-ink)]">{{ __('PM-Kontingent diesen Monat') }}</div>
<div class="text-[color:var(--color-ink-3)]">{{ __('Wird erst bei Veröffentlichung verbraucht — abgelehnte Pressemitteilungen kosten keinen Slot.') }}</div>
</div>
<span @class(['badge', $quotaRemaining > 0 ? 'ok' : 'warn'])>
{{ $quotaRemaining }} / {{ $quotaTotal }}
</span>
</div>
@endif
{{-- Bestätigungen --}}
<div class="space-y-2">
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
<input type="checkbox" x-model="agb" class="mt-0.5" />
<span>{{ __('Der Inhalt entspricht den AGB und gesetzlichen Vorgaben.') }}</span>
</label>
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
<input type="checkbox" x-model="images" class="mt-0.5" />
<span>{{ __('Alle Bildrechte sind geklärt.') }}</span>
</label>
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
<input type="checkbox" x-model="contact" class="mt-0.5" />
<span>{{ __('Die Angaben zum Pressekontakt sind korrekt.') }}</span>
</label>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact)">
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
</flux:button>
</div>
</div>
@endif
</flux:modal> </flux:modal>

View file

@ -8,7 +8,11 @@ use App\Models\Company;
use App\Models\Contact; use App\Models\Contact;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext; use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer; use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux; use Flux\Flux;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str; 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; $this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft;
$slug = (new PressRelease)->generateUniqueSlug($this->title, [ $slug = (new PressRelease)->generateUniqueSlug($this->title, [
'portal' => $this->portal, 'portal' => $this->portal,
'language' => $this->language, '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([ $pr = PressRelease::query()->create([
'uuid' => (string) Str::uuid(), 'uuid' => (string) Str::uuid(),
'user_id' => $user->id, 'user_id' => $user->id,
'status' => $status->value, 'status' => PressReleaseStatus::Draft->value,
...$this->pressReleaseAttributes($slug), ...$this->pressReleaseAttributes($slug),
]); ]);
@ -441,15 +445,47 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
$pr->contacts()->sync([$contact->id]); $pr->contacts()->sync([$contact->id]);
} }
Flux::toast( if ($submitStatus === 'review') {
heading: $status === PressReleaseStatus::Review $this->authorize('submitForReview', $pr);
? __('Eingereicht')
: __('Entwurf gespeichert'), try {
text: $status === PressReleaseStatus::Review app(PressReleaseService::class)->submitForReview($pr);
? __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.') } catch (BlacklistViolationException $e) {
: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'), Flux::toast(
variant: 'success', 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); $this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
} }

View file

@ -9,9 +9,11 @@ use App\Models\Contact;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext; use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseHtmlSanitizer; use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux; use Flux\Flux;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule; 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); $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; return;
} }

View file

@ -5,7 +5,9 @@ use App\Enums\PressReleaseStatus;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext; use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux; use Flux\Flux;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
@ -99,6 +101,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
variant: 'danger', variant: 'danger',
duration: 8000, duration: 8000,
); );
} catch (BookingRequiredException|QuotaExceededException $e) {
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
} catch (\LogicException $e) { } catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger'); Flux::toast(text: $e->getMessage(), variant: 'danger');
} }

View file

@ -4,8 +4,10 @@ use App\Enums\PressReleaseStatus;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator; use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux; use Flux\Flux;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -45,6 +47,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
duration: 8000, 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; return;
} }

View file

@ -31,7 +31,7 @@ test('api create always produces a draft and ignores any status input', function
->assertJsonPath('data.status', PressReleaseStatus::Draft->value); ->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 */ /** @var TestCase $this */
Queue::fake(); // Klassifikations-Routing separat getestet; hier nur der Submit-Übergang. 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); ->assertJsonPath('data.status', PressReleaseStatus::Review->value);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); 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) expect(PressReleaseStatusLog::where('press_release_id', $pressRelease->id)
->where('to_status', PressReleaseStatus::Review->value) ->where('to_status', PressReleaseStatus::Review->value)
->exists())->toBeTrue(); ->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 () { test('api submit auto-rejects a press release containing a banned word', function () {
/** @var TestCase $this */ /** @var TestCase $this */
config()->set('blacklist.words', ['penis']); config()->set('blacklist.words', ['penis']);

View file

@ -166,11 +166,36 @@ test('classify job leaves a green scheduled press release in review for the sche
expect($fresh->status)->toBe(PressReleaseStatus::Review); 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']); fakeOpenAiClassification('yellow', ['grenzwertig']);
$pressRelease = PressRelease::factory()->create([ $pressRelease = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value, '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', 'title' => 'Grenzfall',
'text' => 'Inhalt', 'text' => 'Inhalt',
]); ]);

View file

@ -28,7 +28,7 @@ test('customer show renders the publish confirmation modal with legal note and q
->assertSee('2 / 3'); ->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 */ /** @var TestCase $this */
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
$customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]); $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(); ->assertHasNoErrors();
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); 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:');
}); });

View file

@ -1,12 +1,15 @@
<?php <?php
use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Enums\PressReleaseStatus;
use App\Models\Category; use App\Models\Category;
use App\Models\Company; use App\Models\Company;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase; use Tests\TestCase;
beforeEach(function (): void { beforeEach(function (): void {
@ -14,6 +17,20 @@ beforeEach(function (): void {
$this->seed(RolesAndPermissionsSeeder::class); $this->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 () { test('remaining quota reflects the used counter', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'press_release_quota' => 3, 'press_release_quota' => 3,
@ -23,29 +40,88 @@ test('remaining quota reflects the used counter', function () {
expect($user->pressReleaseQuotaRemaining())->toBe(2); 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([ $user = User::factory()->create([
'press_release_quota' => 3, 'press_release_quota' => 3,
'press_release_quota_used_this_month' => 0, 'press_release_quota_used_this_month' => 0,
]); ]);
$user->assignRole('customer'); $user->assignRole('customer');
$company = Company::factory()->presseecho()->create(); $pr = quotaTestPressRelease($user);
$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',
]);
app(PressReleaseService::class)->submitForReview($pr); 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); 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 () { test('monthly reset command zeroes the used counter', function () {
User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]);
$untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]);

View file

@ -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'); 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 */ /** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00'); 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); 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 () { test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {