feat: add guest notification center

This commit is contained in:
Codex Agent
2025-11-12 16:56:50 +01:00
parent 062932ce38
commit 4495ac1895
27 changed files with 2042 additions and 64 deletions

View File

@@ -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']);

View File

@@ -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;
}
}

View File

@@ -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'],
];
}
}

View 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(),
];
}
}