admin: Verlauf protokolliert jede versendete E-Mail
Neuer Listener RecordMailActivity am MessageSent-Event erfasst zentral alle Versandwege (Mailables, Notifications, Fortify, Queue-Mails beim realen Versand im Worker). Neuer ActivityType "E-Mail" mit Empfänger, Betreff und Mailable-Klasse in den Metadaten; bekannte Empfänger werden mit ihrem User verknüpft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e59e1320a3
commit
33e84afa2a
6 changed files with 165 additions and 74 deletions
|
|
@ -20,6 +20,7 @@ enum ActivityType: string
|
|||
case TerminationRequest = 'termination_request';
|
||||
case NewsletterSubscription = 'newsletter_subscription';
|
||||
case SubscriptionChange = 'subscription_change';
|
||||
case MailSent = 'mail_sent';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -35,6 +36,7 @@ enum ActivityType: string
|
|||
self::TerminationRequest => 'Kündigung',
|
||||
self::NewsletterSubscription => 'Newsletter',
|
||||
self::SubscriptionChange => 'Abo',
|
||||
self::MailSent => 'E-Mail',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ enum ActivityType: string
|
|||
self::BookingPaid, self::CreditTopup => 'amber',
|
||||
self::ContactRequest, self::NewsletterSubscription => 'cyan',
|
||||
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
|
||||
self::MailSent => 'purple',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
app/Listeners/RecordMailActivity.php
Normal file
57
app/Listeners/RecordMailActivity.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLog;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
|
||||
/**
|
||||
* Protokolliert jede tatsächlich versendete E-Mail im Aktivitäts-Verlauf.
|
||||
*
|
||||
* Hängt zentral am MessageSent-Event des Mailers und erfasst damit alle
|
||||
* Versandwege (Mailables, Notifications, Fortify). Bei Queue-Mails feuert
|
||||
* das Event erst beim realen Versand im Worker — der Eintrag entsteht also
|
||||
* zum echten Versandzeitpunkt.
|
||||
*
|
||||
* Die Methode heißt bewusst NICHT `handle`: Laravels Event-Discovery würde
|
||||
* sie sonst zusätzlich zur manuellen Verdrahtung im AppServiceProvider
|
||||
* registrieren (doppelte Einträge).
|
||||
*/
|
||||
class RecordMailActivity
|
||||
{
|
||||
public function __construct(private readonly ActivityLog $activityLog) {}
|
||||
|
||||
public function recordSent(MessageSent $event): void
|
||||
{
|
||||
$recipients = array_values(array_map(
|
||||
fn ($address): string => $address->getAddress(),
|
||||
$event->message->getTo() ?? [],
|
||||
));
|
||||
|
||||
if ($recipients === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = (string) $event->message->getSubject();
|
||||
$user = User::query()->whereIn('email', $recipients)->first();
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::MailSent,
|
||||
sprintf(
|
||||
'E-Mail „%s" an %s versendet.',
|
||||
$subject !== '' ? $subject : __('(ohne Betreff)'),
|
||||
implode(', ', $recipients),
|
||||
),
|
||||
user: $user,
|
||||
actorLabel: $user?->name ?? $recipients[0],
|
||||
meta: array_filter([
|
||||
'to' => $recipients,
|
||||
'subject' => $subject,
|
||||
'mailable' => $event->data['__laravel_mailable'] ?? null,
|
||||
'notification' => $event->data['__laravel_notification'] ?? null,
|
||||
], fn ($value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ use App\Http\Middleware\LogSlowAdminRequests;
|
|||
use App\Http\Middleware\SetEdition;
|
||||
use App\Listeners\ActivateUserAfterVerification;
|
||||
use App\Listeners\RecordAuthActivity;
|
||||
use App\Listeners\RecordMailActivity;
|
||||
use App\Listeners\SyncCompanyMembershipsOnLogin;
|
||||
use App\Models\AdminPreset;
|
||||
use App\Models\Category;
|
||||
|
|
@ -38,6 +39,7 @@ use Illuminate\Auth\Notifications\ResetPassword;
|
|||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -129,6 +131,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
|
||||
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
|
||||
|
||||
// Jede tatsächlich versendete E-Mail landet ebenfalls im Verlauf.
|
||||
Event::listen(MessageSent::class, [RecordMailActivity::class, 'recordSent']);
|
||||
|
||||
// Framework-Auth-Mails (Passwort-Reset, E-Mail-Verifizierung) im
|
||||
// pressekonto-CI rendern statt im Laravel-Standard-Markdown-Theme —
|
||||
// gleiches Layout wie alle übrigen System-Mails (emails/layout).
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@
|
|||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Portale')" class="grid mb-4">
|
||||
<flux:navlist.item icon="arrow-top-right-on-square"
|
||||
:href="config('domains.domain_presseecho_url')" target="_blank">
|
||||
<flux:navlist.item icon="arrow-top-right-on-square" :href="config('domains.domain_presseecho_url')"
|
||||
target="_blank">
|
||||
{{ __('presseecho') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="arrow-top-right-on-square"
|
||||
|
|
@ -109,13 +109,78 @@
|
|||
|
||||
{{-- Content Management (Admin/Editor) --}}
|
||||
@if ($canAdmin)
|
||||
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
|
||||
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
|
||||
<flux:navlist.item icon="newspaper"
|
||||
:href="route('admin.press-releases.index', $reviewCount > 0 ? ['status' => 'review'] : [])"
|
||||
:current="request()->routeIs('admin.press-releases.*')"
|
||||
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
|
||||
{{ __('Pressemitteilungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
|
||||
:current="request()->routeIs('admin.companies.*')" wire:navigate>
|
||||
{{ __('Firmen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
|
||||
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
|
||||
{{ __('Kontakte') }}
|
||||
</flux:navlist.item>
|
||||
@can('users:manage')
|
||||
<flux:navlist.item icon="users" :href="route('admin.users.index')"
|
||||
:current="request()->routeIs('admin.users.*')" wire:navigate>
|
||||
{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
@endcan
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Management')" class="grid mb-4">
|
||||
@can('compliance:manage')
|
||||
<flux:navlist.item icon="queue-list" :href="route('admin.reports.activity')"
|
||||
:current="request()->routeIs('admin.reports.activity')" wire:navigate>
|
||||
{{ __('Verlauf') }}
|
||||
</flux:navlist.item>
|
||||
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
|
||||
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
|
||||
:current="request()->routeIs('admin.contact-messages.*')"
|
||||
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
|
||||
{{ __('Anliegen') }}
|
||||
</flux:navlist.item>
|
||||
|
||||
{{-- Recht & Compliance --}}
|
||||
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
|
||||
:current="request()->routeIs('admin.legal-requests.*')"
|
||||
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
|
||||
{{ __('Anfragen') }}
|
||||
</flux:navlist.item>
|
||||
@endcan
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Billing --}}
|
||||
@can('billing:manage')
|
||||
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
|
||||
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
|
||||
{{ __('Legacy Rechnungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
|
||||
:current="request()->routeIs('admin.payments.index')" wire:navigate>
|
||||
{{ __('Zahlungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="rectangle-stack" :href="route('admin.payments.plans')"
|
||||
:current="request()->routeIs('admin.payments.plans')" wire:navigate>
|
||||
{{ __('Tarife & Pakete') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
|
||||
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
|
||||
{{ __('Gutscheine') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
|
||||
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
|
||||
{{ __('Newsletter Sync') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endcan
|
||||
{{-- Content --}}
|
||||
<flux:navlist.group :heading="__('Content')" class="grid mb-4">
|
||||
@can('categories:manage')
|
||||
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
|
||||
:current="request()->routeIs('admin.categories.*')" wire:navigate>
|
||||
|
|
@ -129,77 +194,13 @@
|
|||
</flux:navlist.item>
|
||||
@endcan
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- CRM --}}
|
||||
<flux:navlist.group :heading="__('CRM')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('admin.companies.index')"
|
||||
:current="request()->routeIs('admin.companies.*')" wire:navigate>
|
||||
{{ __('Firmen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.contacts.index')"
|
||||
:current="request()->routeIs('admin.contacts.*')" wire:navigate>
|
||||
{{ __('Kontakte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
@can('compliance:manage')
|
||||
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
|
||||
<flux:navlist.group :heading="__('Kontakt')" class="grid mb-4">
|
||||
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
|
||||
:current="request()->routeIs('admin.contact-messages.*')"
|
||||
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
|
||||
{{ __('Anliegen') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
{{-- Recht & Compliance --}}
|
||||
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
|
||||
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
|
||||
:current="request()->routeIs('admin.legal-requests.*')"
|
||||
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
|
||||
{{ __('Anfragen') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endcan
|
||||
|
||||
{{-- Billing --}}
|
||||
@can('billing:manage')
|
||||
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
|
||||
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
|
||||
{{ __('Legacy Rechnungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="credit-card" :href="route('admin.payments.index')"
|
||||
:current="request()->routeIs('admin.payments.index')" wire:navigate>
|
||||
{{ __('Zahlungen') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="rectangle-stack" :href="route('admin.payments.plans')"
|
||||
:current="request()->routeIs('admin.payments.plans')" wire:navigate>
|
||||
{{ __('Tarife & Pakete') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="ticket" :href="route('admin.coupons.index')"
|
||||
:current="request()->routeIs('admin.coupons.*')" wire:navigate>
|
||||
{{ __('Gutscheine') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.newsletter.sync')"
|
||||
:current="request()->routeIs('admin.newsletter.sync')" wire:navigate>
|
||||
{{ __('Newsletter Sync') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endcan
|
||||
|
||||
{{-- Administration --}}
|
||||
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
|
||||
<flux:navlist.group :heading="__('Settings')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cog" :href="route('admin.presets.index')"
|
||||
:current="request()->routeIs('admin.presets.*')" wire:navigate>
|
||||
{{ __('Voreinstellungen') }}
|
||||
</flux:navlist.item>
|
||||
@can('users:manage')
|
||||
<flux:navlist.item icon="users" :href="route('admin.users.index')"
|
||||
:current="request()->routeIs('admin.users.*')" wire:navigate>
|
||||
{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
@endcan
|
||||
|
||||
@can('roles:manage')
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
|
||||
:current="request()->routeIs('admin.roles.*')" wire:navigate>
|
||||
|
|
@ -210,10 +211,7 @@
|
|||
|
||||
{{-- Reports --}}
|
||||
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
|
||||
<flux:navlist.item icon="queue-list" :href="route('admin.reports.activity')"
|
||||
:current="request()->routeIs('admin.reports.activity')" wire:navigate>
|
||||
{{ __('Verlauf') }}
|
||||
</flux:navlist.item>
|
||||
|
||||
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
|
||||
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
|
||||
{{ __('Performance') }}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ new #[Layout('components.layouts.app'), Title('Verlauf')] class extends Componen
|
|||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Reports')"
|
||||
:title="__('Verlauf')"
|
||||
:description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen und Formular-Eingänge.')"
|
||||
:description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen, Formular-Eingänge und versendete E-Mails.')"
|
||||
/>
|
||||
|
||||
{{-- ============== FILTER-PANEL ============== --}}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Models\WithdrawalRequest;
|
|||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt;
|
||||
|
|
@ -111,3 +112,30 @@ test('the activity report page lists events and filters by type', function () {
|
|||
->assertSee('Kontaktanfrage „Testthema“')
|
||||
->assertDontSee('hat sich angemeldet');
|
||||
});
|
||||
|
||||
test('a sent mail is recorded and linked to the recipient user', function () {
|
||||
$user = User::factory()->create(['email' => 'empfaenger@example.com']);
|
||||
|
||||
Mail::raw('Inhalt', function ($message) {
|
||||
$message->to('empfaenger@example.com')->subject('Willkommen bei pressekonto');
|
||||
});
|
||||
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::MailSent->value)->sole();
|
||||
|
||||
expect($event->description)->toContain('Willkommen bei pressekonto')
|
||||
->and($event->description)->toContain('empfaenger@example.com')
|
||||
->and($event->user_id)->toBe($user->id)
|
||||
->and($event->meta['to'])->toBe(['empfaenger@example.com'])
|
||||
->and($event->meta['subject'])->toBe('Willkommen bei pressekonto');
|
||||
});
|
||||
|
||||
test('a mail to an unknown address is recorded with the address as actor label', function () {
|
||||
Mail::raw('Inhalt', function ($message) {
|
||||
$message->to('extern@example.com')->subject('Externe Info');
|
||||
});
|
||||
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::MailSent->value)->sole();
|
||||
|
||||
expect($event->user_id)->toBeNull()
|
||||
->and($event->actor_label)->toBe('extern@example.com');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue