press-releases: Mail "wird persoenlich geprueft" beim Block, Blade-Guard fuer @php-Bloecke (Roadmap 1d, Nachtrag)

Der Autor bekommt beim Block nach dem letzten Rot (oder Gelb mit
eingeschalteter Queue) eine kurze Mail statt Stillstand: was passiert ist,
dass nichts zu tun ist, und dass das Ergebnis per Mail kommt. Laeuft ueber
notifyAuthor() wie Veroeffentlichung und Ablehnung (API-Regel, Sperrliste,
adressbezogene Abmeldung, Bounce-Referenz); Opt-out am Typ "abgelehnt".
Neue ConversionSource mail_review fuer den Link ins Konto.

BladePhpBlockGuardTest wendet das Compiler-Muster auf alle Views an und
faengt inline @php( vor einem spaeteren @endphp ab — fand den eigenen
Erklaer-Kommentar im Modal, der nur zufaellig funktionierte. Regel in
CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 15:05:47 +00:00
parent 35db30e019
commit 4fd1db7825
11 changed files with 278 additions and 24 deletions

View file

@ -89,6 +89,17 @@ The application uses a sophisticated domain-based theme system that determines s
- **Flux UI**: Premium UI component library for consistent design (Portal only) - **Flux UI**: Premium UI component library for consistent design (Portal only)
- **Multi-Build System**: Separate Vite configurations for different asset bundles - **Multi-Build System**: Separate Vite configurations for different asset bundles
#### Blade: `@php` blocks (IMPORTANT)
Blade extracts `@php … @endphp` blocks **before** anything else — including
comments — with `/(?<!@)@php(.*?)@endphp/s`. An inline `@php($x = …)` that
is followed anywhere later in the same file by an `@endphp` therefore
swallows everything up to that `@endphp` as one raw PHP block and the view
fails to compile ("unexpected end of file, expecting endif") or silently
loses content. This also applies to `@php(` written inside a `{{-- --}}`
comment. Rule: once a file contains `@endphp`, use the block form for every
`@php` in it. `tests/Feature/BladePhpBlockGuardTest.php` enforces this.
### Authentication & Authorization ### Authentication & Authorization
- **Laravel Fortify**: Handles authentication features - **Laravel Fortify**: Handles authentication features

View file

@ -18,6 +18,9 @@ enum ConversionSource: string
case MailReject = 'mail_reject'; case MailReject = 'mail_reject';
/** Hinweis „ein Mensch prüft jetzt" nach erschöpfter Automatik (Roadmap 1d, E3). */
case MailReview = 'mail_review';
case ProfileClaim = 'profile_claim'; case ProfileClaim = 'profile_claim';
case Organic = 'organic'; case Organic = 'organic';
@ -27,6 +30,7 @@ enum ConversionSource: string
return match ($this) { return match ($this) {
self::MailPublish => 'Veröffentlichungsmail', self::MailPublish => 'Veröffentlichungsmail',
self::MailReject => 'Ablehnungsmail', self::MailReject => 'Ablehnungsmail',
self::MailReview => 'Prüfhinweis-Mail',
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite', self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
self::Organic => 'ohne zugeordnete Herkunft', self::Organic => 'ohne zugeordnete Herkunft',
}; };

View file

@ -38,7 +38,7 @@ enum EmailNotificationType: string
{ {
return match ($this) { return match ($this) {
self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.', self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.', self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde (inklusive Ablehnungsgrund) oder zur persönlichen Prüfung zurückgehalten wird.',
self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.', self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
self::PressReleaseCorrected => 'Bestätigung, wenn eine Korrektur oder kostenpflichtige Änderung an einer veröffentlichten Pressemitteilung übernommen wurde.', self::PressReleaseCorrected => 'Bestätigung, wenn eine Korrektur oder kostenpflichtige Änderung an einer veröffentlichten Pressemitteilung übernommen wurde.',
self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.', self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.',
@ -52,7 +52,7 @@ enum EmailNotificationType: string
{ {
return match ($this) { return match ($this) {
self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.', self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.', self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über das Prüfergebnis Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.', self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
self::PressReleaseCorrected => 'Sie haben die Korrektur bzw. Änderung dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.', self::PressReleaseCorrected => 'Sie haben die Korrektur bzw. Änderung dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
self::Newsletter => 'Sie haben ein Benutzerkonto bei pressekonto bzw. den Portalen presseecho und businessportal24 und erhalten daher gelegentlich Informationen zur Plattform.', self::Newsletter => 'Sie haben ein Benutzerkonto bei pressekonto bzw. den Portalen presseecho und businessportal24 und erhalten daher gelegentlich Informationen zur Plattform.',
@ -66,7 +66,7 @@ enum EmailNotificationType: string
{ {
return match ($this) { return match ($this) {
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen', self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen', self::PressReleaseRejected => 'Benachrichtigungen über Ablehnung und Prüfung Ihrer Pressemitteilungen',
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen', self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
self::PressReleaseCorrected => 'Bestätigungen über Korrekturen und Änderungen Ihrer Pressemitteilungen', self::PressReleaseCorrected => 'Bestätigungen über Korrekturen und Änderungen Ihrer Pressemitteilungen',
self::Newsletter => 'Newsletter und Plattform-Informationen', self::Newsletter => 'Newsletter und Plattform-Informationen',

View file

@ -0,0 +1,84 @@
<?php
namespace App\Mail;
use App\Enums\ConversionSource;
use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* „Ein Mensch prüft jetzt" (Roadmap 1d, E3): Die Meldung ist nach dem
* letzten erlaubten Rot — oder bei Gelb mit eingeschalteter Queue — nicht
* abgelehnt, sondern zur persönlichen Prüfung zurückgehalten. Der Autor
* muss nichts tun; das Ergebnis kommt als Veröffentlichungs- oder
* Ablehnungsmail. Opt-out läuft über den Typ „abgelehnt": Wer keine
* Prüfergebnisse will, will auch diesen Zwischenstand nicht.
*/
class PressReleaseHeldForReview extends Mailable
{
use Queueable, SerializesModels, Unsubscribable;
public string $showUrl;
/**
* Name für die Anrede — bei API-Einlieferungen der Pressekontakt bzw.
* die Firma, nie der Kontoname des API-Einlieferers.
*/
public string $recipientName;
public int $maxAttempts;
public function __construct(
public readonly User $user,
public readonly PressRelease $pressRelease,
public readonly bool $automationExhausted,
?string $recipientName = null,
?string $recipientEmail = null,
) {
$this->recipientName = $recipientName ?? $user->name;
$this->maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
$showUrl = $user->canAccessAdmin()
? route('admin.press-releases.show', $pressRelease->id)
: route('me.press-releases.show', $pressRelease->id);
$this->showUrl = $showUrl.(str_contains($showUrl, '?') ? '&' : '?').http_build_query([
'src' => ConversionSource::MailReview->value,
'pr' => $pressRelease->id,
]);
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
}
}
/**
* Bounce-Referenz für den DSN-Parser (Roadmap 1b.6), gesetzt über den
* gemeinsamen headers() des Unsubscribable-Traits.
*/
public function bounceReference(): string
{
return sprintf('pm:%d:company:%s', $this->pressRelease->id, $this->pressRelease->company_id ?? '-');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Ihre Pressemitteilung wird persönlich geprüft: '.$this->pressRelease->title,
);
}
public function content(): Content
{
return new Content(view: 'emails.press-release.held-for-review');
}
}

View file

@ -9,6 +9,7 @@ use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseStatus;
use App\Jobs\ClassifyPressRelease; use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease; use App\Jobs\ScorePressRelease;
use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleasePublished; use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected; use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset; use App\Models\AdminPreset;
@ -209,7 +210,7 @@ class PressReleaseService
$pressRelease->auto_check_attempts, $pressRelease->auto_check_attempts,
$max, $max,
$reason ?: 'Rot ohne Begründung.', $reason ?: 'Rot ohne Begründung.',
)); ), automationExhausted: true);
return; return;
} }
@ -222,7 +223,7 @@ class PressReleaseService
// Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für // Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für
// die manuelle Queue ist bewusst eingeschaltet. // die manuelle Queue ist bewusst eingeschaltet.
if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) { if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) {
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.'); $this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false);
return; return;
} }
@ -232,10 +233,11 @@ class PressReleaseService
/** /**
* Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und * Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch, keine Mail * wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch. Der Autor
* an den Autor — der sieht den Stand in seinem Konto. * bekommt eine kurze Mail „ein Mensch prüft jetzt" — ohne sie sähe er
* nach dem letzten Rot nur Stillstand statt einer Ablehnung.
*/ */
private function holdForManualReview(PressRelease $pressRelease, string $reason): void private function holdForManualReview(PressRelease $pressRelease, string $reason, bool $automationExhausted): void
{ {
$pressRelease->update(['manual_review_required_at' => now()]); $pressRelease->update(['manual_review_required_at' => now()]);
$this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki'); $this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
@ -246,6 +248,8 @@ class PressReleaseService
subject: $pressRelease, subject: $pressRelease,
actorLabel: 'ki', actorLabel: 'ki',
); );
$this->notifyAuthor($pressRelease, 'held', automationExhausted: $automationExhausted);
} }
/** /**
@ -723,7 +727,7 @@ class PressReleaseService
$this->adminCache->forgetPressReleaseStats(); $this->adminCache->forgetPressReleaseStats();
} }
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null, bool $automationExhausted = false): void
{ {
/** @var User|null $user */ /** @var User|null $user */
$user = $pressRelease->user; $user = $pressRelease->user;
@ -778,10 +782,11 @@ class PressReleaseService
// Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar // Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar
// (One-Click-Unsubscribe aus der Mail bzw. Präferenzen im Profil). // (One-Click-Unsubscribe aus der Mail bzw. Präferenzen im Profil).
// Greift nur, wenn der Einlieferer selbst der Empfänger ist — // Greift nur, wenn der Einlieferer selbst der Empfänger ist —
// Pressekontakt/Firma haben kein Konto mit Präferenzen. // Pressekontakt/Firma haben kein Konto mit Präferenzen. Der
// Zwischenstand „ein Mensch prüft" hängt am Typ „abgelehnt".
$notificationType = match ($event) { $notificationType = match ($event) {
'published' => EmailNotificationType::PressReleasePublished, 'published' => EmailNotificationType::PressReleasePublished,
'rejected' => EmailNotificationType::PressReleaseRejected, 'rejected', 'held' => EmailNotificationType::PressReleaseRejected,
default => null, default => null,
}; };
@ -793,9 +798,11 @@ class PressReleaseService
return; return;
} }
$mailable = $notificationType === EmailNotificationType::PressReleasePublished $mailable = match ($event) {
? new PressReleasePublished($user, $pressRelease, $recipientName, $recipient) 'published' => new PressReleasePublished($user, $pressRelease, $recipientName, $recipient),
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient); 'held' => new PressReleaseHeldForReview($user, $pressRelease, $automationExhausted, $recipientName, $recipient),
default => new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient),
};
Mail::to($recipient)->queue($mailable); Mail::to($recipient)->queue($mailable);
} }

