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:
parent
a574898ca9
commit
3a11963990
19 changed files with 720 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
53
app/Enums/ActivityType.php
Normal file
53
app/Enums/ActivityType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
73
app/Listeners/RecordAuthActivity.php
Normal file
73
app/Listeners/RecordAuthActivity.php
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/Models/ActivityEvent.php
Normal file
47
app/Models/ActivityEvent.php
Normal 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();
|
||||
}
|
||||
}
|
||||
69
app/Observers/ActivityLogObserver.php
Normal file
69
app/Observers/ActivityLogObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
50
app/Services/ActivityLog.php
Normal file
50
app/Services/ActivityLog.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class SocialAuthService
|
|||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
],
|
||||
channel: $provider,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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') }}
|
||||
|
|
|
|||
164
resources/views/livewire/admin/reports/activity.blade.php
Normal file
164
resources/views/livewire/admin/reports/activity.blade.php
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
113
tests/Feature/ActivityLogTest.php
Normal file
113
tests/Feature/ActivityLogTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue