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

@ -18,6 +18,9 @@ enum ConversionSource: string
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 Organic = 'organic';
@ -27,6 +30,7 @@ enum ConversionSource: string
return match ($this) {
self::MailPublish => 'Veröffentlichungsmail',
self::MailReject => 'Ablehnungsmail',
self::MailReview => 'Prüfhinweis-Mail',
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
self::Organic => 'ohne zugeordnete Herkunft',
};

View file

@ -38,7 +38,7 @@ enum EmailNotificationType: string
{
return match ($this) {
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::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.',
@ -52,7 +52,7 @@ enum EmailNotificationType: string
{
return match ($this) {
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::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.',
@ -66,7 +66,7 @@ enum EmailNotificationType: string
{
return match ($this) {
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::PressReleaseCorrected => 'Bestätigungen über Korrekturen und Änderungen Ihrer Pressemitteilungen',
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\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
@ -209,7 +210,7 @@ class PressReleaseService
$pressRelease->auto_check_attempts,
$max,
$reason ?: 'Rot ohne Begründung.',
));
), automationExhausted: true);
return;
}
@ -222,7 +223,7 @@ class PressReleaseService
// 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.');
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.', automationExhausted: false);
return;
}
@ -232,10 +233,11 @@ class PressReleaseService
/**
* 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.
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch. Der Autor
* 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()]);
$this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
@ -246,6 +248,8 @@ class PressReleaseService
subject: $pressRelease,
actorLabel: 'ki',
);
$this->notifyAuthor($pressRelease, 'held', automationExhausted: $automationExhausted);
}
/**
@ -723,7 +727,7 @@ class PressReleaseService
$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 */
$user = $pressRelease->user;
@ -778,10 +782,11 @@ class PressReleaseService
// Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar
// (One-Click-Unsubscribe aus der Mail bzw. Präferenzen im Profil).
// 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) {
'published' => EmailNotificationType::PressReleasePublished,
'rejected' => EmailNotificationType::PressReleaseRejected,
'rejected', 'held' => EmailNotificationType::PressReleaseRejected,
default => null,
};
@ -793,9 +798,11 @@ class PressReleaseService
return;
}
$mailable = $notificationType === EmailNotificationType::PressReleasePublished
? new PressReleasePublished($user, $pressRelease, $recipientName, $recipient)
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient);
$mailable = match ($event) {
'published' => new PressReleasePublished($user, $pressRelease, $recipientName, $recipient),
'held' => new PressReleaseHeldForReview($user, $pressRelease, $automationExhausted, $recipientName, $recipient),
default => new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient),
};
Mail::to($recipient)->queue($mailable);
}