View file

@ -147,7 +147,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
| ~~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.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.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 — **16.09.2026**: für Empfänger ohne Konto (Pressekontakt, Firmen-E-Mail) adressbezogener Abmeldelink, Zustand als `Unsubscribed` in der Sperrliste; selbst angeforderte Anmeldelinks gehen weiter durch. Vorher zeigte der Link in API-Benachrichtigungen auf das Konto des Einlieferers | S | Bounce-Suppression live | | ~~1d.3~~ | Abmeldemöglichkeit für Benachrichtigungen — **16.09.2026**: für Empfänger ohne Konto (Pressekontakt, Firmen-E-Mail) adressbezogener Abmeldelink, Zustand als `Unsubscribed` in der Sperrliste; selbst angeforderte Anmeldelinks gehen weiter durch. Vorher zeigte der Link in API-Benachrichtigungen auf das Konto des Einlieferers | S | Bounce-Suppression live |
| ~~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.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; Autor erhält beim Block die Mail „wird persönlich geprüft" (Nachtrag 16.09.) | 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.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 | | ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 |

View file

@ -1384,9 +1384,9 @@ dem Portal der Meldung mit `src=mail_reject&pr=<id>`.
Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter. Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter.
- `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()` - `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()`
statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft
(N von N): <Grund>", ActivityLog). Gelb → Schalter. Keine Mail an den (N von N): <Grund>", ActivityLog). Gelb → Schalter. Der Autor bekommt
Autor für den Block — er sieht den Stand im Konto (offen: ob eine kurze beim Block die Mail „Ihre Pressemitteilung wird persönlich geprüft"
Mail „ein Mensch prüft" gewünscht ist). (Nachtrag unten).
- Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn > - Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn >
0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste; 0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste;
Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die
@ -1398,7 +1398,8 @@ dem Portal der Meldung mit `src=mail_reject&pr=<id>`.
mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft". mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft".
- Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten - Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten
`@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform `@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform
umgestellt, eine davon Bestand (Modal). umgestellt, eine davon Bestand (Modal). Seit dem Nachtrag unten durch
einen Test abgesichert.
Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2), Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2),
`ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der `ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der
@ -1431,6 +1432,42 @@ fordert die Person selbst an. Harte Bounces sperren weiterhin alles.
Tests: `AddressUnsubscribeTest` (5). Admin ▸ Mail-Sperrliste zeigt den Tests: `AddressUnsubscribeTest` (5). Admin ▸ Mail-Sperrliste zeigt den
Grund „abgemeldet" ohne weitere Änderung. Grund „abgemeldet" ohne weitere Änderung.
**Damit ist Block 1d komplett.** Offen aus dem Block: ob der Autor beim ## Nachtrag 1d (16.09.2026) — Mail beim Block, Blade-Guard
Block nach dem N-ten Rot eine kurze Mail bekommen soll („ein Mensch
prüft"); heute sieht er es nur im Konto. Beide offenen Punkte aus dem Block auf Wunsch des Nutzers umgesetzt.
**Mail „ein Mensch prüft jetzt".** Neue Mailable
`PressReleaseHeldForReview`, ausgelöst aus `holdForManualReview()` über
denselben `notifyAuthor()`-Pfad wie Veröffentlichung und Ablehnung — also
mit der API-Regel (Pressekontakt → Firmen-E-Mail → sonst gar nicht, nie an
connektar), Sperrliste, adressbezogener Abmeldung (1d.3) und
Bounce-Referenz. Zwei Textvarianten: Automatik erschöpft („auch im N.
Durchlauf nicht freigeben … statt einer weiteren Ablehnung sieht sich jetzt
ein Mensch die Mitteilung an") und Gelb-Queue („zur Sicherheit
zurückgehalten"). Kern der Mail: *Sie müssen nichts tun*, die Meldung ist
nicht öffentlich, das Ergebnis kommt als Veröffentlichungs- oder
Ablehnungsmail. Ein Button „Stand der Prüfung ansehen" führt ins Konto
(`src=mail_review`, neue `ConversionSource::MailReview`, damit der Klick im
Funnel unterscheidbar bleibt). Kein Ablehnungsgrund in der Mail — die
KI-Begründungen stehen im Konto, und die Mail soll beruhigen, nicht
nachtreten.
Opt-out: kein neuer Präferenz-Typ. Der Zwischenstand hängt am Typ
„abgelehnt" — wer keine Prüfergebnisse will, will auch den nicht. Die drei
Texte des Typs (Einstellung, Footer-Begründung, Abmelde-Label) sprechen
jetzt von „Prüfergebnis" statt nur „Ablehnung".
**Blade-Guard.** `BladePhpBlockGuardTest` läuft über alle Views und wendet
exakt das Compiler-Muster an (`/(?<!@)@php(.*?)@endphp/s`,
`BladeCompiler::storePhpBlocks`): Jeder Treffer, dessen Inhalt mit `(`
beginnt, ist ein inline `@php(`, das einen späteren Block verschluckt. Der
Test fand sofort eine Stelle: den eigenen Erklär-Kommentar im Modal, der
`@php(` und `@endphp` wörtlich enthielt. Blade liest die Rohblöcke *vor*
den Kommentaren — die Stelle funktionierte nur, weil der Kommentar den
Platzhalter anschließend mit entfernte. Kommentar umformuliert; Regel in
CLAUDE.md festgehalten.
Tests: `ManualReviewGateTest` (+1 → 6, Mail beim Block, Textvarianten,
Opt-out), `BladePhpBlockGuardTest` (1).
**Damit ist Block 1d komplett, ohne offene Punkte.**

View file

@ -30,7 +30,7 @@
Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit
die Checkbox im Zweifel lieber einmal zu viel erscheint. die Checkbox im Zweifel lieber einmal zu viel erscheint.
--}} --}}
{{-- Blockform: ein inline @php(...) würde bis zum nächsten @endphp weiter unten als Rohblock gelesen. --}} {{-- Blockform, nicht Inline-Form: die Inline-Variante würde bis zum Ende des nächsten Blocks als Rohblock gelesen (BladeCompiler::storePhpBlocks, Guard: BladePhpBlockGuardTest). --}}
@php @php
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true); $bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
@endphp @endphp

