press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3)

Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3);
das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin
sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und
entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die
Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen.

Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false).
Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte,
Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung.

Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock
gelesen - vier Stellen auf Blockform umgestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 14:30:26 +00:00
parent 5f7fbd0c65
commit 13716b9c06
17 changed files with 487 additions and 47 deletions

View file

@ -82,6 +82,8 @@ class PressRelease extends Model
'status',
'classification',
'classified_at',
'auto_check_attempts',
'manual_review_required_at',
'content_score',
'content_tier',
'scored_at',
@ -110,6 +112,8 @@ class PressRelease extends Model
'status' => PressReleaseStatus::class,
'classification' => PressReleaseClassification::class,
'classified_at' => 'datetime',
'auto_check_attempts' => 'integer',
'manual_review_required_at' => 'datetime',
'content_score' => 'integer',
'content_tier' => PressReleaseContentTier::class,
'scored_at' => 'datetime',
@ -529,6 +533,23 @@ class PressRelease extends Model
return $this->gone_at !== null;
}
/**
* Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein
* Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue.
*/
public function requiresManualReview(): bool
{
return $this->manual_review_required_at !== null;
}
/**
* Wartet geblockt auf die menschliche Entscheidung.
*/
public function isAwaitingManualReview(): bool
{
return $this->status === PressReleaseStatus::Review && $this->requiresManualReview();
}
/**
* Instanz-Pendant zu scopeVisibleInArchive(): wertet die öffentliche
* Sichtbarkeit aus den bereits geladenen Attributen aus, ohne erneute

View file

@ -94,20 +94,35 @@ class PressReleaseService
// Zeitpunkt wird hier zur Nachweisbarkeit protokolliert. (Spalte
// heißt historisch withdrawal_waiver_consented_at; die frühere
// Erlöschens-Checkbox entfiel mit der B2B-Umstellung, E2.)
// Begrenzter Prüfkreislauf (E3, 16.09.2026): Ist die Automatik für
// diese Meldung erschöpft, geht die Freigabe ohne KI-Lauf direkt an
// einen Menschen. Sonst zählt dieser Lauf.
$manual = $pressRelease->requiresManualReview();
$pressRelease->update([
'status' => PressReleaseStatus::Review->value,
'withdrawal_waiver_consented_at' => now(),
'auto_check_attempts' => $manual ? $pressRelease->auto_check_attempts : $pressRelease->auto_check_attempts + 1,
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, $source);
$this->logStatusChange(
$pressRelease,
$previous,
PressReleaseStatus::Review,
$manual ? 'Erneute Freigabe nach erschöpfter automatischer Prüfung — wartet auf manuelle Entscheidung.' : null,
$source,
);
// Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt
// einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung —
// "Kunde hat am Datum X Option Y deklariert und bestätigt").
$this->logAiDeclaration($pressRelease);
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
if (! $manual) {
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing
// anhand des Ergebnisses übernimmt der Job über
// routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
}
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
@ -183,14 +198,56 @@ class PressReleaseService
}
if ($classification === PressReleaseClassification::Red) {
$max = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
// E3: Nach dem letzten erlaubten Rot nicht noch einmal ablehnen,
// sondern geblockt in die Admin-Queue — ab hier entscheidet ein
// Mensch, und die Automatik ist für diese Meldung aus.
if ($pressRelease->auto_check_attempts >= $max) {
$this->holdForManualReview($pressRelease, sprintf(
'Automatische Prüfung erschöpft (%d von %d): %s',
$pressRelease->auto_check_attempts,
$max,
$reason ?: 'Rot ohne Begründung.',
));
return;
}
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
return;
}
// Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für
// die manuelle Queue ist bewusst eingeschaltet.
if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) {
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.');
return;
}
$this->autoPublishApproved($pressRelease);
}
/**
* Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch, keine Mail
* an den Autor — der sieht den Stand in seinem Konto.
*/
private function holdForManualReview(PressRelease $pressRelease, string $reason): void
{
$pressRelease->update(['manual_review_required_at' => now()]);
$this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
$this->activityLog->record(
ActivityType::PressReleaseStatus,
sprintf('PM #%d wartet auf manuelle Prüfung: %s', $pressRelease->id, mb_substr($reason, 0, 160)),
subject: $pressRelease,
actorLabel: 'ki',
);
}
/**
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
*

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Begrenzter Prüfkreislauf (Roadmap 1d, E3 entschieden 16.09.2026).
*
* `auto_check_attempts` zählt, wie oft die automatische Prüfung mit
* Statuswirkung für diese Meldung gelaufen ist. `manual_review_required_at`
* ist gesetzt, sobald die Automatik erschöpft ist: Die Meldung bleibt in
* `review`, erscheint im Admin als „Prüfung nötig", und jede weitere Freigabe
* des Autors geht ohne KI-Lauf direkt an einen Menschen.
*
* Kosten: `press_releases` hat keine Volltext-Indizes mehr, beide Spalten sind
* `ALGORITHM=INSTANT`. hasColumn hält die Migration wiederholbar.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('press_releases', function (Blueprint $table) {
if (! Schema::hasColumn('press_releases', 'auto_check_attempts')) {
$table->unsignedTinyInteger('auto_check_attempts')->default(0)->after('classified_at');
}
if (! Schema::hasColumn('press_releases', 'manual_review_required_at')) {
$table->timestamp('manual_review_required_at')->nullable()->after('auto_check_attempts');
}
});
}
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table) {
$table->dropColumn(array_filter([
Schema::hasColumn('press_releases', 'auto_check_attempts') ? 'auto_check_attempts' : null,
Schema::hasColumn('press_releases', 'manual_review_required_at') ? 'manual_review_required_at' : null,
]));
});
}
};

View file

@ -144,12 +144,12 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 1d.1 | Hilfeseite ohne kommerziellen Anteil: was eine gute Pressemitteilung ausmacht, wie geprüft wird, was sich verbessern lässt, Abschnitt zu Pressebildern | S | keine |
| 1d.2 | Textbaustein in die Ablehnungsmail, Verlinkung auf 1d.1 | S | 1d.1 |
| ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine |
| ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 |
| 1d.3 | Abmeldemöglichkeit für Benachrichtigungen, Status in der Suppression-Logik geführt | S | Bounce-Suppression live |
| 1d.4 | Prüfwerkzeug: eingefügter Text wird bewertet, Ausgabe als benannte Stufe mit konkreten Hebeln | M | 1d.1 |
| 1d.5 | Bewertung einer bestehenden Meldung über Magic Link | S | 1d.4, 1b.2 |
| 1d.6 | Partnerbereich am Seitenende, vorerst als "BALD" ohne Namen und Logos | S | 1d.1 |
| ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus | M | 1d.1 |
| ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 |
| ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 |
**Offenlegungsgrenze**
@ -233,7 +233,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
|---|---|---|
| ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 |
| ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 |
| E3 | Anzahl kostenloser Prüfläufe und Preis der weiteren in Credits | 1d.4 |
| ~~E3~~ | **Entschieden 16.09.2026: keine Credits.** Prüfung ist Teil der Freigabe, nicht ein Werkzeug; N automatische Läufe je Meldung (einstellbar, 3), danach Mensch, Automatik für die Meldung aus. Gelb geht weiter automatisch online, `yellow_to_manual_queue` bleibt als Schalter (Vorgabe aus). | 1d.4 |
| ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 |
| E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 |
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |

View file

@ -1340,3 +1340,69 @@ und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt
nullbare Werte, eine immer-falsche Negation. Nicht angefasst.
Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig.
---
# Teil 6 — Block 1d: Infoseite und Prüfkreislauf (16.09.2026)
## Entscheidungen mit dem Nutzer
- **E3:** keine Credits für Prüfläufe. Die Prüfung ist Teil des Freigebens;
ein eigenständiges Werkzeug zum Textreinkopieren gibt es nicht (wäre der
Weg, das Gate auszutesten). Automatische Prüfung je Meldung
`scoring.classification.max_automatic_attempts` (Vorgabe 3); danach
geblockt in die Admin-Queue; Freischalten oder Kommentar; ab dann ist die
Automatik für diese Meldung aus.
- **Gelb** geht weiter automatisch online. Der Schalter
`yellow_to_manual_queue` war im Code nie ausgewertet (1 288 gelbe
Meldungen in sechs Monaten ohne Blick); jetzt ausgewertet, Vorgabe
`false`, im Admin sichtbar (Filter „KI: Gelb" bestand schon).
## Befund vorab
84 KI-Ablehnungen in sechs Monaten, jede Meldung genau einmal, eine
davon später veröffentlicht — heute loopt niemand. Der Zähler schützt vor
einem Verhalten, das erst mit 1d.1/1d.2 entsteht.
## 1d.1 / 1d.2 / 1d.6 (Commit c08c9e5)
Hilfeseite `/{edition}/gute-pressemitteilung` (DE/EN): Anlass, Aufbau,
Sprache, Prüfablauf mit Versuchszahl aus der Konfiguration, die sieben
Dimensionen des Content-Scores offen (Pressestil, Struktur, Lesbarkeit,
Vollständigkeit, Quellen, Überschrift, Eigenständigkeit) — Gewichtung und
Schwellen nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD.
Kein Preis-, Tarif- oder Credit-Link im Seiteninhalt (Test prüft den
Prosa-Block). Sitemap, Hilfe-Center. Ablehnungsmail verlinkt die Seite auf
dem Portal der Meldung mit `src=mail_reject&pr=<id>`.
## E3 — begrenzter Prüfkreislauf
- Spalten `auto_check_attempts`, `manual_review_required_at` an
`press_releases` (INSTANT, keine Volltext-Indizes mehr).
- `submitForReview()`: zählt den Lauf; ist die Automatik erschöpft, geht
die Freigabe ohne `ClassifyPressRelease` direkt in `review` mit
Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter.
- `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()`
statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft
(N von N): <Grund>", ActivityLog). Gelb → Schalter. Keine Mail an den
Autor für den Block — er sieht den Stand im Konto (offen: ob eine kurze
Mail „ein Mensch prüft" gewünscht ist).
- Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn >
0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste;
Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die
Aktionen „Freischalten" / „Mit Kommentar zurück" (bestehende
publish/reject-Aktionen, Kommentar geht per Ablehnungsmail an den Autor).
- Kundenseite: Button „Zur Prüfung freigeben" (statt „einreichen", überall
vereinheitlicht), Modal erklärt in drei Schritten, was nach der Freigabe
passiert; abgelehnte Meldung zeigt „Automatische Prüfung X von N genutzt"
mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft".
- Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten
`@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform
umgestellt, eine davon Bestand (Modal).
Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2),
`ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der
Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score).
Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`.
Kein Backfill — bestehende Meldungen starten mit 0 Versuchen.

View file

@ -30,7 +30,10 @@
Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit
die Checkbox im Zweifel lieber einmal zu viel erscheint.
--}}
@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true))
{{-- Blockform: ein inline @php(...) würde bis zum nächsten @endphp weiter unten als Rohblock gelesen. --}}
@php
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
@endphp
<flux:modal :name="$name" class="w-full max-w-xl">
@if ($bookingRequired)
@ -72,16 +75,32 @@
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung freigeben') }}</flux:heading>
</div>
{{-- E3 (16.09.2026): unmissverständlich, dass die Freigabe nicht
„online" heißt — erst die Prüfung, dann die Veröffentlichung. --}}
@php
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
@endphp
<article class="panel overflow-hidden" style="border-color:var(--color-hub); border-left-width:3px;">
<div class="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)]">{{ __('Was nach der Freigabe passiert') }}</p>
<ol class="m-0 list-decimal space-y-1 ps-5">
<li>{{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}</li>
<li>{{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}</li>
<li>{{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}</li>
</ol>
</div>
</article>
{{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}}
<article class="panel overflow-hidden">
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">{{ __('Rechtliche Hinweise') }}</span>
</div>
<div class="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>
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit der Freigabe 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>
@ -205,7 +224,7 @@
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact && waiver && ai)">
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
{{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
</flux:button>
</div>
</div>

View file

@ -307,7 +307,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
});
})
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
// „Prüfung nötig" (E3): geblockt in review, Automatik erschöpft.
->when($this->statusFilter === 'manual', fn ($q) => $q->where('status', PressReleaseStatus::Review->value)->whereNotNull('manual_review_required_at'))
->when($this->statusFilter !== 'all' && $this->statusFilter !== 'manual', fn ($q) => $q->where('status', $this->statusFilter))
->when($this->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
@ -413,6 +415,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rejected', [PressReleaseStatus::Rejected->value])
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as archived', [PressReleaseStatus::Archived->value])
->selectRaw('SUM(CASE WHEN status = ? AND manual_review_required_at IS NOT NULL THEN 1 ELSE 0 END) as manual', [PressReleaseStatus::Review->value])
->first();
return [
@ -422,6 +425,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
'draft' => (int) ($stats->draft ?? 0),
'rejected' => (int) ($stats->rejected ?? 0),
'archived' => (int) ($stats->archived ?? 0),
'manual' => (int) ($stats->manual ?? 0),
];
});
}
@ -574,6 +578,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Review->value])>
{{ __('In Prüfung') }} <span class="cnt">{{ number_format($stats['review']) }}</span>
</button>
{{-- E3: Automatik erschöpft — hier wartet eine menschliche Entscheidung. --}}
<button type="button" wire:click="setView('manual')"
@class(['view-tab', 'is-active' => $statusFilter === 'manual'])>
{{ __('Prüfung nötig') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['manual'] ?? 0) > 0])>{{ number_format($stats['manual'] ?? 0) }}</span>
</button>
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Published->value }}')"
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Published->value])>
{{ __('Veröffentlicht') }} <span class="cnt">{{ number_format($stats['published']) }}</span>
@ -611,6 +620,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@foreach ($statusOptions as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
<option value="manual">{{ __('Prüfung nötig (Automatik erschöpft)') }}</option>
</flux:select>
<flux:select wire:model.live="classificationFilter" class="w-full">
@ -814,10 +824,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@endif
@if ($statusFilter !== 'all')
@php $statusEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
@php $statusEnum = $statusFilter === 'manual' ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
<span class="active-chip">
<span>{{ __('Status') }}:
<strong>{{ $statusEnum?->label() ?? $statusFilter }}</strong></span>
<strong>{{ $statusEnum?->label() ?? ($statusFilter === 'manual' ? __('Prüfung nötig') : $statusFilter) }}</strong></span>
<button type="button" class="x" wire:click="setView('all')"
aria-label="{{ __('Filter entfernen') }}">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
@ -1055,6 +1065,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:table.cell>
<div class="flex items-center gap-1.5 flex-wrap">
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
@if ($pr->isAwaitingManualReview())
<span class="badge err" title="{{ __('Automatische Prüfung erschöpft — Entscheidung nötig') }}">{{ __('Prüfung nötig') }}</span>
@elseif ($pr->requiresManualReview())
<span class="badge muted" title="{{ __('Automatik für diese Meldung aus') }}">{{ __('nur manuell') }}</span>
@endif
@if ($pr->classification)
@php
$kiBadge = match ($pr->classification) {

View file

@ -259,10 +259,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
@php
$manual = $pr->isAwaitingManualReview();
@endphp
<article class="panel" style="border-color:var(--color-{{ $manual ? 'err' : 'warn' }}); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
@if ($manual)
<span class="badge err dot">{{ __('Prüfung nötig — Automatik erschöpft') }}</span>
@else
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
@endif
</div>
<div class="p-5 flex flex-wrap items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
@ -270,8 +277,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:icon.clock class="size-[18px]" />
</div>
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
@if ($latestClassification && $latestClassification->reason)
@if ($manual)
{{-- E3: die automatische Prüfung hat ihre Versuche aufgebraucht.
Ab hier entscheidet ein Mensch — Freischalten oder mit
Kommentar zurück; danach bleibt die Automatik für diese
Meldung aus. --}}
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
{{ __(':n automatische Prüfungen, zuletzt Rot. Ihre Entscheidung: veröffentlichen oder mit Kommentar zurück an den Autor.', ['n' => $pr->auto_check_attempts]) }}
</p>
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Jede weitere Freigabe des Autors landet ohne KI-Lauf wieder hier.') }}
</p>
@php
$kiRejections = $statusLogs->filter(fn ($log) => $log->source === 'ki' && filled($log->reason))->sortBy('created_at')->values();
@endphp
@if ($kiRejections->isNotEmpty())
<ol class="m-0 mt-3 ps-4 space-y-1.5 text-[12px] text-[color:var(--color-ink-2)] list-decimal">
@foreach ($kiRejections as $log)
<li><span class="font-mono text-[11px] text-[color:var(--color-ink-4)]">{{ $log->created_at->format('d.m. H:i') }}</span> · {{ $log->reason }}</li>
@endforeach
</ol>
@endif
@else
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
@endif
@if (! $manual && $latestClassification && $latestClassification->reason)
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
<strong class="text-[color:var(--color-ink-2)]">{{ __('KI-Hinweis') }}:</strong>
{{ $latestClassification->reason }}
@ -286,10 +316,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
<flux:button type="button" variant="primary">{{ $manual ? __('Freischalten') : __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="confirm-show-reject">
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
<flux:button type="button" variant="danger">{{ $manual ? __('Mit Kommentar zurück') : __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</div>

View file

@ -826,7 +826,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
class="w-full"
wire:loading.attr="disabled"
>
{{ __('Zur Prüfung senden') }}
{{ __('Zur Prüfung freigeben') }}
</flux:button>
</flux:modal.trigger>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
@ -1084,14 +1084,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung freigeben") --}}
{{-- Im Erstellen-Formular gibt es noch keinen Bild-Upload (der folgt im
Editor nach dem Entwurf-Sichern), daher entfällt die Bildrechte-Frage. --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="save('review')"
booking-action="saveDraftAndChooseBooking"
:confirm-label="__('Zur Prüfung senden')"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="false" />

View file

@ -1055,7 +1055,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
name="confirm-submit-review"
action="saveAndSubmit"
booking-action="saveDraftAndChooseBooking"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />

View file

@ -691,7 +691,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
03</div>
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
{{ __('Zur Prüfung senden') }}</div>
{{ __('Zur Prüfung freigeben') }}</div>
</div>
</div>
</x-portal.empty-state>
@ -718,7 +718,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
<li>{{ __('Bearbeiten') }}</li>
<li>{{ __('Vorschau') }}</li>
<li class="text-[color:var(--color-accent-deep)] font-semibold">
{{ __('→ Zur Prüfung senden') }}</li>
{{ __('→ Zur Prüfung freigeben') }}</li>
</ul>
</div>
<div>
@ -743,7 +743,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
<li>{{ __('Bearbeiten') }}</li>
<li class="text-[color:var(--color-accent-deep)] font-semibold">
{{ __('→ Erneut einreichen') }}</li>
{{ __('→ Erneut zur Prüfung freigeben') }}</li>
</ul>
</div>
<div>

View file

@ -797,6 +797,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return [
'pr' => $pr,
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung',
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
'viewRanges' => VisibilityStats::ranges(),
'publicLinks' => $pr->status === PressReleaseStatus::Published
@ -1035,6 +1036,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
@php
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
@endphp
@if ($pr->requiresManualReview())
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Für diese Mitteilung entscheidet ab jetzt ein Mensch: Nach der nächsten Freigabe prüft die Redaktion direkt, ohne automatische Vorprüfung.') }}
</span>
@elseif ($pr->auto_check_attempts > 0)
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Automatische Prüfung :used von :max genutzt. Bleibt der Text danach Rot, prüft ein Mensch.', ['used' => $pr->auto_check_attempts, 'max' => $maxAttempts]) }}
<a href="{{ $helpPageUrl }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ __('Was eine gute Pressemitteilung ausmacht') }}</a>
</span>
@endif
</div>
</div>
</article>
@ -1068,8 +1082,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
? __('Sie können den Text bearbeiten und erneut zur Prüfung freigeben.')
: __('Geben Sie den Entwurf zur Prüfung frei, sobald er vollständig ist. Online geht er erst nach der Prüfung.') }}
</p>
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap">
@if ($canEdit)
@ -1080,7 +1094,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($hasActiveBooking)
<flux:modal.trigger name="confirm-submit-review">
<flux:button type="button" variant="primary">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut zur Prüfung freigeben') : __('Zur Prüfung freigeben') }}
</flux:button>
</flux:modal.trigger>
@else
@ -1219,9 +1233,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</p>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($pr->isAwaitingManualReview())
{{-- E3: Automatik erschöpft — ein Mensch prüft. Kein Termin
versprechen, den wir nicht halten. --}}
<p class="m-0 font-semibold text-[color:var(--color-ink)]">{{ __('Ihre Pressemitteilung wird von einem Menschen geprüft.') }}</p>
<p class="m-0 mt-1">{{ __('Die automatische Prüfung hat den Text mehrfach nicht freigegeben. Jetzt sieht sich die Redaktion Text und Begründungen an und schaltet frei oder meldet sich mit einem Kommentar. Sie werden per E-Mail benachrichtigt.') }}</p>
@else
<p class="m-0">{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}</p>
@endif
</div>
</div>
</article>
@endif
@ -1631,7 +1652,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<x-press-release-submit-modal
name="confirm-submit-review"
action="submitForReview"
:confirm-label="__('Veröffentlichung anfordern')"
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />

View file

@ -0,0 +1,168 @@
<?php
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Mail\PressReleaseRejected;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Begrenzter Prüfkreislauf (Roadmap 1d, E3 entschieden 16.09.2026): die
* automatische Prüfung läuft je Meldung N-mal, danach entscheidet ein Mensch
* und die Automatik bleibt für diese Meldung aus.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config()->set('scoring.classification.max_automatic_attempts', 2);
config()->set('billing.enforce_booking', false);
Queue::fake();
Mail::fake();
});
function gateDraft(): array
{
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$pr = PressRelease::factory()->create(['user_id' => $customer->id, 'status' => PressReleaseStatus::Draft->value]);
return [$customer, $pr];
}
function redRun(PressRelease $pr, string $reason): void
{
app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Red, $reason);
}
test('each release counts its automatic checks and the last red blocks instead of rejecting', function () {
/** @var TestCase $this */
[$customer, $pr] = gateDraft();
$service = app(PressReleaseService::class);
$this->actingAs($customer);
// Erster Durchlauf: Rot → abgelehnt, Zähler 1.
$service->submitForReview($pr);
Queue::assertPushed(ClassifyPressRelease::class, 1);
expect($pr->fresh()->auto_check_attempts)->toBe(1);
redRun($pr, 'K1: Werbliche Sprache');
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected)
->and($pr->fresh()->requiresManualReview())->toBeFalse();
// Zweiter (= letzter erlaubter) Durchlauf: Rot → nicht ablehnen, blocken.
$service->submitForReview($pr->fresh());
Queue::assertPushed(ClassifyPressRelease::class, 2);
expect($pr->fresh()->auto_check_attempts)->toBe(2);
redRun($pr, 'K1: Immer noch werblich');
$fresh = $pr->fresh();
expect($fresh->status)->toBe(PressReleaseStatus::Review)
->and($fresh->isAwaitingManualReview())->toBeTrue()
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)
->toContain('Automatische Prüfung erschöpft (2 von 2)');
// Keine Ablehnungsmail für den Block — der Autor sieht den Stand im Konto.
Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft'));
});
test('after the human decision the automation stays off for this release', function () {
/** @var TestCase $this */
[$customer, $pr] = gateDraft();
$service = app(PressReleaseService::class);
$this->actingAs($customer);
$pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value]);
redRun($pr, 'K2: Rot');
expect($pr->fresh()->isAwaitingManualReview())->toBeTrue();
// Admin: mit Kommentar zurück.
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$service->reject($pr->fresh(), 'Bitte die Behauptung im zweiten Absatz belegen.');
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected)
->and($pr->fresh()->requiresManualReview())->toBeTrue();
// Autor gibt erneut frei: kein KI-Job, direkt wieder in der Queue.
$this->actingAs($customer);
Queue::fake();
$service->submitForReview($pr->fresh());
Queue::assertNotPushed(ClassifyPressRelease::class);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review)
->and($pr->fresh()->isAwaitingManualReview())->toBeTrue()
->and($pr->fresh()->auto_check_attempts)->toBe(2)
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)->toContain('wartet auf manuelle Entscheidung');
});
test('yellow publishes by default and is held only when the switch is on', function () {
/** @var TestCase $this */
[, $pr] = gateDraft();
$pr->update(['status' => PressReleaseStatus::Review->value]);
config()->set('scoring.classification.yellow_to_manual_queue', false);
app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Yellow, 'Gelb');
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
[, $other] = gateDraft();
$other->update(['status' => PressReleaseStatus::Review->value]);
config()->set('scoring.classification.yellow_to_manual_queue', true);
app(PressReleaseService::class)->routeByClassification($other->fresh(), PressReleaseClassification::Yellow, 'Gelb');
expect($other->fresh()->status)->toBe(PressReleaseStatus::Review)
->and($other->fresh()->isAwaitingManualReview())->toBeTrue();
});
test('the admin sees blocked releases in their own view and the detail page explains the decision', function () {
/** @var TestCase $this */
[, $pr] = gateDraft();
$pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value, 'title' => 'Geblockte Meldung']);
redRun($pr, 'K4: Falsche Tatsachenbehauptung');
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::test('admin.press-releases.index')
->call('setView', 'manual')
->assertSeeText('Geblockte Meldung')
->assertSeeText('Prüfung nötig');
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
->assertSeeText('Automatik erschöpft')
->assertSeeText('K4: Falsche Tatsachenbehauptung')
->assertSeeText('Freischalten')
->assertSeeText('Mit Kommentar zurück');
});
test('the customer page explains the release button, counts attempts and shows the human review', function () {
/** @var TestCase $this */
[$customer, $pr] = gateDraft();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSeeText('Zur Prüfung freigeben')
->assertSeeText('Was nach der Freigabe passiert')
->assertSeeText('Nach 2 Prüfungen mit Rot');
$pr->update(['status' => PressReleaseStatus::Rejected->value, 'auto_check_attempts' => 1]);
PressReleaseStatusLog::query()->create(['press_release_id' => $pr->id, 'from_status' => 'review', 'to_status' => 'rejected', 'reason' => 'K1: Werblich', 'source' => 'ki']);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSeeText('Automatische Prüfung 1 von 2 genutzt');
$pr->update(['status' => PressReleaseStatus::Review->value, 'auto_check_attempts' => 2, 'manual_review_required_at' => now()]);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSeeText('von einem Menschen geprüft');
});

View file

@ -50,7 +50,7 @@ test('with the gate on a draft without a booking shows the notice and links to b
->assertSee('Noch keine Buchung')
->assertSee('Buchung wählen')
->assertSee(route('me.bookings.index'), false)
->assertDontSee('Zur Prüfung einreichen');
->assertDontSee('Zur Prüfung freigeben');
});
test('with the gate on a paid single purchase unlocks the submission action', function () {
@ -63,7 +63,7 @@ test('with the gate on a paid single purchase unlocks the submission action', fu
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Noch keine Buchung')
->assertSee('Zur Prüfung einreichen');
->assertSee('Zur Prüfung freigeben');
});
test('submitting without a booking is rejected at the service level', function () {
@ -105,5 +105,5 @@ test('with the gate off the notice never shows', function () {
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Noch keine Buchung')
->assertSee('Zur Prüfung einreichen');
->assertSee('Zur Prüfung freigeben');
});

View file

@ -15,7 +15,7 @@ test('the pm create page shows a notice instead of the form without a company',
->assertSet('hasCompanies', false)
->assertSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Firma anlegen')
->assertDontSee('Zur Prüfung senden');
->assertDontSee('Zur Prüfung freigeben');
});
test('the pm create page shows the editor when a company exists', function () {
@ -30,5 +30,5 @@ test('the pm create page shows the editor when a company exists', function () {
->assertSet('hasCompanies', true)
->assertSet('companyId', $company->id)
->assertDontSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Zur Prüfung senden');
->assertSee('Zur Prüfung freigeben');
});

View file

@ -34,9 +34,9 @@ test('customer show renders the publish confirmation modal with legal note and q
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Pressemitteilung zur Prüfung einreichen')
->assertSee('Pressemitteilung zur Prüfung freigeben')
->assertSee('Rechtliche Hinweise')
->assertSee('Mit dem Einreichen versichern Sie:')
->assertSee('Mit der Freigabe versichern Sie:')
->assertSee('PM-Kontingent diesen Monat')
->assertSee('2 / 3')
->assertSee('Ihre Bestätigungen');
@ -49,7 +49,7 @@ test('the quota block is hidden for users with an unlimited quota', function ()
// Launch-Schalter aus → Kontingent unbegrenzt → kein Quota-Block.
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Pressemitteilung zur Prüfung einreichen')
->assertSee('Pressemitteilung zur Prüfung freigeben')
->assertDontSee('PM-Kontingent diesen Monat');
});
@ -113,5 +113,5 @@ test('the modal shows a booking notice instead of the submit flow when the gate
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Buchung erforderlich')
->assertDontSee('Mit dem Einreichen versichern Sie:');
->assertDontSee('Mit der Freigabe versichern Sie:');
});

View file

@ -230,7 +230,7 @@ test('customer show page surfaces latest reject reason prominently', function ()
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Werbliche Sprache wurde markiert.')
->assertSee('Erneut einreichen');
->assertSee('Erneut zur Prüfung freigeben');
});
test('admin index publish goes through service and creates audit log', function () {