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:
parent
8d8d957884
commit
4419d9ff43
21 changed files with 551 additions and 114 deletions
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
17
app/Services/PressRelease/BookingRequiredException.php
Normal file
17
app/Services/PressRelease/BookingRequiredException.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
17
app/Services/PressRelease/QuotaExceededException.php
Normal file
17
app/Services/PressRelease/QuotaExceededException.php
Normal 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
19
config/billing.php
Normal 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),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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) ✅
|
## 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,
|
Abschluss von Phase 8. Die erste Hälfte (8A–8E: Show-Page-Lücken,
|
||||||
|
|
|
||||||
|
|
@ -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: 9A–9C zuerst, dann Review-Stopp,
|
Stand: 2026-06-12 — **Block 1 (9A–9C) abgeschlossen**; Review-Stopp vor
|
||||||
dann Block 2: 9D–9J).
|
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).
|
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 |
|
||||||
|
|
|
||||||
|
|
@ -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 0–100 → Stufe | **umgesetzt** — `content_score`/`content_tier` (`ScorePressRelease`-Job), Editor-Panel, Admin-Badges, öffentliches Stufen-Badge in Customer-Show |
|
| 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) |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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:');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue