diff --git a/app/Enums/EmailNotificationType.php b/app/Enums/EmailNotificationType.php index f82187ee..a6cc9934 100644 --- a/app/Enums/EmailNotificationType.php +++ b/app/Enums/EmailNotificationType.php @@ -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', }; } } diff --git a/app/Enums/NewsletterCampaignStatus.php b/app/Enums/NewsletterCampaignStatus.php new file mode 100644 index 00000000..e8eb30fe --- /dev/null +++ b/app/Enums/NewsletterCampaignStatus.php @@ -0,0 +1,36 @@ + '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', + }; + } +} diff --git a/app/Enums/NewsletterRecipientStatus.php b/app/Enums/NewsletterRecipientStatus.php new file mode 100644 index 00000000..fa061e56 --- /dev/null +++ b/app/Enums/NewsletterRecipientStatus.php @@ -0,0 +1,39 @@ + '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', + }; + } +} diff --git a/app/Jobs/SendNewsletterCampaignMail.php b/app/Jobs/SendNewsletterCampaignMail.php new file mode 100644 index 00000000..5cd04610 --- /dev/null +++ b/app/Jobs/SendNewsletterCampaignMail.php @@ -0,0 +1,105 @@ +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(), + ]); + } + } +} diff --git a/app/Mail/NewsletterCampaignMail.php b/app/Mail/NewsletterCampaignMail.php new file mode 100644 index 00000000..0958e3c4 --- /dev/null +++ b/app/Mail/NewsletterCampaignMail.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/app/Models/NewsletterCampaign.php b/app/Models/NewsletterCampaign.php new file mode 100644 index 00000000..ea66aefb --- /dev/null +++ b/app/Models/NewsletterCampaign.php @@ -0,0 +1,71 @@ + */ + 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')); + } +} diff --git a/app/Models/NewsletterCampaignRecipient.php b/app/Models/NewsletterCampaignRecipient.php new file mode 100644 index 00000000..a5301713 --- /dev/null +++ b/app/Models/NewsletterCampaignRecipient.php @@ -0,0 +1,42 @@ + 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); + } +} diff --git a/app/Services/Newsletter/CampaignRecipients.php b/app/Services/Newsletter/CampaignRecipients.php new file mode 100644 index 00000000..b511ddef --- /dev/null +++ b/app/Services/Newsletter/CampaignRecipients.php @@ -0,0 +1,139 @@ + '', + '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 $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 $filters + * @return list + */ + 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 $filters + * @return Builder + */ + 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'); + } +} diff --git a/app/Services/Newsletter/CampaignSender.php b/app/Services/Newsletter/CampaignSender.php new file mode 100644 index 00000000..354a4fd7 --- /dev/null +++ b/app/Services/Newsletter/CampaignSender.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/config/purifier.php b/config/purifier.php index 81148a6b..332d9c56 100644 --- a/config/purifier.php +++ b/config/purifier.php @@ -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', ], diff --git a/database/factories/NewsletterCampaignFactory.php b/database/factories/NewsletterCampaignFactory.php new file mode 100644 index 00000000..d54afe3d --- /dev/null +++ b/database/factories/NewsletterCampaignFactory.php @@ -0,0 +1,31 @@ + + */ +class NewsletterCampaignFactory extends Factory +{ + public function definition(): array + { + return [ + 'subject' => fake()->sentence(6), + 'body' => '

'.fake()->paragraph().'

', + 'filters' => [], + 'status' => NewsletterCampaignStatus::Draft->value, + ]; + } + + public function sent(): static + { + return $this->state(fn (): array => [ + 'status' => NewsletterCampaignStatus::Sent->value, + 'sent_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_07_21_111646_create_newsletter_campaign_tables.php b/database/migrations/2026_07_21_111646_create_newsletter_campaign_tables.php new file mode 100644 index 00000000..36ee36d6 --- /dev/null +++ b/database/migrations/2026_07_21_111646_create_newsletter_campaign_tables.php @@ -0,0 +1,55 @@ +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'); + } +}; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index d5c31815..2439d33a 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -129,6 +129,10 @@ :current="request()->routeIs('admin.users.*')" wire:navigate> {{ __('Benutzer') }} + + {{ __('Newsletter') }} + @endcan diff --git a/resources/views/emails/newsletter/campaign.blade.php b/resources/views/emails/newsletter/campaign.blade.php new file mode 100644 index 00000000..c57714a1 --- /dev/null +++ b/resources/views/emails/newsletter/campaign.blade.php @@ -0,0 +1,7 @@ + +

