notificationsStorageAvailable = $this->detectStorageAvailability(); } /** * @param array{payload?: array|null, target_identifier?: string|null, priority?: int|null, expires_at?: \DateTimeInterface|null, status?: GuestNotificationState|null, audience_scope?: GuestNotificationAudience|string|null} $options */ public function createNotification( Event $event, GuestNotificationType $type, string $title, ?string $body = null, array $options = [] ): GuestNotification { $audience = $this->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, ]); if (! $this->notificationsStorageAvailable) { $this->logStorageWarningOnce(); return $notification; } $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 */ if (! $this->notificationsStorageAvailable) { $this->logStorageWarningOnce(); return new GuestNotificationReceipt([ 'guest_notification_id' => $notification->getKey(), 'guest_identifier' => $guestIdentifier, ...$this->buildReceiptAttributes($status), ]); } $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; } $photoId = Arr::get($payload, 'photo_id'); if (is_numeric($photoId)) { $photoId = max(1, (int) $photoId); } else { $photoId = null; } $photoIds = Arr::get($payload, 'photo_ids'); if (is_array($photoIds)) { $photoIds = array_values(array_unique(array_filter(array_map(function ($value) { if (! is_numeric($value)) { return null; } $int = (int) $value; return $int > 0 ? $int : null; }, $photoIds)))); $photoIds = array_slice($photoIds, 0, 10); } else { $photoIds = []; } $count = Arr::get($payload, 'count'); if (is_numeric($count)) { $count = max(1, min(9999, (int) $count)); } else { $count = 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, 'photo_id' => $photoId, 'photo_ids' => $photoIds, 'count' => $count, ]); 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; } } private function detectStorageAvailability(): bool { try { return Schema::hasTable('guest_notifications') && Schema::hasTable('guest_notification_receipts'); } catch (Throwable) { return false; } } private function logStorageWarningOnce(): void { static $alreadyWarned = false; if ($alreadyWarned) { return; } $alreadyWarned = true; Log::warning('Guest notifications storage tables are missing. Notification persistence skipped.'); } }