admin: Aktivitäts-Verlauf unter Reports ▸ Verlauf

Chronologisches Protokoll der Systemereignisse als neue Admin-Seite
(/admin/reports/activity) mit Filter nach Ereignistyp, Zeitraum und
Suche. Erfasst ab jetzt fortlaufend:

- Registrierungen inkl. Weg (Formular, Google, Magic-Link)
- Anmeldungen inkl. Weg (Passwort, Google, Magic-Link, Impersonation)
- PM-Statuswechsel mit Ergebnis/Begründung (zentral im Status-Log-Hook;
  KI-/Scheduler-Wechsel erscheinen als „System")
- Zahlungseingänge (Rechnungsspiegelung) und Credit-Aufladungen
- Formular-Eingänge: Kontakt, Rechtsanfrage, Widerruf, Kündigung,
  Newsletter-Anmeldung (Observer auf den Modellen)

Schreiben ist Best-Effort über den ActivityLog-Service — ein
fehlgeschlagener Verlaufseintrag bricht nie den fachlichen Ablauf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 16:00:58 +00:00
parent a574898ca9
commit 3a11963990
19 changed files with 720 additions and 4 deletions

View file

@ -2,6 +2,7 @@
namespace App\Actions\Admin;
use App\Listeners\RecordAuthActivity;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
@ -66,6 +67,7 @@ class UserImpersonation
session([self::SessionKey => $admin->getKey()]);
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($target);
}
@ -85,6 +87,7 @@ class UserImpersonation
return null;
}
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($admin);
return $admin;

View file

@ -0,0 +1,53 @@
<?php
namespace App\Enums;
/**
* Ereignistypen des Aktivitäts-Verlaufs (Admin ▸ Reports ▸ Verlauf).
* Jeder Typ entspricht einem fachlichen Systemereignis; Details stecken
* in description/meta des ActivityEvent.
*/
enum ActivityType: string
{
case UserRegistered = 'user_registered';
case UserLoggedIn = 'user_logged_in';
case PressReleaseStatus = 'press_release_status';
case BookingPaid = 'booking_paid';
case CreditTopup = 'credit_topup';
case ContactRequest = 'contact_request';
case LegalRequest = 'legal_request';
case WithdrawalRequest = 'withdrawal_request';
case TerminationRequest = 'termination_request';
case NewsletterSubscription = 'newsletter_subscription';
public function label(): string
{
return match ($this) {
self::UserRegistered => 'Registrierung',
self::UserLoggedIn => 'Anmeldung',
self::PressReleaseStatus => 'Pressemitteilung',
self::BookingPaid => 'Zahlung',
self::CreditTopup => 'Credit-Aufladung',
self::ContactRequest => 'Kontaktanfrage',
self::LegalRequest => 'Rechtsanfrage',
self::WithdrawalRequest => 'Widerruf',
self::TerminationRequest => 'Kündigung',
self::NewsletterSubscription => 'Newsletter',
};
}
/**
* Flux-Badge-Farbe in der Verlauf-Tabelle.
*/
public function badgeColor(): string
{
return match ($this) {
self::UserRegistered => 'green',
self::UserLoggedIn => 'zinc',
self::PressReleaseStatus => 'blue',
self::BookingPaid, self::CreditTopup => 'amber',
self::ContactRequest, self::NewsletterSubscription => 'cyan',
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest => 'red',
};
}
}

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
@ -45,6 +46,7 @@ class GoogleController extends Controller
]);
}
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
Auth::login($user, remember: true);
$request->session()->regenerate();

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -68,6 +69,7 @@ class MagicLinkConsumeController extends Controller
'last_login_ip' => $request->ip(),
]);
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();

View file

