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+`