diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 7565d7d5..56eb3a5e 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -21,6 +21,7 @@ enum ActivityType: string case NewsletterSubscription = 'newsletter_subscription'; case SubscriptionChange = 'subscription_change'; case MailSent = 'mail_sent'; + case EmailUnsubscribe = 'email_unsubscribe'; public function label(): string { @@ -37,6 +38,7 @@ enum ActivityType: string self::NewsletterSubscription => 'Newsletter', self::SubscriptionChange => 'Abo', self::MailSent => 'E-Mail', + self::EmailUnsubscribe => 'Mail-Abmeldung', }; } @@ -53,6 +55,7 @@ enum ActivityType: string self::ContactRequest, self::NewsletterSubscription => 'cyan', self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red', self::MailSent => 'purple', + self::EmailUnsubscribe => 'orange', }; } } diff --git a/app/Http/Controllers/EmailUnsubscribeController.php b/app/Http/Controllers/EmailUnsubscribeController.php index dca120b6..f2236afe 100644 --- a/app/Http/Controllers/EmailUnsubscribeController.php +++ b/app/Http/Controllers/EmailUnsubscribeController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers; +use App\Enums\ActivityType; use App\Enums\EmailNotificationType; use App\Models\User; +use App\Services\ActivityLog; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -19,9 +21,15 @@ use Illuminate\View\View; * rückgängig zu machen. Der POST auf dieselbe URL bedient den * List-Unsubscribe-Header (RFC 8058) — Mail-Clients senden ihn ohne * Nutzer-Interaktion auf der Seite. + * + * Jede tatsächliche Zustandsänderung landet im Aktivitäts-Verlauf + * (Admin ▸ Reports ▸ Verlauf) — wiederholte Klicks erzeugen keine + * Doppel-Einträge. */ class EmailUnsubscribeController extends Controller { + public function __construct(private readonly ActivityLog $activityLog) {} + public function show(Request $request, User $user, string $type): View { $notificationType = $this->resolveType($type); @@ -33,7 +41,7 @@ class EmailUnsubscribeController extends Controller ]); } - $user->optOutOfEmailNotification($notificationType); + $this->optOut($user, $notificationType, 'link'); return view('web.email-unsubscribe', [ 'state' => 'unsubscribed', @@ -53,7 +61,7 @@ class EmailUnsubscribeController extends Controller { abort_unless($request->hasValidSignature(), 403); - $user->optOutOfEmailNotification($this->resolveType($type)); + $this->optOut($user, $this->resolveType($type), 'one-click'); return response('Sie wurden erfolgreich abgemeldet.'); } @@ -64,7 +72,16 @@ class EmailUnsubscribeController extends Controller $notificationType = $this->resolveType($type); - $user->optIntoEmailNotification($notificationType); + if (! $user->wantsEmailNotification($notificationType)) { + $user->optIntoEmailNotification($notificationType); + + $this->activityLog->record( + ActivityType::EmailUnsubscribe, + sprintf('%s (%s) hat %s wieder aktiviert.', $user->name, $user->email, $notificationType->unsubscribeLabel()), + user: $user, + meta: ['type' => $notificationType->value, 'action' => 'resubscribe'], + ); + } return view('web.email-unsubscribe', [ 'state' => 'resubscribed', @@ -72,6 +89,22 @@ class EmailUnsubscribeController extends Controller ]); } + private function optOut(User $user, EmailNotificationType $notificationType, string $method): void + { + if (! $user->wantsEmailNotification($notificationType)) { + return; + } + + $user->optOutOfEmailNotification($notificationType); + + $this->activityLog->record( + ActivityType::EmailUnsubscribe, + sprintf('%s (%s) hat %s abbestellt.', $user->name, $user->email, $notificationType->unsubscribeLabel()), + user: $user, + meta: ['type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method], + ); + } + private function resolveType(string $type): EmailNotificationType { $notificationType = EmailNotificationType::tryFrom($type); diff --git a/resources/views/livewire/admin/reports/activity.blade.php b/resources/views/livewire/admin/reports/activity.blade.php index 656543fc..133484cf 100644 --- a/resources/views/livewire/admin/reports/activity.blade.php +++ b/resources/views/livewire/admin/reports/activity.blade.php @@ -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, Formular-Eingänge und versendete E-Mails.')" + :description="__('Chronologisches Protokoll der Systemereignisse: Registrierungen, Anmeldungen, Pressemitteilungen, Zahlungen, Formular-Eingänge, versendete E-Mails und Mail-Abmeldungen.')" /> {{-- ============== FILTER-PANEL ============== --}} diff --git a/tests/Feature/Admin/NewsletterCampaignTest.php b/tests/Feature/Admin/NewsletterCampaignTest.php index 87da9858..82c59f2e 100644 --- a/tests/Feature/Admin/NewsletterCampaignTest.php +++ b/tests/Feature/Admin/NewsletterCampaignTest.php @@ -1,5 +1,6 @@ query([])->pluck('id'))->not->toContain($user->id); + + // Die Abmeldung steht im Aktivitäts-Verlauf — ein zweiter Klick + // erzeugt keinen Doppel-Eintrag. + $this->get($url)->assertOk(); + + $event = ActivityEvent::query() + ->where('type', ActivityType::EmailUnsubscribe->value) + ->sole(); + + expect($event->user_id)->toBe($user->id) + ->and($event->description)->toContain('abbestellt') + ->and($event->meta['method'])->toBe('link') + ->and($event->meta['type'])->toBe('newsletter'); }); test('the campaign editor saves drafts and sends via the queue', function () {