admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-22 12:38:47 +00:00
parent 9b73972d33
commit 58c0cc3f23
19 changed files with 499 additions and 20 deletions

View file

@ -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/...

View file

@ -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;
}
}

View file

@ -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<string, mixed> $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');
}

View file

@ -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);
}
}

View file

@ -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),
],
];

View file

@ -227,7 +227,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{{ $company->name }}
</h1>
@if ($company->website)
<a href="{{ $company->website }}" target="_blank" rel="noopener"
<a href="{{ $company->websiteUrl() }}" target="_blank" rel="noopener"
class="text-[12.5px] text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)] mt-1 inline-block">
{{ $company->website }}
</a>

View file

@ -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
<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>
<option value="between">{{ __('Login liegt zwischen X und Y Monaten zurück') }}</option>
</flux:select>
</flux:field>
@if ($filterLogin !== 'any')
@if ($filterLogin === 'between')
<div class="grid grid-cols-2 gap-3">
<flux:field>
<flux:label>{{ __('Von (Monate zurück)') }}</flux:label>
<flux:input type="number" min="0" max="119" wire:model.live.debounce.400ms="filterLoginMonthsFrom" />
</flux:field>
<flux:field>
<flux:label>{{ __('Bis (Monate zurück)') }}</flux:label>
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
</flux:field>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('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.') }}
</p>
@elseif ($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" />
@ -273,6 +305,21 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
</flux:field>
@endif
<flux:field>
<flux:label>{{ __('Empfänger einer Kampagne ausschließen') }}</flux:label>
<flux:select wire:model.live="filterExcludeCampaign">
<option value="">{{ __('Niemanden ausschließen') }}</option>
@foreach ($excludableCampaigns as $excludable)
<option value="{{ $excludable->id }}">
{{ $excludable->subject }} ({{ ($excludable->sent_at ?? $excludable->created_at)->format('d.m.Y') }})
</option>
@endforeach
</flux:select>
<flux:description>
{{ __('Für Follow-ups: Alle, die im Versand-Protokoll der gewählten Kampagne stehen, werden aus der Empfängerliste entfernt.') }}
</flux:description>
</flux:field>
<div class="grid grid-cols-2 gap-3">
<flux:field>
<flux:label>{{ __('Sortierung') }}</flux:label>
@ -333,6 +380,11 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
<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>
@if ($throttleSummary)
<p class="mt-2 text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Der Versand wird gedrosselt: :summary.', ['summary' => $throttleSummary]) }}
</p>
@endif
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>

View file

@ -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.')"
>
<x-slot:actions>
<flux:button variant="filled" icon="bell-slash" href="{{ route('admin.newsletter.unsubscribes') }}" wire:navigate>
{{ __('Abmeldungen') }}
</flux:button>
<flux:button variant="primary" icon="plus" href="{{ route('admin.newsletter.campaigns.create') }}" wire:navigate>
{{ __('Neue Kampagne') }}
</flux:button>

View file

@ -0,0 +1,124 @@
<?php
use App\Enums\EmailNotificationType;
use App\Models\EmailNotificationOptOut;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
/*
* Newsletter-Abmeldungen: alle User, die den Newsletter über den Abmelde-Link
* (oder die Mail-Einstellungen) abbestellt haben — mit Zeitpunkt der Abmeldung.
*/
new #[Layout('components.layouts.app'), Title('Newsletter-Abmeldungen')] class extends Component
{
use WithPagination;
public string $search = '';
public function updated(string $property): void
{
if ($property === 'search') {
$this->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),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="__('Newsletter-Abmeldungen')"
:description="__('User, die den Newsletter über den Abmelde-Link oder die Mail-Einstellungen abbestellt haben. Sie werden bei künftigen Kampagnen automatisch übersprungen.')"
>
<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>
{{-- ============== ABMELDUNGEN ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Abmeldungen') }}</span>
<div class="flex items-center gap-2">
<flux:input size="sm" icon="magnifying-glass" wire:model.live.debounce.400ms="search"
:placeholder="__('Name oder E-Mail suchen…')" clearable />
<span class="badge hub">{{ number_format($optOuts->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
</div>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Abgemeldet am') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($optOuts as $optOut)
<flux:table.row :key="$optOut->id">
<flux:table.cell class="whitespace-nowrap">
<a href="{{ route('admin.users.show', $optOut->user->id) }}" wire:navigate
class="text-[12.5px] font-semibold text-[color:var(--color-ink)] hover:underline">
{{ $optOut->user->name }}
</a>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $optOut->user->email }}</span>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $optOut->user->portal?->label() ?? '—' }}</span>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
@if ($optOut->user->is_active)
<flux:badge size="sm" color="green">{{ __('aktiv') }}</flux:badge>
@else
<flux:badge size="sm" color="zinc">{{ __('inaktiv') }}</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $optOut->created_at?->format('d.m.Y H:i') ?? '—' }}
</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)]">
{{ trim($search) !== ''
? __('Keine Abmeldungen für diese Suche.')
: __('Bislang hat sich niemand vom Newsletter abgemeldet.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if ($optOuts->hasPages())
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
{{ $optOuts->links() }}
</div>
@endif
</article>
</div>

View file

@ -366,7 +366,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<span>{{ $pr->company->phone }}</span>
@endif
@if ($pr->company->website)
<a href="{{ $pr->company->website }}" target="_blank" rel="noopener"
<a href="{{ $pr->company->websiteUrl() }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->company->website }}
</a>

View file

@ -122,9 +122,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button href="{{ route('docs.api.v1') }}" variant="filled" icon="book-open">
<flux:button href="{{ route('api', ['edition' => 'de']) }}" target="_blank" variant="filled" icon="book-open">
{{ __('API-Dokumentation') }}
</flux:button>
<flux:button href="{{ route('docs.api.v1') }}" variant="filled" icon="arrow-down-tray">
{{ __('OpenAPI-Spezifikation (YAML)') }}
</flux:button>
</div>
</header>

View file

@ -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 @@
<h1 class="font-serif text-[28px] sm:text-[38px] lg:text-[42px] font-semibold m-0 leading-[1.05] tracking-[-1px] text-ink">{{ $company->name }}</h1>
<div class="mt-3 text-[12px] text-ink-3 flex items-center gap-2.5 flex-wrap">
@if ($websiteHost)
<a href="{{ $company->website }}" target="_blank" rel="nofollow noopener" class="inline-flex items-center gap-1.5 font-medium hover:text-ink transition-colors">
<a href="{{ $company->websiteUrl() }}" target="_blank" rel="nofollow noopener" class="inline-flex items-center gap-1.5 font-medium hover:text-ink transition-colors">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1"/><path d="M1 6h10M6 1c2 1.5 2 8.5 0 10M6 1c-2 1.5-2 8.5 0 10" stroke="currentColor" stroke-width="1"/></svg>
{{ $websiteHost }}
</a>

View file

@ -485,7 +485,7 @@
@if ($company?->website)
<span class="flex items-center gap-2">
<svg width="14" height="14" viewBox="0 0 14 14" class="text-ink-3 flex-shrink-0" aria-hidden="true"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2" fill="none" /><path d="M1.5 7h11M7 1.5c2 1.5 2 9.5 0 11M7 1.5c-2 1.5-2 9.5 0 11" stroke="currentColor" stroke-width="1" fill="none" /></svg>
<a href="{{ Str::startsWith($company->website, ['http://', 'https://']) ? $company->website : 'https://'.$company->website }}" rel="nofollow noopener" target="_blank" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ Str::of($company->website)->after('//')->rtrim('/') }}</a>
<a href="{{ $company->websiteUrl() }}" rel="nofollow noopener" target="_blank" class="text-ink underline underline-offset-2 hover:text-brand transition-colors">{{ Str::of($company->website)->after('//')->rtrim('/') }}</a>
</span>
@endif
</div>

View file

@ -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');

View file

@ -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');

View file

@ -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', '<p>Nachfassen.</p>')
->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();

View file

@ -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

View file

@ -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]);

View file

@ -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')