feat: add guest notification center
This commit is contained in:
17
app/Enums/GuestNotificationAudience.php
Normal file
17
app/Enums/GuestNotificationAudience.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum GuestNotificationAudience: string
|
||||
{
|
||||
case ALL = 'all';
|
||||
case GUEST = 'guest';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ALL => __('Alle Gäste'),
|
||||
self::GUEST => __('Individuelle Gäste'),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/GuestNotificationDeliveryStatus.php
Normal file
19
app/Enums/GuestNotificationDeliveryStatus.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum GuestNotificationDeliveryStatus: string
|
||||
{
|
||||
case NEW = 'new';
|
||||
case READ = 'read';
|
||||
case DISMISSED = 'dismissed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NEW => __('Neu'),
|
||||
self::READ => __('Gelesen'),
|
||||
self::DISMISSED => __('Ausgeblendet'),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/GuestNotificationState.php
Normal file
19
app/Enums/GuestNotificationState.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum GuestNotificationState: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case ACTIVE = 'active';
|
||||
case ARCHIVED = 'archived';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => __('Entwurf'),
|
||||
self::ACTIVE => __('Aktiv'),
|
||||
self::ARCHIVED => __('Archiviert'),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
app/Enums/GuestNotificationType.php
Normal file
25
app/Enums/GuestNotificationType.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum GuestNotificationType: string
|
||||
{
|
||||
case BROADCAST = 'broadcast';
|
||||
case SUPPORT_TIP = 'support_tip';
|
||||
case UPLOAD_ALERT = 'upload_alert';
|
||||
case ACHIEVEMENT_MAJOR = 'achievement_major';
|
||||
case PHOTO_ACTIVITY = 'photo_activity';
|
||||
case FEEDBACK_REQUEST = 'feedback_request';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BROADCAST => __('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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
app/Events/GuestNotificationCreated.php
Normal file
17
app/Events/GuestNotificationCreated.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\GuestNotification;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GuestNotificationCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public GuestNotification $notification) {}
|
||||
}
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationDeliveryStatus;
|
||||
use App\Enums\GuestNotificationState;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PhotoShareLink;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\EventTasksCacheService;
|
||||
use App\Services\GuestNotificationService;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
@@ -41,6 +46,7 @@ class EventPublicController extends BaseController
|
||||
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
||||
private readonly PackageUsageTracker $packageUsageTracker,
|
||||
private readonly EventTasksCacheService $eventTasksCache,
|
||||
private readonly GuestNotificationService $guestNotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1596,6 +1602,183 @@ class EventPublicController extends BaseController
|
||||
return array_values(array_unique(array_filter($candidates)));
|
||||
}
|
||||
|
||||
public function notifications(Request $request, string $token)
|
||||
{
|
||||
$result = $this->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']);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\BroadcastGuestNotificationRequest;
|
||||
use App\Http\Resources\Tenant\GuestNotificationResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Services\GuestNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EventGuestNotificationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GuestNotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Enums\GuestNotificationType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class BroadcastGuestNotificationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Resources/Tenant/GuestNotificationResource.php
Normal file
30
app/Http/Resources/Tenant/GuestNotificationResource.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin \App\Models\GuestNotification */
|
||||
class GuestNotificationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
98
app/Models/GuestNotification.php
Normal file
98
app/Models/GuestNotification.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationState;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property array<string, mixed>|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();
|
||||
}
|
||||
}
|
||||
36
app/Models/GuestNotificationReceipt.php
Normal file
36
app/Models/GuestNotificationReceipt.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GuestNotificationDeliveryStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GuestNotificationReceipt extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\GuestNotificationReceiptFactory> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
162
app/Services/GuestNotificationService.php
Normal file
162
app/Services/GuestNotificationService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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 Throwable;
|
||||
|
||||
class GuestNotificationService
|
||||
{
|
||||
public function __construct(private readonly Dispatcher $events) {}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
]);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user