Hallo {{ $user->name }},

+ + {!! $campaign->sanitizedBody() !!} + + +
diff --git a/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php new file mode 100644 index 00000000..b2e44531 --- /dev/null +++ b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php @@ -0,0 +1,345 @@ +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()), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + + {{ __('Zurück') }} + + + + +
+ {{-- ============== INHALT ============== --}} +
+
+ {{ __('Inhalt') }} + {{ __('Entwurf') }} +
+
+ + {{ __('Betreff') }} + + + + + + {{ __('Text') }} + + {{ __('Leichte Formatierung (fett, kursiv, Listen, Links). Anrede und Abmelde-Hinweis ergänzt das Mail-Layout automatisch.') }} + + + + + +
+ + {{ __('Als Entwurf speichern') }} + + + {{ __('Test-Mail an mich') }} + + + + + {{ __('Jetzt senden') }} + + +
+
+
+ + {{-- ============== EMPFÄNGER ============== --}} +
+
+
+ {{ __('Empfänger-Filter') }} + {{ number_format($recipientCount, 0, ',', '.') }} {{ __('Empfänger') }} +
+
+ + {{ __('Portal') }} + + + + + + + + + + + {{ __('Nur User mit Legacy-Vertrag (Bestandsschutz)') }} + + + + {{ __('Letzter Login') }} + + + + + + + + @if ($filterLogin !== 'any') + + {{ __('Zeitraum X in Monaten') }} + + {{ __('z. B. 6 oder 12 Monate') }} + + @endif + +
+ + {{ __('Sortierung') }} + + + + + + + {{ __('Reihenfolge') }} + + + + + +
+ +
+
    + @foreach ($filterSummary as $line) +
  • {{ $line }}
  • + @endforeach +