@ -2,6 +2,7 @@
namespace App\Listeners;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Enums\InvoiceStatus;
use App\Enums\PressReleaseStatus;
@ -16,6 +17,7 @@ use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceNumberGenerator;
use App\Services\PressRelease\PressReleaseService;
@ -44,7 +46,10 @@ use Laravel\Cashier\Subscription;
*/
class ProcessStripeWebhook
{
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly ActivityLog $activityLog,
) {}
public function handle(WebhookReceived $event): void
{
@ -185,6 +190,20 @@ class ProcessStripeWebhook
if ($user->email) {
Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice));
}
$this->activityLog->record(
ActivityType::BookingPaid,
sprintf(
'Zahlung eingegangen: Rechnung %s über %s € — %s (%s).',
$invoice->number,
number_format($invoice->total_cents / 100, 2, ',', '.'),
$user->name,
$user->email,
),
user: $user,
subject: $invoice,
meta: ['invoice_number' => $invoice->number, 'total_cents' => $invoice->total_cents],
);
}
/**

View file

@ -0,0 +1,73 @@
<?php
namespace App\Listeners;
use App\Enums\ActivityType;
use App\Models\User;
use App\Services\ActivityLog;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
/**
* Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf.
*
* Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils
* einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation);
* ohne Attribut ist es der Fortify-Passwort-Login.
*
* Die Methoden heißen bewusst NICHT `handle*`: Laravels Event-Discovery
* würde sie sonst zusätzlich zur manuellen Verdrahtung im
* AppServiceProvider registrieren (doppelte Einträge).
*/
class RecordAuthActivity
{
public const AUTH_METHOD_ATTRIBUTE = 'auth_method';
private const METHOD_LABELS = [
'password' => 'Passwort',
'magic-link' => 'Magic-Link',
'google' => 'Google',
'impersonation' => 'Admin-Impersonation',
];
public function __construct(private readonly ActivityLog $activityLog) {}
public function recordRegistered(Registered $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$this->activityLog->record(
ActivityType::UserRegistered,
sprintf('%s (%s) hat sich registriert — Formular.', $user->name, $user->email),
user: $user,
meta: ['way' => 'form'],
);
}
public function recordLogin(Login $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$method = (string) request()->attributes->get(self::AUTH_METHOD_ATTRIBUTE, 'password');
$this->activityLog->record(
ActivityType::UserLoggedIn,
sprintf(
'%s (%s) hat sich angemeldet — %s.',
$user->name,
$user->email,
self::METHOD_LABELS[$method] ?? $method,
),
user: $user,
meta: ['method' => $method],
);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Enums\ActivityType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Ein Eintrag im Aktivitäts-Verlauf (Admin ▸ Reports ▸ Verlauf).
* Wird ausschließlich über den ActivityLog-Service geschrieben.
*/
class ActivityEvent extends Model
{
public $timestamps = false;
protected $fillable = [
'type',
'occurred_at',
'user_id',
'actor_label',
'subject_type',
'subject_id',
'description',
'meta',
];
protected function casts(): array
{
return [
'type' => ActivityType::class,
'occurred_at' => 'datetime',
'meta' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subject(): MorphTo
{
return $this->morphTo();
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Observers;
use App\Enums\ActivityType;
use App\Models\ContactMessage;
use App\Models\LegalRequest;
use App\Models\NewsletterSubscription;
use App\Models\TerminationRequest;
use App\Models\WithdrawalRequest;
use App\Services\ActivityLog;
use Illuminate\Database\Eloquent\Model;
/**
* Protokolliert Formular-Eingänge im Aktivitäts-Verlauf — überall dort,
* wo das Anlegen des Datensatzes selbst das Ereignis ist (Kontakt,
* Rechtsanfrage, Widerruf, Kündigung, Newsletter-Anmeldung).
*/
class ActivityLogObserver
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function created(Model $model): void
{
match (true) {
$model instanceof ContactMessage => $this->activityLog->record(
ActivityType::ContactRequest,
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),
user: $model->user,
subject: $model,
actorLabel: (string) ($model->name ?: $model->email),
meta: ['portal' => $this->stringable($model->portal)],
),
$model instanceof LegalRequest => $this->activityLog->record(
ActivityType::LegalRequest,
sprintf('Rechtsanfrage (%s) von %s.', $model->type->label(), $model->requester_email),
subject: $model,
actorLabel: (string) $model->requester_email,
meta: ['type' => $model->type->value, 'press_release_id' => $model->press_release_id],
),
$model instanceof WithdrawalRequest => $this->activityLog->record(
ActivityType::WithdrawalRequest,
sprintf('Widerruf eingegangen von %s (%s).', $model->name, $model->email),
subject: $model,
actorLabel: (string) ($model->name ?: $model->email),
),
$model instanceof TerminationRequest => $this->activityLog->record(
ActivityType::TerminationRequest,
sprintf('Kündigung (%s) von %s (%s).', $model->kind, $model->name, $model->email),
subject: $model,
actorLabel: (string) ($model->name ?: $model->email),
meta: ['kind' => $model->kind],
),
$model instanceof NewsletterSubscription => $this->activityLog->record(
ActivityType::NewsletterSubscription,
sprintf('Newsletter-Anmeldung von %s.', $model->email),
subject: $model,
actorLabel: (string) $model->email,
meta: ['portal' => $this->stringable($model->portal)],
),
default => null,
};
}
private function stringable(mixed $value): ?string
{
return $value instanceof \BackedEnum ? (string) $value->value : ($value !== null ? (string) $value : null);
}
}

View file

@ -10,15 +10,21 @@ use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Http\Middleware\SetEdition;
use App\Listeners\ActivateUserAfterVerification;
use App\Listeners\RecordAuthActivity;
use App\Listeners\SyncCompanyMembershipsOnLogin;
use App\Models\AdminPreset;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ContactMessage;
use App\Models\LegalRequest;
use App\Models\NewsletterSubscription;
use App\Models\PressRelease;
use App\Models\TerminationRequest;
use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Newsletter\NullNewsletterSyncClient;
@ -118,6 +124,11 @@ class AppServiceProvider extends ServiceProvider
Event::listen(Registered::class, SendEmailVerificationNotification::class);
Event::listen(Verified::class, ActivateUserAfterVerification::class);
// Aktivitäts-Verlauf (Admin ▸ Reports ▸ Verlauf): Registrierung und
// Login; Formular-Eingänge laufen über den ActivityLogObserver unten.
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
// 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).
@ -154,6 +165,13 @@ class AppServiceProvider extends ServiceProvider
// die USt-ID-Abfrage im Stripe Checkout.
Cashier::calculateTaxes();
// Formular-Eingänge in den Aktivitäts-Verlauf schreiben.
ContactMessage::observe(ActivityLogObserver::class);
LegalRequest::observe(ActivityLogObserver::class);
WithdrawalRequest::observe(ActivityLogObserver::class);
TerminationRequest::observe(ActivityLogObserver::class);
NewsletterSubscription::observe(ActivityLogObserver::class);
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);

View file

@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Schreibt Einträge in den Aktivitäts-Verlauf (Admin ▸ Reports ▸ Verlauf).
*
* Bewusst Best-Effort: Ein fehlgeschlagener Verlaufs-Eintrag darf nie den
* fachlichen Ablauf brechen (Login, Checkout, Webhook) — Fehler landen nur
* im Log.
*/
class ActivityLog
{
/**
* @param array<string, mixed> $meta
*/
public function record(
ActivityType $type,
string $description,
?User $user = null,
?Model $subject = null,
array $meta = [],
?string $actorLabel = null,
): void {
try {
ActivityEvent::query()->create([
'type' => $type->value,
'occurred_at' => now(),
'user_id' => $user?->id,
'actor_label' => Str::limit($actorLabel ?? (string) $user?->name, 240),
'subject_type' => $subject?->getMorphClass(),
'subject_id' => $subject?->getKey(),
'description' => Str::limit($description, 480),
'meta' => $meta ?: null,
]);
} catch (\Throwable $exception) {
Log::warning('Aktivitäts-Verlauf konnte nicht geschrieben werden.', [
'type' => $type->value,
'error' => $exception->getMessage(),
]);
}
}
}

View file

@ -2,8 +2,10 @@
namespace App\Services\Auth;
use App\Enums\ActivityType;
use App\Enums\RegistrationType;
use App\Models\User;
use App\Services\ActivityLog;
/**
* Legt verifizierte Kunden-Accounts an, die über einen vertrauenswürdigen Kanal
@ -17,13 +19,18 @@ use App\Models\User;
*/
class CustomerAccountProvisioner
{
public function __construct(private readonly UserRolePermissionSyncService $roleSync) {}
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
private readonly ActivityLog $activityLog,
) {}
/**
* @param array<string, mixed> $extraAttributes Zusätzliche Spalten beim
* Anlegen (z. B. OAuth-Provider).
* @param string $channel Herkunft fürs Aktivitäts-Protokoll
* ('magic-link' oder 'google').
*/
public function provisionVerifiedCustomer(string $email, string $name, array $extraAttributes = []): User
public function provisionVerifiedCustomer(string $email, string $name, array $extraAttributes = [], string $channel = 'magic-link'): User
{
$user = User::create([
'name' => $name !== '' ? $name : $email,
@ -39,6 +46,14 @@ class CustomerAccountProvisioner
$user->forceFill(['email_verified_at' => now()])->save();
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
$channelLabels = ['magic-link' => 'Magic-Link', 'google' => 'Google'];
$this->activityLog->record(
ActivityType::UserRegistered,
sprintf('%s (%s) wurde registriert — %s.', $user->name, $user->email, $channelLabels[$channel] ?? $channel),
user: $user,
meta: ['way' => $channel],
);
return $user;
}
}

View file

@ -73,6 +73,7 @@ class SocialAuthService
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
],
channel: $provider,
);
}
}

