From 58c0cc3f238422eb416740053e4b5cae8e48d769 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 22 Jul 2026 12:38:47 +0000 Subject: [PATCH] admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unterseite /admin/newsletter/abmeldungen listet Newsletter-Opt-outs mit Zeitpunkt, Suche und Link zur User-Detailseite - Kampagnen-Filter: Empfänger einer früheren Kampagne ausschließen (Abgleich per User-ID und E-Mail-Snapshot) für Follow-up-Mailings - Login-Filter kann Monats-Scheiben (von–bis) abgrenzen, um große Listen Monat für Monat abzuarbeiten - Versand läuft in Wellen (Standard 50 Mails / 15 Min, per .env justierbar) gegen SMTP-Limits und Blocklisten; Hinweis im Senden-Modal - web: Firmen-Website im Newsroom & Co. als absolute URL verlinkt (Company::websiteUrl), statt relativ auf /newsroom/www.example.de - kunde: API-Doku-Button öffnet die /de/api-Seite; OpenAPI-Spezifikation als eigener, benannter Download (pressekonto-api-v1.yml) Co-Authored-By: Claude Fable 5 --- .env.example | 5 + app/Models/Company.php | 18 +++ .../Newsletter/CampaignRecipients.php | 53 ++++++- app/Services/Newsletter/CampaignSender.php | 41 ++++- config/newsletter.php | 11 ++ .../livewire/admin/companies/show.blade.php | 2 +- .../admin/newsletter/campaigns/edit.blade.php | 58 ++++++- .../newsletter/campaigns/index.blade.php | 3 + .../admin/newsletter/unsubscribes.blade.php | 124 +++++++++++++++ .../admin/press-releases/show.blade.php | 2 +- .../views/livewire/customer/tokens.blade.php | 5 +- resources/views/web/newsroom.blade.php | 4 +- resources/views/web/release-detail.blade.php | 2 +- routes/admin.php | 1 + routes/web.php | 3 + .../Feature/Admin/NewsletterCampaignTest.php | 150 ++++++++++++++++++ tests/Feature/ApiDocumentationTest.php | 2 + tests/Feature/CustomerPortalTest.php | 15 ++ tests/Feature/Web/NewsroomPageTest.php | 20 +++ 19 files changed, 499 insertions(+), 20 deletions(-) create mode 100644 resources/views/livewire/admin/newsletter/unsubscribes.blade.php diff --git a/.env.example b/.env.example index b615b693..18478a84 100644 --- a/.env.example +++ b/.env.example @@ -181,6 +181,11 @@ NEWSLETTER_SYNC_ENDPOINT= NEWSLETTER_SYNC_API_KEY= # NEWSLETTER_SYNC_TIMEOUT=10 +# Drosselung interner Newsletter-Kampagnen: Wellen à batch_size Mails im +# Abstand von batch_interval_minutes (SMTP-Limit/Spam-Schutz). +# NEWSLETTER_CAMPAIGN_BATCH_SIZE=50 +# NEWSLETTER_CAMPAIGN_BATCH_INTERVAL_MINUTES=15 + # Google-Login (Laravel Socialite). Die Redirect-URI muss in der Google Cloud # Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf # dem Portal-Host, nicht zwingend APP_URL). Prod: https://pressekonto.com/... diff --git a/app/Models/Company.php b/app/Models/Company.php index 9ea4e011..d56ccbd0 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -180,4 +180,22 @@ class Company extends Model return null; } + + /** + * Absolute Website-URL inklusive Schema. Bestandsdaten enthalten oft + * Adressen wie „www.example.de“ — als roher href würde der Browser + * daraus einen relativen Link auf der aktuellen Seite machen. + */ + public function websiteUrl(): ?string + { + if (blank($this->website)) { + return null; + } + + $website = trim((string) $this->website); + + return Str::startsWith($website, ['http://', 'https://']) + ? $website + : 'https://'.$website; + } } diff --git a/app/Services/Newsletter/CampaignRecipients.php b/app/Services/Newsletter/CampaignRecipients.php index b511ddef..59e66859 100644 --- a/app/Services/Newsletter/CampaignRecipients.php +++ b/app/Services/Newsletter/CampaignRecipients.php @@ -4,17 +4,21 @@ namespace App\Services\Newsletter; use App\Enums\EmailNotificationType; use App\Enums\UserPaymentOptionStatus; +use App\Models\NewsletterCampaign; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder as QueryBuilder; /** * 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 + * Portal, Legacy-Vertrag (Bestandsschutz-Zahlungsoption), der * Login-Zeitraum (eingeloggt innerhalb X Monaten bzw. dessen Komplement: - * länger nicht oder noch nie eingeloggt). + * länger nicht oder noch nie eingeloggt) und optional der Ausschluss aller + * Empfänger einer früheren Kampagne (Follow-up an die noch nicht + * Angeschriebenen). */ class CampaignRecipients { @@ -24,12 +28,14 @@ class CampaignRecipients public const LOGIN_NOT_WITHIN = 'not_within'; + public const LOGIN_BETWEEN = 'between'; + 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} + * @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string} */ public static function defaultFilters(): array { @@ -38,6 +44,8 @@ class CampaignRecipients 'legacy_contract' => false, 'login' => self::LOGIN_ANY, 'login_months' => 6, + 'login_months_from' => 0, + 'exclude_campaign_id' => 0, 'sort' => 'created_at', 'direction' => 'desc', ]; @@ -48,7 +56,7 @@ class CampaignRecipients * 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} + * @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string} */ public function normalize(array $filters): array { @@ -56,15 +64,19 @@ class CampaignRecipients $portal = (string) ($filters['portal'] ?? ''); $login = (string) ($filters['login'] ?? $defaults['login']); - $months = (int) ($filters['login_months'] ?? $defaults['login_months']); + $months = max(1, min(120, (int) ($filters['login_months'] ?? $defaults['login_months']))); + $monthsFrom = max(0, (int) ($filters['login_months_from'] ?? $defaults['login_months_from'])); $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)), + 'login' => in_array($login, [self::LOGIN_ANY, self::LOGIN_WITHIN, self::LOGIN_NOT_WITHIN, self::LOGIN_BETWEEN], true) ? $login : self::LOGIN_ANY, + 'login_months' => $months, + // Untergrenze der Monats-Scheibe; liegt immer unter der Obergrenze. + 'login_months_from' => min($monthsFrom, $months - 1), + 'exclude_campaign_id' => max(0, (int) ($filters['exclude_campaign_id'] ?? 0)), 'sort' => in_array($sort, self::SORT_FIELDS, true) ? $sort : $defaults['sort'], 'direction' => $direction === 'asc' ? 'asc' : 'desc', ]; @@ -94,6 +106,18 @@ class CampaignRecipients $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']); + } elseif ($filters['login'] === self::LOGIN_BETWEEN) { + $parts[] = $filters['login_months_from'] > 0 + ? sprintf('Letzter Login vor %d bis %d Monaten', $filters['login_months_from'], $filters['login_months']) + : sprintf('In den letzten %d Monaten eingeloggt', $filters['login_months']); + } + + if ($filters['exclude_campaign_id'] > 0) { + $excluded = NewsletterCampaign::query()->find($filters['exclude_campaign_id'], ['id', 'subject']); + $parts[] = sprintf( + 'Ohne Empfänger der Kampagne „%s“', + $excluded->subject ?? sprintf('#%d (gelöscht)', $filters['exclude_campaign_id']), + ); } $parts[] = sprintf( @@ -133,6 +157,21 @@ class CampaignRecipients ->where(fn (Builder $inner) => $inner ->whereNull('last_login_at') ->orWhere('last_login_at', '<', $loginCutoff))) + // Monats-Scheibe [vor Y Monaten, vor X Monaten): Untergrenze + // inklusiv, Obergrenze exklusiv — benachbarte Scheiben (z. B. + // 0–1 und 1–2) überschneiden sich damit nicht. + ->when($filters['login'] === self::LOGIN_BETWEEN, fn (Builder $query) => $query + ->where('last_login_at', '>=', $loginCutoff) + ->where('last_login_at', '<', now()->subMonths($filters['login_months_from']))) + // Follow-up-Ausschluss: wer im Protokoll der gewählten Kampagne + // steht (per User-ID oder E-Mail-Snapshot), fällt raus. + ->when($filters['exclude_campaign_id'] > 0, fn (Builder $query) => $query + ->whereNotExists(fn (QueryBuilder $sub) => $sub + ->from('newsletter_campaign_recipients', 'excluded') + ->where('excluded.newsletter_campaign_id', $filters['exclude_campaign_id']) + ->where(fn (QueryBuilder $match) => $match + ->whereColumn('excluded.user_id', 'users.id') + ->orWhereColumn('excluded.email', 'users.email')))) ->orderBy($filters['sort'], $filters['direction']) ->orderBy('id'); } diff --git a/app/Services/Newsletter/CampaignSender.php b/app/Services/Newsletter/CampaignSender.php index 354a4fd7..0e9f80c7 100644 --- a/app/Services/Newsletter/CampaignSender.php +++ b/app/Services/Newsletter/CampaignSender.php @@ -14,6 +14,10 @@ use App\Models\User; * 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. + * + * Große Listen werden gedrosselt: Die Jobs erhalten gestaffelte Delays in + * Wellen laut config('newsletter.campaign'), damit das SMTP-Limit des + * Providers nicht reißt und die Domain nicht auf Blocklisten landet. */ class CampaignSender { @@ -55,14 +59,43 @@ class CampaignSender 'recipients_total' => $total, ]); + $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); + $intervalSeconds = max(1, (int) config('newsletter.campaign.batch_interval_minutes')) * 60; + $position = 0; + $campaign->recipients() - ->select(['id']) - ->chunkById(500, function ($recipients): void { - foreach ($recipients as $recipient) { - SendNewsletterCampaignMail::dispatch($recipient->id); + ->toBase() + ->select('id') + ->chunkById(500, function ($rows) use ($batchSize, $intervalSeconds, &$position): void { + foreach ($rows as $row) { + SendNewsletterCampaignMail::dispatch((int) $row->id) + ->delay(intdiv($position, $batchSize) * $intervalSeconds); + $position++; } }); return $total; } + + /** + * Drossel-Hinweis für die Admin-UI, z. B. „max. 50 Mails alle 15 Minuten — + * Gesamtdauer ca. 3,5 Stunden“. Null, wenn alles in die erste Welle passt. + */ + public static function throttleSummary(int $total): ?string + { + $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); + $intervalMinutes = max(1, (int) config('newsletter.campaign.batch_interval_minutes')); + + if ($total <= $batchSize) { + return null; + } + + $durationMinutes = ((int) ceil($total / $batchSize) - 1) * $intervalMinutes; + + $duration = $durationMinutes < 60 + ? sprintf('%d Minuten', $durationMinutes) + : sprintf('%s Stunden', number_format($durationMinutes / 60, 1, ',', '.')); + + return sprintf('max. %d Mails alle %d Minuten — Gesamtdauer ca. %s', $batchSize, $intervalMinutes, $duration); + } } diff --git a/config/newsletter.php b/config/newsletter.php index 79ad60b0..93b76d45 100644 --- a/config/newsletter.php +++ b/config/newsletter.php @@ -8,4 +8,15 @@ return [ 'api_key' => env('NEWSLETTER_SYNC_API_KEY'), 'timeout' => (int) env('NEWSLETTER_SYNC_TIMEOUT', 10), ], + + /* + * Drosselung des Kampagnen-Versands: Mails gehen in Wellen von + * `batch_size` Stück im Abstand von `batch_interval_minutes` raus, + * damit der SMTP-Provider (All-Inkl) nicht limitiert und die Domain + * nicht auf Spam-/Blocklisten landet. + */ + 'campaign' => [ + 'batch_size' => (int) env('NEWSLETTER_CAMPAIGN_BATCH_SIZE', 50), + 'batch_interval_minutes' => (int) env('NEWSLETTER_CAMPAIGN_BATCH_INTERVAL_MINUTES', 15), + ], ]; diff --git a/resources/views/livewire/admin/companies/show.blade.php b/resources/views/livewire/admin/companies/show.blade.php index 78b6ee96..26cb9e49 100644 --- a/resources/views/livewire/admin/companies/show.blade.php +++ b/resources/views/livewire/admin/companies/show.blade.php @@ -227,7 +227,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C {{ $company->name }} @if ($company->website) - {{ $company->website }} diff --git a/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php index b2e44531..5d7f5271 100644 --- a/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php +++ b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php @@ -35,6 +35,10 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte public int $filterLoginMonths = 6; + public int $filterLoginMonthsFrom = 0; + + public string $filterExcludeCampaign = ''; + public string $sortField = 'created_at'; public string $sortDirection = 'desc'; @@ -63,6 +67,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte $this->filterLegacyContract = $filters['legacy_contract']; $this->filterLogin = $filters['login']; $this->filterLoginMonths = $filters['login_months']; + $this->filterLoginMonthsFrom = $filters['login_months_from']; + $this->filterExcludeCampaign = $filters['exclude_campaign_id'] > 0 ? (string) $filters['exclude_campaign_id'] : ''; $this->sortField = $filters['sort']; $this->sortDirection = $filters['direction']; } @@ -149,7 +155,7 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte } /** - * @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string} + * @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string} */ private function filters(): array { @@ -158,6 +164,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte 'legacy_contract' => $this->filterLegacyContract, 'login' => $this->filterLogin, 'login_months' => $this->filterLoginMonths, + 'login_months_from' => $this->filterLoginMonthsFrom, + 'exclude_campaign_id' => $this->filterExcludeCampaign, 'sort' => $this->sortField, 'direction' => $this->sortDirection, ]); @@ -167,11 +175,20 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte { $recipients = app(CampaignRecipients::class); $query = $recipients->query($this->filters()); + $recipientCount = (clone $query)->count(); return [ - 'recipientCount' => (clone $query)->count(), + 'recipientCount' => $recipientCount, 'recipientPreview' => $query->limit(8)->get(['id', 'name', 'email', 'last_login_at', 'created_at']), 'filterSummary' => $recipients->describe($this->filters()), + // Nur versendete/laufende Kampagnen haben ein Empfänger-Protokoll, + // gegen das ein Follow-up abgleichen kann. + 'excludableCampaigns' => NewsletterCampaign::query() + ->where('status', '!=', NewsletterCampaignStatus::Draft->value) + ->orderByDesc('created_at') + ->limit(50) + ->get(['id', 'subject', 'sent_at', 'created_at']), + 'throttleSummary' => CampaignSender::throttleSummary($recipientCount), ]; } }; ?> @@ -262,10 +279,25 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte + - @if ($filterLogin !== 'any') + @if ($filterLogin === 'between') +
+ + {{ __('Von (Monate zurück)') }} + + + + {{ __('Bis (Monate zurück)') }} + + +
+

+ {{ __('Monats-Scheibe für gestaffelten Versand: 0–1 = letzter Monat, 11–12 = zwölfter Monat zurück. Benachbarte Scheiben überschneiden sich nicht — so lässt sich ein Zeitraum Monat für Monat abarbeiten.') }} +

+ @elseif ($filterLogin !== 'any') {{ __('Zeitraum X in Monaten') }} @@ -273,6 +305,21 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte @endif + + {{ __('Empfänger einer Kampagne ausschließen') }} + + + @foreach ($excludableCampaigns as $excludable) + + @endforeach + + + {{ __('Für Follow-ups: Alle, die im Versand-Protokoll der gewählten Kampagne stehen, werden aus der Empfängerliste entfernt.') }} + + +
{{ __('Sortierung') }} @@ -333,6 +380,11 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte {{ __('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, ',', '.')]) }} + @if ($throttleSummary) +

+ {{ __('Der Versand wird gedrosselt: :summary.', ['summary' => $throttleSummary]) }} +

+ @endif
diff --git a/resources/views/livewire/admin/newsletter/campaigns/index.blade.php b/resources/views/livewire/admin/newsletter/campaigns/index.blade.php index 8392cc51..e40950b2 100644 --- a/resources/views/livewire/admin/newsletter/campaigns/index.blade.php +++ b/resources/views/livewire/admin/newsletter/campaigns/index.blade.php @@ -30,6 +30,9 @@ new #[Layout('components.layouts.app'), Title('Newsletter')] class extends Compo :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.')" > + + {{ __('Abmeldungen') }} + {{ __('Neue Kampagne') }} diff --git a/resources/views/livewire/admin/newsletter/unsubscribes.blade.php b/resources/views/livewire/admin/newsletter/unsubscribes.blade.php new file mode 100644 index 00000000..10949be2 --- /dev/null +++ b/resources/views/livewire/admin/newsletter/unsubscribes.blade.php @@ -0,0 +1,124 @@ +resetPage(); + } + } + + public function with(): array + { + return [ + 'optOuts' => EmailNotificationOptOut::query() + ->where('type', EmailNotificationType::Newsletter->value) + ->whereHas('user', fn ($query) => $query + ->when(trim($this->search) !== '', fn ($inner) => $inner + ->where(fn ($match) => $match + ->where('name', 'like', '%'.trim($this->search).'%') + ->orWhere('email', 'like', '%'.trim($this->search).'%')))) + ->with('user:id,name,email,portal,is_active') + ->orderByDesc('created_at') + ->paginate(50), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + + {{ __('Zurück') }} + + + + + {{-- ============== ABMELDUNGEN ============== --}} +
+
+ {{ __('Abmeldungen') }} +
+ + {{ number_format($optOuts->total(), 0, ',', '.') }} {{ __('Einträge') }} +
+
+ + + {{ __('User') }} + {{ __('E-Mail') }} + {{ __('Portal') }} + {{ __('Status') }} + {{ __('Abgemeldet am') }} + + + @forelse ($optOuts as $optOut) + + + + {{ $optOut->user->name }} + + + + {{ $optOut->user->email }} + + + {{ $optOut->user->portal?->label() ?? '—' }} + + + @if ($optOut->user->is_active) + {{ __('aktiv') }} + @else + {{ __('inaktiv') }} + @endif + + + + {{ $optOut->created_at?->format('d.m.Y H:i') ?? '—' }} + + + + @empty + + +
+ {{ trim($search) !== '' + ? __('Keine Abmeldungen für diese Suche.') + : __('Bislang hat sich niemand vom Newsletter abgemeldet.') }} +
+
+
+ @endforelse +
+
+ @if ($optOuts->hasPages()) +
+ {{ $optOuts->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index b01554be..901f5ca3 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -366,7 +366,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ $pr->company->phone }} @endif @if ($pr->company->website) - {{ $pr->company->website }} diff --git a/resources/views/livewire/customer/tokens.blade.php b/resources/views/livewire/customer/tokens.blade.php index 704c3359..f1f697e6 100644 --- a/resources/views/livewire/customer/tokens.blade.php +++ b/resources/views/livewire/customer/tokens.blade.php @@ -122,9 +122,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
- + {{ __('API-Dokumentation') }} + + {{ __('OpenAPI-Spezifikation (YAML)') }} +
diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php index e66756df..e9489fcd 100644 --- a/resources/views/web/newsroom.blade.php +++ b/resources/views/web/newsroom.blade.php @@ -40,7 +40,7 @@ '@type' => 'Organization', 'name' => $company->name, 'url' => $newsroomUrl, - ...(filled($company->website) ? ['sameAs' => [$company->website]] : []), + ...($company->websiteUrl() ? ['sameAs' => [$company->websiteUrl()]] : []), ...($logoUrl ? ['logo' => $logoUrl] : []), ...($boilerplate ? ['description' => Str::limit($boilerplate, 300)] : []), ]; @@ -99,7 +99,7 @@

{{ $company->name }}

@if ($websiteHost) - + {{ $websiteHost }} diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index 394a1da9..7d65261b 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -485,7 +485,7 @@ @if ($company?->website) - {{ Str::of($company->website)->after('//')->rtrim('/') }} + {{ Str::of($company->website)->after('//')->rtrim('/') }} @endif
diff --git a/routes/admin.php b/routes/admin.php index 14616b2d..4bf61175 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -105,6 +105,7 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens // 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/abmeldungen', 'admin.newsletter.unsubscribes')->name('admin.newsletter.unsubscribes'); 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'); diff --git a/routes/web.php b/routes/web.php index ba9bac01..f7d37b6d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -307,7 +307,10 @@ Route::get('/docs/api/v1', function () { abort_unless(is_file($path), 404); + // Bewusst als benannter Download: Browser zeigen YAML ohnehin nicht an, + // und „v1.yml“ wäre als Dateiname im Download-Ordner nichtssagend. return response((string) file_get_contents($path), 200, [ 'Content-Type' => 'application/yaml; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename=pressekonto-api-v1.yml', ]); })->name('docs.api.v1'); diff --git a/tests/Feature/Admin/NewsletterCampaignTest.php b/tests/Feature/Admin/NewsletterCampaignTest.php index 82c59f2e..b3616d24 100644 --- a/tests/Feature/Admin/NewsletterCampaignTest.php +++ b/tests/Feature/Admin/NewsletterCampaignTest.php @@ -17,6 +17,7 @@ use App\Services\Newsletter\CampaignRecipients; use App\Services\Newsletter\CampaignSender; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\URL; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -80,6 +81,134 @@ test('the recipient filters cover legacy contracts and login windows', function ->and(end($sorted))->toBe($recentUser->id); }); +test('the login range filter slices the window month by month', function () { + $recipients = app(CampaignRecipients::class); + + $recent = User::factory()->create(['name' => 'Frisch Frida', 'last_login_at' => now()->subMonths(2)->addWeek()]); + $middle = User::factory()->create(['name' => 'Mittel Mia', 'last_login_at' => now()->subMonths(8)->addWeek()]); + $old = User::factory()->create(['name' => 'Alt Ada', 'last_login_at' => now()->subMonths(18)]); + $never = User::factory()->create(['name' => 'Nie Nora', 'last_login_at' => null]); + + $slice = fn (int $from, int $to) => $recipients->query([ + 'login' => CampaignRecipients::LOGIN_BETWEEN, + 'login_months_from' => $from, + 'login_months' => $to, + ])->pluck('id'); + + // Ganze Spanne 0–12 entspricht "in den letzten 12 Monaten". + expect($slice(0, 12))->toContain($recent->id, $middle->id) + ->not->toContain($old->id) + ->not->toContain($never->id); + + // Einzelne Monats-Scheiben treffen genau ihre Kohorte … + expect($slice(1, 2))->toContain($recent->id)->not->toContain($middle->id); + expect($slice(7, 8))->toContain($middle->id)->not->toContain($recent->id); + + // … und die Scheibe dazwischen keinen von beiden. + expect($slice(2, 7))->not->toContain($recent->id)->not->toContain($middle->id); + + // Vertauschte/gleiche Grenzen werden auf eine gültige Scheibe normalisiert. + expect($recipients->normalize(['login_months_from' => 12, 'login_months' => 12])) + ->toMatchArray(['login_months_from' => 11, 'login_months' => 12]); + + expect(implode(' ', $recipients->describe([ + 'login' => CampaignRecipients::LOGIN_BETWEEN, + 'login_months_from' => 11, + 'login_months' => 12, + ])))->toContain('Letzter Login vor 11 bis 12 Monaten'); +}); + +test('a campaign can exclude the recipients of an earlier campaign', function () { + $recipients = app(CampaignRecipients::class); + + $alreadyMailed = User::factory()->create(['name' => 'Bereits Berta']); + $renamedEmail = User::factory()->create(['name' => 'Snapshot Sven']); + $notYetMailed = User::factory()->create(['name' => 'Neu Nina']); + + $earlier = NewsletterCampaign::factory()->create([ + 'subject' => 'Legacy-Info Runde 1', + 'status' => NewsletterCampaignStatus::Sent->value, + ]); + $earlier->recipients()->create([ + 'user_id' => $alreadyMailed->id, + 'email' => $alreadyMailed->email, + 'status' => NewsletterRecipientStatus::Sent->value, + ]); + // Protokoll-Zeile ohne User-Verknüpfung (z. B. gelöschter Account): + // der E-Mail-Snapshot greift trotzdem. + $earlier->recipients()->create([ + 'user_id' => null, + 'email' => $renamedEmail->email, + 'status' => NewsletterRecipientStatus::Sent->value, + ]); + + $ids = $recipients->query(['exclude_campaign_id' => $earlier->id])->pluck('id'); + expect($ids)->toContain($notYetMailed->id) + ->not->toContain($alreadyMailed->id) + ->not->toContain($renamedEmail->id); + + // Ohne Ausschluss bleiben alle drin. + expect($recipients->query([])->pluck('id')) + ->toContain($alreadyMailed->id, $renamedEmail->id, $notYetMailed->id); + + // Die Zusammenfassung benennt die ausgeschlossene Kampagne. + expect(implode(' ', $recipients->describe(['exclude_campaign_id' => $earlier->id]))) + ->toContain('Legacy-Info Runde 1'); +}); + +test('the campaign editor stores the exclusion filter in the snapshot', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $this->actingAs(newsletterAdmin()); + + $earlier = NewsletterCampaign::factory()->create([ + 'status' => NewsletterCampaignStatus::Sent->value, + ]); + + LivewireVolt::test('admin.newsletter.campaigns.edit') + ->set('subject', 'Follow-up an die noch nicht Erreichten') + ->set('body', '

Nachfassen.

') + ->set('filterExcludeCampaign', (string) $earlier->id) + ->call('saveDraft') + ->assertHasNoErrors(); + + $draft = NewsletterCampaign::query()->whereKeyNot($earlier->id)->sole(); + expect($draft->filters['exclude_campaign_id'])->toBe($earlier->id); + + // Beim erneuten Öffnen ist die Auswahl vorbelegt. + LivewireVolt::test('admin.newsletter.campaigns.edit', ['id' => $draft->id]) + ->assertSet('filterExcludeCampaign', (string) $earlier->id); +}); + +test('the unsubscribes page lists newsletter opt-outs with their date', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $optedOut = User::factory()->create(['name' => 'Abgemeldet Anna']); + $optedOut->optOutOfEmailNotification(EmailNotificationType::Newsletter); + $subscribed = User::factory()->create(['name' => 'Dabei Doris']); + + $editor = User::factory()->create(['is_active' => true]); + $editor->assignRole('editor'); + $this->actingAs($editor); + $this->get(route('admin.newsletter.unsubscribes'))->assertForbidden(); + + $this->actingAs(newsletterAdmin()); + $this->get(route('admin.newsletter.unsubscribes')) + ->assertOk() + ->assertSee('Abgemeldet Anna') + ->assertDontSee('Dabei Doris'); + + // Suche grenzt die Liste ein. + LivewireVolt::test('admin.newsletter.unsubscribes') + ->assertSee('Abgemeldet Anna') + ->set('search', $optedOut->email) + ->assertSee('Abgemeldet Anna') + ->set('search', 'trifft-nichts') + ->assertDontSee('Abgemeldet Anna'); +}); + 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([ @@ -131,6 +260,27 @@ test('sending a campaign mails every recipient and freezes the protocol', functi expect(app(CampaignSender::class)->start($campaign))->toBe(0); }); +test('campaign sending is throttled into timed waves', function () { + Queue::fake(); + config(['newsletter.campaign.batch_size' => 2, 'newsletter.campaign.batch_interval_minutes' => 15]); + + User::factory()->count(5)->create(); + $campaign = NewsletterCampaign::factory()->create(); + + expect(app(CampaignSender::class)->start($campaign))->toBe(5); + + // Wellen à 2 Mails im 15-Minuten-Abstand: 0s, 0s, 900s, 900s, 1800s. + $delays = collect(Queue::pushedJobs()[SendNewsletterCampaignMail::class]) + ->map(fn (array $pushed) => $pushed['job']->delay) + ->all(); + + expect($delays)->toBe([0, 0, 900, 900, 1800]); + + // UI-Hinweis: erst ab der zweiten Welle nötig. + expect(CampaignSender::throttleSummary(2))->toBeNull() + ->and(CampaignSender::throttleSummary(5))->toContain('max. 2 Mails alle 15 Minuten', '30 Minuten'); +}); + test('an opt-out between send start and delivery is skipped and recorded', function () { Mail::fake(); diff --git a/tests/Feature/ApiDocumentationTest.php b/tests/Feature/ApiDocumentationTest.php index 52a40c68..3cd223c2 100644 --- a/tests/Feature/ApiDocumentationTest.php +++ b/tests/Feature/ApiDocumentationTest.php @@ -7,6 +7,8 @@ test('api v1 documentation is publicly available', function () { $this->get('/docs/api/v1') ->assertOk() ->assertHeader('content-type', 'application/yaml; charset=UTF-8') + // Benannter Download statt anonymem „v1.yml“ im Download-Ordner. + ->assertDownload('pressekonto-api-v1.yml') ->assertSee('openapi: 3.1.0', false) ->assertSee('/press-releases:', false) // Übernahme der Legacy-Integrationen (06.07.): Alt-Schlüssel bleiben diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index f4278478..de1e7dd6 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -43,6 +43,21 @@ test('customer can create and revoke api tokens with selected abilities', functi expect($customer->tokens()->count())->toBe(0); }); +test('the tokens page links the api docs page and the named spec download', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + + $this->actingAs($customer); + + // Doku-Button öffnet die API-Seite; der Spec-Download ist ein eigener, + // klar benannter Button (kein Sofort-Download hinter „Dokumentation“). + LivewireVolt::test('customer.tokens') + ->assertSee('API-Dokumentation') + ->assertSee(route('api', ['edition' => 'de'])) + ->assertSee('OpenAPI-Spezifikation (YAML)') + ->assertSee(route('docs.api.v1')); +}); + test('customer cannot create api token without active payment access', function () { /** @var TestCase $this */ $customer = User::factory()->create(['is_active' => true]); diff --git a/tests/Feature/Web/NewsroomPageTest.php b/tests/Feature/Web/NewsroomPageTest.php index 0fbf6b10..0190472a 100644 --- a/tests/Feature/Web/NewsroomPageTest.php +++ b/tests/Feature/Web/NewsroomPageTest.php @@ -84,6 +84,26 @@ test('the newsroom detail page renders company data and its releases', function ->assertSeeText('Alle Meldungen'); }); +test('the newsroom detail page links the company website with an absolute url', function () { + /** @var TestCase $this */ + // Bestandsdaten ohne Schema („www.akgsoftware.de“) — als roher href + // würde daraus der relative Link /de/newsroom/www.akgsoftware.de. + $company = makeNewsroom('AKG Software Consulting GmbH', attributes: [ + 'website' => 'www.akgsoftware.de', + ]); + publishForCompany($company, 'AKG veröffentlicht neue Version'); + + $this->get('https://businessportal24.test/de/newsroom/'.$company->slug) + ->assertSuccessful() + ->assertSee('href="https://www.akgsoftware.de"', false) + ->assertDontSee('newsroom/www.akgsoftware.de', false); + + // Hinterlegte URLs mit Schema bleiben unverändert. + expect(Company::factory()->make(['website' => 'https://akgsoftware.de'])->websiteUrl()) + ->toBe('https://akgsoftware.de') + ->and(Company::factory()->make(['website' => null])->websiteUrl())->toBeNull(); +}); + test('the newsroom detail page returns 404 for an unknown slug', function () { /** @var TestCase $this */ $this->get('https://businessportal24.test/de/newsroom/gibt-es-nicht')