+
+
+
+ +
+
+ {{ __('Vorschau der Empfänger') }} + {{ __('erste :count', ['count' => $recipientPreview->count()]) }} +
+
+ @forelse ($recipientPreview as $user) +
+
{{ $user->name }}
+
{{ $user->email }}
+
+ {{ __('Login:') }} {{ $user->last_login_at?->format('d.m.Y') ?? __('nie') }} + · {{ __('Erstellt:') }} {{ $user->created_at?->format('d.m.Y') ?? '—' }} +
+
+ @empty +
+ {{ __('Keine Empfänger für die gewählten Filter.') }} +
+ @endforelse +
+
+
+
+ + {{-- ============== SENDEN-MODAL ============== --}} + +
+
+ {{ __('Kampagne jetzt versenden?') }} + + {{ __('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, ',', '.')]) }} + +
+
+ + {{ __('Abbrechen') }} + + {{ __('Senden') }} +
+
+
+
diff --git a/resources/views/livewire/admin/newsletter/campaigns/index.blade.php b/resources/views/livewire/admin/newsletter/campaigns/index.blade.php new file mode 100644 index 00000000..8392cc51 --- /dev/null +++ b/resources/views/livewire/admin/newsletter/campaigns/index.blade.php @@ -0,0 +1,118 @@ + NewsletterCampaign::query() + ->with('createdBy:id,name') + ->orderByDesc('created_at') + ->paginate(25), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + + {{ __('Neue Kampagne') }} + + + + + {{-- ============== KAMPAGNEN ============== --}} +
+
+ {{ __('Kampagnen') }} + {{ number_format($campaigns->total(), 0, ',', '.') }} {{ __('Einträge') }} +
+ + + {{ __('Erstellt') }} + {{ __('Betreff') }} + {{ __('Status') }} + {{ __('Empfänger') }} + {{ __('Angelegt von') }} + + + + @forelse ($campaigns as $campaign) + + + + {{ $campaign->created_at->format('d.m.Y H:i') }} + + + + + {{ $campaign->subject }} + + + + {{ $campaign->status->label() }} + + + @if ($campaign->isDraft()) + — + @else + + {{ number_format($campaign->sent_count, 0, ',', '.') }} / {{ number_format($campaign->recipients_total, 0, ',', '.') }} + + @if ($campaign->failed_count > 0) + {{ $campaign->failed_count }} {{ __('Fehler') }} + @endif + @if ($campaign->skipped_count > 0) + {{ $campaign->skipped_count }} {{ __('abgemeldet') }} + @endif + @endif + + + {{ $campaign->createdBy?->name ?? '—' }} + + + @if ($campaign->isDraft()) + + {{ __('Bearbeiten') }} + + @else + + {{ __('Protokoll') }} + + @endif + + + @empty + + +
+ {{ __('Noch keine Kampagnen. Legen Sie die erste Kampagne an, um Bestands-User zu informieren.') }} +
+
+
+ @endforelse +
+
+ @if ($campaigns->hasPages()) +
+ {{ $campaigns->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/admin/newsletter/campaigns/show.blade.php b/resources/views/livewire/admin/newsletter/campaigns/show.blade.php new file mode 100644 index 00000000..b029fe22 --- /dev/null +++ b/resources/views/livewire/admin/newsletter/campaigns/show.blade.php @@ -0,0 +1,198 @@ +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(), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + @if ($campaign->isDraft()) + + {{ __('Bearbeiten') }} + + @endif + + {{ __('Zurück') }} + + + + + {{-- ============== KPI-Reihe ============== --}} +
+ + {{ $campaign->status->label() }} + + + {{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }} + + + {{ __('abgemeldet') }} + + + {{ __('siehe Protokoll') }} + +
+ +
+ {{-- ============== INHALT ============== --}} +
+
+ {{ __('Inhalt') }} + {{ $campaign->status->label() }} +
+
+
+ {{ __('Betreff') }}: {{ $campaign->subject }} +
+
+ {!! $campaign->sanitizedBody() !!} +
+
+
+ + {{-- ============== FILTER ============== --}} +
+
+ {{ __('Empfänger-Auswahl') }} +
+
+
    + @foreach ($filterSummary as $line) +
  • {{ $line }}
  • + @endforeach +
+

+ {{ __('Die Liste wurde beim Versandstart eingefroren. Abmeldungen nach dem Start werden beim Versand übersprungen und im Protokoll vermerkt.') }} +

+
+
+
+ + {{-- ============== PROTOKOLL ============== --}} +
+
+ {{ __('Versand-Protokoll') }} +
+ + + @foreach ($recipientStatuses as $status) + + @endforeach + + {{ number_format($recipients->total(), 0, ',', '.') }} {{ __('Einträge') }} +
+
+ + + {{ __('Empfänger') }} + {{ __('E-Mail') }} + {{ __('Status') }} + {{ __('Versendet am') }} + {{ __('Fehler') }} + + + @forelse ($recipients as $recipient) + + + @if ($recipient->user) + + {{ $recipient->user->name }} + + @else + {{ __('Gelöschter User') }} + @endif + + + {{ $recipient->email }} + + + {{ $recipient->status->label() }} + + + + {{ $recipient->sent_at?->format('d.m.Y H:i:s') ?? '—' }} + + + + {{ $recipient->error ?? '' }} + + + @empty + + +
+ {{ $campaign->isDraft() + ? __('Noch kein Versand — die Kampagne ist ein Entwurf.') + : __('Keine Einträge für diesen Filter.') }} +
+
+
+ @endforelse +
+
+ @if ($recipients->hasPages()) +
+ {{ $recipients->links() }} +
+ @endif +
+
diff --git a/routes/admin.php b/routes/admin.php index 8de1a2fd..14616b2d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Admin/NewsletterCampaignTest.php b/tests/Feature/Admin/NewsletterCampaignTest.php new file mode 100644 index 00000000..87da9858 --- /dev/null +++ b/tests/Feature/Admin/NewsletterCampaignTest.php @@ -0,0 +1,223 @@ +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' => '

Hallo zusammen, es gibt Neuigkeiten.

', + ]); + + $html = (new NewsletterCampaignMail($campaign, $user))->render(); + + expect($html) + ->toContain('Hallo Empfang Emil') + ->toContain('zusammen') + ->not->toContain('