F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).
F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.
F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.
Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
203 lines
9.4 KiB
PHP
203 lines
9.4 KiB
PHP
<?php
|
|
|
|
use App\Enums\NewsletterRecipientStatus;
|
|
use App\Models\NewsletterCampaign;
|
|
use App\Services\Newsletter\CampaignRecipients;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
/*
|
|
* Kampagnen-Detail: Inhalt, Filter-Zusammenfassung und das Versand-Protokoll
|
|
* (welche Mail ging an welchen Empfänger, mit Status und Fehlern).
|
|
*/
|
|
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
#[Locked]
|
|
public int $id;
|
|
|
|
public string $statusFilter = '';
|
|
|
|
public function mount(int $id): void
|
|
{
|
|
$this->id = $id;
|
|
}
|
|
|
|
public function updated(string $property): void
|
|
{
|
|
if ($property === 'statusFilter') {
|
|
$this->resetPage();
|
|
}
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
$campaign = NewsletterCampaign::query()
|
|
->with('createdBy:id,name')
|
|
->findOrFail($this->id);
|
|
|
|
$recipients = $campaign->recipients()
|
|
->with('user:id,name')
|
|
->when($this->statusFilter !== '', fn ($query) => $query->where('status', $this->statusFilter))
|
|
->orderBy('id')
|
|
->paginate(50);
|
|
|
|
return [
|
|
'campaign' => $campaign,
|
|
'recipients' => $recipients,
|
|
'filterSummary' => app(CampaignRecipients::class)->describe($campaign->filters ?? []),
|
|
'recipientStatuses' => NewsletterRecipientStatus::cases(),
|
|
// Gesperrte Adressen zählen in keinen Kampagnenzähler — sie
|
|
// werden aus dem Protokoll abgeleitet.
|
|
'suppressedCount' => $campaign->recipients()
|
|
->where('status', NewsletterRecipientStatus::Suppressed->value)
|
|
->count(),
|
|
];
|
|
}
|
|
}; ?>
|
|
|
|
<div class="space-y-8">
|
|
{{-- ============== PAGE HEADER ============== --}}
|
|
<x-portal.page-header
|
|
:badge="__('Admin Backend')"
|
|
:eyebrow="__('Administration · Newsletter')"
|
|
:title="$campaign->subject"
|
|
:description="__('Angelegt am :date:by', ['date' => $campaign->created_at->format('d.m.Y H:i'), 'by' => $campaign->createdBy ? ' von '.$campaign->createdBy->name : ''])"
|
|
>
|
|
<x-slot:actions>
|
|
@if ($campaign->isDraft())
|
|
<flux:button variant="primary" icon="pencil" href="{{ route('admin.newsletter.campaigns.edit', $campaign->id) }}" wire:navigate>
|
|
{{ __('Bearbeiten') }}
|
|
</flux:button>
|
|
@endif
|
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
|
|
{{ __('Zurück') }}
|
|
</flux:button>
|
|
</x-slot:actions>
|
|
</x-portal.page-header>
|
|
|
|
{{-- ============== KPI-Reihe ============== --}}
|
|
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
<x-portal.stat-card variant="primary" :label="__('Empfänger')" :value="number_format($campaign->recipients_total)">
|
|
<x-slot:meta>{{ $campaign->status->label() }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
<x-portal.stat-card variant="ok" :label="__('Versendet')" :value="number_format($campaign->sent_count)">
|
|
<x-slot:meta>{{ $campaign->sent_at ? __('abgeschlossen :date', ['date' => $campaign->sent_at->format('d.m.Y H:i')]) : __('läuft…') }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
<x-portal.stat-card variant="muted" :label="__('Übersprungen')" :value="number_format($campaign->skipped_count)">
|
|
<x-slot:meta>{{ __('abgemeldet') }}{{ $suppressedCount > 0 ? ' · '.__(':count gesperrt', ['count' => number_format($suppressedCount)]) : '' }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
<x-portal.stat-card variant="muted" :label="__('Fehlgeschlagen')" :value="number_format($campaign->failed_count)">
|
|
<x-slot:meta>{{ __('siehe Protokoll') }}</x-slot:meta>
|
|
</x-portal.stat-card>
|
|
</section>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-2">
|
|
{{-- ============== INHALT ============== --}}
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
|
<flux:badge size="sm" :color="$campaign->status->badgeColor()">{{ $campaign->status->label() }}</flux:badge>
|
|
</div>
|
|
<div class="p-5">
|
|
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mb-3">
|
|
{{ __('Betreff') }}: {{ $campaign->subject }}
|
|
</div>
|
|
<div class="pr-content max-w-[640px] text-[13px]">
|
|
{!! $campaign->sanitizedBody() !!}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
{{-- ============== FILTER ============== --}}
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Empfänger-Auswahl') }}</span>
|
|
</div>
|
|
<div class="p-5">
|
|
<ul class="list-disc ps-4 space-y-1 text-[12.5px] text-[color:var(--color-ink-2)]">
|
|
@foreach ($filterSummary as $line)
|
|
<li>{{ $line }}</li>
|
|
@endforeach
|
|
</ul>
|
|
<p class="mt-3 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
|
{{ __('Die Liste wurde beim Versandstart eingefroren. Abmeldungen nach dem Start werden beim Versand übersprungen und im Protokoll vermerkt.') }}
|
|
</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
{{-- ============== PROTOKOLL ============== --}}
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Versand-Protokoll') }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<flux:select wire:model.live="statusFilter" size="sm">
|
|
<option value="">{{ __('Alle Status') }}</option>
|
|
@foreach ($recipientStatuses as $status)
|
|
<option value="{{ $status->value }}">{{ $status->label() }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
<span class="badge hub">{{ number_format($recipients->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
|
</div>
|
|
</div>
|
|
<flux:table>
|
|
<flux:table.columns>
|
|
<flux:table.column>{{ __('Empfänger') }}</flux:table.column>
|
|
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Versendet am') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Fehler') }}</flux:table.column>
|
|
</flux:table.columns>
|
|
<flux:table.rows>
|
|
@forelse ($recipients as $recipient)
|
|
<flux:table.row :key="$recipient->id">
|
|
<flux:table.cell class="whitespace-nowrap">
|
|
@if ($recipient->user)
|
|
<a href="{{ route('admin.users.show', $recipient->user->id) }}" wire:navigate
|
|
class="text-[12.5px] text-[color:var(--color-ink)] hover:underline">
|
|
{{ $recipient->user->name }}
|
|
</a>
|
|
@else
|
|
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Gelöschter User') }}</span>
|
|
@endif
|
|
</flux:table.cell>
|
|
<flux:table.cell class="whitespace-nowrap">
|
|
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $recipient->email }}</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<flux:badge size="sm" :color="$recipient->status->badgeColor()">{{ $recipient->status->label() }}</flux:badge>
|
|
</flux:table.cell>
|
|
<flux:table.cell class="whitespace-nowrap">
|
|
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
|
{{ $recipient->sent_at?->format('d.m.Y H:i:s') ?? '—' }}
|
|
</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $recipient->error ?? '' }}</span>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@empty
|
|
<flux:table.row>
|
|
<flux:table.cell colspan="5">
|
|
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
|
{{ $campaign->isDraft()
|
|
? __('Noch kein Versand — die Kampagne ist ein Entwurf.')
|
|
: __('Keine Einträge für diesen Filter.') }}
|
|
</div>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforelse
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
@if ($recipients->hasPages())
|
|
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
|
{{ $recipients->links() }}
|
|
</div>
|
|
@endif
|
|
</article>
|
|
</div>
|