View file

@ -0,0 +1,34 @@
<x-emails.layout title="Ihre Pressemitteilung wird persönlich geprüft" preheader="Ein Mensch sieht sich Ihre Pressemitteilung jetzt an — Sie müssen nichts tun.">
<p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
@if ($automationExhausted)
<p>
Die automatische Prüfung konnte Ihre Pressemitteilung auch im {{ $maxAttempts }}. Durchlauf nicht
freigeben. Statt einer weiteren Ablehnung sieht sich jetzt ein Mensch die Mitteilung an und
entscheidet.
</p>
@else
<p>
Die automatische Prüfung hat Ihre Pressemitteilung zur Sicherheit zurückgehalten. Ein Mensch sieht
sich die Mitteilung jetzt an und entscheidet.
</p>
@endif
<p class="title">{{ $pressRelease->title }}</p>
<div class="note">
<p>
<strong>Sie müssen nichts tun.</strong> Die Mitteilung bleibt bis zur Entscheidung in der Prüfung
und ist nicht öffentlich. Sobald entschieden ist, erhalten Sie eine E-Mail — entweder mit dem Link
zur veröffentlichten Mitteilung oder mit einem konkreten Hinweis, was fehlt.
</p>
</div>
<x-emails.button :url="$showUrl">Stand der Prüfung ansehen</x-emails.button>
<p style="font-size: 12.5px; color: #6b7280;">
Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.
</p>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Support\Facades\File;
/**
* Blade liest `@php … @endphp` VOR allen anderen Direktiven und Kommentaren
* mit dem Muster `/(?<!@)@php(.*?)@endphp/s` (BladeCompiler::storePhpBlocks).
* Ein inline `@php(...)` vor einem späteren Block wird dadurch samt allem
* dazwischen als ein Rohblock genommen — die Seite kompiliert nicht mehr
* („unexpected end of file, expecting endif") oder verliert Inhalt. Das
* gilt auch für `@php(`, das nur in einem Blade-Kommentar steht.
*
* Regel: Sobald eine Datei `@endphp` enthält, gibt es darin kein inline
* `@php(` — immer die Blockform benutzen.
*/
test('no view mixes inline @php( with a later @php block', function () {
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $view) {
if (! str_ends_with($view->getFilename(), '.blade.php')) {
continue;
}
$contents = File::get($view->getRealPath());
if (! str_contains($contents, '@endphp')) {
continue;
}
// Exakt das Muster des Compilers: jeder Treffer, dessen Inhalt mit
// `(` beginnt, ist ein inline @php(, das einen Block verschluckt.
preg_match_all('/(?<!@)@php(.*?)@endphp/s', $contents, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as [$body, $offset]) {
if (str_starts_with($body, '(')) {
$offenders[] = sprintf('%s:%d', $view->getRelativePathname(), substr_count($contents, "\n", 0, $offset) + 1);
}
}
}
expect($offenders)->toBe([], "Inline @php( vor einem @php-Block — auf Blockform umstellen:\n".implode("\n", $offenders));
});

