From 792b5dfe8b737f23fdfea22da386aca756450601 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 2 Nov 2025 11:11:13 +0100 Subject: [PATCH] verbesserung von benachrichtungen und warnungen an nutzer abgeschlossen. layout editor nun auf gutem stand. --- .../Commands/RetryTenantNotification.php | 250 ++++++++++++++++++ .../Api/Tenant/NotificationLogController.php | 53 ++++ app/Jobs/Concerns/LogsTenantNotifications.php | 60 +++++ .../SendEventPackageGalleryExpired.php | 36 ++- .../SendEventPackageGalleryWarning.php | 43 ++- ...SendEventPackageGuestLimitNotification.php | 43 ++- .../SendEventPackageGuestThresholdWarning.php | 49 +++- ...SendEventPackagePhotoLimitNotification.php | 47 +++- .../SendEventPackagePhotoThresholdWarning.php | 53 +++- .../SendTenantCreditsLowNotification.php | 47 +++- ...endTenantPackageEventLimitNotification.php | 50 +++- ...SendTenantPackageEventThresholdWarning.php | 56 +++- .../SendTenantPackageExpiredNotification.php | 44 ++- .../SendTenantPackageExpiringNotification.php | 53 +++- app/Models/Tenant.php | 5 + app/Models/TenantNotificationLog.php | 31 +++ .../Packages/TenantNotificationLogger.php | 37 +++ ..._create_tenant_notification_logs_table.php | 39 +++ docs/prp/03-api.md | 1 + .../todo/package-limit-experience-overhaul.md | 8 +- ...72cf6fb2da3f16cadd7202702687cdeaa7901.webm | Bin 0 -> 27311 bytes ...33d5db6370b6de345e990751aa1f1da65ad675.png | Bin 0 -> 4253 bytes ...93c4cccd959a8ecdb174bd08799b82fa8c840.webm | Bin 0 -> 18727 bytes playwright-report/index.html | 2 +- resources/js/admin/pages/DashboardPage.tsx | 18 ++ resources/js/admin/pages/EventDetailPage.tsx | 18 ++ resources/js/admin/pages/EventFormPage.tsx | 18 ++ .../InviteLayoutCustomizerPanel.tsx | 91 +++---- .../invite-layout/DesignerCanvas.tsx | 36 ++- routes/api.php | 4 + .../SendTenantCreditsLowNotificationTest.php | 40 +++ tests/e2e/guest-limit-experience.test.ts | 209 +++++++++++++++ 32 files changed, 1292 insertions(+), 149 deletions(-) create mode 100644 app/Console/Commands/RetryTenantNotification.php create mode 100644 app/Http/Controllers/Api/Tenant/NotificationLogController.php create mode 100644 app/Jobs/Concerns/LogsTenantNotifications.php create mode 100644 app/Models/TenantNotificationLog.php create mode 100644 app/Services/Packages/TenantNotificationLogger.php create mode 100644 database/migrations/2025_11_01_231615_create_tenant_notification_logs_table.php create mode 100644 playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm create mode 100644 playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png create mode 100644 playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm create mode 100644 tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php create mode 100644 tests/e2e/guest-limit-experience.test.ts diff --git a/app/Console/Commands/RetryTenantNotification.php b/app/Console/Commands/RetryTenantNotification.php new file mode 100644 index 0000000..5d56117 --- /dev/null +++ b/app/Console/Commands/RetryTenantNotification.php @@ -0,0 +1,250 @@ +argument('log_id')) { + $query->whereKey($logId); + } + + if ($tenantId = $this->option('tenant')) { + $query->where('tenant_id', $tenantId); + } + + if ($status = $this->option('status')) { + $query->where('status', $status); + } + + $logs = $query->orderBy('id')->get(); + + if ($logs->isEmpty()) { + $this->warn('No notification logs matched the given filters.'); + + return self::SUCCESS; + } + + foreach ($logs as $log) { + $dispatched = $this->dispatchForLog($log); + + if ($dispatched) { + $log->forceFill([ + 'status' => 'queued', + 'failed_at' => null, + 'failure_reason' => null, + ])->save(); + + $this->info("Queued retry for log #{$log->id} ({$log->type})"); + } else { + $this->warn("Skipped log #{$log->id} ({$log->type}) – missing context"); + } + } + + return self::SUCCESS; + } + + private function dispatchForLog(TenantNotificationLog $log): bool + { + $context = $log->context ?? []; + + return match ($log->type) { + 'photo_threshold' => $this->dispatchPhotoThreshold($context), + 'photo_limit' => $this->dispatchPhotoLimit($context), + 'guest_threshold' => $this->dispatchGuestThreshold($context), + 'guest_limit' => $this->dispatchGuestLimit($context), + 'gallery_warning' => $this->dispatchGalleryWarning($context), + 'gallery_expired' => $this->dispatchGalleryExpired($context), + 'credits_low' => $this->dispatchCreditsLow($log->tenant_id, $context), + 'event_threshold' => $this->dispatchEventThreshold($context), + 'event_limit' => $this->dispatchEventLimit($context), + 'package_expiring' => $this->dispatchPackageExpiring($context), + 'package_expired' => $this->dispatchPackageExpired($context), + default => false, + }; + } + + private function dispatchPhotoThreshold(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + $threshold = Arr::get($context, 'threshold'); + $limit = Arr::get($context, 'limit'); + $used = Arr::get($context, 'used'); + + if ($eventPackageId === null || $threshold === null || $limit === null || $used === null) { + return false; + } + + SendEventPackagePhotoThresholdWarning::dispatch($eventPackageId, (float) $threshold, (int) $limit, (int) $used); + + return true; + } + + private function dispatchPhotoLimit(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + $limit = Arr::get($context, 'limit'); + + if ($eventPackageId === null || $limit === null) { + return false; + } + + SendEventPackagePhotoLimitNotification::dispatch($eventPackageId, (int) $limit); + + return true; + } + + private function dispatchGuestThreshold(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + $threshold = Arr::get($context, 'threshold'); + $limit = Arr::get($context, 'limit'); + $used = Arr::get($context, 'used'); + + if ($eventPackageId === null || $threshold === null || $limit === null || $used === null) { + return false; + } + + SendEventPackageGuestThresholdWarning::dispatch($eventPackageId, (float) $threshold, (int) $limit, (int) $used); + + return true; + } + + private function dispatchGuestLimit(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + $limit = Arr::get($context, 'limit'); + + if ($eventPackageId === null || $limit === null) { + return false; + } + + SendEventPackageGuestLimitNotification::dispatch($eventPackageId, (int) $limit); + + return true; + } + + private function dispatchGalleryWarning(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + $days = Arr::get($context, 'days_remaining'); + + if ($eventPackageId === null || $days === null) { + return false; + } + + SendEventPackageGalleryWarning::dispatch($eventPackageId, (int) $days); + + return true; + } + + private function dispatchGalleryExpired(array $context): bool + { + $eventPackageId = Arr::get($context, 'event_package_id'); + + if ($eventPackageId === null) { + return false; + } + + SendEventPackageGalleryExpired::dispatch($eventPackageId); + + return true; + } + + private function dispatchCreditsLow(int $tenantId, array $context): bool + { + $balance = Arr::get($context, 'balance'); + $threshold = Arr::get($context, 'threshold'); + + if ($balance === null || $threshold === null) { + Log::warning('credits_low retry missing balance or threshold', compact('tenantId', 'context')); + } + + $balance = $balance !== null ? (int) $balance : 0; + $threshold = $threshold !== null ? (int) $threshold : 0; + + SendTenantCreditsLowNotification::dispatch($tenantId, $balance, $threshold); + + return true; + } + + private function dispatchEventThreshold(array $context): bool + { + $tenantPackageId = Arr::get($context, 'tenant_package_id'); + $threshold = Arr::get($context, 'threshold'); + $limit = Arr::get($context, 'limit'); + $used = Arr::get($context, 'used'); + + if ($tenantPackageId === null || $threshold === null || $limit === null || $used === null) { + return false; + } + + SendTenantPackageEventThresholdWarning::dispatch($tenantPackageId, (float) $threshold, (int) $limit, (int) $used); + + return true; + } + + private function dispatchEventLimit(array $context): bool + { + $tenantPackageId = Arr::get($context, 'tenant_package_id'); + $limit = Arr::get($context, 'limit'); + + if ($tenantPackageId === null || $limit === null) { + return false; + } + + SendTenantPackageEventLimitNotification::dispatch($tenantPackageId, (int) $limit); + + return true; + } + + private function dispatchPackageExpiring(array $context): bool + { + $tenantPackageId = Arr::get($context, 'tenant_package_id'); + $days = Arr::get($context, 'days_remaining'); + + if ($tenantPackageId === null || $days === null) { + return false; + } + + SendTenantPackageExpiringNotification::dispatch($tenantPackageId, (int) $days); + + return true; + } + + private function dispatchPackageExpired(array $context): bool + { + $tenantPackageId = Arr::get($context, 'tenant_package_id'); + + if ($tenantPackageId === null) { + return false; + } + + SendTenantPackageExpiredNotification::dispatch($tenantPackageId); + + return true; + } +} diff --git a/app/Http/Controllers/Api/Tenant/NotificationLogController.php b/app/Http/Controllers/Api/Tenant/NotificationLogController.php new file mode 100644 index 0000000..5fe1b8a --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/NotificationLogController.php @@ -0,0 +1,53 @@ +attributes->get('tenant') ?? $request->user()?->tenant; + + if (! $tenant) { + return response()->json([ + 'error' => [ + 'code' => 'tenant_context_missing', + 'title' => 'Tenant context missing', + 'message' => 'Unable to resolve tenant for notification logs.', + ], + ], 403); + } + + $query = TenantNotificationLog::query() + ->where('tenant_id', $tenant->id) + ->latest(); + + if ($type = $request->query('type')) { + $query->where('type', $type); + } + + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + $perPage = (int) $request->query('per_page', 20); + $perPage = max(1, min($perPage, 100)); + + $logs = $query->paginate($perPage); + + return response()->json([ + 'data' => $logs->items(), + 'meta' => [ + 'current_page' => $logs->currentPage(), + 'last_page' => $logs->lastPage(), + 'per_page' => $logs->perPage(), + 'total' => $logs->total(), + ], + ]); + } +} diff --git a/app/Jobs/Concerns/LogsTenantNotifications.php b/app/Jobs/Concerns/LogsTenantNotifications.php new file mode 100644 index 0000000..638b681 --- /dev/null +++ b/app/Jobs/Concerns/LogsTenantNotifications.php @@ -0,0 +1,60 @@ +notificationLogger()->log($tenant, $attributes); + } + + protected function dispatchToRecipients( + Tenant $tenant, + iterable $recipients, + string $type, + callable $callback, + array $context = [] + ): void { + foreach ($recipients as $recipient) { + try { + $callback($recipient); + + $this->logNotification($tenant, [ + 'type' => $type, + 'channel' => 'mail', + 'recipient' => $recipient, + 'status' => 'sent', + 'context' => $context, + 'sent_at' => now(), + ]); + } catch (\Throwable $e) { + Log::error('Tenant notification failed', [ + 'tenant_id' => $tenant->id, + 'type' => $type, + 'recipient' => $recipient, + 'error' => $e->getMessage(), + ]); + + $this->logNotification($tenant, [ + 'type' => $type, + 'channel' => 'mail', + 'recipient' => $recipient, + 'status' => 'failed', + 'context' => $context, + 'failed_at' => now(), + 'failure_reason' => $e->getMessage(), + ]); + } + } + } +} diff --git a/app/Jobs/Packages/SendEventPackageGalleryExpired.php b/app/Jobs/Packages/SendEventPackageGalleryExpired.php index 2be5232..1325650 100644 --- a/app/Jobs/Packages/SendEventPackageGalleryExpired.php +++ b/app/Jobs/Packages/SendEventPackageGalleryExpired.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackageGalleryExpiredNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackageGalleryExpired implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -40,6 +42,12 @@ class SendEventPackageGalleryExpired implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'gallery_expired')) { + $this->logNotification($tenant, [ + 'type' => 'gallery_expired', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -54,11 +62,33 @@ class SendEventPackageGalleryExpired implements ShouldQueue 'event_package_id' => $eventPackage->id, ]); + $this->logNotification($tenant, [ + 'type' => 'gallery_expired', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage)); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'gallery_expired', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify(new EventPackageGalleryExpiredNotification($eventPackage)); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + ]; } } diff --git a/app/Jobs/Packages/SendEventPackageGalleryWarning.php b/app/Jobs/Packages/SendEventPackageGalleryWarning.php index bc5baa6..f1ba151 100644 --- a/app/Jobs/Packages/SendEventPackageGalleryWarning.php +++ b/app/Jobs/Packages/SendEventPackageGalleryWarning.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackageGalleryExpiringNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackageGalleryWarning implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -43,6 +45,12 @@ class SendEventPackageGalleryWarning implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) { + $this->logNotification($tenant, [ + 'type' => 'gallery_warning', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -58,14 +66,37 @@ class SendEventPackageGalleryWarning implements ShouldQueue 'days_remaining' => $this->daysRemaining, ]); + $this->logNotification($tenant, [ + 'type' => 'gallery_warning', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification( - $eventPackage, - $this->daysRemaining, - )); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'gallery_warning', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify(new EventPackageGalleryExpiringNotification( + $eventPackage, + $this->daysRemaining, + )); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + 'days_remaining' => $this->daysRemaining, + ]; } } diff --git a/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php index 1278100..b7ca00e 100644 --- a/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php +++ b/app/Jobs/Packages/SendEventPackageGuestLimitNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackageGuestLimitNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -43,6 +45,12 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'guest_limits')) { + $this->logNotification($tenant, [ + 'type' => 'guest_limit', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -57,14 +65,37 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue 'event_package_id' => $eventPackage->id, ]); + $this->logNotification($tenant, [ + 'type' => 'guest_limit', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification( - $eventPackage, - $this->limit, - )); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'guest_limit', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify(new EventPackageGuestLimitNotification( + $eventPackage, + $this->limit, + )); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + 'limit' => $this->limit, + ]; } } diff --git a/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php index c4023bd..c82baad 100644 --- a/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php +++ b/app/Jobs/Packages/SendEventPackageGuestThresholdWarning.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackageGuestThresholdNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -45,6 +47,12 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) { + $this->logNotification($tenant, [ + 'type' => 'guest_threshold', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -60,16 +68,41 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue 'threshold' => $this->threshold, ]); + $this->logNotification($tenant, [ + 'type' => 'guest_threshold', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification( - $eventPackage, - $this->threshold, - $this->limit, - $this->used, - )); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'guest_threshold', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify(new EventPackageGuestThresholdNotification( + $eventPackage, + $this->threshold, + $this->limit, + $this->used, + )); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + 'threshold' => $this->threshold, + 'limit' => $this->limit, + 'used' => $this->used, + ]; } } diff --git a/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php index d39d183..f6adf0c 100644 --- a/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php +++ b/app/Jobs/Packages/SendEventPackagePhotoLimitNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackagePhotoLimitNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -43,6 +45,12 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'photo_limits')) { + $this->logNotification($tenant, [ + 'type' => 'photo_limit', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -58,16 +66,39 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue 'limit' => $this->limit, ]); + $this->logNotification($tenant, [ + 'type' => 'photo_limit', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify( - new EventPackagePhotoLimitNotification( - $eventPackage, - $this->limit, - ) - ); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'photo_limit', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify( + new EventPackagePhotoLimitNotification( + $eventPackage, + $this->limit, + ) + ); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + 'limit' => $this->limit, + ]; } } diff --git a/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php index 35f9737..1cf50ac 100644 --- a/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php +++ b/app/Jobs/Packages/SendEventPackagePhotoThresholdWarning.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\EventPackage; use App\Notifications\Packages\EventPackagePhotoThresholdNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -45,6 +47,12 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) { + $this->logNotification($tenant, [ + 'type' => 'photo_threshold', + 'status' => 'skipped', + 'context' => $this->context($eventPackage), + ]); + return; } @@ -60,18 +68,43 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue 'threshold' => $this->threshold, ]); + $this->logNotification($tenant, [ + 'type' => 'photo_threshold', + 'status' => 'skipped', + 'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify( - new EventPackagePhotoThresholdNotification( - $eventPackage, - $this->threshold, - $this->limit, - $this->used, - ) - ); - } + $context = $this->context($eventPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'photo_threshold', + function (string $email) use ($eventPackage) { + Notification::route('mail', $email)->notify( + new EventPackagePhotoThresholdNotification( + $eventPackage, + $this->threshold, + $this->limit, + $this->used, + ) + ); + }, + $context + ); + } + + private function context(EventPackage $eventPackage): array + { + return [ + 'event_package_id' => $eventPackage->id, + 'event_id' => $eventPackage->event_id, + 'threshold' => $this->threshold, + 'limit' => $this->limit, + 'used' => $this->used, + ]; } } diff --git a/app/Jobs/Packages/SendTenantCreditsLowNotification.php b/app/Jobs/Packages/SendTenantCreditsLowNotification.php index 4987a2d..d490dca 100644 --- a/app/Jobs/Packages/SendTenantCreditsLowNotification.php +++ b/app/Jobs/Packages/SendTenantCreditsLowNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\Tenant; use App\Notifications\Packages\TenantCreditsLowNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendTenantCreditsLowNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -39,6 +41,16 @@ class SendTenantCreditsLowNotification implements ShouldQueue $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); if (! $preferences->shouldNotify($tenant, 'credits_low')) { + $this->logNotification($tenant, [ + 'type' => 'credits_low', + 'status' => 'skipped', + 'context' => [ + 'balance' => $this->balance, + 'threshold' => $this->threshold, + 'reason' => 'opt_out', + ], + ]); + return; } @@ -53,15 +65,36 @@ class SendTenantCreditsLowNotification implements ShouldQueue 'tenant_id' => $tenant->id, ]); + $this->logNotification($tenant, [ + 'type' => 'credits_low', + 'status' => 'skipped', + 'context' => [ + 'balance' => $this->balance, + 'threshold' => $this->threshold, + 'reason' => 'no_recipient', + ], + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new TenantCreditsLowNotification( - $tenant, - $this->balance, - $this->threshold, - )); - } + $context = [ + 'balance' => $this->balance, + 'threshold' => $this->threshold, + ]; + + $this->dispatchToRecipients( + $tenant, + $emails, + 'credits_low', + function (string $email) use ($tenant) { + Notification::route('mail', $email)->notify(new TenantCreditsLowNotification( + $tenant, + $this->balance, + $this->threshold, + )); + }, + $context + ); } } diff --git a/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php index 6f0b525..7c8da4a 100644 --- a/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php +++ b/app/Jobs/Packages/SendTenantPackageEventLimitNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\TenantPackage; use App\Notifications\Packages\TenantPackageEventLimitNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -36,14 +38,22 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue return; } + $tenant = $tenantPackage->tenant; + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); - if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_limits')) { + if (! $preferences->shouldNotify($tenant, 'event_limits')) { + $this->logNotification($tenant, [ + 'type' => 'event_limit', + 'status' => 'skipped', + 'context' => $this->context($tenantPackage), + ]); + return; } $emails = collect([ - $tenantPackage->tenant->contact_email, - $tenantPackage->tenant->user?->email, + $tenant->contact_email, + $tenant->user?->email, ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) ->unique(); @@ -52,14 +62,36 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue 'tenant_package_id' => $tenantPackage->id, ]); + $this->logNotification($tenant, [ + 'type' => 'event_limit', + 'status' => 'skipped', + 'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification( - $tenantPackage, - $this->limit, - )); - } + $context = $this->context($tenantPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'event_limit', + function (string $email) use ($tenantPackage) { + Notification::route('mail', $email)->notify(new TenantPackageEventLimitNotification( + $tenantPackage, + $this->limit, + )); + }, + $context + ); + } + + private function context(TenantPackage $tenantPackage): array + { + return [ + 'tenant_package_id' => $tenantPackage->id, + 'limit' => $this->limit, + ]; } } diff --git a/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php index 07e990d..5f8f895 100644 --- a/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php +++ b/app/Jobs/Packages/SendTenantPackageEventThresholdWarning.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\TenantPackage; use App\Notifications\Packages\TenantPackageEventThresholdNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -38,14 +40,22 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue return; } + $tenant = $tenantPackage->tenant; + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); - if (! $preferences->shouldNotify($tenantPackage->tenant, 'event_thresholds')) { + if (! $preferences->shouldNotify($tenant, 'event_thresholds')) { + $this->logNotification($tenant, [ + 'type' => 'event_threshold', + 'status' => 'skipped', + 'context' => $this->context($tenantPackage), + ]); + return; } $emails = collect([ - $tenantPackage->tenant->contact_email, - $tenantPackage->tenant->user?->email, + $tenant->contact_email, + $tenant->user?->email, ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) ->unique(); @@ -55,16 +65,40 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue 'threshold' => $this->threshold, ]); + $this->logNotification($tenant, [ + 'type' => 'event_threshold', + 'status' => 'skipped', + 'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']), + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification( - $tenantPackage, - $this->threshold, - $this->limit, - $this->used, - )); - } + $context = $this->context($tenantPackage); + + $this->dispatchToRecipients( + $tenant, + $emails, + 'event_threshold', + function (string $email) use ($tenantPackage) { + Notification::route('mail', $email)->notify(new TenantPackageEventThresholdNotification( + $tenantPackage, + $this->threshold, + $this->limit, + $this->used, + )); + }, + $context + ); + } + + private function context(TenantPackage $tenantPackage): array + { + return [ + 'tenant_package_id' => $tenantPackage->id, + 'threshold' => $this->threshold, + 'limit' => $this->limit, + 'used' => $this->used, + ]; } } diff --git a/app/Jobs/Packages/SendTenantPackageExpiredNotification.php b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php index 1887352..f508cdd 100644 --- a/app/Jobs/Packages/SendTenantPackageExpiredNotification.php +++ b/app/Jobs/Packages/SendTenantPackageExpiredNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\TenantPackage; use App\Notifications\Packages\TenantPackageExpiredNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendTenantPackageExpiredNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -33,14 +35,25 @@ class SendTenantPackageExpiredNotification implements ShouldQueue return; } + $tenant = $tenantPackage->tenant; + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); - if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expired')) { + if (! $preferences->shouldNotify($tenant, 'package_expired')) { + $this->logNotification($tenant, [ + 'type' => 'package_expired', + 'status' => 'skipped', + 'context' => [ + 'tenant_package_id' => $tenantPackage->id, + 'reason' => 'opt_out', + ], + ]); + return; } $emails = collect([ - $tenantPackage->tenant->contact_email, - $tenantPackage->tenant->user?->email, + $tenant->contact_email, + $tenant->user?->email, ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) ->unique(); @@ -49,11 +62,30 @@ class SendTenantPackageExpiredNotification implements ShouldQueue 'tenant_package_id' => $tenantPackage->id, ]); + $this->logNotification($tenant, [ + 'type' => 'package_expired', + 'status' => 'skipped', + 'context' => [ + 'tenant_package_id' => $tenantPackage->id, + 'reason' => 'no_recipient', + ], + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage)); - } + $context = [ + 'tenant_package_id' => $tenantPackage->id, + ]; + + $this->dispatchToRecipients( + $tenant, + $emails, + 'package_expired', + function (string $email) use ($tenantPackage) { + Notification::route('mail', $email)->notify(new TenantPackageExpiredNotification($tenantPackage)); + }, + $context + ); } } diff --git a/app/Jobs/Packages/SendTenantPackageExpiringNotification.php b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php index 9e2643c..8d064d9 100644 --- a/app/Jobs/Packages/SendTenantPackageExpiringNotification.php +++ b/app/Jobs/Packages/SendTenantPackageExpiringNotification.php @@ -2,6 +2,7 @@ namespace App\Jobs\Packages; +use App\Jobs\Concerns\LogsTenantNotifications; use App\Models\TenantPackage; use App\Notifications\Packages\TenantPackageExpiringNotification; use Illuminate\Bus\Queueable; @@ -16,6 +17,7 @@ class SendTenantPackageExpiringNotification implements ShouldQueue { use Dispatchable; use InteractsWithQueue; + use LogsTenantNotifications; use Queueable; use SerializesModels; @@ -36,14 +38,26 @@ class SendTenantPackageExpiringNotification implements ShouldQueue return; } + $tenant = $tenantPackage->tenant; + $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); - if (! $preferences->shouldNotify($tenantPackage->tenant, 'package_expiring')) { + if (! $preferences->shouldNotify($tenant, 'package_expiring')) { + $this->logNotification($tenant, [ + 'type' => 'package_expiring', + 'status' => 'skipped', + 'context' => [ + 'tenant_package_id' => $tenantPackage->id, + 'days_remaining' => $this->daysRemaining, + 'reason' => 'opt_out', + ], + ]); + return; } $emails = collect([ - $tenantPackage->tenant->contact_email, - $tenantPackage->tenant->user?->email, + $tenant->contact_email, + $tenant->user?->email, ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) ->unique(); @@ -53,14 +67,35 @@ class SendTenantPackageExpiringNotification implements ShouldQueue 'days_remaining' => $this->daysRemaining, ]); + $this->logNotification($tenant, [ + 'type' => 'package_expiring', + 'status' => 'skipped', + 'context' => [ + 'tenant_package_id' => $tenantPackage->id, + 'days_remaining' => $this->daysRemaining, + 'reason' => 'no_recipient', + ], + ]); + return; } - foreach ($emails as $email) { - Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification( - $tenantPackage, - $this->daysRemaining, - )); - } + $context = [ + 'tenant_package_id' => $tenantPackage->id, + 'days_remaining' => $this->daysRemaining, + ]; + + $this->dispatchToRecipients( + $tenant, + $emails, + 'package_expiring', + function (string $email) use ($tenantPackage) { + Notification::route('mail', $email)->notify(new TenantPackageExpiringNotification( + $tenantPackage, + $this->daysRemaining, + )); + }, + $context + ); } } diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c3a33b2..a928610 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -71,6 +71,11 @@ class Tenant extends Model return $this->hasOne(TenantPackage::class)->where('active', true); } + public function notificationLogs(): HasMany + { + return $this->hasMany(TenantNotificationLog::class); + } + public function canCreateEvent(): bool { return $this->hasEventAllowance(); diff --git a/app/Models/TenantNotificationLog.php b/app/Models/TenantNotificationLog.php new file mode 100644 index 0000000..97b9141 --- /dev/null +++ b/app/Models/TenantNotificationLog.php @@ -0,0 +1,31 @@ + 'array', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Services/Packages/TenantNotificationLogger.php b/app/Services/Packages/TenantNotificationLogger.php new file mode 100644 index 0000000..ba30aa0 --- /dev/null +++ b/app/Services/Packages/TenantNotificationLogger.php @@ -0,0 +1,37 @@ +notificationLogs()->create([ + 'type' => $attributes['type'] ?? 'unknown', + 'channel' => $attributes['channel'] ?? 'mail', + 'recipient' => $attributes['recipient'] ?? null, + 'status' => $attributes['status'] ?? 'sent', + 'context' => $context, + 'sent_at' => $attributes['sent_at'] ?? now(), + 'failed_at' => $attributes['failed_at'] ?? null, + 'failure_reason' => $attributes['failure_reason'] ?? null, + ]); + + Log::info('tenant_notification_log', [ + 'tenant_id' => $tenant->id, + 'log_id' => $log->id, + 'type' => $log->type, + 'status' => $log->status, + 'recipient' => $log->recipient, + ]); + + return $log; + } +} diff --git a/database/migrations/2025_11_01_231615_create_tenant_notification_logs_table.php b/database/migrations/2025_11_01_231615_create_tenant_notification_logs_table.php new file mode 100644 index 0000000..993fd5e --- /dev/null +++ b/database/migrations/2025_11_01_231615_create_tenant_notification_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('channel', 20)->default('mail'); + $table->string('recipient')->nullable(); + $table->string('status', 20)->default('sent'); + $table->json('context')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->string('failure_reason')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'type']); + $table->index(['tenant_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_notification_logs'); + } +}; diff --git a/docs/prp/03-api.md b/docs/prp/03-api.md index 438c26b..976aea9 100644 --- a/docs/prp/03-api.md +++ b/docs/prp/03-api.md @@ -17,6 +17,7 @@ Key Endpoints (abridged) - Emotions & Tasks: list, tenant overrides; task library scoping. - Purchases & Ledger: create purchase intent, webhook ingest, ledger list. - Settings: read/update tenant theme, limits, legal page links. +- Notifications: `GET /api/v1/tenant/notifications/logs` (filterable by `type` / `status`) exposes recent package/limit alerts with timestamps and retry context. Guest Polling (no WebSockets in v1) - GET `/events/{token}/stats` — lightweight counters for Home info bar. diff --git a/docs/todo/package-limit-experience-overhaul.md b/docs/todo/package-limit-experience-overhaul.md index 227b667..e1e16f6 100644 --- a/docs/todo/package-limit-experience-overhaul.md +++ b/docs/todo/package-limit-experience-overhaul.md @@ -34,7 +34,7 @@ - [x] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände). - [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen. - [x] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action. -- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren. +- [x] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren. ### 4. Tenant Admin PWA Improvements - [x] Dashboard-Karten & Event-Header mit Ampelsystem für Limitfortschritt. @@ -44,10 +44,10 @@ - [x] E-Mail-Schablonen & Notifications für Foto- und Gäste-Schwellen/Limits. - [x] Galerie-Warnungen (D-7/D-1) & Ablauf-Mails + Cron Task. -- [ ] Weitere Benachrichtigungen (Paket-Ablauf, Reseller-Eventlimit, Credits fast leer). +- [x] Weitere Benachrichtigungen (Paket-Ablauf, Reseller-Eventlimit, Credits fast leer). - [x] Opt-in/Opt-out-Konfiguration pro Tenant implementieren. -- [ ] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern). -- [ ] Audit-Log & Retry-Logik für gesendete Mails. +- [x] In-App/Toast-Benachrichtigungen für Admin UI (und optional Push/Slack intern). +- [x] Audit-Log & Retry-Logik für gesendete Mails. ### 6. Monitoring, Docs & Support - [x] Prometheus/Grafana-Metriken für Paketnutzung & Warns triggern. *(`PackageLimitMetrics` + `php artisan metrics:package-limits` Snapshot)* diff --git a/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm b/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm new file mode 100644 index 0000000000000000000000000000000000000000..95cc501f49a98e07c918cf471f8b5d09c2f05eff GIT binary patch literal 27311 zcmeI*eQXcBF8k2|s(J)Pu84oiLe|T8~-n6#2J`97ZwNNlLLTW9|~e|qLgJ2Z2-^Us_1;LJbl$V~pocfPvk z8#Bl3@XS#=Jh{`_A2R%}$9^!r_1SN~ymjID&R-mP&wW38bmxoq8F_TeuCoU^kN&iE z;pxt?jk|umf9H$&&`0FqZ95k_2lJ1u|GU&Nz8I9d5&!Z>Rk{6@bBD)f5?igOANb0l zM?d+|J3GH(u`k^7j)z;GauTm@494IO{tsubjcxW7J2dwj=eN(DoOtWw51&0{8z1=P zsl$&S+~?TFlSdzVwk)eie- z;L6Af{;zk70a{@Cocd$(_y-Ld7^I|eZ~_s6}p<9k=y?RQJ7@rlj^_I4)lF@FM6 zU;o;kL)qMZ_ss0Pvt{>=dv@KoecP5}Ct|TrcfRks`_8-XipiJp{(E0~e8)?RwlP2R zjBOZJeY^2o==Q0Biy`Clk)ubQlSiCz-Wa}R@ba*|@AQFJpWPgPqW|4L12^;xeAqRX z{{216nE$D5OdAgl80*H<#-uU%&3}%*_sa0_x~l_*arL#%SHoI*W6GF+-Zo6*Tj5o# z=Xg$zC$E)Rb-Wt<{*oMz_8j-tCj9zta6J0gp5vFI*8CaU@Qibv{?W?vaijG8H#%R7 ziKVv=e%I=h2u(@}YngPX@UL_J5JCw#{^g$IZ{KgspSD98IiBb_e)Ui`00jYe^nj(O zCIe6v@S$GtS}p)h0O1IM7=aW4i$Iw`oj@DFz!ZTbfh>U{ff|7pfWat%IDs?)hd_lu z1Hh0$V48qQAV;7?z$M@T7@j1MAdn$YAW$XH1h6JTAVwfXz#>p4P$$p^5Sb#7B#$9sx|Nr}E;D0y+rm=MC;3tgv7i=Tv2Vk3)-`70>*pg&7M#+DBueg!Yd6akAsnLwRD8-Qe3$~4y} zsX~@OkwA?=3xH%;$}Bd-sY057L!d&S0YEY=Wfo)8RKX;WBTypX67T>>hNVn1nxG09 z0tEt90!;vtVJWj1k5Ppb0gFJHK%GDvfMi(8G&d%xLY6?0K#f2PfMi(8EGFVqAx*&1 zK#*ZAEao?-moqG7G?HOyz|@-gEpjTDDcDA7)kR=~zq^)#-5Ty+1a?EOdFmH`*BS6H z0(z2Wpg^EXpb0<%EM*oFU}+#lEi3|M0(Amy z01{v+vycEw16gWOBv2y|Y%t0gB*4-@99j&f2?QIA)}lfc8UQ4~Qd&rWr2&&#ssBfggc+t0J(0W7i_EiT=+)_J8#Ark(*m0=s;hAAxm}t4;*A0V6QE0&B(fMqnBL z&98xAgV8{+!DyffEhNBFDoB8(fnbBtfJI{{69_gKtwpfGC_n-%4J2U<1HlHPRR}g1 z4b-Sb3xEVzN(;FHO9N?Y;Si`0XaJA^OBtjDSQ;>?MUFs;fJ?vwAOVkk3^kf9a@ z0#yP{01{v+gOmVE11V}@5hxR=6KDgF085#L1Xvo#Qi~#i8i5u739ytwN`R$-G_`OD zR0uQxNPwjbQUWXunA9RiphUnW-~o^TOPPfPSQ^MsivocvfhGV6u#`bcfTe*HwXg`3 z3DgO+0Z4$Q%t8Vz4P>cBkwA?=3xEVz${;1c(mJ1lj;3z*1%*0hR``)S^hB zMxX^i0xV^a5@2Z{O)VS^bOWqywdDXyX(j=d228Ela)6~(C{YEMfCoS>gt_h*IuTeX zu_^)!cdr5)>t6+SQv;xPT@v+w_|T8Qt~?w>V42`fU>t!J{5QYygTSF+gV8_}D)euI zB|md2S70e;QUWXu1RIP7f(=Fk!3Lv%{%x@2#Tp7WCgns8<=|~U6l_ddg zKmsfcBw&!k83MFE4Ac5BOzT7cHdykOlK@M3H6vMgu_ClSL}-18(E8B74VJuEBeXt@ z(E2b!>%$1G4%%y$5B=L<$%{2X>%)Y) zKKKFF{ihc5d$P*`mhv%{080b(IhP+H)GE;Dyn8q8>))%3KIal(DP!o~P5ZhnLK$_% z^dqpsst7F9jlf3w5m-M0yQycukHFq82N77+kHFS&1lIK5+bgc;DliGKG!UZ-!3LvM z2sRiE1RIP7f(=Fk!3LuMxdKZANtmVtSXzZ5Rj3hY0gwPo8G~GbrGYfHa0pZgGyupI zSjr&f3M>tn)FMZqM8GBB0gwPonT1?|rGX5!C=jR;XabM`OBtkGfu(^IwXg`33DgO+ z0mv0t$}A+n(m_|ou{__2e9^?IsgCw literal 0 HcmV?d00001 diff --git a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png new file mode 100644 index 0000000000000000000000000000000000000000..6d360f6bba60307ddce12a4bda5ae0e2ff9278b8 GIT binary patch literal 4253 zcmeAS@N?(olHy`uVBq!ia0y~yUeX7 q@D_FkhX4QX9*X@7G?5KtA~VB;)qHl1Z#nXSA`G6celF{r5}E*b2*WS{ literal 0 HcmV?d00001 diff --git a/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm b/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm new file mode 100644 index 0000000000000000000000000000000000000000..a0347bde4087f698e07748a361559d49e939e6ff GIT binary patch literal 18727 zcmeI)e~8<69mnxclI!)lc5T|L^Oyq<9rhffWQvlhe`ML6%fz;^*Ul({v8}tvv_{U= z=tREXwB3E@21X7f`{9}rl*qUi`Owniv(KG-+xmNU=&N$QwSh2?I=ApF8v52k$!Q|4Jl2 zdCyxOPCw?Pe)WxThG6(#zOXvm%&T@}{^!nR`{3>OjV&I1#y0Qz@iS*1K7Gou&7;pv6NMB+pK{jTJ}yWf|Pn|bHGFFt(q#bw*n)8Dm?u{uZ0_ak>q#;!!n zePid(J5Qc-qGdCB%gD7+`_zSpe*5%+jY z=FNne`0`(Oz5UJ6(Vf?0#$12RzjdvZ*C$Q=S=(gISE8HP(0NJDGpl7bowvgOUz79r z(7CrG71Vdb^Y|Zz&acL;`bFD#=91q(-dsO7wQs%d-?qk%P$d`kP_B9SzF=n zmx2)@DLMbl(D^?;X6hI0NI}k1L+97e6hlxEaOV(Md2AvCEdjR=gV#zS=mCh15l9fo z5wHj}2y_Sx0K_H$P;h~GzoM8 z+++w$5y%oK5vUPx33vc@P7p{DC=jR+Xc6cE*fmBVK_ExKBG4evAus@N^CW=`fg*t_ zfi{6YfZcHdNdkES4uK|tE`U9Tz!ZTjff9il0hfRWAU;7LMW8^SLZC&U2Vn0QfdqjZ z0gFI`K!?Bpz`jWW83IKDRRV1SeE{Qe0!adS0*(g4?CPh>zkYG+ zCkYxzFZv;Z70<54U)#2?#$WFU<1g#kUgyrmKR+^mYI)DbJ^LS@-7L2Lz4Z$G53fMh ztXw_)0aO2>ZAw7^wtxM7JrsbgNOogt!P|SoZ6sW4z$M@TkOWIfJLA|phI8)Kr$?4 z&9MwsC=#d=XcOoIkPJ)NVn>oHRfMi(87JCy^AxFR> z&>+wuFaRJKma^u)3{@x+s1j%s=mU@pOW9&PNfq)091VmS*3xqMKz=>LQl^m%O9NT0 zS<4PETxj_Ur&Jmawytg;p zPy{9cmIhK(p+KNQphchuKmsgf3kk3^kfRnBfd+vNfdK#su#_z%z|ugGT2u+N34|9I zWd;ebG?0WABY6Vh1x9PpqzYXC5@0DUB*4-@mRghu)Cjl)JOC14DO*T@rGWyqs1Rro z=mC%bOBqrEEDhwSg+-u2phI8)Kmsgf3kk3^P^1=B0&N0)01{v+LrQ?9fjqTv2s8*Jd;)934M$*w;LWdr@B*WO@B*WO9<-1E zOQ|3MmIlHLj0P;4p+O+Lz-TSP3ycCJz|uelW{8Cs7_CBhfzd#lTJ!-(fTgsMPhe>v zPc0k*O#)p25@0DqN`R$-EVU>Rs1a}pcmO28Qnru)O9KUJQ6bPG&;uX=mNKLSSQ^Mt z3yVO5K!?BpfCN~|77}1-phzvM1lk1p03^UthLiwH19@uU5NHzU0+0Yp8Bzi)4P>cB zi9n5jOTYsl0hY3b1Xvm

TwI7J(iB39ytQCBV`^j#^j*8U#871^^_$Qnru)O9Mq} zQ6UXxBVY(bhR95cy?^5a=E;J?V0_j dJo}@;GgE8N9GLQKCwS%~YtP*MN6-GnzW{+#QI-Gz literal 0 HcmV?d00001 diff --git a/playwright-report/index.html b/playwright-report/index.html index c38bb5d..8b32ed0 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`

- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 23abeac..921d15a 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -16,6 +16,7 @@ import { Package as PackageIcon, Loader2, } from 'lucide-react'; +import toast from 'react-hot-toast'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -205,6 +206,23 @@ export default function DashboardPage() { [primaryEventLimits, limitTranslate], ); + const shownToastsRef = React.useRef>(new Set()); + + React.useEffect(() => { + limitWarnings.forEach((warning) => { + const toastKey = `${warning.id}-${warning.message}`; + if (shownToastsRef.current.has(toastKey)) { + return; + } + + shownToastsRef.current.add(toastKey); + toast(warning.message, { + icon: warning.tone === 'danger' ? '🚨' : '⚠️', + id: toastKey, + }); + }); + }, [limitWarnings]); + const limitScopeLabels = React.useMemo( () => ({ photos: tc('limits.photosTitle'), diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 647bdec..9ec62c7 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -18,6 +18,7 @@ import { Sparkles, Users, } from 'lucide-react'; +import toast from 'react-hot-toast'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -216,6 +217,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp [event?.limits, tCommon], ); + const shownWarningToasts = React.useRef>(new Set()); + + React.useEffect(() => { + limitWarnings.forEach((warning) => { + const id = `${warning.id}-${warning.message}`; + if (shownWarningToasts.current.has(id)) { + return; + } + + shownWarningToasts.current.add(id); + toast(warning.message, { + icon: warning.tone === 'danger' ? '🚨' : '⚠️', + id, + }); + }); + }, [limitWarnings]); + return ( {error && ( diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index d748dfa..48f5a40 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; +import toast from 'react-hot-toast'; import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -203,6 +204,23 @@ export default function EventFormPage() { return buildLimitWarnings(loadedEvent?.limits, tLimits); }, [isEdit, loadedEvent?.limits, tLimits]); + const shownToastRef = React.useRef>(new Set()); + + React.useEffect(() => { + limitWarnings.forEach((warning) => { + const key = `${warning.id}-${warning.message}`; + if (shownToastRef.current.has(key)) { + return; + } + + shownToastRef.current.add(key); + toast(warning.message, { + icon: warning.tone === 'danger' ? '🚨' : '⚠️', + id: key, + }); + }); + }, [limitWarnings]); + const limitScopeLabels = React.useMemo(() => ({ photos: tLimits('photosTitle'), guests: tLimits('guestsTitle'), diff --git a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx index fbcb5a7..a3e51ae 100644 --- a/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx +++ b/resources/js/admin/pages/components/InviteLayoutCustomizerPanel.tsx @@ -622,6 +622,8 @@ export function InviteLayoutCustomizerPanel({ if (!invite || !activeLayout) { setForm({}); setInstructions([]); + commitElements(() => [], { silent: true }); + resetHistory([]); return; } @@ -635,7 +637,7 @@ export function InviteLayoutCustomizerPanel({ setInstructions(baseInstructions); - setForm({ + const newForm: QrLayoutCustomization = { layout_id: activeLayout.id, headline: reuseCustomization ? initialCustomization?.headline ?? eventName : eventName, subtitle: reuseCustomization ? initialCustomization?.subtitle ?? activeLayout.subtitle ?? '' : activeLayout.subtitle ?? '', @@ -652,53 +654,35 @@ export function InviteLayoutCustomizerPanel({ badge_color: reuseCustomization ? initialCustomization?.badge_color ?? '#2563EB' : '#2563EB', background_gradient: reuseCustomization ? initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null, logo_data_url: reuseCustomization ? initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null : null, - }); + mode: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.mode : 'standard', + elements: initialCustomization?.layout_id === activeLayout.id ? initialCustomization?.elements : undefined, + }; + setForm(newForm); setError(null); - }, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]); - React.useEffect(() => { - if (!activeLayout) { - const cleared: LayoutElement[] = []; - commitElements(() => cleared, { silent: true }); - resetHistory(cleared); - return; - } + const isCustomizedAdvanced = newForm.mode === 'advanced' && Array.isArray(newForm.elements) && newForm.elements.length > 0; - const layoutKey = activeLayout.id ?? '__default'; - const inviteKey = invite?.id ?? null; - if (prevInviteRef.current !== inviteKey) { - initializedLayoutsRef.current = {}; - prevInviteRef.current = inviteKey; + if (isCustomizedAdvanced) { + const initialElements = normalizeElements(payloadToElements(newForm.elements)); + commitElements(() => initialElements, { silent: true }); + resetHistory(initialElements); + } else { + const defaults = buildDefaultElements(activeLayout, newForm, eventName, activeLayoutQrSize); + commitElements(() => defaults, { silent: true }); + resetHistory(defaults); } - - if (!initializedLayoutsRef.current[layoutKey]) { - if (initialCustomization?.mode === 'advanced' && Array.isArray(initialCustomization.elements) && initialCustomization.elements.length) { - const initialElements = normalizeElements(payloadToElements(initialCustomization.elements)); - commitElements(() => initialElements, { silent: true }); - resetHistory(initialElements); - } else { - const defaults = buildDefaultElements(activeLayout, formStateRef.current, eventName, activeLayoutQrSize); - commitElements(() => defaults, { silent: true }); - resetHistory(defaults); - } - initializedLayoutsRef.current[layoutKey] = true; - return; - } - - if (historyIndexRef.current === -1 && elements.length > 0) { - resetHistory(cloneElements(elements)); - } - }, [ + setActiveElementId(null); + }, [ activeLayout, invite?.id, + initialCustomization, + defaultInstructions, + eventName, + inviteUrl, + t, activeLayoutQrSize, - initialCustomization?.mode, - initialCustomization?.elements, commitElements, resetHistory, - elements, - cloneElements, - eventName, ]); React.useEffect(() => { @@ -756,6 +740,17 @@ export function InviteLayoutCustomizerPanel({ hasCustomization: Boolean(initialCustomization?.elements?.length), }); + // Erweiterter Log für Duplikate-Check + const idCounts = base.reduce((acc, e) => { + acc[e.id] = (acc[e.id] || 0) + 1; + return acc; + }, {} as Record); + const duplicates = Object.entries(idCounts).filter(([_, count]) => count > 1); + if (duplicates.length > 0) { + console.warn('[Invites][CanvasElements] Duplicates detected in base', { duplicates, baseIds: base.map(e => ({ id: e.id, type: e.type, y: e.y })) }); + } + console.debug('[Invites][CanvasElements] Base IDs overview', base.map(e => ({ id: e.id, type: e.type, y: e.y }))); + const boundContent: Record = { headline: form.headline ?? eventName, subtitle: form.subtitle ?? '', @@ -783,7 +778,7 @@ export function InviteLayoutCustomizerPanel({ }; }); }, [ - activeLayout, + activeLayout?.id, elements, form.headline, form.subtitle, @@ -794,7 +789,6 @@ export function InviteLayoutCustomizerPanel({ eventName, inviteUrl, t, - activeLayout, activeLayoutQrSize, ]); @@ -1254,21 +1248,6 @@ export function InviteLayoutCustomizerPanel({ function handleLayoutSelect(layout: EventQrInviteLayout) { setSelectedLayoutId(layout.id); - updateForm('layout_id', layout.id); - setForm((prev) => ({ - ...prev, - accent_color: sanitizeColor(layout.preview?.accent ?? prev.accent_color ?? null) ?? '#6366F1', - text_color: sanitizeColor(layout.preview?.text ?? prev.text_color ?? null) ?? '#111827', - background_color: sanitizeColor(layout.preview?.background ?? prev.background_color ?? null) ?? '#FFFFFF', - secondary_color: '#1F2937', - badge_color: '#2563EB', - background_gradient: layout.preview?.background_gradient ?? null, - })); - setInstructions((layout.instructions ?? []).length ? [...(layout.instructions as string[])] : [...defaultInstructions]); - const defaults = buildDefaultElements(layout, formStateRef.current, eventName, layout.preview?.qr_size_px ?? activeLayoutQrSize); - commitElements(() => defaults, { silent: true }); - resetHistory(defaults); - setActiveElementId(null); } function handleInstructionChange(index: number, value: string) { diff --git a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx index 64193b2..66d7eb4 100644 --- a/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx +++ b/resources/js/admin/pages/components/invite-layout/DesignerCanvas.tsx @@ -450,7 +450,7 @@ export async function renderFabricLayout( } = options; canvas.discardActiveObject(); - canvas.getObjects().forEach((object) => canvas.remove(object)); + canvas.clear(); applyBackground(canvas, backgroundColor, backgroundGradient); @@ -461,6 +461,7 @@ export async function renderFabricLayout( readOnly, }); + const abortController = new AbortController(); const objectPromises = elements.map((element) => createFabricObject({ element, @@ -471,10 +472,11 @@ export async function renderFabricLayout( qrCodeDataUrl, logoDataUrl, readOnly, - }), + }, abortController.signal), ); const fabricObjects = await Promise.all(objectPromises); + abortController.abort(); // Abort any pending loads console.debug('[Invites][Fabric] resolved objects', { count: fabricObjects.length, nulls: fabricObjects.filter((obj) => !obj).length, @@ -765,8 +767,9 @@ export async function loadImageObject( element: LayoutElement, baseConfig: FabricObjectWithId, options?: { objectFit?: 'contain' | 'cover'; shadow?: string; padding?: number }, + abortSignal?: AbortSignal, ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let resolved = false; const resolveSafely = (value: fabric.Object | null) => { if (resolved) { @@ -779,7 +782,7 @@ export async function loadImageObject( const isDataUrl = source.startsWith('data:'); const onImageLoaded = (img?: HTMLImageElement | HTMLCanvasElement | null) => { - if (!img) { + if (!img || resolved) { console.warn('[Invites][Fabric] image load returned empty', { source }); resolveSafely(null); return; @@ -819,14 +822,26 @@ export async function loadImageObject( }; const onError = (error?: unknown) => { + if (resolved) return; console.warn('[Invites][Fabric] failed to load image', source, error); resolveSafely(null); }; + const abortHandler = () => { + if (resolved) return; + console.debug('[Invites][Fabric] Image load aborted', { source }); + resolveSafely(null); + }; + + if (abortSignal) { + abortSignal.addEventListener('abort', abortHandler); + } + try { if (isDataUrl) { const imageElement = new Image(); imageElement.onload = () => { + if (resolved) return; console.debug('[Invites][Fabric] image loaded (data-url)', { source: source.slice(0, 48), width: imageElement.naturalWidth, @@ -840,6 +855,7 @@ export async function loadImageObject( // Use direct Image constructor approach for better compatibility const img = new Image(); img.onload = () => { + if (resolved) return; console.debug('[Invites][Fabric] image loaded', { source: source.slice(0, 48), width: img.width, @@ -854,7 +870,17 @@ export async function loadImageObject( onError(error); } - window.setTimeout(() => resolveSafely(null), 3000); + const timeoutId = window.setTimeout(() => { + if (resolved) return; + resolveSafely(null); + }, 3000); + + return () => { + clearTimeout(timeoutId); + if (abortSignal) { + abortSignal.removeEventListener('abort', abortHandler); + } + }; }); } diff --git a/routes/api.php b/routes/api.php index 01657ec..981f4dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController; use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\EventTypeController; +use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\TaskCollectionController; @@ -145,6 +146,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('tenant.settings.notifications.update'); }); + Route::get('notifications/logs', [NotificationLogController::class, 'index']) + ->name('tenant.notifications.logs.index'); + Route::prefix('credits')->group(function () { Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance'); Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); diff --git a/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php b/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php new file mode 100644 index 0000000..f6b82a6 --- /dev/null +++ b/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php @@ -0,0 +1,40 @@ +create([ + 'contact_email' => 'admin@example.com', + ]); + + $job = new SendTenantCreditsLowNotification($tenant->id, balance: 5, threshold: 10); + $job->handle(); + + Notification::assertSentOnDemand(TenantCreditsLowNotification::class, function ($notification, $channels, $notifiable) { + return in_array('mail', $channels, true) && ($notifiable->routes['mail'] ?? null) === 'admin@example.com'; + }); + + $tenant->refresh(); + + $this->assertCount(1, $tenant->notificationLogs); + $log = $tenant->notificationLogs()->first(); + $this->assertSame('credits_low', $log->type); + $this->assertSame('sent', $log->status); + $this->assertSame('admin@example.com', $log->recipient); + $this->assertNotNull($log->sent_at); + } +} diff --git a/tests/e2e/guest-limit-experience.test.ts b/tests/e2e/guest-limit-experience.test.ts new file mode 100644 index 0000000..b00d9bb --- /dev/null +++ b/tests/e2e/guest-limit-experience.test.ts @@ -0,0 +1,209 @@ +import { test, expect } from '@playwright/test'; + +const EVENT_TOKEN = 'limit-event'; + +function nowIso(): string { + return new Date().toISOString(); +} + +test.describe('Guest PWA limit experiences', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ token }) => { + try { + window.localStorage.setItem(`guestName_${token}`, 'Playwright Gast'); + window.localStorage.setItem(`guestCameraPrimerDismissed_${token}`, '1'); + } catch (error) { + console.warn('Failed to seed guest storage', error); + } + + if (!navigator.mediaDevices) { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: {}, + }); + } + + navigator.mediaDevices.getUserMedia = () => Promise.resolve(new MediaStream()); + }, + { token: EVENT_TOKEN } + ); + + const timestamp = nowIso(); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 1, + slug: EVENT_TOKEN, + name: 'Limit Experience Event', + default_locale: 'de', + created_at: timestamp, + updated_at: timestamp, + }), + }); + }); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}/tasks`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 1, + title: 'Playwright Mission', + description: 'Test mission for upload limits', + instructions: 'Mach ein Testfoto', + duration: 2, + }, + ]), + }); + }); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}/stats`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + online_guests: 5, + tasks_solved: 12, + latest_photo_at: timestamp, + }), + }); + }); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}/photos**`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + id: 101, + file_path: '/photos/101.jpg', + thumbnail_path: '/photos/101-thumb.jpg', + created_at: timestamp, + likes_count: 3, + }, + ], + latest_photo_at: timestamp, + }), + }); + }); + }); + + test('shows limit warnings and countdown before limits are reached', async ({ page }) => { + const expiresAt = new Date(Date.now() + 2 * 86_400_000).toISOString(); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}/package`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 42, + event_id: 1, + used_photos: 95, + expires_at: expiresAt, + package: { + id: 77, + name: 'Starter', + max_photos: 100, + max_guests: 150, + gallery_days: 30, + }, + limits: { + photos: { + limit: 100, + used: 95, + remaining: 5, + percentage: 95, + state: 'warning', + threshold_reached: 95, + next_threshold: 100, + thresholds: [80, 95, 100], + }, + guests: null, + gallery: { + state: 'warning', + expires_at: expiresAt, + days_remaining: 2, + warning_thresholds: [7, 1], + warning_triggered: 2, + warning_sent_at: null, + expired_notified_at: null, + }, + can_upload_photos: true, + can_add_guests: true, + }, + }), + }); + }); + + await page.goto(`/e/${EVENT_TOKEN}/upload?task=1`); + await expect(page.getByText(/Nur noch 5 von 100 Fotos möglich/i)).toBeVisible(); + await expect(page.getByText(/Galerie läuft in 2 Tagen ab/i)).toBeVisible(); + + await page.goto(`/e/${EVENT_TOKEN}/gallery`); + await expect(page.getByText(/Noch 2 Tage online/i)).toBeVisible(); + await expect(page.getByText(/Nur noch 5 von 100 Fotos möglich/i)).toBeVisible(); + await expect(page.getByText(/Galerie läuft in 2 Tagen ab/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /Letzte Fotos hochladen/i })).toBeVisible(); + }); + + test('marks uploads as blocked and highlights expired gallery state', async ({ page }) => { + const expiredAt = new Date(Date.now() - 86_400_000).toISOString(); + + await page.route(`**/api/v1/events/${EVENT_TOKEN}/package`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 43, + event_id: 1, + used_photos: 100, + expires_at: expiredAt, + package: { + id: 77, + name: 'Starter', + max_photos: 100, + max_guests: 150, + gallery_days: 30, + }, + limits: { + photos: { + limit: 100, + used: 100, + remaining: 0, + percentage: 100, + state: 'limit_reached', + threshold_reached: 100, + next_threshold: null, + thresholds: [80, 95, 100], + }, + guests: null, + gallery: { + state: 'expired', + expires_at: expiredAt, + days_remaining: 0, + warning_thresholds: [7, 1], + warning_triggered: 0, + warning_sent_at: null, + expired_notified_at: expiredAt, + }, + can_upload_photos: false, + can_add_guests: true, + }, + }), + }); + }); + + await page.goto(`/e/${EVENT_TOKEN}/upload?task=1`); + await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible(); + + await page.goto(`/e/${EVENT_TOKEN}/gallery`); + await expect(page.getByText(/Galerie abgelaufen/i)).toBeVisible(); + await expect(page.getByText(/Die Galerie ist abgelaufen\. Uploads sind nicht mehr möglich\./i)).toBeVisible(); + }); +});