diff --git a/app/Actions/Admin/UserImpersonation.php b/app/Actions/Admin/UserImpersonation.php index dba3d134..04bf7370 100644 --- a/app/Actions/Admin/UserImpersonation.php +++ b/app/Actions/Admin/UserImpersonation.php @@ -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; diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php new file mode 100644 index 00000000..bdd0664b --- /dev/null +++ b/app/Enums/ActivityType.php @@ -0,0 +1,53 @@ + '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', + }; + } +} diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index 227ddbd0..b7fb5f5c 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -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(); diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index ef659760..e65bb1bf 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -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(); diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index b3a715b5..21d75f02 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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], + ); } /** diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php new file mode 100644 index 00000000..805c00d3 --- /dev/null +++ b/app/Listeners/RecordAuthActivity.php @@ -0,0 +1,73 @@ + '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], + ); + } +} diff --git a/app/Models/ActivityEvent.php b/app/Models/ActivityEvent.php new file mode 100644 index 00000000..09e56125 --- /dev/null +++ b/app/Models/ActivityEvent.php @@ -0,0 +1,47 @@ + ActivityType::class, + 'occurred_at' => 'datetime', + 'meta' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Observers/ActivityLogObserver.php b/app/Observers/ActivityLogObserver.php new file mode 100644 index 00000000..4f463852 --- /dev/null +++ b/app/Observers/ActivityLogObserver.php @@ -0,0 +1,69 @@ + $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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9c3e4c3f..f2e29799 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Services/ActivityLog.php b/app/Services/ActivityLog.php new file mode 100644 index 00000000..fe1ea273 --- /dev/null +++ b/app/Services/ActivityLog.php @@ -0,0 +1,50 @@ + $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(), + ]); + } + } +} diff --git a/app/Services/Auth/CustomerAccountProvisioner.php b/app/Services/Auth/CustomerAccountProvisioner.php index fd112491..62e9b752 100644 --- a/app/Services/Auth/CustomerAccountProvisioner.php +++ b/app/Services/Auth/CustomerAccountProvisioner.php @@ -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 $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; } } diff --git a/app/Services/Auth/SocialAuthService.php b/app/Services/Auth/SocialAuthService.php index 3b68b911..0d5c6d1a 100644 --- a/app/Services/Auth/SocialAuthService.php +++ b/app/Services/Auth/SocialAuthService.php @@ -73,6 +73,7 @@ class SocialAuthService 'oauth_provider' => $provider, 'oauth_provider_id' => $providerId, ], + channel: $provider, ); } } diff --git a/app/Services/Billing/CreditTopupService.php b/app/Services/Billing/CreditTopupService.php index 7747d534..271c1067 100644 --- a/app/Services/Billing/CreditTopupService.php +++ b/app/Services/Billing/CreditTopupService.php @@ -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()); }); } diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 18a11ab5..e466187d 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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(); } diff --git a/database/migrations/2026_07_15_174809_create_activity_events_table.php b/database/migrations/2026_07_15_174809_create_activity_events_table.php new file mode 100644 index 00000000..dc159120 --- /dev/null +++ b/database/migrations/2026_07_15_174809_create_activity_events_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index d4c6b1ff..1240ae53 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -210,6 +210,10 @@ {{-- Reports --}} + + {{ __('Verlauf') }} + {{ __('Performance') }} diff --git a/resources/views/livewire/admin/reports/activity.blade.php b/resources/views/livewire/admin/reports/activity.blade.php new file mode 100644 index 00000000..ab34c46f --- /dev/null +++ b/resources/views/livewire/admin/reports/activity.blade.php @@ -0,0 +1,164 @@ +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(), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} + + + {{-- ============== FILTER-PANEL ============== --}} +
+
+ {{ __('Filter') }} + + {{ __('Filter zurücksetzen') }} + +
+
+
+ + {{ __('Ereignis') }} + + + @foreach ($types as $type) + + @endforeach + + + + + {{ __('Von') }} + + + + + {{ __('Bis') }} + + + + + {{ __('Suche') }} + + +
+
+
+ + {{-- ============== VERLAUF ============== --}} +
+
+ {{ __('Ereignisse') }} + {{ number_format($events->total(), 0, ',', '.') }} {{ __('Einträge') }} +
+ + + {{ __('Zeitpunkt') }} + {{ __('Ereignis') }} + {{ __('Benutzer') }} + {{ __('Beschreibung') }} + + + @forelse ($events as $event) + + + + {{ $event->occurred_at->format('d.m.Y H:i:s') }} + + + + {{ $event->type->label() }} + + + @if ($event->user) + + {{ $event->user->name }} + + @else + {{ $event->actor_label ?: '—' }} + @endif + + + {{ $event->description }} + + + @empty + + +
+ {{ __('Keine Ereignisse gefunden. Der Verlauf wird ab jetzt fortlaufend befüllt.') }} +
+
+
+ @endforelse +
+
+ @if ($events->hasPages()) +
+ {{ $events->links() }} +
+ @endif +
+
diff --git a/routes/admin.php b/routes/admin.php index 0aa28e63..8de1a2fd 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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 { diff --git a/tests/Feature/ActivityLogTest.php b/tests/Feature/ActivityLogTest.php new file mode 100644 index 00000000..3b45da9a --- /dev/null +++ b/tests/Feature/ActivityLogTest.php @@ -0,0 +1,113 @@ +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'); +});