View file

@ -1,8 +1,10 @@
<?php <?php
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease; use App\Jobs\ClassifyPressRelease;
use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleaseRejected; use App\Mail\PressReleaseRejected;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog; use App\Models\PressReleaseStatusLog;
@ -70,8 +72,41 @@ test('each release counts its automatic checks and the last red blocks instead o
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason) ->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)
->toContain('Automatische Prüfung erschöpft (2 von 2)'); ->toContain('Automatische Prüfung erschöpft (2 von 2)');
// Keine Ablehnungsmail für den Block — der Autor sieht den Stand im Konto. // Keine Ablehnungsmail für den Block — stattdessen „ein Mensch prüft jetzt".
Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft')); Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft'));
Mail::assertQueued(PressReleaseHeldForReview::class, function (PressReleaseHeldForReview $mail) use ($customer, $pr) {
return $mail->hasTo($customer->email)
&& $mail->automationExhausted === true
&& $mail->pressRelease->is($pr)
&& str_contains($mail->showUrl, '/admin/me/press-releases/'.$pr->id.'?src=mail_review&pr='.$pr->id);
});
});
test('the held-for-review mail says nothing is required and respects the rejected opt-out', function () {
/** @var TestCase $this */
[$customer, $pr] = gateDraft();
$pr->update(['title' => 'Zurückgehaltene Meldung']);
$html = (new PressReleaseHeldForReview($customer, $pr, automationExhausted: true))->render();
expect($html)
->toContain('Zurückgehaltene Meldung')
->toContain('auch im 2. Durchlauf nicht')
->toContain('Sie müssen nichts tun')
->toContain('Stand der Prüfung ansehen');
$this->assertStringNotContainsString('Ablehnungsgrund', $html);
$yellow = (new PressReleaseHeldForReview($customer, $pr, automationExhausted: false))->render();
expect($yellow)->toContain('zur Sicherheit zurückgehalten');
$this->assertStringNotContainsString('Durchlauf', $yellow);
// Wer die Ablehnungsmails abbestellt hat, bekommt auch den Zwischenstand nicht.
$customer->optOutOfEmailNotification(EmailNotificationType::PressReleaseRejected);
$pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value]);
redRun($pr, 'K1: Rot');
expect($pr->fresh()->isAwaitingManualReview())->toBeTrue();
Mail::assertNotQueued(PressReleaseHeldForReview::class);
}); });
test('after the human decision the automation stays off for this release', function () { test('after the human decision the automation stays off for this release', function () {