From 574aa47ce747aa8780782c9ce597a4d0f3df40ff Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 12 Nov 2025 20:38:49 +0100 Subject: [PATCH] Add guest push notifications and queue alerts --- .env.example | 15 + .../Commands/CheckUploadQueuesCommand.php | 135 +++++++ .../Controllers/Api/EventPublicController.php | 80 ++++- app/Jobs/SendGuestPushNotificationBatch.php | 100 ++++++ .../DispatchGuestNotificationPush.php | 41 +++ .../SendPhotoUploadedNotification.php | 6 +- app/Models/PushSubscription.php | 51 +++ app/Providers/AppServiceProvider.php | 7 + app/Services/Push/WebPushDispatcher.php | 82 +++++ app/Services/PushSubscriptionService.php | 153 ++++++++ composer.json | 1 + composer.lock | 330 +++++++++++++++++- config/notifications.php | 7 + config/push.php | 13 + config/storage-monitor.php | 5 + .../factories/PushSubscriptionFactory.php | 41 +++ ...201445_create_push_subscriptions_table.php | 49 +++ docs/deployment/docker.md | 10 + docs/ops/guest-notification-ops.md | 68 ++++ docs/prp/07-guest-pwa.md | 4 + docs/queue-supervisor/README.md | 2 + public/guest-sw.js | 103 +++--- resources/js/guest/components/Header.tsx | 75 +++- .../context/NotificationCenterContext.tsx | 14 + .../js/guest/hooks/usePushSubscription.ts | 168 +++++++++ resources/js/guest/lib/runtime-config.ts | 24 ++ resources/js/guest/services/pushApi.ts | 71 ++++ resources/js/guest/types/global.d.ts | 13 + resources/views/guest.blade.php | 8 + routes/api.php | 4 + .../Api/Event/PushSubscriptionApiTest.php | 66 ++++ .../Console/CheckUploadQueuesCommandTest.php | 29 +- .../SendGuestPushNotificationBatchTest.php | 100 ++++++ tsconfig.json | 5 +- 34 files changed, 1806 insertions(+), 74 deletions(-) create mode 100644 app/Jobs/SendGuestPushNotificationBatch.php create mode 100644 app/Listeners/DispatchGuestNotificationPush.php create mode 100644 app/Models/PushSubscription.php create mode 100644 app/Services/Push/WebPushDispatcher.php create mode 100644 app/Services/PushSubscriptionService.php create mode 100644 config/notifications.php create mode 100644 config/push.php create mode 100644 database/factories/PushSubscriptionFactory.php create mode 100644 database/migrations/2025_11_12_201445_create_push_subscriptions_table.php create mode 100644 docs/ops/guest-notification-ops.md create mode 100644 resources/js/guest/hooks/usePushSubscription.ts create mode 100644 resources/js/guest/lib/runtime-config.ts create mode 100644 resources/js/guest/services/pushApi.ts create mode 100644 resources/js/guest/types/global.d.ts create mode 100644 tests/Feature/Api/Event/PushSubscriptionApiTest.php create mode 100644 tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php diff --git a/.env.example b/.env.example index b3ab393..cc46678 100644 --- a/.env.example +++ b/.env.example @@ -138,4 +138,19 @@ COOLIFY_WEB_URL= COOLIFY_API_TIMEOUT=5 COOLIFY_SERVICE_IDS={"app":"svc_app","queue":"svc_queue","scheduler":"svc_scheduler","ftp":"svc_ftp","control":"svc_control"} +GUEST_ACHIEVEMENT_MILESTONES=10,25,50 + +# Push notifications +PUSH_ENABLED=false +PUSH_VAPID_PUBLIC_KEY= +PUSH_VAPID_PRIVATE_KEY= +PUSH_VAPID_SUBJECT="mailto:hello@example.com" + +# Storage queue guest alert tuning +STORAGE_QUEUE_PENDING_EVENT_THRESHOLD=5 +STORAGE_QUEUE_PENDING_EVENT_MINUTES=8 +STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2 +STORAGE_QUEUE_FAILED_EVENT_MINUTES=30 +STORAGE_QUEUE_GUEST_ALERT_TTL=30 + diff --git a/app/Console/Commands/CheckUploadQueuesCommand.php b/app/Console/Commands/CheckUploadQueuesCommand.php index 8a80231..e21ca5a 100644 --- a/app/Console/Commands/CheckUploadQueuesCommand.php +++ b/app/Console/Commands/CheckUploadQueuesCommand.php @@ -3,7 +3,11 @@ namespace App\Console\Commands; use App\Console\Concerns\InteractsWithCacheLocks; +use App\Enums\GuestNotificationAudience; +use App\Enums\GuestNotificationType; +use App\Models\Event; use App\Models\EventMediaAsset; +use App\Services\GuestNotificationService; use Illuminate\Console\Command; use Illuminate\Contracts\Cache\Lock; use Illuminate\Queue\QueueManager; @@ -20,6 +24,11 @@ class CheckUploadQueuesCommand extends Command protected $description = 'Inspect upload-related queues and flag stalled or overloaded workers.'; + public function __construct(private readonly GuestNotificationService $guestNotifications) + { + parent::__construct(); + } + public function handle(QueueManager $queueManager): int { $lockSeconds = (int) config('storage-monitor.queue_health.lock_seconds', 120); @@ -117,6 +126,8 @@ class CheckUploadQueuesCommand extends Command count($alerts) )); + $this->maybeNotifyGuests($alerts); + return self::SUCCESS; } finally { if ($lock instanceof Lock) { @@ -125,6 +136,130 @@ class CheckUploadQueuesCommand extends Command } } + private function maybeNotifyGuests(array $alerts): void + { + if (empty($alerts)) { + return; + } + + $this->dispatchPendingAlerts(); + $this->dispatchFailureAlerts(); + } + + private function dispatchPendingAlerts(): void + { + $threshold = max(1, (int) config('storage-monitor.queue_health.pending_event_threshold', 5)); + $minutes = max(1, (int) config('storage-monitor.queue_health.pending_event_minutes', 8)); + + $pending = EventMediaAsset::query() + ->selectRaw('event_id, COUNT(*) as pending_count, MIN(created_at) as oldest_created_at') + ->where('status', 'pending') + ->where('created_at', '<=', now()->subMinutes($minutes)) + ->groupBy('event_id') + ->havingRaw('COUNT(*) >= ?', [$threshold]) + ->limit(50) + ->get(); + + foreach ($pending as $row) { + $event = Event::query()->find($row->event_id); + + if (! $event) { + continue; + } + + $title = 'Uploads werden noch verarbeitet …'; + if ($this->recentlySentAlert($event->id, $title)) { + continue; + } + + $count = (int) $row->pending_count; + $body = $count > 1 + ? sprintf('%d Fotos stehen noch in der Warteschlange. Wir sagen Bescheid, sobald alles gespeichert ist.', $count) + : 'Ein Upload-Schub wird gerade verarbeitet. Danke für deine Geduld!'; + + $this->guestNotifications->createNotification( + $event, + GuestNotificationType::UPLOAD_ALERT, + $title, + $body, + [ + 'audience_scope' => GuestNotificationAudience::ALL, + 'priority' => 1, + 'expires_at' => now()->addMinutes(90), + ] + ); + + $this->rememberAlert($event->id, $title); + } + } + + private function dispatchFailureAlerts(): void + { + $threshold = max(1, (int) config('storage-monitor.queue_health.failed_event_threshold', 2)); + $minutes = max(1, (int) config('storage-monitor.queue_health.failed_event_minutes', 30)); + + $failed = EventMediaAsset::query() + ->selectRaw('event_id, COUNT(*) as failed_count') + ->where('status', 'failed') + ->where('updated_at', '>=', now()->subMinutes($minutes)) + ->groupBy('event_id') + ->havingRaw('COUNT(*) >= ?', [$threshold]) + ->limit(50) + ->get(); + + foreach ($failed as $row) { + $event = Event::query()->find($row->event_id); + + if (! $event) { + continue; + } + + $title = 'Einige Uploads mussten neu gestartet werden'; + if ($this->recentlySentAlert($event->id, $title)) { + continue; + } + + $count = (int) $row->failed_count; + $body = $count > 1 + ? sprintf('%d Fotos wurden automatisch erneut angestoßen. Bitte öffne kurz die App, falls deine Uploads hängen.', $count) + : 'Ein Upload wurde neu gestartet. Öffne bitte kurz die App, damit nichts verloren geht.'; + + $this->guestNotifications->createNotification( + $event, + GuestNotificationType::SUPPORT_TIP, + $title, + $body, + [ + 'audience_scope' => GuestNotificationAudience::ALL, + 'priority' => 2, + 'expires_at' => now()->addHours(2), + ] + ); + + $this->rememberAlert($event->id, $title); + } + } + + private function recentlySentAlert(int $eventId, string $title): bool + { + $key = $this->alertCacheKey($eventId, $title); + + return Cache::has($key); + } + + private function rememberAlert(int $eventId, string $title): void + { + $key = $this->alertCacheKey($eventId, $title); + $ttl = max(5, (int) config('storage-monitor.queue_health.guest_alert_ttl', 30)); + + Cache::put($key, true, now()->addMinutes($ttl)); + } + + private function alertCacheKey(int $eventId, string $title): string + { + return sprintf('guest-queue-alert:%d:%s', $eventId, sha1($title)); + } + private function readQueueSize(QueueManager $manager, ?string $connection, string $queue): int { try { diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index e1a798d..e50bc15 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -19,6 +19,7 @@ use App\Services\EventTasksCacheService; use App\Services\GuestNotificationService; use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageUsageTracker; +use App\Services\PushSubscriptionService; use App\Services\Storage\EventStorageManager; use App\Support\ApiError; use App\Support\ImageHelper; @@ -49,6 +50,7 @@ class EventPublicController extends BaseController private readonly PackageUsageTracker $packageUsageTracker, private readonly EventTasksCacheService $eventTasksCache, private readonly GuestNotificationService $guestNotificationService, + private readonly PushSubscriptionService $pushSubscriptions, ) {} /** @@ -1666,6 +1668,69 @@ class EventPublicController extends BaseController ->header('Vary', 'X-Device-Id, Accept-Language'); } + public function registerPushSubscription(Request $request, string $token) + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$eventRecord] = $result; + $validated = $request->validate([ + 'endpoint' => ['required', 'url', 'max:500'], + 'keys.p256dh' => ['required', 'string', 'max:255'], + 'keys.auth' => ['required', 'string', 'max:255'], + 'expiration_time' => ['nullable'], + 'content_encoding' => ['nullable', 'string', 'max:32'], + ]); + + $event = Event::findOrFail($eventRecord->id); + $guestIdentifier = $this->resolveNotificationIdentifier($request); + $deviceId = $this->resolveDeviceIdentifier($request); + + $payload = [ + 'endpoint' => $validated['endpoint'], + 'keys' => [ + 'p256dh' => $validated['keys']['p256dh'], + 'auth' => $validated['keys']['auth'], + ], + 'expiration_time' => $validated['expiration_time'] ?? null, + 'content_encoding' => $validated['content_encoding'] ?? null, + 'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'), + 'user_agent' => (string) $request->userAgent(), + ]; + + $subscription = $this->pushSubscriptions->register($event, $guestIdentifier, $deviceId, $payload); + + return response()->json([ + 'id' => $subscription->id, + 'status' => $subscription->status, + ], Response::HTTP_CREATED); + } + + public function destroyPushSubscription(Request $request, string $token) + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$eventRecord] = $result; + + $validated = $request->validate([ + 'endpoint' => ['required', 'url', 'max:500'], + ]); + + $event = Event::findOrFail($eventRecord->id); + $revoked = $this->pushSubscriptions->revoke($event, $validated['endpoint']); + + return response()->json([ + 'status' => $revoked ? 'revoked' : 'not_found', + ]); + } + public function markNotificationRead(Request $request, string $token, GuestNotification $notification) { return $this->handleNotificationAction($request, $token, $notification, 'read'); @@ -1831,10 +1896,16 @@ class EventPublicController extends BaseController return $identifier; } - $deviceId = (string) $request->headers->get('X-Device-Id', ''); - $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? '', 0, 120); + return $this->resolveDeviceIdentifier($request); + } - return $deviceId !== '' ? $deviceId : 'anonymous'; + private function resolveDeviceIdentifier(Request $request): string + { + $deviceId = (string) $request->headers->get('X-Device-Id', ''); + $normalized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? ''; + $normalized = trim(substr($normalized, 0, 120)); + + return $normalized !== '' ? $normalized : 'anonymous'; } public function stats(Request $request, string $token) @@ -2216,8 +2287,7 @@ class EventPublicController extends BaseController $eventPackage = $this->packageLimitEvaluator ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); - $deviceId = (string) $request->header('X-Device-Id', 'anon'); - $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon'; + $deviceId = $this->resolveDeviceIdentifier($request); // Per-device cap per event (MVP: 50) $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); diff --git a/app/Jobs/SendGuestPushNotificationBatch.php b/app/Jobs/SendGuestPushNotificationBatch.php new file mode 100644 index 0000000..0d98ff6 --- /dev/null +++ b/app/Jobs/SendGuestPushNotificationBatch.php @@ -0,0 +1,100 @@ +onQueue('notifications'); + } + + public function handle( + WebPushDispatcher $dispatcher, + PushSubscriptionService $subscriptions + ): void { + if (! config('push.enabled')) { + return; + } + + /** @var GuestNotification|null $notification */ + $notification = GuestNotification::query()->find($this->notificationId); + + if (! $notification) { + return; + } + + /** @var Collection $targets */ + $targets = PushSubscription::query() + ->whereIn('id', $this->subscriptionIds) + ->where('status', 'active') + ->get(); + + if ($targets->isEmpty()) { + return; + } + + $payload = [ + 'title' => $notification->title, + 'body' => $notification->body, + 'data' => [ + 'notification_id' => $notification->id, + 'event_id' => $notification->event_id, + 'type' => $notification->type->value, + 'cta' => $notification->payload['cta'] ?? null, + ], + ]; + + foreach ($targets as $target) { + try { + $report = $dispatcher->send($target, $payload); + + if ($report === null) { + continue; + } + + if ($report->isSuccess()) { + $subscriptions->markDelivered($target); + + continue; + } + + if ($report->isSubscriptionExpired()) { + $target->update(['status' => 'revoked']); + } + + $subscriptions->markFailed($target, $report->getReason()); + } catch (\Throwable $exception) { + Log::channel('notifications')->warning('Web push delivery failed', [ + 'subscription_id' => $target->id, + 'event_id' => $notification->event_id, + 'reason' => $exception->getMessage(), + ]); + + $subscriptions->markFailed($target, $exception->getMessage()); + } + } + } +} diff --git a/app/Listeners/DispatchGuestNotificationPush.php b/app/Listeners/DispatchGuestNotificationPush.php new file mode 100644 index 0000000..e37dc4b --- /dev/null +++ b/app/Listeners/DispatchGuestNotificationPush.php @@ -0,0 +1,41 @@ +notification; + $query = PushSubscription::query() + ->where('event_id', $notification->event_id) + ->where('status', 'active'); + + if ($notification->audience_scope === GuestNotificationAudience::GUEST && $notification->target_identifier) { + $target = $notification->target_identifier; + $query->where(function ($builder) use ($target) { + $builder->where('guest_identifier', $target) + ->orWhere('device_id', $target); + }); + } + + $subscriptionIds = $query->pluck('id')->all(); + + if ($subscriptionIds === []) { + return; + } + + foreach (array_chunk($subscriptionIds, 50) as $chunk) { + SendGuestPushNotificationBatch::dispatch($notification->id, $chunk); + } + } +} diff --git a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php index 38e1c1d..d5cb599 100644 --- a/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php +++ b/app/Listeners/GuestNotifications/SendPhotoUploadedNotification.php @@ -15,7 +15,7 @@ class SendPhotoUploadedNotification */ public function __construct( private readonly GuestNotificationService $notifications, - private readonly array $milestones = [5, 10, 20] + private readonly array $milestones = [] ) {} public function handle(GuestPhotoUploaded $event): void @@ -52,7 +52,9 @@ class SendPhotoUploadedNotification ->where('guest_name', $event->guestIdentifier) ->count(); - if (! in_array($count, $this->milestones, true)) { + $milestones = $this->milestones ?: config('notifications.guest_achievements.milestones', [10, 25, 50]); + + if (! in_array($count, $milestones, true)) { return; } diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 0000000..d6da756 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,51 @@ + 'datetime', + 'last_seen_at' => 'datetime', + 'last_notified_at' => 'datetime', + 'last_failed_at' => 'datetime', + 'meta' => 'array', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index db3c1e8..de2a414 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Events\GuestNotificationCreated; use App\Events\GuestPhotoUploaded; use App\Events\Packages\EventPackageGalleryExpired; use App\Events\Packages\EventPackageGalleryExpiring; @@ -14,6 +15,7 @@ use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventThresholdReached; use App\Events\Packages\TenantPackageExpired; use App\Events\Packages\TenantPackageExpiring; +use App\Listeners\DispatchGuestNotificationPush; use App\Listeners\GuestNotifications\SendPhotoUploadedNotification; use App\Listeners\Packages\QueueGalleryExpiredNotification; use App\Listeners\Packages\QueueGalleryWarningNotification; @@ -125,6 +127,11 @@ class AppServiceProvider extends ServiceProvider [SendPhotoUploadedNotification::class, 'handle'] ); + EventFacade::listen( + GuestNotificationCreated::class, + [DispatchGuestNotificationPush::class, 'handle'] + ); + RateLimiter::for('tenant-api', function (Request $request) { $tenantId = $request->attributes->get('tenant_id') ?? $request->user()?->tenant_id diff --git a/app/Services/Push/WebPushDispatcher.php b/app/Services/Push/WebPushDispatcher.php new file mode 100644 index 0000000..383263f --- /dev/null +++ b/app/Services/Push/WebPushDispatcher.php @@ -0,0 +1,82 @@ +client ??= $this->buildClient(); + + if (! $client) { + return null; + } + + try { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + Log::channel('notifications')->warning('Unable to encode push payload', [ + 'reason' => $exception->getMessage(), + ]); + + $body = '{}'; + } + + try { + return $client->sendOneNotification( + WebPushSubscription::create([ + 'endpoint' => $subscription->endpoint, + 'publicKey' => $subscription->public_key, + 'authToken' => $subscription->auth_token, + 'contentEncoding' => $subscription->content_encoding ?? 'aes128gcm', + ]), + $body + ); + } catch (\Throwable $exception) { + Log::channel('notifications')->warning('Web push transport error', [ + 'event_id' => $subscription->event_id, + 'subscription_id' => $subscription->id, + 'reason' => $exception->getMessage(), + ]); + + return null; + } + } + + private function buildClient(): ?WebPush + { + $vapid = config('push.vapid', []); + + if (empty($vapid['public_key']) || empty($vapid['private_key'])) { + Log::channel('notifications')->warning('Web push skipped because VAPID keys are missing.'); + + return null; + } + + $client = new WebPush([ + 'VAPID' => [ + 'subject' => $vapid['subject'] ?? config('app.url'), + 'publicKey' => $vapid['public_key'], + 'privateKey' => $vapid['private_key'], + ], + ]); + + $client->setDefaultOptions([ + 'TTL' => (int) config('push.ttl', 900), + ]); + + return $client; + } +} diff --git a/app/Services/PushSubscriptionService.php b/app/Services/PushSubscriptionService.php new file mode 100644 index 0000000..9ee6501 --- /dev/null +++ b/app/Services/PushSubscriptionService.php @@ -0,0 +1,153 @@ +normalizeExpiration(Arr::get($payload, 'expiration_time')); + + $endpointHash = hash('sha256', $endpoint); + $data = [ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->getKey(), + 'guest_identifier' => $this->sanitizeIdentifier($guestIdentifier), + 'device_id' => $this->sanitizeIdentifier($deviceId) ?? 'anonymous', + 'public_key' => $publicKey, + 'auth_token' => $authToken, + 'content_encoding' => $contentEncoding ?: 'aes128gcm', + 'status' => 'active', + 'expires_at' => $expiresAt, + 'last_seen_at' => now(), + 'language' => $language !== '' ? substr($language, 0, 12) : null, + 'user_agent' => $userAgent !== '' ? substr($userAgent, 0, 255) : null, + 'failure_count' => 0, + ]; + + /** @var PushSubscription $subscription */ + $subscription = PushSubscription::query() + ->where('endpoint_hash', $endpointHash) + ->first(); + + if ($subscription) { + $subscription->fill($data); + $subscription->status = 'active'; + $subscription->endpoint = $endpoint; + $subscription->save(); + + return $subscription; + } + + return PushSubscription::create(array_merge($data, [ + 'endpoint' => $endpoint, + 'endpoint_hash' => $endpointHash, + ])); + } + + public function revoke(Event $event, string $endpoint): bool + { + $hash = hash('sha256', (string) $endpoint); + + $subscription = PushSubscription::query() + ->where('event_id', $event->getKey()) + ->where(function ($query) use ($hash, $endpoint) { + $query->where('endpoint_hash', $hash) + ->orWhere('endpoint', $endpoint); + }) + ->first(); + + if (! $subscription) { + return false; + } + + $subscription->update([ + 'status' => 'revoked', + 'last_failed_at' => now(), + ]); + + return true; + } + + public function markFailed(PushSubscription $subscription, ?string $message = null): void + { + $subscription->fill([ + 'last_failed_at' => now(), + 'failure_count' => min(65535, $subscription->failure_count + 1), + ]); + + if ($subscription->failure_count >= 3) { + $subscription->status = 'revoked'; + } + + $meta = $subscription->meta ?? []; + if ($message) { + $meta['last_error'] = substr($message, 0, 255); + } + + $subscription->meta = $meta; + $subscription->save(); + } + + public function markDelivered(PushSubscription $subscription): void + { + $subscription->fill([ + 'last_notified_at' => now(), + 'last_failed_at' => null, + 'failure_count' => 0, + ])->save(); + } + + private function sanitizeIdentifier(?string $value): ?string + { + if ($value === null) { + return null; + } + + $sanitized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $value) ?? ''; + $sanitized = trim(mb_substr($sanitized, 0, 120)); + + return $sanitized === '' ? null : $sanitized; + } + + private function normalizeExpiration(mixed $value): ?CarbonImmutable + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + // Push API reports milliseconds + $seconds = (int) round(((float) $value) / 1000); + + return CarbonImmutable::createFromTimestampUTC($seconds); + } + + try { + return CarbonImmutable::parse((string) $value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/composer.json b/composer.json index 313e4a6..2589427 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9", "league/commonmark": "^2.7", + "minishlink/web-push": "*", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-translatable": "^6.11", "staudenmeir/belongs-to-through": "^2.17", diff --git a/composer.lock b/composer.lock index dc45084..123486f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5409eee4f26e2827449d85cf6b40209d", + "content-hash": "0db4b5e72dbe203bba7d50aa2e5d5e89", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -3969,6 +3969,71 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "minishlink/web-push", + "version": "v9.0.2", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "9c9623bf2f455015cb03f21f175cd42345e039a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/9c9623bf2f455015cb03f21f175cd42345e039a0", + "reference": "9c9623bf2f455015cb03f21f175cd42345e039a0", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.4.5", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.68.3", + "phpstan/phpstan": "^1.10.57", + "phpunit/phpunit": "^10.5.9" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.2" + }, + "time": "2025-01-29T17:44:07+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -6448,6 +6513,180 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-10-22T08:24:34+00:00" + }, { "name": "staudenmeir/belongs-to-through", "version": "v2.17", @@ -9405,6 +9644,95 @@ } ], "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "b05d01d4138b1e06328e29075a21a6da974935df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/b05d01d4138b1e06328e29075a21a6da974935df", + "reference": "b05d01d4138b1e06328e29075a21a6da974935df", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-10-22T08:01:38+00:00" } ], "packages-dev": [ diff --git a/config/notifications.php b/config/notifications.php new file mode 100644 index 0000000..5d9d8da --- /dev/null +++ b/config/notifications.php @@ -0,0 +1,7 @@ + [ + 'milestones' => array_map('intval', explode(',', (string) env('GUEST_ACHIEVEMENT_MILESTONES', '10,25,50'))), + ], +]; diff --git a/config/push.php b/config/push.php new file mode 100644 index 0000000..968aaff --- /dev/null +++ b/config/push.php @@ -0,0 +1,13 @@ + (bool) env('PUSH_ENABLED', false), + + 'ttl' => (int) env('PUSH_TTL', 600), + + 'vapid' => [ + 'public_key' => env('PUSH_VAPID_PUBLIC_KEY'), + 'private_key' => env('PUSH_VAPID_PRIVATE_KEY'), + 'subject' => env('PUSH_VAPID_SUBJECT', env('APP_URL', 'mailto:support@example.com')), + ], +]; diff --git a/config/storage-monitor.php b/config/storage-monitor.php index 16474aa..598c1bd 100644 --- a/config/storage-monitor.php +++ b/config/storage-monitor.php @@ -29,6 +29,11 @@ return [ 'lock_seconds' => (int) env('STORAGE_QUEUE_HEALTH_LOCK_SECONDS', 120), 'cache_minutes' => (int) env('STORAGE_QUEUE_HEALTH_CACHE_MINUTES', 10), 'stalled_minutes' => (int) env('STORAGE_QUEUE_STALLED_MINUTES', 10), + 'pending_event_minutes' => (int) env('STORAGE_QUEUE_PENDING_EVENT_MINUTES', 8), + 'pending_event_threshold' => (int) env('STORAGE_QUEUE_PENDING_EVENT_THRESHOLD', 5), + 'failed_event_minutes' => (int) env('STORAGE_QUEUE_FAILED_EVENT_MINUTES', 30), + 'failed_event_threshold' => (int) env('STORAGE_QUEUE_FAILED_EVENT_THRESHOLD', 2), + 'guest_alert_ttl' => (int) env('STORAGE_QUEUE_GUEST_ALERT_TTL', 30), 'thresholds' => [ 'default' => [ 'warning' => (int) env('STORAGE_QUEUE_DEFAULT_WARNING', 100), diff --git a/database/factories/PushSubscriptionFactory.php b/database/factories/PushSubscriptionFactory.php new file mode 100644 index 0000000..50234e3 --- /dev/null +++ b/database/factories/PushSubscriptionFactory.php @@ -0,0 +1,41 @@ +faker->url(); + + return [ + 'tenant_id' => Tenant::factory(), + 'event_id' => Event::factory(), + 'guest_identifier' => Str::slug($this->faker->firstName()), + 'device_id' => (string) Str::uuid(), + 'endpoint' => $endpoint, + 'endpoint_hash' => hash('sha256', $endpoint), + 'public_key' => base64_encode(random_bytes(32)), + 'auth_token' => base64_encode(random_bytes(16)), + 'content_encoding' => 'aes128gcm', + 'status' => 'active', + 'language' => 'de', + 'user_agent' => 'Mozilla/5.0', + ]; + } + + public function revoked(): static + { + return $this->state([ + 'status' => 'revoked', + ]); + } +} diff --git a/database/migrations/2025_11_12_201445_create_push_subscriptions_table.php b/database/migrations/2025_11_12_201445_create_push_subscriptions_table.php new file mode 100644 index 0000000..89fc332 --- /dev/null +++ b/database/migrations/2025_11_12_201445_create_push_subscriptions_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->string('guest_identifier', 120)->nullable(); + $table->string('device_id', 120); + $table->string('endpoint', 500)->unique(); + $table->string('endpoint_hash', 128)->index(); + $table->string('public_key', 255); + $table->string('auth_token', 255); + $table->string('content_encoding', 32)->default('aes128gcm'); + $table->string('status', 32)->default('active'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('last_notified_at')->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->unsignedSmallInteger('failure_count')->default(0); + $table->string('language', 12)->nullable(); + $table->string('user_agent', 255)->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'status']); + $table->index(['event_id', 'guest_identifier']); + $table->index(['event_id', 'device_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 3fbe761..155f61f 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -79,6 +79,16 @@ To enable Horizon (dashboard, smart balancing): docker compose --profile horizon up -d horizon ``` +## 6. Scheduler & cron jobs + +The compose stack ships a `scheduler` service that runs `php artisan schedule:work`, so all scheduled commands defined in `App\Console\Kernel` stay active. For upload health monitoring, keep the helper script from `cron/upload_queue_health.sh` on the host (or inside a management container) and add a cron entry: + +``` +*/5 * * * * /var/www/html/cron/upload_queue_health.sh +``` + +This wrapper logs to `storage/logs/cron-upload-queue-health.log` and executes `php artisan storage:check-upload-queues`, which in turn issues guest-facing upload alerts when queues stall or fail repeatedly. In containerised environments mount the repository so the script can reuse the same PHP binary as the app, or call the artisan command directly via `docker compose exec app php artisan storage:check-upload-queues`. + The dashboard becomes available at `/horizon` and is protected by the Filament super-admin auth guard. ## 6. Persistent data & volumes diff --git a/docs/ops/guest-notification-ops.md b/docs/ops/guest-notification-ops.md new file mode 100644 index 0000000..7e0a35f --- /dev/null +++ b/docs/ops/guest-notification-ops.md @@ -0,0 +1,68 @@ +## Guest Notification & Push Ops Guide + +This runbook explains how to keep the guest notification centre healthy, roll out web push, and operate the new upload health alerts. + +### 1. Database & config prerequisites + +1. Run the latest migrations so the `push_subscriptions` table exists: + ```bash + php artisan migrate --force + ``` +2. Generate VAPID keys (using `web-push` or any Web Push helper) and store them in the environment: + ``` + PUSH_ENABLED=true + PUSH_VAPID_PUBLIC_KEY= + PUSH_VAPID_PRIVATE_KEY= + PUSH_VAPID_SUBJECT="mailto:ops@example.com" + ``` +3. Redeploy the guest PWA (Vite build) so the runtime config exposes the new keys to the service worker. + +### 2. Queue workers + +Push deliveries are dispatched on the dedicated `notifications` queue. Ensure one of the queue workers listens to it: + +```bash +docs/queue-supervisor/queue-worker.sh default,notifications +``` + +If Horizon is in use just add `notifications` to the list of queues for at least one supervisor. Monitor `storage/logs/notifications.log` (channel `notifications`) for transport failures. + +### 3. Upload health alerts + +The `storage:check-upload-queues` command now emits guest-facing alerts when uploads stall or fail repeatedly. Schedule it every 5 minutes via cron (see `cron/upload_queue_health.sh`) or the Laravel scheduler: + +``` +*/5 * * * * /var/www/html/cron/upload_queue_health.sh +``` + +Tune thresholds with the `STORAGE_QUEUE_*` variables in `.env` (see `.env.example` for defaults). When an alert fires, the tenant admin toolkit also surfaces the same issues. + +### 4. Manual API interactions + +- Register push subscription (from browser dev-tools): + ``` + POST /api/v1/events/{token}/push-subscriptions + Headers: X-Device-Id + Body: { endpoint, keys:{p256dh, auth}, content_encoding } + ``` +- Revoke subscription: + ``` + DELETE /api/v1/events/{token}/push-subscriptions + Body: { endpoint } + ``` +- Inspect per-guest state: + ```bash + php artisan tinker + >>> App\Models\PushSubscription::where('event_id', 123)->get(); + ``` + +### 5. Smoke tests + +After enabling push: + +1. Join a published event, open the notification centre, and enable push (browser prompt must appear). +2. Trigger a host broadcast or upload-queue alert; confirm the browser shows a native notification and that the notification drawer refreshes without polling. +3. Temporarily stop the upload workers to create ≥5 pending assets; re-run `storage:check-upload-queues` and verify guests receive the “Uploads werden noch verarbeitet …” message. + +Document any deviations in `docs/changes/` for future regressions. + diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md index 8f86086..683ea2b 100644 --- a/docs/prp/07-guest-pwa.md +++ b/docs/prp/07-guest-pwa.md @@ -41,6 +41,8 @@ Core Features - Header bell opens a drawer that merges upload queue stats with server-driven notifications (photo highlights, major achievements, host broadcasts, upload failure hints, feedback reminders). - Data fetched from `/api/v1/events/{token}/notifications` with `X-Device-Id` for per-device read receipts; guests can mark items as read/dismissed and follow CTAs (internal routes or external links). - Pull-to-refresh + background poll every 90s to keep single-day events reactive without WS infrastructure. + - When push is available (VAPID keys configured) the drawer surfaces a push toggle, persists subscriptions via `/push-subscriptions`, and the service worker refreshes notifications after every push message. + - Operations playbook: see `docs/ops/guest-notification-ops.md` for enabling push, required queues, and cron health checks. - Privacy & legal - First run shows legal links (imprint/privacy); consent for push if enabled. - No PII stored; guest name is optional free text and not required by default. @@ -103,6 +105,8 @@ API Touchpoints - POST `/api/v1/photos/{id}/like` — idempotent like with device token. - GET `/api/v1/events/{token}/notifications` — list guest notifications (requires `X-Device-Id`). - POST `/api/v1/events/{token}/notifications/{notification}/read|dismiss` — mark/dismiss notification with device identity. +- POST `/api/v1/events/{token}/push-subscriptions` — register a browser push subscription (requires `X-Device-Id` + VAPID public key). +- DELETE `/api/v1/events/{token}/push-subscriptions` — revoke a stored push subscription by endpoint. Limits (MVP defaults) - Max uploads per device per event: 50 diff --git a/docs/queue-supervisor/README.md b/docs/queue-supervisor/README.md index fb58d29..92398ea 100644 --- a/docs/queue-supervisor/README.md +++ b/docs/queue-supervisor/README.md @@ -62,6 +62,8 @@ services: Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2). +> **Heads-up:** Guest push notifications are dispatched on the `notifications` queue. Either add that queue to the default worker (`queue-worker.sh default,notifications`) or create a dedicated worker so push jobs are consumed even when other queues are busy. + ### 3. Optional: Horizon container If you prefer Horizon’s dashboard and auto-balancing, add another service: diff --git a/public/guest-sw.js b/public/guest-sw.js index 033080f..4e0f1b0 100644 --- a/public/guest-sw.js +++ b/public/guest-sw.js @@ -1,5 +1,4 @@ -// Minimal service worker for Guest PWA queue sync -self.addEventListener('install', (event) => { +self.addEventListener('install', () => { self.skipWaiting(); }); @@ -7,63 +6,61 @@ self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); -const ASSETS_CACHE = 'guest-assets-v1'; -const IMAGES_CACHE = 'guest-images-v1'; - -self.addEventListener('fetch', (event) => { - const req = event.request; - if (req.method !== 'GET') return; - - const url = new URL(req.url); - // Only handle same-origin requests - if (url.origin !== self.location.origin) return; - - // Never cache API calls; let them hit network directly - if (url.pathname.startsWith('/api/')) return; - - // Cache-first for images - if (req.destination === 'image' || /\.(png|jpg|jpeg|webp|avif|gif|svg)(\?.*)?$/i.test(url.pathname)) { - event.respondWith((async () => { - const cache = await caches.open(IMAGES_CACHE); - const cached = await cache.match(req); - if (cached) return cached; - try { - const res = await fetch(req, { credentials: 'same-origin' }); - if (res.ok) cache.put(req, res.clone()); - return res; - } catch (e) { - return cached || Response.error(); - } - })()); - return; - } - - // Stale-while-revalidate for CSS/JS assets - if (req.destination === 'style' || req.destination === 'script') { - event.respondWith((async () => { - const cache = await caches.open(ASSETS_CACHE); - const cached = await cache.match(req); - const networkPromise = fetch(req, { credentials: 'same-origin' }) - .then((res) => { - if (res.ok) cache.put(req, res.clone()); - return res; - }) - .catch(() => null); - return cached || (await networkPromise) || Response.error(); - })()); - return; - } -}); - self.addEventListener('sync', (event) => { if (event.tag === 'upload-queue') { event.waitUntil( (async () => { const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }); - for (const client of clients) { - client.postMessage({ type: 'sync-queue' }); - } + clients.forEach((client) => client.postMessage({ type: 'sync-queue' })); })() ); } }); + +self.addEventListener('push', (event) => { + const payload = event.data?.json?.() ?? {}; + + event.waitUntil( + (async () => { + const title = payload.title ?? 'Neue Nachricht'; + const options = { + body: payload.body ?? '', + icon: '/icons/icon-192.png', + badge: '/icons/badge.png', + data: payload.data ?? {}, + }; + + await self.registration.showNotification(title, options); + + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: 'guest-notification-refresh' })); + })() + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url || '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if ('focus' in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + }) + ); +}); + +self.addEventListener('pushsubscriptionchange', (event) => { + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' })); + }) + ); +}); diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 40abdde..8c290fe 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -26,6 +26,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress'; +import { usePushSubscription } from '../hooks/usePushSubscription'; const EVENT_ICON_COMPONENTS: Record> = { heart: Heart, @@ -224,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
- {notificationCenter && ( + {notificationCenter && eventToken && ( ; + function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) { const badgeCount = center.totalCount; const progressRatio = taskProgress ? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET) : 0; const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all'); + const pushState = usePushSubscription(eventToken); React.useEffect(() => { if (!open) { @@ -338,6 +342,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
@@ -528,7 +533,6 @@ function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: stri return ( { + if (!push.supported) { + return t('header.notifications.pushUnsupported', 'Push wird nicht unterstützt'); + } + if (push.permission === 'denied') { + return t('header.notifications.pushDenied', 'Browser blockiert Benachrichtigungen'); + } + if (push.subscribed) { + return t('header.notifications.pushActive', 'Push aktiv'); + } + return t('header.notifications.pushInactive', 'Push deaktiviert'); + }, [push.permission, push.subscribed, push.supported, t]); + + const buttonLabel = push.subscribed + ? t('header.notifications.pushDisable', 'Deaktivieren') + : t('header.notifications.pushEnable', 'Aktivieren'); + + const pushButtonDisabled = push.loading || !push.supported || push.permission === 'denied'; return ( -
- - {t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} - - {isOffline && ( - - - {t('header.notifications.offline', 'Offline')} +
+
+ + {t('header.notifications.lastSync', 'Zuletzt aktualisiert')}: {label} + {isOffline && ( + + + {t('header.notifications.offline', 'Offline')} + + )} +
+
+
+ + {pushDescription} +
+ +
+ {push.error && ( +

+ {push.error} +

)}
); diff --git a/resources/js/guest/context/NotificationCenterContext.tsx b/resources/js/guest/context/NotificationCenterContext.tsx index 6ec6e7e..f52e475 100644 --- a/resources/js/guest/context/NotificationCenterContext.tsx +++ b/resources/js/guest/context/NotificationCenterContext.tsx @@ -125,6 +125,20 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke }; }, []); + React.useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.data?.type === 'guest-notification-refresh') { + void loadNotifications({ silent: true }); + } + }; + + navigator.serviceWorker?.addEventListener('message', handler); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handler); + }; + }, [loadNotifications]); + const markAsRead = React.useCallback( async (id: number) => { if (!eventToken) { diff --git a/resources/js/guest/hooks/usePushSubscription.ts b/resources/js/guest/hooks/usePushSubscription.ts new file mode 100644 index 0000000..44904e5 --- /dev/null +++ b/resources/js/guest/hooks/usePushSubscription.ts @@ -0,0 +1,168 @@ +import React from 'react'; +import { getPushConfig } from '../lib/runtime-config'; +import { registerPushSubscription, unregisterPushSubscription } from '../services/pushApi'; + +type PushSubscriptionState = { + supported: boolean; + permission: NotificationPermission; + subscribed: boolean; + loading: boolean; + error: string | null; + enable: () => Promise; + disable: () => Promise; + refresh: () => Promise; +}; + +export function usePushSubscription(eventToken?: string): PushSubscriptionState { + const pushConfig = React.useMemo(() => getPushConfig(), []); + const supported = React.useMemo(() => { + return typeof window !== 'undefined' + && typeof navigator !== 'undefined' + && typeof Notification !== 'undefined' + && 'serviceWorker' in navigator + && 'PushManager' in window + && pushConfig.enabled; + }, [pushConfig.enabled]); + + const [permission, setPermission] = React.useState(() => { + if (typeof Notification === 'undefined') { + return 'default'; + } + + return Notification.permission; + }); + const [subscription, setSubscription] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!supported || !eventToken) { + return; + } + + try { + const registration = await navigator.serviceWorker.ready; + const current = await registration.pushManager.getSubscription(); + setSubscription(current); + } catch (err) { + console.warn('Unable to refresh push subscription', err); + setSubscription(null); + } + }, [eventToken, supported]); + + React.useEffect(() => { + if (!supported) { + return; + } + + void refresh(); + + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === 'push-subscription-change') { + void refresh(); + } + }; + + navigator.serviceWorker?.addEventListener('message', handleMessage); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handleMessage); + }; + }, [refresh, supported]); + + const enable = React.useCallback(async () => { + if (!supported || !eventToken) { + setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.'); + + return; + } + + setLoading(true); + setError(null); + + try { + const permissionResult = await Notification.requestPermission(); + setPermission(permissionResult); + + if (permissionResult !== 'granted') { + throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.'); + } + + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + + if (existing) { + await registerPushSubscription(eventToken, existing); + setSubscription(existing); + + return; + } + + if (!pushConfig.vapidPublicKey) { + throw new Error('Push-Konfiguration ist nicht vollständig.'); + } + + const newSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey), + }); + + await registerPushSubscription(eventToken, newSubscription); + setSubscription(newSubscription); + } catch (err) { + const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.'; + setError(message); + console.error(err); + await refresh(); + } finally { + setLoading(false); + } + }, [eventToken, pushConfig.vapidPublicKey, refresh, supported]); + + const disable = React.useCallback(async () => { + if (!supported || !eventToken || !subscription) { + return; + } + + setLoading(true); + setError(null); + + try { + await unregisterPushSubscription(eventToken, subscription.endpoint); + await subscription.unsubscribe(); + setSubscription(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.'; + setError(message); + console.error(err); + } finally { + setLoading(false); + } + }, [eventToken, subscription, supported]); + + return { + supported, + permission, + subscribed: Boolean(subscription), + loading, + error, + enable, + disable, + refresh, + }; +} + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = typeof window !== 'undefined' + ? window.atob(base64) + : Buffer.from(base64, 'base64').toString('binary'); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; i += 1) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} diff --git a/resources/js/guest/lib/runtime-config.ts b/resources/js/guest/lib/runtime-config.ts new file mode 100644 index 0000000..0af8055 --- /dev/null +++ b/resources/js/guest/lib/runtime-config.ts @@ -0,0 +1,24 @@ +type PushConfig = { + enabled: boolean; + vapidPublicKey: string | null; +}; + +type RuntimeConfig = { + push: PushConfig; +}; + +export function getRuntimeConfig(): RuntimeConfig { + const raw = typeof window !== 'undefined' ? window.__GUEST_RUNTIME_CONFIG__ : undefined; + + return { + push: { + enabled: Boolean(raw?.push?.enabled), + vapidPublicKey: raw?.push?.vapidPublicKey ?? null, + }, + }; +} + +export function getPushConfig(): PushConfig { + return getRuntimeConfig().push; +} + diff --git a/resources/js/guest/services/pushApi.ts b/resources/js/guest/services/pushApi.ts new file mode 100644 index 0000000..1b34597 --- /dev/null +++ b/resources/js/guest/services/pushApi.ts @@ -0,0 +1,71 @@ +import { getDeviceId } from '../lib/device'; + +type PushSubscriptionPayload = { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + expirationTime?: number | null; + contentEncoding?: string | null; +}; + +function buildHeaders(): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Device-Id': getDeviceId(), + }; +} + +export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise { + const json = subscription.toJSON() as PushSubscriptionPayload; + + const body = { + endpoint: json.endpoint, + keys: json.keys, + expiration_time: json.expirationTime ?? null, + content_encoding: json.contentEncoding ?? null, + }; + + const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, { + method: 'POST', + headers: buildHeaders(), + credentials: 'include', + body: JSON.stringify(body), + }); + + if (!response.ok) { + const message = await parseError(response); + throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.'); + } +} + +export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise { + const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, { + method: 'DELETE', + headers: buildHeaders(), + credentials: 'include', + body: JSON.stringify({ endpoint }), + }); + + if (!response.ok) { + const message = await parseError(response); + throw new Error(message ?? 'Push konnte nicht deaktiviert werden.'); + } +} + +async function parseError(response: Response): Promise { + try { + const payload = await response.clone().json(); + const errorMessage = payload?.error?.message ?? payload?.message; + if (typeof errorMessage === 'string' && errorMessage.trim() !== '') { + return errorMessage; + } + } catch (error) { + console.warn('Failed to parse push API error', error); + } + + return null; +} + diff --git a/resources/js/guest/types/global.d.ts b/resources/js/guest/types/global.d.ts new file mode 100644 index 0000000..7c90655 --- /dev/null +++ b/resources/js/guest/types/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + interface Window { + __GUEST_RUNTIME_CONFIG__?: { + push?: { + enabled?: boolean; + vapidPublicKey?: string | null; + }; + }; + } +} + diff --git a/resources/views/guest.blade.php b/resources/views/guest.blade.php index b4472ad..085424f 100644 --- a/resources/views/guest.blade.php +++ b/resources/views/guest.blade.php @@ -7,6 +7,14 @@ @viteReactRefresh @vite(['resources/css/app.css', 'resources/js/guest/main.tsx']) +
diff --git a/routes/api.php b/routes/api.php index 28f31aa..4344183 100644 --- a/routes/api.php +++ b/routes/api.php @@ -75,6 +75,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/events/{token}/notifications/{notification}/dismiss', [EventPublicController::class, 'dismissNotification']) ->whereNumber('notification') ->name('events.notifications.dismiss'); + Route::post('/events/{token}/push-subscriptions', [EventPublicController::class, 'registerPushSubscription']) + ->name('events.push-subscriptions.store'); + Route::delete('/events/{token}/push-subscriptions', [EventPublicController::class, 'destroyPushSubscription']) + ->name('events.push-subscriptions.destroy'); Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements'); Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks'); diff --git a/tests/Feature/Api/Event/PushSubscriptionApiTest.php b/tests/Feature/Api/Event/PushSubscriptionApiTest.php new file mode 100644 index 0000000..b99f55f --- /dev/null +++ b/tests/Feature/Api/Event/PushSubscriptionApiTest.php @@ -0,0 +1,66 @@ +create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $payload = [ + 'endpoint' => 'https://updates.example.com/push/abc', + 'keys' => [ + 'p256dh' => base64_encode('key'), + 'auth' => base64_encode('auth'), + ], + ]; + + $response = $this->withHeaders(['X-Device-Id' => 'device-test-1']) + ->postJson("/api/v1/events/{$token}/push-subscriptions", $payload); + + $response->assertCreated(); + $this->assertDatabaseHas('push_subscriptions', [ + 'event_id' => $event->id, + 'device_id' => 'device-test-1', + 'endpoint' => $payload['endpoint'], + 'status' => 'active', + ]); + } + + public function test_guest_can_revoke_push_subscription(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $subscription = PushSubscription::factory() + ->for($tenant) + ->for($event) + ->create([ + 'endpoint' => 'https://updates.example.com/push/cached', + 'device_id' => 'device-revoke', + ]); + + $response = $this->deleteJson("/api/v1/events/{$token}/push-subscriptions", [ + 'endpoint' => $subscription->endpoint, + ]); + + $response->assertOk()->assertJsonPath('status', 'revoked'); + $this->assertDatabaseHas('push_subscriptions', [ + 'id' => $subscription->id, + 'status' => 'revoked', + ]); + } +} diff --git a/tests/Feature/Console/CheckUploadQueuesCommandTest.php b/tests/Feature/Console/CheckUploadQueuesCommandTest.php index 2fda4f5..02ed3b4 100644 --- a/tests/Feature/Console/CheckUploadQueuesCommandTest.php +++ b/tests/Feature/Console/CheckUploadQueuesCommandTest.php @@ -2,9 +2,11 @@ namespace Tests\Feature\Console; +use App\Enums\GuestNotificationType; use App\Models\Event; use App\Models\EventMediaAsset; use App\Models\MediaStorageTarget; +use App\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Queue\QueueManager; use Illuminate\Support\Facades\Cache; @@ -24,6 +26,10 @@ class CheckUploadQueuesCommandTest extends TestCase ]); config()->set('storage-monitor.queue_health.stalled_minutes', 5); config()->set('storage-monitor.queue_health.cache_minutes', 5); + config()->set('storage-monitor.queue_health.pending_event_threshold', 1); + config()->set('storage-monitor.queue_health.pending_event_minutes', 5); + config()->set('storage-monitor.queue_health.failed_event_threshold', 1); + config()->set('storage-monitor.queue_health.failed_event_minutes', 5); $manager = Mockery::mock(QueueManager::class); $connection = Mockery::mock(\Illuminate\Contracts\Queue\Queue::class); @@ -51,7 +57,8 @@ class CheckUploadQueuesCommandTest extends TestCase 'priority' => 100, ]); - $event = Event::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); $asset = EventMediaAsset::create([ 'event_id' => $event->id, 'media_storage_target_id' => $target->id, @@ -66,6 +73,16 @@ class CheckUploadQueuesCommandTest extends TestCase 'updated_at' => now()->subMinutes(10), ]); + EventMediaAsset::create([ + 'event_id' => $event->id, + 'media_storage_target_id' => $target->id, + 'variant' => 'original', + 'disk' => 'local-hot', + 'path' => 'events/'.$event->id.'/failed.jpg', + 'size_bytes' => 256, + 'status' => 'failed', + ]); + $this->artisan('storage:check-upload-queues') ->expectsOutput('Checked 1 queue(s); 3 alert(s).') ->assertExitCode(0); @@ -74,5 +91,15 @@ class CheckUploadQueuesCommandTest extends TestCase $this->assertNotNull($snapshot); $this->assertSame('critical', $snapshot['queues'][0]['severity']); $this->assertGreaterThanOrEqual(1, count($snapshot['alerts'])); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => GuestNotificationType::UPLOAD_ALERT->value, + ]); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => GuestNotificationType::SUPPORT_TIP->value, + ]); } } diff --git a/tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php b/tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php new file mode 100644 index 0000000..9754331 --- /dev/null +++ b/tests/Feature/Jobs/SendGuestPushNotificationBatchTest.php @@ -0,0 +1,100 @@ +set('push.enabled', true); + + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $notification = GuestNotification::factory()->create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + ]); + + $subscription = PushSubscription::factory() + ->for($tenant) + ->for($event) + ->create(); + + $report = new MessageSentReport( + new Request('POST', 'https://push.example.com'), + new Response(201), + true, + 'OK' + ); + + $dispatcher = new class($report) extends WebPushDispatcher + { + public function __construct(private MessageSentReport $report) {} + + public function send(PushSubscription $subscription, array $payload): ?MessageSentReport + { + return $this->report; + } + }; + + $job = new SendGuestPushNotificationBatch($notification->id, [$subscription->id]); + $job->handle($dispatcher, app(PushSubscriptionService::class)); + + $this->assertNotNull($subscription->fresh()->last_notified_at); + $this->assertSame('active', $subscription->fresh()->status); + } + + public function test_job_revokes_expired_subscription(): void + { + config()->set('push.enabled', true); + + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(['status' => 'published']); + $notification = GuestNotification::factory()->create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + ]); + + $subscription = PushSubscription::factory() + ->for($tenant) + ->for($event) + ->create(); + + $report = new MessageSentReport( + new Request('POST', 'https://push.example.com'), + new Response(410), + false, + 'Gone' + ); + + $dispatcher = new class($report) extends WebPushDispatcher + { + public function __construct(private MessageSentReport $report) {} + + public function send(PushSubscription $subscription, array $payload): ?MessageSentReport + { + return $this->report; + } + }; + + $job = new SendGuestPushNotificationBatch($notification->id, [$subscription->id]); + $job->handle($dispatcher, app(PushSubscriptionService::class)); + + $this->assertSame('revoked', $subscription->fresh()->status); + } +} diff --git a/tsconfig.json b/tsconfig.json index 7ad28af..7939a83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -123,6 +123,9 @@ "resources/js/routes/**/*.tsx", "resources/js/types/**/*.ts", "resources/js/lib/**/*.ts", - "resources/js/lib/**/*.tsx" + "resources/js/lib/**/*.tsx", + "resources/js/guest/**/*.ts", + "resources/js/guest/**/*.tsx", + "resources/js/guest/**/*.d.ts" ] }