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 0000000..95cc501 Binary files /dev/null and b/playwright-report/data/2b972cf6fb2da3f16cadd7202702687cdeaa7901.webm differ diff --git a/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png new file mode 100644 index 0000000..6d360f6 Binary files /dev/null and b/playwright-report/data/7a33d5db6370b6de345e990751aa1f1da65ad675.png differ diff --git a/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm b/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm new file mode 100644 index 0000000..a0347bd Binary files /dev/null and b/playwright-report/data/b0493c4cccd959a8ecdb174bd08799b82fa8c840.webm differ 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(); + }); +});