admin: internes Newsletter-Modul mit Filtern, Protokoll und Abmeldung
Neues Modul unter Admin ▸ Newsletter (nur users:manage): Kampagnen mit Betreff + leicht formatiertem Text (Purifier-Profil newsletter: fett/ kursiv, Listen, Links) im zentralen Mail-Layout. Empfänger-Filter auf der User-Datenbank: Portal, Legacy-Vertrag (Bestandsschutz), Login innerhalb/außerhalb X Monaten, Sortierung nach Login/Erstellung — mit Live-Zähler und Vorschau im Editor. Versand friert die Empfängerliste als Protokoll ein (newsletter_ campaign_recipients) und stellt pro Empfänger einen Queue-Job ein; der Opt-out wird im Worker erneut geprüft. Jede Mail trägt den signierten One-Click-Abmelde-Link (neuer EmailNotificationType:: Newsletter, bestehende Unsubscribe-Infrastruktur inkl. RFC 8058); die Abmeldung greift sofort für alle künftigen Auswahlen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
33e84afa2a
commit
f327853541
19 changed files with 1560 additions and 5 deletions
|
|
@ -5,11 +5,11 @@ namespace App\Enums;
|
|||
/**
|
||||
* Abbestellbare Benachrichtigungs-Mails (Opt-out je Typ).
|
||||
*
|
||||
* Umfasst bewusst nur Benachrichtigungen zum PM-Lebenszyklus — keine
|
||||
* System-/Transaktionsmails (Passwort-Reset, Magic-Link, Kündigungs-/
|
||||
* Widerrufsbestätigungen), die rechtlich bzw. funktional zwingend sind.
|
||||
* Jeder Case entspricht genau einer Mailable-Klasse; vor dem Versand wird
|
||||
* geprüft, ob der Empfänger den Typ abbestellt hat
|
||||
* Umfasst Benachrichtigungen zum PM-Lebenszyklus sowie den internen
|
||||
* Newsletter — keine System-/Transaktionsmails (Passwort-Reset, Magic-Link,
|
||||
* Kündigungs-/Widerrufsbestätigungen), die rechtlich bzw. funktional
|
||||
* zwingend sind. Jeder Case entspricht genau einer Mailable-Klasse; vor dem
|
||||
* Versand wird geprüft, ob der Empfänger den Typ abbestellt hat
|
||||
* (User::wantsEmailNotification()).
|
||||
*/
|
||||
enum EmailNotificationType: string
|
||||
|
|
@ -17,6 +17,7 @@ enum EmailNotificationType: string
|
|||
case PressReleasePublished = 'press_release_published';
|
||||
case PressReleaseRejected = 'press_release_rejected';
|
||||
case PressReleaseDepublished = 'press_release_depublished';
|
||||
case Newsletter = 'newsletter';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -24,6 +25,7 @@ enum EmailNotificationType: string
|
|||
self::PressReleasePublished => 'Pressemitteilung veröffentlicht',
|
||||
self::PressReleaseRejected => 'Pressemitteilung abgelehnt',
|
||||
self::PressReleaseDepublished => 'Pressemitteilung depubliziert',
|
||||
self::Newsletter => 'Newsletter & Plattform-Infos',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ enum EmailNotificationType: string
|
|||
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::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
|
||||
self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +51,7 @@ enum EmailNotificationType: string
|
|||
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::PressReleaseDepublished => 'Sie haben die Depublikation 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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +64,7 @@ enum EmailNotificationType: string
|
|||
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
|
||||
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen',
|
||||
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
|
||||
self::Newsletter => 'Newsletter und Plattform-Informationen',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
app/Enums/NewsletterCampaignStatus.php
Normal file
36
app/Enums/NewsletterCampaignStatus.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Lebenszyklus einer internen Newsletter-Kampagne (Admin ▸ Newsletter).
|
||||
* Draft ist editierbar; mit dem Versandstart wird die Empfängerliste
|
||||
* eingefroren (Protokoll) und die Kampagne unveränderlich.
|
||||
*/
|
||||
enum NewsletterCampaignStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Sending = 'sending';
|
||||
case Sent = 'sent';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Entwurf',
|
||||
self::Sending => 'Wird versendet',
|
||||
self::Sent => 'Versendet',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux-Badge-Farbe in den Kampagnen-Übersichten.
|
||||
*/
|
||||
public function badgeColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'zinc',
|
||||
self::Sending => 'amber',
|
||||
self::Sent => 'green',
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Enums/NewsletterRecipientStatus.php
Normal file
39
app/Enums/NewsletterRecipientStatus.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Versandstatus eines einzelnen Newsletter-Empfängers (Protokoll).
|
||||
* SkippedOptOut greift, wenn sich der User zwischen Versandstart und
|
||||
* tatsächlichem Versand im Worker abgemeldet hat.
|
||||
*/
|
||||
enum NewsletterRecipientStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
case SkippedOptOut = 'skipped_opt_out';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Ausstehend',
|
||||
self::Sent => 'Versendet',
|
||||
self::Failed => 'Fehlgeschlagen',
|
||||
self::SkippedOptOut => 'Übersprungen (abgemeldet)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux-Badge-Farbe in der Protokoll-Tabelle.
|
||||
*/
|
||||
public function badgeColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'zinc',
|
||||
self::Sent => 'green',
|
||||
self::Failed => 'red',
|
||||
self::SkippedOptOut => 'amber',
|
||||
};
|
||||
}
|
||||
}
|
||||
105
app/Jobs/SendNewsletterCampaignMail.php
Normal file
105
app/Jobs/SendNewsletterCampaignMail.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Mail\NewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Models\NewsletterCampaignRecipient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Versendet eine Newsletter-Kampagne an genau einen Empfänger (Protokoll-
|
||||
* Zeile). Pro Empfänger ein Job, damit Fehler einzeln nachvollziehbar
|
||||
* bleiben und Retries keinen Doppelversand an die übrige Liste auslösen.
|
||||
*
|
||||
* Der Opt-out wird hier — zum tatsächlichen Versandzeitpunkt — erneut
|
||||
* geprüft: Wer sich nach Versandstart abmeldet, wird übersprungen.
|
||||
*/
|
||||
class SendNewsletterCampaignMail implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly int $recipientId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$recipient = NewsletterCampaignRecipient::query()
|
||||
->with(['campaign', 'user'])
|
||||
->find($this->recipientId);
|
||||
|
||||
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = $recipient->campaign;
|
||||
$user = $recipient->user;
|
||||
|
||||
if ($user === null || ! $user->is_active) {
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', 'Benutzer gelöscht oder deaktiviert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::SkippedOptOut, 'skipped_count');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($recipient->email)->send(new NewsletterCampaignMail($campaign, $user));
|
||||
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Sent, 'sent_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Endgültiger Fehlschlag nach allen Versuchen des Workers.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$recipient = NewsletterCampaignRecipient::query()->find($this->recipientId);
|
||||
|
||||
if ($recipient === null || $recipient->status !== NewsletterRecipientStatus::Pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->conclude($recipient, NewsletterRecipientStatus::Failed, 'failed_count', $exception->getMessage());
|
||||
}
|
||||
|
||||
private function conclude(
|
||||
NewsletterCampaignRecipient $recipient,
|
||||
NewsletterRecipientStatus $status,
|
||||
string $campaignCounter,
|
||||
?string $error = null,
|
||||
): void {
|
||||
$recipient->update([
|
||||
'status' => $status->value,
|
||||
'error' => $error !== null ? Str::limit($error, 480) : null,
|
||||
'sent_at' => $status === NewsletterRecipientStatus::Sent ? now() : null,
|
||||
]);
|
||||
|
||||
$campaign = NewsletterCampaign::query()->find($recipient->newsletter_campaign_id);
|
||||
|
||||
if ($campaign === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign->increment($campaignCounter);
|
||||
|
||||
$hasPending = $campaign->recipients()
|
||||
->where('status', NewsletterRecipientStatus::Pending->value)
|
||||
->exists();
|
||||
|
||||
if (! $hasPending && $campaign->status === NewsletterCampaignStatus::Sending) {
|
||||
$campaign->update([
|
||||
'status' => NewsletterCampaignStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Mail/NewsletterCampaignMail.php
Normal file
43
app/Mail/NewsletterCampaignMail.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Mail\Concerns\Unsubscribable;
|
||||
use App\Models\NewsletterCampaign;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Eine interne Newsletter-Kampagne an einen einzelnen Empfänger.
|
||||
* Der Kampagnen-Text (leichtes HTML) wird ins zentrale Mail-Layout
|
||||
* eingesetzt; der Abmelde-Link kommt aus dem Unsubscribable-Trait
|
||||
* (signiert, One-Click, RFC 8058).
|
||||
*/
|
||||
class NewsletterCampaignMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels, Unsubscribable;
|
||||
|
||||
public function __construct(
|
||||
public readonly NewsletterCampaign $campaign,
|
||||
public readonly User $user,
|
||||
) {
|
||||
$this->configureUnsubscribe($user, EmailNotificationType::Newsletter);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: $this->campaign->subject,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.newsletter.campaign');
|
||||
}
|
||||
}
|
||||
71
app/Models/NewsletterCampaign.php
Normal file
71
app/Models/NewsletterCampaign.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use Database\Factories\NewsletterCampaignFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
/**
|
||||
* Interne Newsletter-Kampagne (Admin ▸ Newsletter): Betreff + leicht
|
||||
* formatierter Text an gefilterte Bestands-User. `filters` hält die
|
||||
* Empfänger-Auswahl als Snapshot ({@see CampaignRecipients}),
|
||||
* die Empfänger-Zeilen bilden das Versand-Protokoll.
|
||||
*/
|
||||
class NewsletterCampaign extends Model
|
||||
{
|
||||
/** @use HasFactory<NewsletterCampaignFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'subject',
|
||||
'body',
|
||||
'filters',
|
||||
'status',
|
||||
'created_by_user_id',
|
||||
'recipients_total',
|
||||
'sent_count',
|
||||
'failed_count',
|
||||
'skipped_count',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
'status' => NewsletterCampaignStatus::class,
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function recipients(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterCampaignRecipient::class);
|
||||
}
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === NewsletterCampaignStatus::Draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versandfertiger, gesäuberter Inhalt: nur leichtes HTML laut
|
||||
* Purifier-Profil `newsletter` (fett/kursiv, Listen, Links).
|
||||
*/
|
||||
public function sanitizedBody(): HtmlString
|
||||
{
|
||||
return new HtmlString((string) Purifier::clean($this->body ?? '', 'newsletter'));
|
||||
}
|
||||
}
|
||||
42
app/Models/NewsletterCampaignRecipient.php
Normal file
42
app/Models/NewsletterCampaignRecipient.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Protokoll-Eintrag: ein Empfänger einer Newsletter-Kampagne. Die E-Mail
|
||||
* wird als Snapshot gespeichert, damit das Protokoll auch nach Adress-
|
||||
* Änderung oder User-Löschung nachvollziehbar bleibt.
|
||||
*/
|
||||
class NewsletterCampaignRecipient extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'newsletter_campaign_id',
|
||||
'user_id',
|
||||
'email',
|
||||
'status',
|
||||
'error',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => NewsletterRecipientStatus::class,
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function campaign(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NewsletterCampaign::class, 'newsletter_campaign_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
139
app/Services/Newsletter/CampaignRecipients.php
Normal file
139
app/Services/Newsletter/CampaignRecipients.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Newsletter;
|
||||
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Empfänger-Auswahl für interne Newsletter-Kampagnen (Admin ▸ Newsletter).
|
||||
*
|
||||
* Grundmenge sind alle aktiven User mit E-Mail-Adresse, die den Newsletter
|
||||
* nicht abbestellt haben. Darauf setzen die Kampagnen-Filter auf:
|
||||
* Portal, Legacy-Vertrag (Bestandsschutz-Zahlungsoption) und der
|
||||
* Login-Zeitraum (eingeloggt innerhalb X Monaten bzw. dessen Komplement:
|
||||
* länger nicht oder noch nie eingeloggt).
|
||||
*/
|
||||
class CampaignRecipients
|
||||
{
|
||||
public const LOGIN_ANY = 'any';
|
||||
|
||||
public const LOGIN_WITHIN = 'within';
|
||||
|
||||
public const LOGIN_NOT_WITHIN = 'not_within';
|
||||
|
||||
public const SORT_FIELDS = ['created_at', 'last_login_at'];
|
||||
|
||||
/**
|
||||
* Filter-Defaults; unbekannte Werte fallen auf diese zurück.
|
||||
*
|
||||
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
|
||||
*/
|
||||
public static function defaultFilters(): array
|
||||
{
|
||||
return [
|
||||
'portal' => '',
|
||||
'legacy_contract' => false,
|
||||
'login' => self::LOGIN_ANY,
|
||||
'login_months' => 6,
|
||||
'sort' => 'created_at',
|
||||
'direction' => 'desc',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt einen (z. B. aus der UI oder dem JSON-Snapshot kommenden)
|
||||
* Filter-Satz in eine validierte, vollständige Form.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
|
||||
*/
|
||||
public function normalize(array $filters): array
|
||||
{
|
||||
$defaults = self::defaultFilters();
|
||||
|
||||
$portal = (string) ($filters['portal'] ?? '');
|
||||
$login = (string) ($filters['login'] ?? $defaults['login']);
|
||||
$months = (int) ($filters['login_months'] ?? $defaults['login_months']);
|
||||
$sort = (string) ($filters['sort'] ?? $defaults['sort']);
|
||||
$direction = (string) ($filters['direction'] ?? $defaults['direction']);
|
||||
|
||||
return [
|
||||
'portal' => in_array($portal, ['presseecho', 'businessportal24', 'both'], true) ? $portal : '',
|
||||
'legacy_contract' => (bool) ($filters['legacy_contract'] ?? false),
|
||||
'login' => in_array($login, [self::LOGIN_ANY, self::LOGIN_WITHIN, self::LOGIN_NOT_WITHIN], true) ? $login : self::LOGIN_ANY,
|
||||
'login_months' => max(1, min(120, $months)),
|
||||
'sort' => in_array($sort, self::SORT_FIELDS, true) ? $sort : $defaults['sort'],
|
||||
'direction' => $direction === 'asc' ? 'asc' : 'desc',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menschenlesbare Zusammenfassung eines Filter-Satzes für die Admin-UI.
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
* @return list<string>
|
||||
*/
|
||||
public function describe(array $filters): array
|
||||
{
|
||||
$filters = $this->normalize($filters);
|
||||
|
||||
$parts = ['Aktive User mit E-Mail, Newsletter nicht abbestellt'];
|
||||
|
||||
if ($filters['portal'] !== '') {
|
||||
$parts[] = 'Portal: '.$filters['portal'];
|
||||
}
|
||||
|
||||
if ($filters['legacy_contract']) {
|
||||
$parts[] = 'Nur User mit Legacy-Vertrag (Bestandsschutz)';
|
||||
}
|
||||
|
||||
if ($filters['login'] === self::LOGIN_WITHIN) {
|
||||
$parts[] = sprintf('In den letzten %d Monaten eingeloggt', $filters['login_months']);
|
||||
} elseif ($filters['login'] === self::LOGIN_NOT_WITHIN) {
|
||||
$parts[] = sprintf('Seit %d Monaten nicht eingeloggt (oder noch nie)', $filters['login_months']);
|
||||
}
|
||||
|
||||
$parts[] = sprintf(
|
||||
'Sortiert nach %s (%s)',
|
||||
$filters['sort'] === 'last_login_at' ? 'letztem Login' : 'Erstellungsdatum',
|
||||
$filters['direction'] === 'asc' ? 'älteste zuerst' : 'neueste zuerst',
|
||||
);
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Builder<User>
|
||||
*/
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$filters = $this->normalize($filters);
|
||||
$loginCutoff = now()->subMonths($filters['login_months']);
|
||||
|
||||
return User::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('email')
|
||||
->whereDoesntHave('emailNotificationOptOuts', fn (Builder $query) => $query
|
||||
->where('type', EmailNotificationType::Newsletter->value))
|
||||
->when($filters['portal'] !== '', fn (Builder $query) => $query
|
||||
->where('portal', $filters['portal']))
|
||||
->when($filters['legacy_contract'], fn (Builder $query) => $query
|
||||
->whereHas('userPaymentOptions', fn (Builder $inner) => $inner
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->where(fn (Builder $until) => $until
|
||||
->whereNull('grandfathered_until')
|
||||
->orWhere('grandfathered_until', '>=', now()))))
|
||||
->when($filters['login'] === self::LOGIN_WITHIN, fn (Builder $query) => $query
|
||||
->where('last_login_at', '>=', $loginCutoff))
|
||||
->when($filters['login'] === self::LOGIN_NOT_WITHIN, fn (Builder $query) => $query
|
||||
->where(fn (Builder $inner) => $inner
|
||||
->whereNull('last_login_at')
|
||||
->orWhere('last_login_at', '<', $loginCutoff)))
|
||||
->orderBy($filters['sort'], $filters['direction'])
|
||||
->orderBy('id');
|
||||
}
|
||||
}
|
||||
68
app/Services/Newsletter/CampaignSender.php
Normal file
68
app/Services/Newsletter/CampaignSender.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Newsletter;
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Jobs\SendNewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Startet den Versand einer Newsletter-Kampagne: friert die Empfängerliste
|
||||
* laut Filter als Protokoll-Zeilen ein (Status pending) und stellt pro
|
||||
* Empfänger einen Queue-Job ein. Der tatsächliche Versand läuft im Worker;
|
||||
* dort wird der Opt-out-Stand erneut geprüft, damit eine Abmeldung zwischen
|
||||
* Versandstart und Versand sofort greift.
|
||||
*/
|
||||
class CampaignSender
|
||||
{
|
||||
public function __construct(private readonly CampaignRecipients $recipients) {}
|
||||
|
||||
/**
|
||||
* @return int Anzahl der eingeplanten Empfänger.
|
||||
*/
|
||||
public function start(NewsletterCampaign $campaign): int
|
||||
{
|
||||
if (! $campaign->isDraft()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
|
||||
$this->recipients->query($campaign->filters ?? [])
|
||||
->select(['id', 'email'])
|
||||
->chunkById(500, function ($users) use ($campaign, &$total): void {
|
||||
$rows = $users->map(fn (User $user): array => [
|
||||
'newsletter_campaign_id' => $campaign->id,
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'status' => NewsletterRecipientStatus::Pending->value,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
$campaign->recipients()->insert($rows);
|
||||
$total += count($rows);
|
||||
});
|
||||
|
||||
if ($total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$campaign->update([
|
||||
'status' => NewsletterCampaignStatus::Sending->value,
|
||||
'recipients_total' => $total,
|
||||
]);
|
||||
|
||||
$campaign->recipients()
|
||||
->select(['id'])
|
||||
->chunkById(500, function ($recipients): void {
|
||||
foreach ($recipients as $recipient) {
|
||||
SendNewsletterCampaignMail::dispatch($recipient->id);
|
||||
}
|
||||
});
|
||||
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,23 @@ return [
|
|||
'tel' => true,
|
||||
],
|
||||
],
|
||||
/*
|
||||
* Interner Newsletter (Admin ▸ Newsletter): leichter Text mit
|
||||
* fett/kursiv, Listen und Links — passend zur reduzierten
|
||||
* flux:editor-Toolbar des Kampagnen-Editors. Kein Heading/Bild/
|
||||
* Blockquote; das Grundgerüst kommt aus dem Mail-Layout.
|
||||
*/
|
||||
'newsletter' => [
|
||||
'HTML.Doctype' => 'HTML 4.01 Transitional',
|
||||
'HTML.Allowed' => 'p,br,strong,em,b,i,u,ul,ol,li,a[href|title]',
|
||||
'AutoFormat.AutoParagraph' => true,
|
||||
'AutoFormat.RemoveEmpty' => true,
|
||||
'URI.AllowedSchemes' => [
|
||||
'http' => true,
|
||||
'https' => true,
|
||||
'mailto' => true,
|
||||
],
|
||||
],
|
||||
'test' => [
|
||||
'Attr.EnableID' => 'true',
|
||||
],
|
||||
|
|
|
|||
31
database/factories/NewsletterCampaignFactory.php
Normal file
31
database/factories/NewsletterCampaignFactory.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<NewsletterCampaign>
|
||||
*/
|
||||
class NewsletterCampaignFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'subject' => fake()->sentence(6),
|
||||
'body' => '<p>'.fake()->paragraph().'</p>',
|
||||
'filters' => [],
|
||||
'status' => NewsletterCampaignStatus::Draft->value,
|
||||
];
|
||||
}
|
||||
|
||||
public function sent(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => NewsletterCampaignStatus::Sent->value,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Internes Newsletter-Modul (Admin ▸ Newsletter): Kampagnen an Bestands-User
|
||||
* auf Basis der hinterlegten E-Mail-Adressen, mit Empfänger-Protokoll.
|
||||
*
|
||||
* newsletter_campaigns: Betreff + leicht formatierter HTML-Text (Purifier-
|
||||
* Profil `newsletter`) + Empfänger-Filter als JSON-Snapshot.
|
||||
* newsletter_campaign_recipients: ein Protokoll-Eintrag pro Empfänger mit
|
||||
* Versandstatus — welche Mail an wen ging, wann, und ob sie z. B. wegen
|
||||
* Abmeldung übersprungen wurde.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('newsletter_campaigns', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('subject', 190);
|
||||
$table->text('body');
|
||||
$table->json('filters')->nullable();
|
||||
$table->string('status', 20)->default('draft')->index();
|
||||
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->unsignedInteger('recipients_total')->default(0);
|
||||
$table->unsignedInteger('sent_count')->default(0);
|
||||
$table->unsignedInteger('failed_count')->default(0);
|
||||
$table->unsignedInteger('skipped_count')->default(0);
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('newsletter_campaign_recipients', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('newsletter_campaign_id')->constrained('newsletter_campaigns')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('email');
|
||||
$table->string('status', 20)->default('pending');
|
||||
$table->string('error', 500)->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['newsletter_campaign_id', 'status'], 'newsletter_recipients_campaign_status_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('newsletter_campaign_recipients');
|
||||
Schema::dropIfExists('newsletter_campaigns');
|
||||
}
|
||||
};
|
||||
|
|
@ -129,6 +129,10 @@
|
|||
:current="request()->routeIs('admin.users.*')" wire:navigate>
|
||||
{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="paper-airplane" :href="route('admin.newsletter.campaigns.index')"
|
||||
:current="request()->routeIs('admin.newsletter.campaigns.*')" wire:navigate>
|
||||
{{ __('Newsletter') }}
|
||||
</flux:navlist.item>
|
||||
@endcan
|
||||
</flux:navlist.group>
|
||||
|
||||
|
|
|
|||
7
resources/views/emails/newsletter/campaign.blade.php
Normal file
7
resources/views/emails/newsletter/campaign.blade.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<x-emails.layout :title="$campaign->subject" :preheader="$campaign->subject">
|
||||
<p>Hallo {{ $user->name }},</p>
|
||||
|
||||
{!! $campaign->sanitizedBody() !!}
|
||||
|
||||
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
|
||||
</x-emails.layout>
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Mail\NewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use App\Services\Newsletter\CampaignSender;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
/*
|
||||
* Kampagnen-Editor: Betreff + leicht formatierter Text (fett/kursiv, Listen,
|
||||
* Links) und die Empfänger-Filter. Empfängerzahl und Vorschau aktualisieren
|
||||
* live beim Ändern der Filter. Versand friert die Liste als Protokoll ein.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public ?int $id = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $filterPortal = '';
|
||||
|
||||
public bool $filterLegacyContract = false;
|
||||
|
||||
public string $filterLogin = CampaignRecipients::LOGIN_ANY;
|
||||
|
||||
public int $filterLoginMonths = 6;
|
||||
|
||||
public string $sortField = 'created_at';
|
||||
|
||||
public string $sortDirection = 'desc';
|
||||
|
||||
public function mount(?int $id = null): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
if ($id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = NewsletterCampaign::query()->findOrFail($id);
|
||||
|
||||
if (! $campaign->isDraft()) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filters = app(CampaignRecipients::class)->normalize($campaign->filters ?? []);
|
||||
|
||||
$this->subject = $campaign->subject;
|
||||
$this->body = $campaign->body;
|
||||
$this->filterPortal = $filters['portal'];
|
||||
$this->filterLegacyContract = $filters['legacy_contract'];
|
||||
$this->filterLogin = $filters['login'];
|
||||
$this->filterLoginMonths = $filters['login_months'];
|
||||
$this->sortField = $filters['sort'];
|
||||
$this->sortDirection = $filters['direction'];
|
||||
}
|
||||
|
||||
public function saveDraft(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
Flux::toast(text: __('Entwurf gespeichert.'), variant: 'success');
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
Mail::to(auth()->user()->email)->send(new NewsletterCampaignMail($campaign, auth()->user()));
|
||||
|
||||
Flux::toast(
|
||||
text: __('Test-Mail an :email versendet.', ['email' => auth()->user()->email]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
if ($this->id === null) {
|
||||
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
$campaign = $this->persist();
|
||||
|
||||
$total = app(CampaignSender::class)->start($campaign);
|
||||
|
||||
if ($total === 0) {
|
||||
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
|
||||
Flux::modal('confirm-campaign-send')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
text: __(':count Mails eingeplant. Der Versand läuft über die Queue.', ['count' => $total]),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
|
||||
}
|
||||
|
||||
private function persist(): NewsletterCampaign
|
||||
{
|
||||
$this->validate([
|
||||
'subject' => ['required', 'string', 'max:190'],
|
||||
'body' => ['required', 'string', 'max:20000'],
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'subject' => trim($this->subject),
|
||||
// Bereits beim Speichern auf leichtes HTML reduzieren — der
|
||||
// Entwurf enthält damit nie mehr, als die Mail rendern darf.
|
||||
'body' => (string) Purifier::clean($this->body, 'newsletter'),
|
||||
'filters' => $this->filters(),
|
||||
];
|
||||
|
||||
if ($this->id !== null) {
|
||||
$campaign = NewsletterCampaign::query()->findOrFail($this->id);
|
||||
abort_unless($campaign->isDraft(), 403);
|
||||
$campaign->update($attributes);
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
$campaign = NewsletterCampaign::query()->create($attributes + [
|
||||
'status' => NewsletterCampaignStatus::Draft->value,
|
||||
'created_by_user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->id = $campaign->id;
|
||||
|
||||
return $campaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
|
||||
*/
|
||||
private function filters(): array
|
||||
{
|
||||
return app(CampaignRecipients::class)->normalize([
|
||||
'portal' => $this->filterPortal,
|
||||
'legacy_contract' => $this->filterLegacyContract,
|
||||
'login' => $this->filterLogin,
|
||||
'login_months' => $this->filterLoginMonths,
|
||||
'sort' => $this->sortField,
|
||||
'direction' => $this->sortDirection,
|
||||
]);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$recipients = app(CampaignRecipients::class);
|
||||
$query = $recipients->query($this->filters());
|
||||
|
||||
return [
|
||||
'recipientCount' => (clone $query)->count(),
|
||||
'recipientPreview' => $query->limit(8)->get(['id', 'name', 'email', 'last_login_at', 'created_at']),
|
||||
'filterSummary' => $recipients->describe($this->filters()),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="$id === null ? __('Neue Kampagne') : __('Kampagne bearbeiten')"
|
||||
:description="__('Der Text wird in das zentrale Mail-Layout eingesetzt; jede Mail enthält automatisch den Abmelde-Link des Empfängers.')"
|
||||
>
|
||||
<x-slot:actions>
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||
{{-- ============== INHALT ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
<flux:badge size="sm" color="zinc">{{ __('Entwurf') }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5 space-y-5">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Betreff') }}</flux:label>
|
||||
<flux:input wire:model="subject" :placeholder="__('z. B. Ihr Presseportal ist umgezogen — das ist neu')" maxlength="190" />
|
||||
<flux:error name="subject" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Text') }}</flux:label>
|
||||
<flux:description>
|
||||
{{ __('Leichte Formatierung (fett, kursiv, Listen, Links). Anrede und Abmelde-Hinweis ergänzt das Mail-Layout automatisch.') }}
|
||||
</flux:description>
|
||||
<flux:editor wire:model="body"
|
||||
toolbar="bold italic underline | bullet ordered | link | undo redo"
|
||||
placeholder="{{ __('Hier den Newsletter-Text schreiben…') }}" />
|
||||
<flux:error name="body" />
|
||||
</flux:field>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] pt-4">
|
||||
<flux:button variant="filled" icon="document-check" wire:click="saveDraft">
|
||||
{{ __('Als Entwurf speichern') }}
|
||||
</flux:button>
|
||||
<flux:button variant="filled" icon="beaker" wire:click="sendTest">
|
||||
{{ __('Test-Mail an mich') }}
|
||||
</flux:button>
|
||||
<flux:spacer />
|
||||
<flux:modal.trigger name="confirm-campaign-send">
|
||||
<flux:button variant="primary" icon="paper-airplane">
|
||||
{{ __('Jetzt senden') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== EMPFÄNGER ============== --}}
|
||||
<div class="space-y-6">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Empfänger-Filter') }}</span>
|
||||
<flux:badge size="sm" color="blue">{{ number_format($recipientCount, 0, ',', '.') }} {{ __('Empfänger') }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Portal') }}</flux:label>
|
||||
<flux:select wire:model.live="filterPortal">
|
||||
<option value="">{{ __('Alle Portale') }}</option>
|
||||
<option value="presseecho">presseecho</option>
|
||||
<option value="businessportal24">businessportal24</option>
|
||||
<option value="both">{{ __('Beide (both)') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
<flux:field variant="inline">
|
||||
<flux:checkbox wire:model.live="filterLegacyContract" />
|
||||
<flux:label>{{ __('Nur User mit Legacy-Vertrag (Bestandsschutz)') }}</flux:label>
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Letzter Login') }}</flux:label>
|
||||
<flux:select wire:model.live="filterLogin">
|
||||
<option value="any">{{ __('Alle — unabhängig vom Login') }}</option>
|
||||
<option value="within">{{ __('In den letzten X Monaten eingeloggt') }}</option>
|
||||
<option value="not_within">{{ __('Seit X Monaten nicht eingeloggt (oder nie)') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
|
||||
@if ($filterLogin !== 'any')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Zeitraum X in Monaten') }}</flux:label>
|
||||
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
|
||||
<flux:description>{{ __('z. B. 6 oder 12 Monate') }}</flux:description>
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Sortierung') }}</flux:label>
|
||||
<flux:select wire:model.live="sortField">
|
||||
<option value="created_at">{{ __('Erstellungsdatum') }}</option>
|
||||
<option value="last_login_at">{{ __('Letzter Login') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Reihenfolge') }}</flux:label>
|
||||
<flux:select wire:model.live="sortDirection">
|
||||
<option value="desc">{{ __('Neueste zuerst') }}</option>
|
||||
<option value="asc">{{ __('Älteste zuerst') }}</option>
|
||||
</flux:select>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<ul class="list-disc ps-4 space-y-0.5">
|
||||
@foreach ($filterSummary as $line)
|
||||
<li>{{ $line }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Vorschau der Empfänger') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('erste :count', ['count' => $recipientPreview->count()]) }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-2">
|
||||
@forelse ($recipientPreview as $user)
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2">
|
||||
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)] truncate">{{ $user->name }}</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">{{ $user->email }}</div>
|
||||
<div class="text-[11px] text-[color:var(--color-ink-3)] tabular-nums">
|
||||
{{ __('Login:') }} {{ $user->last_login_at?->format('d.m.Y') ?? __('nie') }}
|
||||
· {{ __('Erstellt:') }} {{ $user->created_at?->format('d.m.Y') ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Keine Empfänger für die gewählten Filter.') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ============== SENDEN-MODAL ============== --}}
|
||||
<flux:modal name="confirm-campaign-send" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Kampagne jetzt versenden?') }}</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Die Mail geht an :count Empfänger laut aktueller Filter. Die Empfängerliste wird als Protokoll eingefroren; der Versand läuft über die Queue und lässt sich nicht zurückholen.', ['count' => number_format($recipientCount, 0, ',', '.')]) }}
|
||||
</flux:subheading>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button variant="primary" wire:click="send">{{ __('Senden') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
use App\Models\NewsletterCampaign;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'campaigns' => NewsletterCampaign::query()
|
||||
->with('createdBy:id,name')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(25),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="__('Newsletter-Kampagnen')"
|
||||
:description="__('Interne Mailings an Bestands-User auf Basis der hinterlegten E-Mail-Adressen — mit Empfänger-Filtern, Versand-Protokoll und Abmelde-Link in jeder Mail.')"
|
||||
>
|
||||
<x-slot:actions>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('admin.newsletter.campaigns.create') }}" wire:navigate>
|
||||
{{ __('Neue Kampagne') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
{{-- ============== KAMPAGNEN ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Kampagnen') }}</span>
|
||||
<span class="badge hub">{{ number_format($campaigns->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Betreff') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Angelegt von') }}</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($campaigns as $campaign)
|
||||
<flux:table.row :key="$campaign->id">
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $campaign->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<a href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate
|
||||
class="text-[12.5px] font-semibold text-[color:var(--color-ink)] hover:underline">
|
||||
{{ $campaign->subject }}
|
||||
</a>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
@if ($campaign->isDraft())
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">—</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ number_format($campaign->sent_count, 0, ',', '.') }} / {{ number_format($campaign->recipients_total, 0, ',', '.') }}
|
||||
</span>
|
||||
@if ($campaign->failed_count > 0)
|
||||
<flux:badge size="sm" color="red">{{ $campaign->failed_count }} {{ __('Fehler') }}</flux:badge>
|
||||
@endif
|
||||
@if ($campaign->skipped_count > 0)
|
||||
<flux:badge size="sm" color="amber">{{ $campaign->skipped_count }} {{ __('abgemeldet') }}</flux:badge>
|
||||
@endif
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ $campaign->createdBy?->name ?? '—' }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap text-right">
|
||||
@if ($campaign->isDraft())
|
||||
<flux:button size="xs" variant="filled" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button size="xs" variant="filled" icon="eye" href="{{ route('admin.newsletter.campaigns.show', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Protokoll') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Noch keine Kampagnen. Legen Sie die erste Kampagne an, um Bestands-User zu informieren.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($campaigns->hasPages())
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
||||
{{ $campaigns->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/*
|
||||
* Kampagnen-Detail: Inhalt, Filter-Zusammenfassung und das Versand-Protokoll
|
||||
* (welche Mail ging an welchen Empfänger, mit Status und Fehlern).
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $statusFilter = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if ($property === 'statusFilter') {
|
||||
$this->resetPage();
|
||||
}
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$campaign = NewsletterCampaign::query()
|
||||
->with('createdBy:id,name')
|
||||
->findOrFail($this->id);
|
||||
|
||||
$recipients = $campaign->recipients()
|
||||
->with('user:id,name')
|
||||
->when($this->statusFilter !== '', fn ($query) => $query->where('status', $this->statusFilter))
|
||||
->orderBy('id')
|
||||
->paginate(50);
|
||||
|
||||
return [
|
||||
'campaign' => $campaign,
|
||||
'recipients' => $recipients,
|
||||
'filterSummary' => app(CampaignRecipients::class)->describe($campaign->filters ?? []),
|
||||
'recipientStatuses' => NewsletterRecipientStatus::cases(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="$campaign->subject"
|
||||
:description="__('Angelegt am :date:by', ['date' => $campaign->created_at->format('d.m.Y H:i'), 'by' => $campaign->createdBy ? ' von '.$campaign->createdBy->name : ''])"
|
||||
>
|
||||
<x-slot:actions>
|
||||
@if ($campaign->isDraft())
|
||||
<flux:button variant="primary" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<x-portal.stat-card variant="primary" :label="__('Empfänger')" :value="number_format($campaign->recipients_total)">
|
||||
<x-slot:meta>{{ $campaign->status->label() }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="ok" :label="__('Versendet')" :value="number_format($campaign->sent_count)">
|
||||
<x-slot:meta>{{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Übersprungen')" :value="number_format($campaign->skipped_count)">
|
||||
<x-slot:meta>{{ __('abgemeldet') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Fehlgeschlagen')" :value="number_format($campaign->failed_count)">
|
||||
<x-slot:meta>{{ __('siehe Protokoll') }}</x-slot:meta>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
{{-- ============== INHALT ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mb-3">
|
||||
{{ __('Betreff') }}: {{ $campaign->subject }}
|
||||
</div>
|
||||
<div class="pr-content max-w-[640px] text-[13px]">
|
||||
{!! $campaign->sanitizedBody() !!}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== FILTER ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Empfänger-Auswahl') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<ul class="list-disc ps-4 space-y-1 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||
@foreach ($filterSummary as $line)
|
||||
<li>{{ $line }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-3 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Die Liste wurde beim Versandstart eingefroren. Abmeldungen nach dem Start werden beim Versand übersprungen und im Protokoll vermerkt.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{{-- ============== PROTOKOLL ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Versand-Protokoll') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:select wire:model.live="statusFilter" size="sm">
|
||||
<option value="">{{ __('Alle Status') }}</option>
|
||||
@foreach ($recipientStatuses as $status)
|
||||
<option value="{{ $status->value }}">{{ $status->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<span class="badge hub">{{ number_format($recipients->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Versendet am') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Fehler') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($recipients as $recipient)
|
||||
<flux:table.row :key="$recipient->id">
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
@if ($recipient->user)
|
||||
<a href="{{ route('admin.users.show', $recipient->user->id) }}" wire:navigate
|
||||
class="text-[12.5px] text-[color:var(--color-ink)] hover:underline">
|
||||
{{ $recipient->user->name }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Gelöschter User') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $recipient->email }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="$recipient->status->badgeColor()">{{ $recipient->status->label() }}</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $recipient->sent_at?->format('d.m.Y H:i:s') ?? '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $recipient->error ?? '' }}</span>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $campaign->isDraft()
|
||||
? __('Noch kein Versand — die Kampagne ist ein Entwurf.')
|
||||
: __('Keine Einträge für diesen Filter.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($recipients->hasPages())
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
||||
{{ $recipients->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -101,6 +101,15 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
Volt::route('admin/users/{id}', 'admin.users.show')->name('admin.users.show');
|
||||
Volt::route('admin/users/{id}/edit', 'admin.users.edit')->name('admin.users.edit');
|
||||
|
||||
// Interner Newsletter: Kampagnen an Bestands-User (Filter + Protokoll).
|
||||
// Massenversand an die User-Datenbank → nur mit users:manage (admin).
|
||||
Route::middleware('can:users:manage')->group(function (): void {
|
||||
Volt::route('admin/newsletter', 'admin.newsletter.campaigns.index')->name('admin.newsletter.campaigns.index');
|
||||
Volt::route('admin/newsletter/create', 'admin.newsletter.campaigns.edit')->name('admin.newsletter.campaigns.create');
|
||||
Volt::route('admin/newsletter/{id}', 'admin.newsletter.campaigns.show')->where('id', '[0-9]+')->name('admin.newsletter.campaigns.show');
|
||||
Volt::route('admin/newsletter/{id}/edit', 'admin.newsletter.campaigns.edit')->where('id', '[0-9]+')->name('admin.newsletter.campaigns.edit');
|
||||
});
|
||||
|
||||
Volt::route('admin/roles', 'admin.roles.index')->name('admin.roles.index');
|
||||
Volt::route('admin/roles/create', 'admin.roles.create')->name('admin.roles.create');
|
||||
Volt::route('admin/roles/{id}/edit', 'admin.roles.edit')->name('admin.roles.edit');
|
||||
|
|
|
|||
223
tests/Feature/Admin/NewsletterCampaignTest.php
Normal file
223
tests/Feature/Admin/NewsletterCampaignTest.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\NewsletterCampaignStatus;
|
||||
use App\Enums\NewsletterRecipientStatus;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Jobs\SendNewsletterCampaignMail;
|
||||
use App\Mail\NewsletterCampaignMail;
|
||||
use App\Models\NewsletterCampaign;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Newsletter\CampaignRecipients;
|
||||
use App\Services\Newsletter\CampaignSender;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Internes Newsletter-Modul (Admin ▸ Newsletter): Empfänger-Filter,
|
||||
* Versand mit Protokoll und die sofort greifende Abmeldung.
|
||||
*/
|
||||
function newsletterAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('the recipient filters cover legacy contracts and login windows', function () {
|
||||
$recipients = app(CampaignRecipients::class);
|
||||
|
||||
$legacyUser = User::factory()->create(['name' => 'Legacy Lisa', 'last_login_at' => now()->subMonths(2)]);
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $legacyUser->id,
|
||||
'payment_option_id' => PaymentOption::factory()->create()->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => null,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
|
||||
$recentUser = User::factory()->create(['name' => 'Recent Rita', 'last_login_at' => now()->subMonth()]);
|
||||
$dormantUser = User::factory()->create(['name' => 'Dormant Doris', 'last_login_at' => now()->subMonths(18)]);
|
||||
$neverLoggedIn = User::factory()->create(['name' => 'Nie Nora', 'last_login_at' => null]);
|
||||
$inactiveUser = User::factory()->create(['is_active' => false, 'last_login_at' => now()->subMonth()]);
|
||||
|
||||
$unsubscribed = User::factory()->create(['name' => 'Abgemeldet Anna', 'last_login_at' => now()->subMonth()]);
|
||||
$unsubscribed->optOutOfEmailNotification(EmailNotificationType::Newsletter);
|
||||
|
||||
// Grundmenge: aktiv, mit E-Mail, nicht abgemeldet.
|
||||
$all = $recipients->query([])->pluck('id');
|
||||
expect($all)->toContain($legacyUser->id, $recentUser->id, $dormantUser->id, $neverLoggedIn->id)
|
||||
->not->toContain($inactiveUser->id)
|
||||
->not->toContain($unsubscribed->id);
|
||||
|
||||
// Nur Legacy-Verträge (Bestandsschutz).
|
||||
expect($recipients->query(['legacy_contract' => true])->pluck('id')->all())
|
||||
->toBe([$legacyUser->id]);
|
||||
|
||||
// In den letzten 6 Monaten eingeloggt.
|
||||
$within = $recipients->query(['login' => 'within', 'login_months' => 6])->pluck('id');
|
||||
expect($within)->toContain($legacyUser->id, $recentUser->id)
|
||||
->not->toContain($dormantUser->id)
|
||||
->not->toContain($neverLoggedIn->id);
|
||||
|
||||
// Komplement: seit 6 Monaten nicht (oder noch nie) eingeloggt.
|
||||
$notWithin = $recipients->query(['login' => 'not_within', 'login_months' => 6])->pluck('id');
|
||||
expect($notWithin)->toContain($dormantUser->id, $neverLoggedIn->id)
|
||||
->not->toContain($recentUser->id);
|
||||
|
||||
// Sortierung nach letztem Login, älteste zuerst (NULL zuerst).
|
||||
$sorted = $recipients->query(['sort' => 'last_login_at', 'direction' => 'asc'])->pluck('id')->all();
|
||||
expect($sorted[0])->toBe($neverLoggedIn->id)
|
||||
->and(end($sorted))->toBe($recentUser->id);
|
||||
});
|
||||
|
||||
test('the campaign mail carries the body, salutation and a signed unsubscribe link', function () {
|
||||
$user = User::factory()->create(['name' => 'Empfang Emil']);
|
||||
$campaign = NewsletterCampaign::factory()->create([
|
||||
'subject' => 'Unser Portal ist umgezogen',
|
||||
'body' => '<p>Hallo <strong>zusammen</strong>,<script>alert(1)</script> es gibt Neuigkeiten.</p>',
|
||||
]);
|
||||
|
||||
$html = (new NewsletterCampaignMail($campaign, $user))->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('Hallo Empfang Emil')
|
||||
->toContain('<strong>zusammen</strong>')
|
||||
->not->toContain('<script>')
|
||||
->toContain('Warum erhalten Sie diese E-Mail?')
|
||||
->toContain('e-mail-benachrichtigungen/abmelden/'.$user->id.'/'.EmailNotificationType::Newsletter->value)
|
||||
->toContain('signature=');
|
||||
});
|
||||
|
||||
test('sending a campaign mails every recipient and freezes the protocol', function () {
|
||||
Mail::fake();
|
||||
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
$optedOut = User::factory()->create();
|
||||
$optedOut->optOutOfEmailNotification(EmailNotificationType::Newsletter);
|
||||
|
||||
$campaign = NewsletterCampaign::factory()->create();
|
||||
|
||||
// Sync-Queue im Test: start() versendet direkt.
|
||||
$total = app(CampaignSender::class)->start($campaign);
|
||||
|
||||
expect($total)->toBe(2);
|
||||
|
||||
Mail::assertSent(NewsletterCampaignMail::class, 2);
|
||||
Mail::assertNotSent(NewsletterCampaignMail::class, fn (NewsletterCampaignMail $mail) => $mail->user->is($optedOut));
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
|
||||
->and($campaign->recipients_total)->toBe(2)
|
||||
->and($campaign->sent_count)->toBe(2)
|
||||
->and($campaign->failed_count)->toBe(0)
|
||||
->and($campaign->sent_at)->not->toBeNull();
|
||||
|
||||
$emails = $campaign->recipients()->pluck('email');
|
||||
expect($emails)->toContain($userA->email, $userB->email)
|
||||
->and($campaign->recipients()->where('status', NewsletterRecipientStatus::Sent->value)->count())->toBe(2);
|
||||
|
||||
// Ein zweiter Start ist wirkungslos (kein Doppelversand).
|
||||
expect(app(CampaignSender::class)->start($campaign))->toBe(0);
|
||||
});
|
||||
|
||||
test('an opt-out between send start and delivery is skipped and recorded', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$campaign = NewsletterCampaign::factory()->create([
|
||||
'status' => NewsletterCampaignStatus::Sending->value,
|
||||
'recipients_total' => 1,
|
||||
]);
|
||||
$recipient = $campaign->recipients()->create([
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'status' => NewsletterRecipientStatus::Pending->value,
|
||||
]);
|
||||
|
||||
// Abmeldung nach Versandstart, bevor der Worker den Job verarbeitet.
|
||||
$user->optOutOfEmailNotification(EmailNotificationType::Newsletter);
|
||||
|
||||
(new SendNewsletterCampaignMail($recipient->id))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
|
||||
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::SkippedOptOut);
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->skipped_count)->toBe(1)
|
||||
->and($campaign->status)->toBe(NewsletterCampaignStatus::Sent);
|
||||
});
|
||||
|
||||
test('the newsletter unsubscribe link records the opt-out immediately', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
|
||||
$url = URL::signedRoute('email-preferences.unsubscribe', [
|
||||
'user' => $user->id,
|
||||
'type' => EmailNotificationType::Newsletter->value,
|
||||
]);
|
||||
|
||||
$this->get($url)->assertOk()->assertSee('abgemeldet');
|
||||
|
||||
expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse();
|
||||
|
||||
// Abgemeldete User fallen sofort aus jeder Empfänger-Auswahl.
|
||||
expect(app(CampaignRecipients::class)->query([])->pluck('id'))->not->toContain($user->id);
|
||||
});
|
||||
|
||||
test('the campaign editor saves drafts and sends via the queue', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
User::factory()->create(['portal' => Portal::Presseecho, 'last_login_at' => now()->subMonth()]);
|
||||
|
||||
LivewireVolt::test('admin.newsletter.campaigns.edit')
|
||||
->set('subject', 'Willkommen auf der neuen Plattform')
|
||||
->set('body', '<p>Es gibt <em>Neuigkeiten</em>.</p>')
|
||||
->call('saveDraft')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$campaign = NewsletterCampaign::query()->sole();
|
||||
expect($campaign->isDraft())->toBeTrue()
|
||||
->and($campaign->created_by_user_id)->toBe($admin->id);
|
||||
|
||||
LivewireVolt::test('admin.newsletter.campaigns.edit', ['id' => $campaign->id])
|
||||
->call('send')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$campaign->refresh();
|
||||
expect($campaign->status)->toBe(NewsletterCampaignStatus::Sent)
|
||||
->and($campaign->recipients_total)->toBeGreaterThanOrEqual(2); // Empfänger + Admin selbst
|
||||
|
||||
Mail::assertSent(NewsletterCampaignMail::class, $campaign->recipients_total);
|
||||
});
|
||||
|
||||
test('the campaign pages require the users manage permission', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$editor = User::factory()->create(['is_active' => true]);
|
||||
$editor->assignRole('editor');
|
||||
|
||||
$this->actingAs($editor);
|
||||
|
||||
$this->get(route('admin.newsletter.campaigns.index'))->assertForbidden();
|
||||
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get(route('admin.newsletter.campaigns.index'))->assertOk();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue