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:
parent
35db30e019
commit
4fd1db7825
11 changed files with 278 additions and 24 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
84
app/Mail/PressReleaseHeldForReview.php
Normal file
84
app/Mail/PressReleaseHeldForReview.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
42
tests/Feature/BladePhpBlockGuardTest.php
Normal file
42
tests/Feature/BladePhpBlockGuardTest.php
Normal 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));
|
||||||
|
});
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue