Files
fotospiel-app/app/Services/GuestNotificationService.php
Codex Agent 7786e3d134
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Switch photobooth uploader to Avalonia
2026-01-12 17:26:45 +01:00

240 lines
7.6 KiB
PHP

<?php
namespace App\Services;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationDeliveryStatus;
use App\Enums\GuestNotificationState;
use App\Enums\GuestNotificationType;
use App\Events\GuestNotificationCreated;
use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestNotificationReceipt;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Throwable;
class GuestNotificationService
{
private bool $notificationsStorageAvailable;
public function __construct(private readonly Dispatcher $events)
{
$this->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.');
}
}