From 4495ac1895777cc750955530679feb1163013586 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 12 Nov 2025 16:56:50 +0100 Subject: [PATCH] feat: add guest notification center --- app/Enums/GuestNotificationAudience.php | 17 + app/Enums/GuestNotificationDeliveryStatus.php | 19 ++ app/Enums/GuestNotificationState.php | 19 ++ app/Enums/GuestNotificationType.php | 25 ++ app/Events/GuestNotificationCreated.php | 17 + .../Controllers/Api/EventPublicController.php | 183 +++++++++++ .../EventGuestNotificationController.php | 131 ++++++++ .../BroadcastGuestNotificationRequest.php | 31 ++ .../Tenant/GuestNotificationResource.php | 30 ++ app/Models/Event.php | 5 + app/Models/GuestNotification.php | 98 ++++++ app/Models/GuestNotificationReceipt.php | 36 +++ app/Services/GuestNotificationService.php | 162 ++++++++++ .../factories/GuestNotificationFactory.php | 51 +++ .../GuestNotificationReceiptFactory.php | 43 +++ ...0000_create_guest_notifications_tables.php | 50 +++ docs/prp/06-tenant-admin-pwa.md | 1 + docs/prp/07-guest-pwa.md | 6 + resources/js/admin/api.ts | 86 +++++ .../admin/components/GuestBroadcastCard.tsx | 256 +++++++++++++++ resources/js/admin/pages/EventDetailPage.tsx | 10 + resources/js/guest/components/Header.tsx | 303 +++++++++++++++--- .../context/NotificationCenterContext.tsx | 214 +++++++++++-- .../js/guest/services/notificationApi.ts | 146 +++++++++ routes/api.php | 10 + .../Api/Event/GuestNotificationCenterTest.php | 92 ++++++ .../Tenant/GuestNotificationBroadcastTest.php | 65 ++++ 27 files changed, 2042 insertions(+), 64 deletions(-) create mode 100644 app/Enums/GuestNotificationAudience.php create mode 100644 app/Enums/GuestNotificationDeliveryStatus.php create mode 100644 app/Enums/GuestNotificationState.php create mode 100644 app/Enums/GuestNotificationType.php create mode 100644 app/Events/GuestNotificationCreated.php create mode 100644 app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php create mode 100644 app/Http/Requests/Tenant/BroadcastGuestNotificationRequest.php create mode 100644 app/Http/Resources/Tenant/GuestNotificationResource.php create mode 100644 app/Models/GuestNotification.php create mode 100644 app/Models/GuestNotificationReceipt.php create mode 100644 app/Services/GuestNotificationService.php create mode 100644 database/factories/GuestNotificationFactory.php create mode 100644 database/factories/GuestNotificationReceiptFactory.php create mode 100644 database/migrations/2025_11_16_120000_create_guest_notifications_tables.php create mode 100644 resources/js/admin/components/GuestBroadcastCard.tsx create mode 100644 resources/js/guest/services/notificationApi.ts create mode 100644 tests/Feature/Api/Event/GuestNotificationCenterTest.php create mode 100644 tests/Feature/Tenant/GuestNotificationBroadcastTest.php diff --git a/app/Enums/GuestNotificationAudience.php b/app/Enums/GuestNotificationAudience.php new file mode 100644 index 0000000..f09033b --- /dev/null +++ b/app/Enums/GuestNotificationAudience.php @@ -0,0 +1,17 @@ + __('Alle Gäste'), + self::GUEST => __('Individuelle Gäste'), + }; + } +} diff --git a/app/Enums/GuestNotificationDeliveryStatus.php b/app/Enums/GuestNotificationDeliveryStatus.php new file mode 100644 index 0000000..0c4fabc --- /dev/null +++ b/app/Enums/GuestNotificationDeliveryStatus.php @@ -0,0 +1,19 @@ + __('Neu'), + self::READ => __('Gelesen'), + self::DISMISSED => __('Ausgeblendet'), + }; + } +} diff --git a/app/Enums/GuestNotificationState.php b/app/Enums/GuestNotificationState.php new file mode 100644 index 0000000..432ed38 --- /dev/null +++ b/app/Enums/GuestNotificationState.php @@ -0,0 +1,19 @@ + __('Entwurf'), + self::ACTIVE => __('Aktiv'), + self::ARCHIVED => __('Archiviert'), + }; + } +} diff --git a/app/Enums/GuestNotificationType.php b/app/Enums/GuestNotificationType.php new file mode 100644 index 0000000..f042c52 --- /dev/null +++ b/app/Enums/GuestNotificationType.php @@ -0,0 +1,25 @@ + __('Allgemeine Nachricht'), + self::SUPPORT_TIP => __('Support-Hinweis'), + self::UPLOAD_ALERT => __('Upload-Status'), + self::ACHIEVEMENT_MAJOR => __('Achievement'), + self::PHOTO_ACTIVITY => __('Fotoupdate'), + self::FEEDBACK_REQUEST => __('Feedback-Einladung'), + }; + } +} diff --git a/app/Events/GuestNotificationCreated.php b/app/Events/GuestNotificationCreated.php new file mode 100644 index 0000000..730914c --- /dev/null +++ b/app/Events/GuestNotificationCreated.php @@ -0,0 +1,17 @@ +resolvePublishedEvent($request, $token, ['id', 'tenant_id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$event] = $result; + $guestIdentifier = $this->resolveNotificationIdentifier($request); + $limit = max(1, min(50, (int) $request->integer('limit', 35))); + + $baseQuery = GuestNotification::query() + ->where('event_id', $event->id) + ->active() + ->notExpired() + ->visibleToGuest($guestIdentifier); + + $notifications = (clone $baseQuery) + ->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)]) + ->orderByDesc('priority') + ->orderByDesc('id') + ->limit($limit) + ->get(); + + $unreadCount = (clone $baseQuery) + ->where(function ($query) use ($guestIdentifier) { + $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier)) + ->orWhereHas('receipts', fn ($receipt) => $receipt + ->where('guest_identifier', $guestIdentifier) + ->where('status', GuestNotificationDeliveryStatus::NEW->value)); + }) + ->count(); + + $data = $notifications->map(fn (GuestNotification $notification) => $this->formatGuestNotification($notification, $guestIdentifier)); + + $etag = sha1(json_encode([ + $event->id, + $guestIdentifier, + $unreadCount, + $notifications->first()?->updated_at?->toAtomString(), + ])); + + $clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags()); + if (in_array($etag, $clientEtags, true)) { + return response('', 304) + ->header('ETag', $etag) + ->header('Cache-Control', 'no-store') + ->header('Vary', 'X-Device-Id, Accept-Language'); + } + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'unread_count' => $unreadCount, + 'poll_after_seconds' => 90, + ], + ])->header('ETag', $etag) + ->header('Cache-Control', 'no-store') + ->header('Vary', 'X-Device-Id, Accept-Language'); + } + + public function markNotificationRead(Request $request, string $token, GuestNotification $notification) + { + return $this->handleNotificationAction($request, $token, $notification, 'read'); + } + + public function dismissNotification(Request $request, string $token, GuestNotification $notification) + { + return $this->handleNotificationAction($request, $token, $notification, 'dismiss'); + } + + private function handleNotificationAction(Request $request, string $token, GuestNotification $notification, string $action) + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$event] = $result; + + if ((int) $notification->event_id !== (int) $event->id) { + return ApiError::response( + 'notification_not_found', + 'Notification not found', + 'Diese Benachrichtigung gehört nicht zu diesem Event.', + Response::HTTP_NOT_FOUND + ); + } + + $guestIdentifier = $this->resolveNotificationIdentifier($request); + + if (! $this->notificationVisibleToGuest($notification, $guestIdentifier)) { + return ApiError::response( + 'notification_forbidden', + 'Notification unavailable', + 'Diese Nachricht steht nicht zur Verfügung.', + Response::HTTP_FORBIDDEN + ); + } + + $receipt = $action === 'read' + ? $this->guestNotificationService->markAsRead($notification, $guestIdentifier) + : $this->guestNotificationService->dismiss($notification, $guestIdentifier); + + return response()->json([ + 'id' => $notification->id, + 'status' => $receipt->status->value, + 'read_at' => $receipt->read_at?->toAtomString(), + 'dismissed_at' => $receipt->dismissed_at?->toAtomString(), + ]); + } + + private function notificationVisibleToGuest(GuestNotification $notification, string $guestIdentifier): bool + { + if ($notification->status !== GuestNotificationState::ACTIVE) { + return false; + } + + if ($notification->hasExpired()) { + return false; + } + + if ($notification->audience_scope === GuestNotificationAudience::ALL) { + return true; + } + + return $notification->audience_scope === GuestNotificationAudience::GUEST + && $notification->target_identifier === $guestIdentifier; + } + + private function formatGuestNotification(GuestNotification $notification, string $guestIdentifier): array + { + $receipt = $notification->receipts->firstWhere('guest_identifier', $guestIdentifier); + $status = $receipt?->status ?? GuestNotificationDeliveryStatus::NEW; + $payload = $notification->payload ?? []; + + $cta = null; + if (is_array($payload) && isset($payload['cta']) && is_array($payload['cta'])) { + $ctaPayload = $payload['cta']; + if (! empty($ctaPayload['label']) && ! empty($ctaPayload['href'])) { + $cta = [ + 'label' => (string) $ctaPayload['label'], + 'href' => (string) $ctaPayload['href'], + ]; + } + } + + return [ + 'id' => (int) $notification->id, + 'type' => $notification->type->value, + 'title' => $notification->title, + 'body' => $notification->body, + 'status' => $status->value, + 'created_at' => $notification->created_at?->toAtomString(), + 'read_at' => $receipt?->read_at?->toAtomString(), + 'dismissed_at' => $receipt?->dismissed_at?->toAtomString(), + 'cta' => $cta, + 'payload' => $payload, + ]; + } + + private function resolveNotificationIdentifier(Request $request): string + { + $identifier = $this->determineGuestIdentifier($request); + + if ($identifier) { + return $identifier; + } + + $deviceId = (string) $request->headers->get('X-Device-Id', ''); + $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? '', 0, 120); + + return $deviceId !== '' ? $deviceId : 'anonymous'; + } + public function stats(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); diff --git a/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php new file mode 100644 index 0000000..bf2bb10 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php @@ -0,0 +1,131 @@ +assertEventTenant($request, $event); + + $limit = max(1, min(100, (int) $request->integer('limit', 25))); + + $notifications = GuestNotification::query() + ->forEvent($event) + ->orderByDesc('id') + ->limit($limit) + ->get(); + + return GuestNotificationResource::collection($notifications)->response(); + } + + public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse + { + $this->assertEventTenant($request, $event); + + $data = $request->validated(); + + $type = $this->resolveType($data['type'] ?? null); + $audience = $this->resolveAudience($data['audience'] ?? null); + $targetIdentifier = $audience === GuestNotificationAudience::GUEST + ? $this->sanitizeIdentifier($data['guest_identifier'] ?? '') + : null; + + if ($audience === GuestNotificationAudience::GUEST && ! $targetIdentifier) { + throw ValidationException::withMessages([ + 'guest_identifier' => __('Ein Gastname oder Geräte-Token wird benötigt.'), + ]); + } + + $expiresAt = null; + if (! empty($data['expires_in_minutes'])) { + $expiresAt = now()->addMinutes((int) $data['expires_in_minutes']); + } + + $payload = null; + if (! empty($data['cta'])) { + $payload = [ + 'cta' => [ + 'label' => $data['cta']['label'], + 'href' => $data['cta']['url'], + ], + ]; + } + + $notification = $this->notifications->createNotification( + $event, + $type, + $data['title'], + $data['message'], + [ + 'payload' => $payload, + 'audience_scope' => $audience, + 'target_identifier' => $targetIdentifier, + 'expires_at' => $expiresAt, + 'priority' => $data['priority'] ?? 1, + ] + ); + + return (new GuestNotificationResource($notification)) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + private function assertEventTenant(Request $request, Event $event): void + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($tenantId === null || (int) $event->tenant_id !== (int) $tenantId) { + abort(403, 'Event belongs to a different tenant.'); + } + } + + private function resolveType(?string $value): GuestNotificationType + { + if (! $value) { + return GuestNotificationType::BROADCAST; + } + + foreach (GuestNotificationType::cases() as $type) { + if ($type->value === $value) { + return $type; + } + } + + return GuestNotificationType::BROADCAST; + } + + private function resolveAudience(?string $value): GuestNotificationAudience + { + if (! $value) { + return GuestNotificationAudience::ALL; + } + + return $value === GuestNotificationAudience::GUEST->value + ? GuestNotificationAudience::GUEST + : GuestNotificationAudience::ALL; + } + + private function sanitizeIdentifier(string $value): ?string + { + $normalized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $value) ?? ''; + $normalized = trim(mb_substr($normalized, 0, 120)); + + return $normalized === '' ? null : $normalized; + } +} diff --git a/app/Http/Requests/Tenant/BroadcastGuestNotificationRequest.php b/app/Http/Requests/Tenant/BroadcastGuestNotificationRequest.php new file mode 100644 index 0000000..fccf613 --- /dev/null +++ b/app/Http/Requests/Tenant/BroadcastGuestNotificationRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:160'], + 'message' => ['required', 'string', 'max:2000'], + 'type' => ['nullable', 'string', Rule::in(array_map(fn (GuestNotificationType $type) => $type->value, GuestNotificationType::cases()))], + 'audience' => ['nullable', 'string', Rule::in(['all', 'guest'])], + 'guest_identifier' => ['nullable', 'string', 'max:120', 'required_if:audience,guest'], + 'cta' => ['nullable', 'array'], + 'cta.label' => ['required_with:cta.url', 'string', 'max:80'], + 'cta.url' => ['required_with:cta.label', 'string', 'max:2048'], + 'expires_in_minutes' => ['nullable', 'integer', 'between:5,2880'], + 'priority' => ['nullable', 'integer', 'between:0,5'], + ]; + } +} diff --git a/app/Http/Resources/Tenant/GuestNotificationResource.php b/app/Http/Resources/Tenant/GuestNotificationResource.php new file mode 100644 index 0000000..9c7fd20 --- /dev/null +++ b/app/Http/Resources/Tenant/GuestNotificationResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'title' => $this->title, + 'body' => $this->body, + 'status' => $this->status->value, + 'audience_scope' => $this->audience_scope->value, + 'target_identifier' => $this->target_identifier, + 'payload' => $this->payload, + 'priority' => $this->priority, + 'expires_at' => $this->expires_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index b6c9b97..d05267c 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -121,6 +121,11 @@ class Event extends Model return $this->hasMany(EventMember::class); } + public function guestNotifications(): HasMany + { + return $this->hasMany(GuestNotification::class); + } + public function hasActivePackage(): bool { return $this->eventPackage && $this->eventPackage->isActive(); diff --git a/app/Models/GuestNotification.php b/app/Models/GuestNotification.php new file mode 100644 index 0000000..187edd2 --- /dev/null +++ b/app/Models/GuestNotification.php @@ -0,0 +1,98 @@ +|null $payload + */ +class GuestNotification extends Model +{ + /** @use HasFactory<\Database\Factories\GuestNotificationFactory> */ + use HasFactory; + + protected $fillable = [ + 'tenant_id', + 'event_id', + 'type', + 'title', + 'body', + 'payload', + 'audience_scope', + 'target_identifier', + 'status', + 'priority', + 'expires_at', + ]; + + protected function casts(): array + { + return [ + 'payload' => 'array', + 'expires_at' => 'datetime', + 'type' => GuestNotificationType::class, + 'audience_scope' => GuestNotificationAudience::class, + 'status' => GuestNotificationState::class, + ]; + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function receipts(): HasMany + { + return $this->hasMany(GuestNotificationReceipt::class); + } + + public function scopeForEvent(Builder $query, Event $event): void + { + $query->where('event_id', $event->getKey()); + } + + public function scopeActive(Builder $query): void + { + $query->where('status', GuestNotificationState::ACTIVE->value); + } + + public function scopeNotExpired(Builder $query): void + { + $query->where(function (Builder $builder) { + $builder->whereNull('expires_at')->orWhere('expires_at', '>', Carbon::now()); + }); + } + + public function scopeVisibleToGuest(Builder $query, ?string $guestIdentifier): void + { + $query->where(function (Builder $builder) use ($guestIdentifier) { + $builder->where('audience_scope', GuestNotificationAudience::ALL->value); + + if ($guestIdentifier) { + $builder->orWhere(function (Builder $inner) use ($guestIdentifier) { + $inner->where('audience_scope', GuestNotificationAudience::GUEST->value) + ->where('target_identifier', $guestIdentifier); + }); + } + }); + } + + public function hasExpired(): bool + { + return $this->expires_at instanceof Carbon && $this->expires_at->isPast(); + } +} diff --git a/app/Models/GuestNotificationReceipt.php b/app/Models/GuestNotificationReceipt.php new file mode 100644 index 0000000..4bbc235 --- /dev/null +++ b/app/Models/GuestNotificationReceipt.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + protected $fillable = [ + 'guest_notification_id', + 'guest_identifier', + 'status', + 'read_at', + 'dismissed_at', + ]; + + protected function casts(): array + { + return [ + 'status' => GuestNotificationDeliveryStatus::class, + 'read_at' => 'datetime', + 'dismissed_at' => 'datetime', + ]; + } + + public function notification(): BelongsTo + { + return $this->belongsTo(GuestNotification::class, 'guest_notification_id'); + } +} diff --git a/app/Services/GuestNotificationService.php b/app/Services/GuestNotificationService.php new file mode 100644 index 0000000..ac44cd3 --- /dev/null +++ b/app/Services/GuestNotificationService.php @@ -0,0 +1,162 @@ +normalizeAudience($options['audience_scope'] ?? null); + $target = $audience === GuestNotificationAudience::GUEST + ? $this->sanitizeIdentifier($options['target_identifier'] ?? null) + : null; + + $notification = new GuestNotification([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->getKey(), + 'type' => $type, + 'title' => mb_substr(trim($title), 0, 160), + 'body' => $body ? mb_substr(trim($body), 0, 2000) : null, + 'payload' => $this->sanitizePayload($options['payload'] ?? null), + 'audience_scope' => $audience, + 'target_identifier' => $target, + 'status' => ($options['status'] ?? null) instanceof GuestNotificationState + ? $options['status'] + : GuestNotificationState::ACTIVE, + 'priority' => $this->normalizePriority($options['priority'] ?? null), + 'expires_at' => $options['expires_at'] ?? null, + ]); + + $notification->save(); + + $this->events->dispatch(new GuestNotificationCreated($notification)); + + return $notification; + } + + public function markAsRead(GuestNotification $notification, string $guestIdentifier): GuestNotificationReceipt + { + return $this->storeReceipt($notification, $guestIdentifier, GuestNotificationDeliveryStatus::READ); + } + + public function dismiss(GuestNotification $notification, string $guestIdentifier): GuestNotificationReceipt + { + return $this->storeReceipt($notification, $guestIdentifier, GuestNotificationDeliveryStatus::DISMISSED); + } + + private function storeReceipt(GuestNotification $notification, string $guestIdentifier, GuestNotificationDeliveryStatus $status): GuestNotificationReceipt + { + $guestIdentifier = $this->sanitizeIdentifier($guestIdentifier) ?? 'anonymous'; + + /** @var GuestNotificationReceipt $receipt */ + $receipt = GuestNotificationReceipt::query()->updateOrCreate( + [ + 'guest_notification_id' => $notification->getKey(), + 'guest_identifier' => $guestIdentifier, + ], + $this->buildReceiptAttributes($status) + ); + + return $receipt; + } + + private function buildReceiptAttributes(GuestNotificationDeliveryStatus $status): array + { + $attributes = ['status' => $status]; + + if ($status === GuestNotificationDeliveryStatus::READ) { + $attributes['read_at'] = now(); + $attributes['dismissed_at'] = null; + } elseif ($status === GuestNotificationDeliveryStatus::DISMISSED) { + $attributes['dismissed_at'] = now(); + $attributes['read_at'] = $attributes['read_at'] ?? now(); + } + + return $attributes; + } + + private function sanitizePayload(?array $payload): ?array + { + if (! $payload) { + return null; + } + + $cta = Arr::get($payload, 'cta'); + if (is_array($cta)) { + $cta = [ + 'label' => mb_substr((string) ($cta['label'] ?? ''), 0, 80), + 'href' => mb_substr((string) ($cta['href'] ?? $cta['url'] ?? ''), 0, 2048), + ]; + + if (trim($cta['label']) === '' || trim($cta['href']) === '') { + $cta = null; + } + } else { + $cta = null; + } + + $clean = array_filter([ + 'cta' => $cta, + ]); + + return $clean === [] ? null : $clean; + } + + private function normalizePriority(mixed $value): int + { + if (! is_numeric($value)) { + return 0; + } + + $int = (int) $value; + + return max(0, min(5, $int)); + } + + private function sanitizeIdentifier(?string $identifier): ?string + { + if ($identifier === null) { + return null; + } + + $sanitized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $identifier) ?? ''; + $sanitized = trim(mb_substr($sanitized, 0, 120)); + + return $sanitized === '' ? null : $sanitized; + } + + private function normalizeAudience(mixed $audience): GuestNotificationAudience + { + if ($audience instanceof GuestNotificationAudience) { + return $audience; + } + + try { + return GuestNotificationAudience::from(is_string($audience) ? strtolower($audience) : 'all'); + } catch (Throwable) { + return GuestNotificationAudience::ALL; + } + } +} diff --git a/database/factories/GuestNotificationFactory.php b/database/factories/GuestNotificationFactory.php new file mode 100644 index 0000000..2072058 --- /dev/null +++ b/database/factories/GuestNotificationFactory.php @@ -0,0 +1,51 @@ + + */ +class GuestNotificationFactory extends Factory +{ + protected $model = GuestNotification::class; + + public function definition(): array + { + $tenantFactory = Tenant::factory(); + + return [ + 'tenant_id' => $tenantFactory, + 'event_id' => Event::factory()->for($tenantFactory), + 'type' => GuestNotificationType::BROADCAST, + 'title' => $this->faker->sentence(4), + 'body' => $this->faker->sentences(2, true), + 'payload' => null, + 'audience_scope' => GuestNotificationAudience::ALL, + 'target_identifier' => null, + 'status' => GuestNotificationState::ACTIVE, + 'priority' => 0, + 'expires_at' => null, + ]; + } + + public function guestTarget(string $identifier): self + { + return $this->state(fn () => [ + 'audience_scope' => GuestNotificationAudience::GUEST, + 'target_identifier' => $identifier, + ]); + } + + public function system(GuestNotificationType $type): self + { + return $this->state(fn () => ['type' => $type]); + } +} diff --git a/database/factories/GuestNotificationReceiptFactory.php b/database/factories/GuestNotificationReceiptFactory.php new file mode 100644 index 0000000..5914965 --- /dev/null +++ b/database/factories/GuestNotificationReceiptFactory.php @@ -0,0 +1,43 @@ + + */ +class GuestNotificationReceiptFactory extends Factory +{ + protected $model = GuestNotificationReceipt::class; + + public function definition(): array + { + return [ + 'guest_notification_id' => GuestNotification::factory(), + 'guest_identifier' => $this->faker->unique()->userName(), + 'status' => GuestNotificationDeliveryStatus::NEW, + 'read_at' => null, + 'dismissed_at' => null, + ]; + } + + public function read(): self + { + return $this->state(fn () => [ + 'status' => GuestNotificationDeliveryStatus::READ, + 'read_at' => now(), + ]); + } + + public function dismissed(): self + { + return $this->state(fn () => [ + 'status' => GuestNotificationDeliveryStatus::DISMISSED, + 'dismissed_at' => now(), + ]); + } +} diff --git a/database/migrations/2025_11_16_120000_create_guest_notifications_tables.php b/database/migrations/2025_11_16_120000_create_guest_notifications_tables.php new file mode 100644 index 0000000..74f7967 --- /dev/null +++ b/database/migrations/2025_11_16_120000_create_guest_notifications_tables.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->string('type', 64); + $table->string('title', 160); + $table->text('body')->nullable(); + $table->json('payload')->nullable(); + $table->string('audience_scope', 32)->default('all'); + $table->string('target_identifier', 120)->nullable(); + $table->string('status', 32)->default('active'); + $table->unsignedTinyInteger('priority')->default(0); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'audience_scope']); + $table->index(['event_id', 'status']); + $table->index(['event_id', 'created_at']); + }); + + Schema::create('guest_notification_receipts', function (Blueprint $table) { + $table->id(); + $table->foreignId('guest_notification_id')->constrained()->cascadeOnDelete(); + $table->string('guest_identifier', 120); + $table->string('status', 32)->default('new'); + $table->timestamp('read_at')->nullable(); + $table->timestamp('dismissed_at')->nullable(); + $table->timestamps(); + + $table->unique(['guest_notification_id', 'guest_identifier']); + $table->index(['guest_identifier', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('guest_notification_receipts'); + Schema::dropIfExists('guest_notifications'); + } +}; diff --git a/docs/prp/06-tenant-admin-pwa.md b/docs/prp/06-tenant-admin-pwa.md index b2d9065..bfbeffd 100644 --- a/docs/prp/06-tenant-admin-pwa.md +++ b/docs/prp/06-tenant-admin-pwa.md @@ -15,6 +15,7 @@ Capabilities - Conflict handling: ETag/If-Match; audit changes. - Dashboard highlights tenant quota status (photo uploads, guest slots, gallery expiry) with traffic-light cards fed by package limit metrics. - Global toast handler consumes the shared API error schema and surfaces localized error messages for tenant operators. +- Guest broadcast module on the Event detail page: tenant admins can compose short guest-facing notifications (broadcast/support tip/upload alert/feedback) with optional CTA links and expirations. Calls `/api/v1/tenant/events/{slug}/guest-notifications` and stores history (last 5 messages) for quick status checks. Support Playbook (Limits) - Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`). diff --git a/docs/prp/07-guest-pwa.md b/docs/prp/07-guest-pwa.md index ba9db73..8f86086 100644 --- a/docs/prp/07-guest-pwa.md +++ b/docs/prp/07-guest-pwa.md @@ -37,6 +37,10 @@ Core Features - Safety & abuse controls - Rate limits per device and IP; content-length checks; mime/type sniffing. - Upload moderation state: pending → approved/hidden; show local status. +- Notification Center + - 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. - 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. @@ -97,6 +101,8 @@ API Touchpoints - POST `/api/v1/events/{token}/photos` — signed upload initiation; returns URL + fields. - POST (S3) — direct upload to object storage; then backend finalize call. - 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. Limits (MVP defaults) - Max uploads per device per event: 50 diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index de9ccc3..6589666 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -85,6 +85,31 @@ export type TenantEvent = { [key: string]: unknown; }; +export type GuestNotificationSummary = { + id: number; + type: string; + title: string; + body: string | null; + status: 'draft' | 'active' | 'archived'; + audience_scope: 'all' | 'guest'; + target_identifier?: string | null; + payload?: Record | null; + priority: number; + created_at: string | null; + expires_at: string | null; +}; + +export type SendGuestNotificationPayload = { + title: string; + message: string; + type?: string; + audience?: 'all' | 'guest'; + guest_identifier?: string | null; + cta?: { label: string; url: string } | null; + expires_in_minutes?: number | null; + priority?: number | null; +}; + export type TenantPhoto = { id: number; filename: string; @@ -968,10 +993,36 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite { }; } +function normalizeGuestNotification(raw: JsonValue): GuestNotificationSummary | null { + if (!raw || typeof raw !== 'object') { + return null; + } + + const record = raw as Record; + + return { + id: Number(record.id ?? 0), + type: typeof record.type === 'string' ? record.type : 'broadcast', + title: typeof record.title === 'string' ? record.title : '', + body: typeof record.body === 'string' ? record.body : null, + status: (record.status as GuestNotificationSummary['status']) ?? 'active', + audience_scope: (record.audience_scope as GuestNotificationSummary['audience_scope']) ?? 'all', + target_identifier: typeof record.target_identifier === 'string' ? record.target_identifier : null, + payload: (record.payload as Record) ?? null, + priority: Number(record.priority ?? 0), + created_at: typeof record.created_at === 'string' ? record.created_at : null, + expires_at: typeof record.expires_at === 'string' ? record.expires_at : null, + }; +} + function eventEndpoint(slug: string): string { return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; } +function guestNotificationsEndpoint(slug: string): string { + return `${eventEndpoint(slug)}/guest-notifications`; +} + function photoboothEndpoint(slug: string): string { return `${eventEndpoint(slug)}/photobooth`; } @@ -1239,6 +1290,41 @@ export async function getEventToolkit(slug: string): Promise { return toolkit; } +export async function listGuestNotifications(slug: string): Promise { + const response = await authorizedFetch(guestNotificationsEndpoint(slug)); + const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load guest notifications'); + const rows = Array.isArray(data.data) ? data.data : []; + + return rows + .map((row) => normalizeGuestNotification(row)) + .filter((row): row is GuestNotificationSummary => Boolean(row)); +} + +export async function sendGuestNotification( + slug: string, + payload: SendGuestNotificationPayload +): Promise { + const response = await authorizedFetch(guestNotificationsEndpoint(slug), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to send guest notification'); + return normalizeGuestNotification(data.data ?? {}) ?? normalizeGuestNotification({ + id: 0, + type: payload.type ?? 'broadcast', + title: payload.title, + body: payload.message, + status: 'active', + audience_scope: payload.audience ?? 'all', + target_identifier: payload.guest_identifier ?? null, + payload: payload.cta ? { cta: payload.cta } : null, + priority: payload.priority ?? 0, + created_at: new Date().toISOString(), + expires_at: null, + }); +} + export async function getEventPhotoboothStatus(slug: string): Promise { return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status'); } diff --git a/resources/js/admin/components/GuestBroadcastCard.tsx b/resources/js/admin/components/GuestBroadcastCard.tsx new file mode 100644 index 0000000..0831a36 --- /dev/null +++ b/resources/js/admin/components/GuestBroadcastCard.tsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-hot-toast'; +import { AlertCircle, Send, RefreshCw } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import type { GuestNotificationSummary, SendGuestNotificationPayload } from '../api'; +import { listGuestNotifications, sendGuestNotification } from '../api'; + +const TYPE_OPTIONS = [ + { value: 'broadcast', label: 'Allgemein' }, + { value: 'support_tip', label: 'Support-Hinweis' }, + { value: 'upload_alert', label: 'Upload-Status' }, + { value: 'feedback_request', label: 'Feedback' }, +]; + +const AUDIENCE_OPTIONS = [ + { value: 'all', label: 'Alle Gäste' }, + { value: 'guest', label: 'Einzelne Geräte-ID' }, +]; + +type GuestBroadcastCardProps = { + eventSlug: string; + eventName?: string | null; +}; + +export function GuestBroadcastCard({ eventSlug, eventName }: GuestBroadcastCardProps) { + const { t } = useTranslation('management'); + const [form, setForm] = React.useState({ + title: '', + message: '', + type: 'broadcast', + audience: 'all', + guest_identifier: '', + cta_label: '', + cta_url: '', + expires_in_minutes: 120, + }); + const [history, setHistory] = React.useState([]); + const [loadingHistory, setLoadingHistory] = React.useState(true); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + const loadHistory = React.useCallback(async () => { + setLoadingHistory(true); + try { + const data = await listGuestNotifications(eventSlug); + setHistory(data.slice(0, 5)); + } catch (err) { + console.error(err); + } finally { + setLoadingHistory(false); + } + }, [eventSlug]); + + React.useEffect(() => { + void loadHistory(); + }, [loadHistory]); + + function updateField(field: string, value: string): void { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSubmit(event: React.FormEvent): Promise { + event.preventDefault(); + setSubmitting(true); + setError(null); + + const payload: SendGuestNotificationPayload = { + title: form.title.trim(), + message: form.message.trim(), + type: form.type, + audience: form.audience as 'all' | 'guest', + guest_identifier: form.audience === 'guest' ? form.guest_identifier.trim() : undefined, + expires_in_minutes: Number(form.expires_in_minutes) || undefined, + cta: + form.cta_label.trim() && form.cta_url.trim() + ? { label: form.cta_label.trim(), url: form.cta_url.trim() } + : undefined, + }; + + try { + await sendGuestNotification(eventSlug, payload); + toast.success(t('events.notifications.toastSuccess', 'Nachricht gesendet.')); + setForm((prev) => ({ + ...prev, + title: '', + message: '', + guest_identifier: '', + cta_label: '', + cta_url: '', + })); + void loadHistory(); + } catch (err) { + console.error(err); + setError(t('events.notifications.toastError', 'Nachricht konnte nicht gesendet werden.')); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

+ {t('events.notifications.description', 'Sende kurze Hinweise direkt an deine Gäste. Ideal für Programmpunkte, Upload-Hilfe oder Feedback-Aufrufe.')} {eventName && {eventName}} +

+
+
+
+
+ + +
+
+ + +
+
+ {form.audience === 'guest' && ( +
+ + updateField('guest_identifier', event.target.value)} + placeholder="z. B. device-123" + required + /> +
+ )} +
+ + updateField('title', event.target.value)} + placeholder={t('events.notifications.titlePlaceholder', 'Buffet schließt in 10 Minuten')} + required + /> +
+
+ +