View file

@ -2,10 +2,12 @@
namespace App\Services\Billing;
use App\Enums\ActivityType;
use App\Enums\CreditTransactionType;
use App\Enums\SinglePurchaseStatus;
use App\Models\CreditTopup;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Billing\Concerns\LocksModelForUpdate;
/**
@ -17,7 +19,10 @@ class CreditTopupService
{
use LocksModelForUpdate;
public function __construct(private readonly CreditWalletService $wallet) {}
public function __construct(
private readonly CreditWalletService $wallet,
private readonly ActivityLog $activityLog,
) {}
/**
* Verfügbare Credit-Pakete aus der Config.
@ -94,6 +99,20 @@ class CreditTopupService
$locked,
);
$this->activityLog->record(
ActivityType::CreditTopup,
sprintf(
'Wallet-Aufladung: %d Credits (Paket %s) — %s (%s).',
$locked->credits,
$locked->pack_key,
$locked->user->name,
$locked->user->email,
),
user: $locked->user,
subject: $locked,
meta: ['credits' => $locked->credits, 'pack' => $locked->pack_key],
);
$topup->setRawAttributes($locked->getAttributes());
});
}

View file

@ -2,6 +2,7 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
@ -18,6 +19,7 @@ use App\Models\PressReleaseImage;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Admin\AdminPerformanceCache;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
@ -36,6 +38,7 @@ class PressReleaseService
public function __construct(
private readonly BlacklistService $blacklist,
private readonly AdminPerformanceCache $adminCache,
private readonly ActivityLog $activityLog,
) {}
/**
@ -530,6 +533,30 @@ class PressReleaseService
'created_at' => now(),
]);
// Aktivitäts-Verlauf: ohne eingeloggten Akteur (KI-Jobs, Scheduler)
// erscheint der Wechsel als „System".
$actor = Auth::user();
$actor = $actor instanceof User ? $actor : null;
$this->activityLog->record(
ActivityType::PressReleaseStatus,
sprintf(
'PM „%s“: %s → %s%s',
$pressRelease->title,
$from?->label() ?? 'Neu',
$to->label(),
$reason ? " — {$reason}" : '',
),
user: $actor,
subject: $pressRelease,
actorLabel: $actor?->name ?? 'System',
meta: [
'from' => $from?->value,
'to' => $to->value,
'source' => $source,
],
);
$this->adminCache->forgetPressReleaseStats();
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Aktivitäts-Verlauf für Admin ▸ Reports ▸ Verlauf: chronologisches
* Protokoll der Systemereignisse (Registrierung, Login, PM-Lebenszyklus,
* Zahlungen, Formular-Eingänge). Befüllt ab Einführung über ActivityLog.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_events', function (Blueprint $table) {
$table->id();
$table->string('type', 40)->index();
$table->timestamp('occurred_at')->index();
// Bewusst ohne FK-Constraint: Der Verlauf soll User-Löschungen
// überleben und Inserts dürfen nie am Constraint scheitern.
$table->unsignedBigInteger('user_id')->nullable()->index();
// Anzeige-Snapshot des Handelnden (auch für Gäste, z. B.
// Kontaktformular ohne Konto).
$table->string('actor_label')->nullable();
$table->nullableMorphs('subject');
$table->string('description', 500);
$table->json('meta')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('activity_events');
}
};

View file

@ -210,6 +210,10 @@
{{-- 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') }}

View file

@ -0,0 +1,164 @@
<?php
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Verlauf')] class extends Component
{
use WithPagination;
public string $typeFilter = '';
public string $from = '';
public string $to = '';
public string $search = '';
public function resetFilters(): void
{
$this->typeFilter = '';
$this->from = '';
$this->to = '';
$this->search = '';
$this->resetPage();
}
public function updated(string $property): void
{
if (in_array($property, ['typeFilter', 'from', 'to', 'search'], true)) {
$this->resetPage();
}
}
public function with(): array
{
$events = ActivityEvent::query()
->with('user')
->when($this->typeFilter !== '', fn ($query) => $query->where('type', $this->typeFilter))
->when($this->from !== '', fn ($query) => $query->where('occurred_at', '>=', $this->from))
->when($this->to !== '', fn ($query) => $query->where('occurred_at', '<=', $this->to))
->when($this->search !== '', function ($query) {
$query->where(function ($inner) {
$inner->where('description', 'like', "%{$this->search}%")
->orWhere('actor_label', 'like', "%{$this->search}%");
});
})
->orderByDesc('occurred_at')
->orderByDesc('id')
->paginate(50);
return [
'events' => $events,
'types' => ActivityType::cases(),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Reports')"
:title="__('Verlauf')"
:description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen und Formular-Eingänge.')"
/>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter') }}</span>
<flux:button size="sm" variant="filled" icon="arrow-path" wire:click="resetFilters">
{{ __('Filter zurücksetzen') }}
</flux:button>
</div>
<div class="p-5">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<flux:field>
<flux:label>{{ __('Ereignis') }}</flux:label>
<flux:select wire:model.live="typeFilter">
<option value="">{{ __('Alle') }}</option>
@foreach ($types as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Von') }}</flux:label>
<flux:input type="datetime-local" wire:model.live.debounce.500ms="from" />
</flux:field>
<flux:field>
<flux:label>{{ __('Bis') }}</flux:label>
<flux:input type="datetime-local" wire:model.live.debounce.500ms="to" />
</flux:field>
<flux:field>
<flux:label>{{ __('Suche') }}</flux:label>
<flux:input wire:model.live.debounce.500ms="search" :placeholder="__('Name, E-Mail oder Beschreibung')" />
</flux:field>
</div>
</div>
</article>
{{-- ============== VERLAUF ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Ereignisse') }}</span>
<span class="badge hub">{{ number_format($events->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Zeitpunkt') }}</flux:table.column>
<flux:table.column>{{ __('Ereignis') }}</flux:table.column>
<flux:table.column>{{ __('Benutzer') }}</flux:table.column>
<flux:table.column>{{ __('Beschreibung') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($events as $event)
<flux:table.row :key="$event->id">
<flux:table.cell class="whitespace-nowrap">
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $event->occurred_at->format('d.m.Y H:i:s') }}
</span>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$event->type->badgeColor()">{{ $event->type->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell class="whitespace-nowrap">
@if ($event->user)
<a href="{{ route('admin.users.show', $event->user->id) }}" wire:navigate
class="text-[12.5px] text-[color:var(--color-ink)] hover:underline">
{{ $event->user->name }}
</a>
@else
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">{{ $event->actor_label ?: '—' }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $event->description }}</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="4">
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Keine Ereignisse gefunden. Der Verlauf wird ab jetzt fortlaufend befüllt.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if ($events->hasPages())
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
{{ $events->links() }}
</div>
@endif
</article>
</div>

View file

@ -106,6 +106,7 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Volt::route('admin/roles/{id}/edit', 'admin.roles.edit')->name('admin.roles.edit');
// Reports
Volt::route('admin/reports/activity', 'admin.reports.activity')->name('admin.reports.activity');
Volt::route('admin/reports/slow-requests', 'admin.reports.slow-requests')->name('admin.reports.slow-requests');
Route::middleware('can:settings:manage')->group(function (): void {

View file

@ -0,0 +1,113 @@
<?php
use App\Enums\ActivityType;
use App\Enums\PressReleaseStatus;
use App\Listeners\RecordAuthActivity;
use App\Models\ActivityEvent;
use App\Models\ContactMessage;
use App\Models\PressRelease;
use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt;
use Tests\TestCase;
test('a self registration is recorded in the activity log', function () {
/** @var TestCase $this */
Notification::fake();
Volt::test('auth.register')
->set('name', 'Verlauf Tester')
->set('email', 'verlauf@example.com')
->set('password', 'password')
->set('password_confirmation', 'password')
->set('terms_accepted', true)
->call('register');
$event = ActivityEvent::query()->where('type', ActivityType::UserRegistered->value)->sole();
expect($event->meta['way'])->toBe('form')
->and($event->description)->toContain('verlauf@example.com')
->and($event->user_id)->not->toBeNull();
});
test('a login is recorded with its method', function () {
$user = User::factory()->create(['is_active' => true]);
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
event(new Login('web', $user, false));
$event = ActivityEvent::query()->where('type', ActivityType::UserLoggedIn->value)->sole();
expect($event->meta['method'])->toBe('magic-link')
->and($event->description)->toContain('Magic-Link')
->and($event->user_id)->toBe($user->id);
});
test('a press release status change is recorded', function () {
Queue::fake();
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Draft]);
app(PressReleaseService::class)->submitForReview($pressRelease);
$event = ActivityEvent::query()->where('type', ActivityType::PressReleaseStatus->value)->first();
expect($event)->not->toBeNull()
->and($event->description)->toContain($pressRelease->title)
->and($event->subject_id)->toBe($pressRelease->id)
->and($event->actor_label)->toBe('System');
});
test('form submissions are recorded via the observer', function () {
ContactMessage::factory()->create([
'name' => 'Max Beispiel',
'email' => 'max@example.com',
]);
WithdrawalRequest::factory()->create([
'name' => 'Erika Beispiel',
'email' => 'erika@example.com',
]);
expect(ActivityEvent::query()->where('type', ActivityType::ContactRequest->value)->sole()->description)
->toContain('max@example.com');
expect(ActivityEvent::query()->where('type', ActivityType::WithdrawalRequest->value)->sole()->description)
->toContain('erika@example.com');
});
test('the activity report page lists events and filters by type', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
ActivityEvent::query()->create([
'type' => ActivityType::ContactRequest->value,
'occurred_at' => now(),
'actor_label' => 'Gast',
'description' => 'Kontaktanfrage „Testthema“ von Gast (gast@example.com).',
]);
ActivityEvent::query()->create([
'type' => ActivityType::UserLoggedIn->value,
'occurred_at' => now(),
'user_id' => $admin->id,
'actor_label' => $admin->name,
'description' => 'Admin hat sich angemeldet — Passwort.',
]);
$this->actingAs($admin);
$this->get(route('admin.reports.activity'))->assertOk();
Volt::test('admin.reports.activity')
->assertSee('Kontaktanfrage „Testthema“')
->assertSee('hat sich angemeldet')
->set('typeFilter', ActivityType::ContactRequest->value)
->assertSee('Kontaktanfrage „Testthema“')
->assertDontSee('hat sich angemeldet');
});