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;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\GuestNotificationAudience;
|
||||||
|
use App\Enums\GuestNotificationDeliveryStatus;
|
||||||
|
use App\Enums\GuestNotificationState;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Models\EventMediaAsset;
|
use App\Models\EventMediaAsset;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\PhotoShareLink;
|
use App\Models\PhotoShareLink;
|
||||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\EventTasksCacheService;
|
use App\Services\EventTasksCacheService;
|
||||||
|
use App\Services\GuestNotificationService;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Services\Packages\PackageUsageTracker;
|
use App\Services\Packages\PackageUsageTracker;
|
||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
@@ -41,6 +46,7 @@ class EventPublicController extends BaseController
|
|||||||
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
private readonly PackageLimitEvaluator $packageLimitEvaluator,
|
||||||
private readonly PackageUsageTracker $packageUsageTracker,
|
private readonly PackageUsageTracker $packageUsageTracker,
|
||||||
private readonly EventTasksCacheService $eventTasksCache,
|
private readonly EventTasksCacheService $eventTasksCache,
|
||||||
|
private readonly GuestNotificationService $guestNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1596,6 +1602,183 @@ class EventPublicController extends BaseController
|
|||||||
return array_values(array_unique(array_filter($candidates)));
|
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)
|
public function stats(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$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);
|
return $this->hasMany(EventMember::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function guestNotifications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(GuestNotification::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function hasActivePackage(): bool
|
public function hasActivePackage(): bool
|
||||||
{
|
{
|
||||||
return $this->eventPackage && $this->eventPackage->isActive();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
database/factories/GuestNotificationFactory.php
Normal file
51
database/factories/GuestNotificationFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\GuestNotificationAudience;
|
||||||
|
use App\Enums\GuestNotificationState;
|
||||||
|
use App\Enums\GuestNotificationType;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<GuestNotification>
|
||||||
|
*/
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
database/factories/GuestNotificationReceiptFactory.php
Normal file
43
database/factories/GuestNotificationReceiptFactory.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\GuestNotificationDeliveryStatus;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
|
use App\Models\GuestNotificationReceipt;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<GuestNotificationReceipt>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('guest_notifications', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ Capabilities
|
|||||||
- Conflict handling: ETag/If-Match; audit changes.
|
- 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.
|
- 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.
|
- 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)
|
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`).
|
- Wenn Tenant-Admins Upload- oder Gäste-Limits erreichen, zeigt der Header Warn-Badges + Toast mit derselben Fehlermeldung wie im Backend (`code`, `title`, `message`).
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ Core Features
|
|||||||
- Safety & abuse controls
|
- Safety & abuse controls
|
||||||
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
- Rate limits per device and IP; content-length checks; mime/type sniffing.
|
||||||
- Upload moderation state: pending → approved/hidden; show local status.
|
- 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
|
- Privacy & legal
|
||||||
- First run shows legal links (imprint/privacy); consent for push if enabled.
|
- 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.
|
- 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 `/api/v1/events/{token}/photos` — signed upload initiation; returns URL + fields.
|
||||||
- POST (S3) — direct upload to object storage; then backend finalize call.
|
- POST (S3) — direct upload to object storage; then backend finalize call.
|
||||||
- POST `/api/v1/photos/{id}/like` — idempotent like with device token.
|
- 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)
|
Limits (MVP defaults)
|
||||||
- Max uploads per device per event: 50
|
- Max uploads per device per event: 50
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ export type TenantEvent = {
|
|||||||
[key: string]: unknown;
|
[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<string, unknown> | 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 = {
|
export type TenantPhoto = {
|
||||||
id: number;
|
id: number;
|
||||||
filename: string;
|
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<string, JsonValue>;
|
||||||
|
|
||||||
|
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<string, unknown>) ?? 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 {
|
function eventEndpoint(slug: string): string {
|
||||||
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function guestNotificationsEndpoint(slug: string): string {
|
||||||
|
return `${eventEndpoint(slug)}/guest-notifications`;
|
||||||
|
}
|
||||||
|
|
||||||
function photoboothEndpoint(slug: string): string {
|
function photoboothEndpoint(slug: string): string {
|
||||||
return `${eventEndpoint(slug)}/photobooth`;
|
return `${eventEndpoint(slug)}/photobooth`;
|
||||||
}
|
}
|
||||||
@@ -1239,6 +1290,41 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
|||||||
return toolkit;
|
return toolkit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGuestNotifications(slug: string): Promise<GuestNotificationSummary[]> {
|
||||||
|
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<GuestNotificationSummary> {
|
||||||
|
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<PhotoboothStatus> {
|
export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> {
|
||||||
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
|
||||||
}
|
}
|
||||||
|
|||||||
256
resources/js/admin/components/GuestBroadcastCard.tsx
Normal file
256
resources/js/admin/components/GuestBroadcastCard.tsx
Normal file
@@ -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<GuestNotificationSummary[]>([]);
|
||||||
|
const [loadingHistory, setLoadingHistory] = React.useState(true);
|
||||||
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(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<HTMLFormElement>): Promise<void> {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('events.notifications.description', 'Sende kurze Hinweise direkt an deine Gäste. Ideal für Programmpunkte, Upload-Hilfe oder Feedback-Aufrufe.')} {eventName && <span className="font-semibold text-foreground">{eventName}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-type">{t('events.notifications.type', 'Art der Nachricht')}</Label>
|
||||||
|
<select
|
||||||
|
id="notification-type"
|
||||||
|
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
value={form.type}
|
||||||
|
onChange={(event) => updateField('type', event.target.value)}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-audience">{t('events.notifications.audience', 'Zielgruppe')}</Label>
|
||||||
|
<select
|
||||||
|
id="notification-audience"
|
||||||
|
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
value={form.audience}
|
||||||
|
onChange={(event) => updateField('audience', event.target.value)}
|
||||||
|
>
|
||||||
|
{AUDIENCE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.audience === 'guest' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-target">{t('events.notifications.target', 'Geräte-ID oder Gastname')}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-target"
|
||||||
|
value={form.guest_identifier}
|
||||||
|
onChange={(event) => updateField('guest_identifier', event.target.value)}
|
||||||
|
placeholder="z. B. device-123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notification-title">{t('events.notifications.titleLabel', 'Überschrift')}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => updateField('title', event.target.value)}
|
||||||
|
placeholder={t('events.notifications.titlePlaceholder', 'Buffet schließt in 10 Minuten')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notification-message">{t('events.notifications.message', 'Nachricht')}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notification-message"
|
||||||
|
value={form.message}
|
||||||
|
onChange={(event) => updateField('message', event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder={t('events.notifications.messagePlaceholder', 'Kommt zur Hauptbühne für das Gruppenfoto.')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-cta-label">{t('events.notifications.ctaLabel', 'CTA-Label (optional)')}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-cta-label"
|
||||||
|
value={form.cta_label}
|
||||||
|
onChange={(event) => updateField('cta_label', event.target.value)}
|
||||||
|
placeholder={t('events.notifications.ctaLabelPlaceholder', 'Zum Upload')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-cta-url">{t('events.notifications.ctaUrl', 'CTA-Link')}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-cta-url"
|
||||||
|
value={form.cta_url}
|
||||||
|
onChange={(event) => updateField('cta_url', event.target.value)}
|
||||||
|
placeholder="https://... oder /e/token/queue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notification-expiry">{t('events.notifications.expiry', 'Automatisch ausblenden nach (Minuten)')}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-expiry"
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={2880}
|
||||||
|
value={form.expires_in_minutes}
|
||||||
|
onChange={(event) => updateField('expires_in_minutes', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="inline-flex items-center gap-2" disabled={submitting}>
|
||||||
|
{submitting && <RefreshCw className="h-4 w-4 animate-spin" aria-hidden />}
|
||||||
|
{!submitting && <Send className="h-4 w-4" aria-hidden />}
|
||||||
|
{t('events.notifications.sendCta', 'Benachrichtigung senden')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{t('events.notifications.historyTitle', 'Zuletzt versendet')}</p>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2" onClick={() => loadHistory()} disabled={loadingHistory}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loadingHistory ? 'animate-spin' : ''}`} aria-hidden />
|
||||||
|
{t('events.notifications.reload', 'Aktualisieren')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{loadingHistory ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('events.notifications.historyLoading', 'Verlauf wird geladen ...')}</p>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('events.notifications.historyEmpty', 'Noch keine Benachrichtigungen versendet.')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{history.map((notification) => (
|
||||||
|
<li key={notification.id} className="rounded-lg border border-border bg-card px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">{notification.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(notification.created_at ?? '').toLocaleString()} · {notification.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{notification.audience_scope === 'all' ? t('events.notifications.audienceAll', 'Alle') : t('events.notifications.audienceGuest', 'Gast')}</Badge>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
ActionGrid,
|
ActionGrid,
|
||||||
TenantHeroCard,
|
TenantHeroCard,
|
||||||
} from '../components/tenant';
|
} from '../components/tenant';
|
||||||
|
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
|
||||||
|
|
||||||
type EventDetailPageProps = {
|
type EventDetailPageProps = {
|
||||||
mode?: 'detail' | 'toolkit';
|
mode?: 'detail' | 'toolkit';
|
||||||
@@ -268,6 +269,15 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
|
|
||||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||||
|
|
||||||
|
<SectionCard className="space-y-4">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
|
||||||
|
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
|
||||||
|
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
|
||||||
|
/>
|
||||||
|
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||||||
<InviteSummary
|
<InviteSummary
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { User, Heart, Users, PartyPopper, Camera, Bell, ArrowUpRight } from 'lucide-react';
|
import {
|
||||||
|
User,
|
||||||
|
Heart,
|
||||||
|
Users,
|
||||||
|
PartyPopper,
|
||||||
|
Camera,
|
||||||
|
Bell,
|
||||||
|
ArrowUpRight,
|
||||||
|
MessageSquare,
|
||||||
|
Sparkles,
|
||||||
|
LifeBuoy,
|
||||||
|
UploadCloud,
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useEventData } from '../hooks/useEventData';
|
import { useEventData } from '../hooks/useEventData';
|
||||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||||
import { SettingsSheet } from './settings-sheet';
|
import { SettingsSheet } from './settings-sheet';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||||
import { useOptionalNotificationCenter } from '../context/NotificationCenterContext';
|
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||||
|
|
||||||
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@@ -18,6 +34,15 @@ const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: st
|
|||||||
camera: Camera,
|
camera: Camera,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
broadcast: MessageSquare,
|
||||||
|
feedback_request: MessageSquare,
|
||||||
|
achievement_major: Sparkles,
|
||||||
|
support_tip: LifeBuoy,
|
||||||
|
upload_alert: UploadCloud,
|
||||||
|
photo_activity: Camera,
|
||||||
|
};
|
||||||
|
|
||||||
function isLikelyEmoji(value: string): boolean {
|
function isLikelyEmoji(value: string): boolean {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
@@ -208,6 +233,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
checklistItems={checklistItems}
|
checklistItems={checklistItems}
|
||||||
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
taskProgress={taskProgress?.hydrated ? taskProgress : undefined}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AppearanceToggleDropdown />
|
<AppearanceToggleDropdown />
|
||||||
@@ -217,32 +243,19 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationButton({
|
type NotificationButtonProps = {
|
||||||
center,
|
center: NotificationCenterValue;
|
||||||
eventToken,
|
|
||||||
open,
|
|
||||||
onToggle,
|
|
||||||
panelRef,
|
|
||||||
checklistItems,
|
|
||||||
taskProgress,
|
|
||||||
}: {
|
|
||||||
center: {
|
|
||||||
queueCount: number;
|
|
||||||
inviteCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
};
|
|
||||||
eventToken: string;
|
eventToken: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
panelRef: React.RefObject<HTMLDivElement>;
|
panelRef: React.RefObject<HTMLDivElement>;
|
||||||
checklistItems: string[];
|
checklistItems: string[];
|
||||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||||
}) {
|
t: TranslateFn;
|
||||||
if (!center) {
|
};
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = center.totalCount;
|
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||||||
|
const badgeCount = center.totalCount;
|
||||||
const progressRatio = taskProgress
|
const progressRatio = taskProgress
|
||||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -253,34 +266,81 @@ function NotificationButton({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
|
||||||
aria-label="Benachrichtigungen anzeigen"
|
aria-label={t('header.notifications.open', 'Benachrichtigungen anzeigen')}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" aria-hidden />
|
<Bell className="h-5 w-5" aria-hidden />
|
||||||
{totalCount > 0 && (
|
{badgeCount > 0 && (
|
||||||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||||||
{totalCount}
|
{badgeCount > 9 ? '9+' : badgeCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="absolute right-0 mt-2 w-72 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold text-slate-900">Benachrichtigungen</p>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-xs text-slate-500">Uploads in Warteschlange: {center.queueCount}</p>
|
<div>
|
||||||
<Link
|
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||||||
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
<p className="text-xs text-slate-500">
|
||||||
className="mt-2 flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm font-semibold text-pink-600 transition hover:border-pink-300"
|
{center.unreadCount > 0
|
||||||
>
|
? t('header.notifications.unread', '{{count}} neu', { count: center.unreadCount })
|
||||||
Zur Warteschlange
|
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||||
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => center.refresh()}
|
||||||
|
disabled={center.loading}
|
||||||
|
className="flex items-center gap-1 rounded-full border border-slate-200 px-2 py-1 text-xs font-semibold text-slate-600 transition hover:border-pink-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${center.loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||||
|
{t('header.notifications.refresh', 'Aktualisieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{center.loading ? (
|
||||||
|
<NotificationSkeleton />
|
||||||
|
) : center.notifications.length === 0 ? (
|
||||||
|
<NotificationEmptyState t={t} />
|
||||||
|
) : (
|
||||||
|
center.notifications.map((item) => (
|
||||||
|
<NotificationListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkRead={() => center.markAsRead(item.id)}
|
||||||
|
onDismiss={() => center.dismiss(item.id)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-600">
|
||||||
|
{t('header.notifications.queueLabel', 'Uploads in Warteschlange')}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-slate-900">{center.queueCount}</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/e/${encodeURIComponent(eventToken)}/queue`}
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||||
|
onClick={() => {
|
||||||
|
if (center.unreadCount > 0) {
|
||||||
|
void center.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('header.notifications.queueCta', 'Upload-Verlauf öffnen')}
|
||||||
|
<ArrowUpRight className="h-4 w-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
{taskProgress && (
|
{taskProgress && (
|
||||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">Badge-Fortschritt</p>
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||||
<p className="text-lg font-semibold text-slate-900">
|
<p className="text-lg font-semibold text-slate-900">
|
||||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||||
</p>
|
</p>
|
||||||
@@ -289,7 +349,7 @@ function NotificationButton({
|
|||||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||||
>
|
>
|
||||||
Weiter
|
{t('header.notifications.tasksCta', 'Weiter')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||||
@@ -301,7 +361,7 @@ function NotificationButton({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="my-3 h-px w-full bg-slate-100" />
|
<div className="my-3 h-px w-full bg-slate-100" />
|
||||||
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">So funktioniert’s</p>
|
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.checklistTitle', 'So funktioniert’s')}</p>
|
||||||
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
<ul className="mt-2 space-y-2 text-sm text-slate-600">
|
||||||
{checklistItems.map((item) => (
|
{checklistItems.map((item) => (
|
||||||
<li key={item} className="flex gap-2">
|
<li key={item} className="flex gap-2">
|
||||||
@@ -315,3 +375,170 @@ function NotificationButton({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationListItem({
|
||||||
|
item,
|
||||||
|
onMarkRead,
|
||||||
|
onDismiss,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
item: NotificationCenterValue['notifications'][number];
|
||||||
|
onMarkRead: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
t: TranslateFn;
|
||||||
|
}) {
|
||||||
|
const IconComponent = NOTIFICATION_ICON_MAP[item.type] ?? Bell;
|
||||||
|
const isNew = item.status === 'new';
|
||||||
|
const createdLabel = item.createdAt ? formatRelativeTime(item.createdAt) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border px-3 py-2.5 transition ${isNew ? 'border-pink-200 bg-pink-50/70' : 'border-slate-200 bg-white/90'}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isNew) {
|
||||||
|
onMarkRead();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`rounded-full p-1.5 ${isNew ? 'bg-white text-pink-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||||
|
<IconComponent className="h-4 w-4" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||||||
|
{item.body && <p className="text-xs text-slate-600">{item.body}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1 text-slate-400 transition hover:text-slate-700"
|
||||||
|
aria-label={t('header.notifications.dismiss', 'Ausblenden')}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
|
{createdLabel && <span>{createdLabel}</span>}
|
||||||
|
{isNew && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-1.5 py-0.5 text-[10px] font-semibold text-pink-600">
|
||||||
|
<Sparkles className="h-3 w-3" aria-hidden />
|
||||||
|
{t('header.notifications.badge.new', 'Neu')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.cta && (
|
||||||
|
<NotificationCta cta={item.cta} onFollow={onMarkRead} />
|
||||||
|
)}
|
||||||
|
{!isNew && item.status !== 'dismissed' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onMarkRead();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-pink-600"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" aria-hidden />
|
||||||
|
{t('header.notifications.markRead', 'Als gelesen markieren')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationCta({ cta, onFollow }: { cta: { label?: string; href?: string }; onFollow: () => void }) {
|
||||||
|
const href = cta.href ?? '#';
|
||||||
|
const label = cta.label ?? '';
|
||||||
|
const isInternal = /^\//.test(href);
|
||||||
|
const content = (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInternal) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||||
|
onClick={onFollow}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={cta.href}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-pink-600"
|
||||||
|
onClick={onFollow}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationEmptyState({ t }: { t: TranslateFn }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/70 p-4 text-center text-sm text-slate-500">
|
||||||
|
<AlertCircle className="mx-auto mb-2 h-5 w-5 text-slate-400" aria-hidden />
|
||||||
|
<p>{t('header.notifications.empty', 'Gerade gibt es keine neuen Hinweise.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div key={index} className="animate-pulse rounded-2xl border border-slate-200 bg-slate-100/60 p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-slate-200" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3 w-3/4 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const diffMinutes = Math.max(0, Math.round(diffMs / 60000));
|
||||||
|
|
||||||
|
if (diffMinutes < 1) {
|
||||||
|
return 'Gerade eben';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMinutes < 60) {
|
||||||
|
return `${diffMinutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffHours = Math.round(diffMinutes / 60);
|
||||||
|
if (diffHours < 24) {
|
||||||
|
return `${diffHours} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.round(diffHours / 24);
|
||||||
|
return `${diffDays} d`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,214 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useUploadQueue } from '../queue/hooks';
|
import { useUploadQueue } from '../queue/hooks';
|
||||||
import type { QueueItem } from '../queue/queue';
|
import type { QueueItem } from '../queue/queue';
|
||||||
|
import {
|
||||||
|
dismissGuestNotification,
|
||||||
|
fetchGuestNotifications,
|
||||||
|
markGuestNotificationRead,
|
||||||
|
type GuestNotificationItem,
|
||||||
|
} from '../services/notificationApi';
|
||||||
|
|
||||||
type NotificationCenterValue = {
|
export type NotificationCenterValue = {
|
||||||
|
notifications: GuestNotificationItem[];
|
||||||
|
unreadCount: number;
|
||||||
queueItems: QueueItem[];
|
queueItems: QueueItem[];
|
||||||
queueCount: number;
|
queueCount: number;
|
||||||
inviteCount: number;
|
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
refreshQueue: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
markAsRead: (id: number) => Promise<void>;
|
||||||
|
dismiss: (id: number) => Promise<void>;
|
||||||
eventToken: string;
|
eventToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||||
|
|
||||||
export function NotificationCenterProvider({
|
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
|
||||||
eventToken,
|
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
|
||||||
children,
|
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||||
}: {
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||||
eventToken: string;
|
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||||
children: React.ReactNode;
|
const etagRef = React.useRef<string | null>(null);
|
||||||
}) {
|
const fetchLockRef = React.useRef(false);
|
||||||
const { items, loading, refresh } = useUploadQueue();
|
|
||||||
|
|
||||||
const queueCount = React.useMemo(
|
const queueCount = React.useMemo(
|
||||||
() => items.filter((item) => item.status !== 'done').length,
|
() => items.filter((item) => item.status !== 'done').length,
|
||||||
[items],
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = React.useMemo<NotificationCenterValue>(
|
const loadNotifications = React.useCallback(
|
||||||
() => ({
|
async (options: { silent?: boolean } = {}) => {
|
||||||
queueItems: items,
|
if (!eventToken) {
|
||||||
queueCount,
|
if (!options.silent) {
|
||||||
inviteCount: 0,
|
setLoadingNotifications(false);
|
||||||
totalCount: queueCount,
|
}
|
||||||
loading,
|
return;
|
||||||
refreshQueue: refresh,
|
}
|
||||||
eventToken,
|
|
||||||
}),
|
if (fetchLockRef.current) {
|
||||||
[items, queueCount, loading, refresh, eventToken],
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLockRef.current = true;
|
||||||
|
if (!options.silent) {
|
||||||
|
setLoadingNotifications(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchGuestNotifications(eventToken, etagRef.current);
|
||||||
|
if (!result.notModified) {
|
||||||
|
setNotifications(result.notifications);
|
||||||
|
setUnreadCount(result.unreadCount);
|
||||||
|
}
|
||||||
|
etagRef.current = result.etag;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load guest notifications', error);
|
||||||
|
if (!options.silent) {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchLockRef.current = false;
|
||||||
|
if (!options.silent) {
|
||||||
|
setLoadingNotifications(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[eventToken]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
etagRef.current = null;
|
||||||
|
|
||||||
|
if (!eventToken) {
|
||||||
|
setLoadingNotifications(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingNotifications(true);
|
||||||
|
void loadNotifications();
|
||||||
|
}, [eventToken, loadNotifications]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!eventToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadNotifications({ silent: true });
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [eventToken, loadNotifications]);
|
||||||
|
|
||||||
|
const markAsRead = React.useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
if (!eventToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decremented = false;
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.id !== id) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'new') {
|
||||||
|
decremented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: 'read',
|
||||||
|
readAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decremented) {
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markGuestNotificationRead(eventToken, id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark notification as read', error);
|
||||||
|
void loadNotifications({ silent: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[eventToken, loadNotifications]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = React.useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
if (!eventToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decremented = false;
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.id !== id) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'new') {
|
||||||
|
decremented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: 'dismissed',
|
||||||
|
dismissedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decremented) {
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dismissGuestNotification(eventToken, id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dismiss notification', error);
|
||||||
|
void loadNotifications({ silent: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[eventToken, loadNotifications]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
await Promise.all([loadNotifications(), refreshQueue()]);
|
||||||
|
}, [loadNotifications, refreshQueue]);
|
||||||
|
|
||||||
|
const loading = loadingNotifications || queueLoading;
|
||||||
|
const totalCount = unreadCount + queueCount;
|
||||||
|
|
||||||
|
const value: NotificationCenterValue = {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
queueItems: items,
|
||||||
|
queueCount,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
markAsRead,
|
||||||
|
dismiss,
|
||||||
|
eventToken,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationCenterContext.Provider value={value}>{children}</NotificationCenterContext.Provider>
|
<NotificationCenterContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NotificationCenterContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotificationCenter() {
|
export function useNotificationCenter(): NotificationCenterValue {
|
||||||
const ctx = React.useContext(NotificationCenterContext);
|
const ctx = React.useContext(NotificationCenterContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
||||||
@@ -54,6 +216,6 @@ export function useNotificationCenter() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOptionalNotificationCenter() {
|
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
|
||||||
return React.useContext(NotificationCenterContext);
|
return React.useContext(NotificationCenterContext);
|
||||||
}
|
}
|
||||||
|
|||||||
146
resources/js/guest/services/notificationApi.ts
Normal file
146
resources/js/guest/services/notificationApi.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { getDeviceId } from '../lib/device';
|
||||||
|
|
||||||
|
export type GuestNotificationCta = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuestNotificationItem = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
status: 'new' | 'read' | 'dismissed';
|
||||||
|
createdAt: string;
|
||||||
|
readAt?: string | null;
|
||||||
|
dismissedAt?: string | null;
|
||||||
|
cta?: GuestNotificationCta | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuestNotificationFetchResult = {
|
||||||
|
notifications: GuestNotificationItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
etag: string | null;
|
||||||
|
notModified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuestNotificationResponse = {
|
||||||
|
data?: Array<{
|
||||||
|
id?: number | string;
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string | null;
|
||||||
|
status?: 'new' | 'read' | 'dismissed';
|
||||||
|
created_at?: string;
|
||||||
|
read_at?: string | null;
|
||||||
|
dismissed_at?: string | null;
|
||||||
|
cta?: GuestNotificationCta | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
}>;
|
||||||
|
meta?: {
|
||||||
|
unread_count?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuestNotificationRow = NonNullable<GuestNotificationResponse['data']>[number];
|
||||||
|
|
||||||
|
function buildHeaders(etag?: string | null): HeadersInit {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Device-Id': getDeviceId(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (etag) {
|
||||||
|
headers['If-None-Match'] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
|
||||||
|
return {
|
||||||
|
id: Number(payload.id ?? 0),
|
||||||
|
type: payload.type ?? 'broadcast',
|
||||||
|
title: payload.title ?? '',
|
||||||
|
body: payload.body ?? null,
|
||||||
|
status: payload.status === 'read' || payload.status === 'dismissed' ? payload.status : 'new',
|
||||||
|
createdAt: payload.created_at ?? new Date().toISOString(),
|
||||||
|
readAt: payload.read_at ?? null,
|
||||||
|
dismissedAt: payload.dismissed_at ?? null,
|
||||||
|
cta: payload.cta ?? null,
|
||||||
|
payload: payload.payload ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGuestNotifications(eventToken: string, etag?: string | null): Promise<GuestNotificationFetchResult> {
|
||||||
|
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: buildHeaders(etag),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 304 && etag) {
|
||||||
|
return {
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
etag,
|
||||||
|
notModified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = await safeParseError(response);
|
||||||
|
throw new Error(reason ?? 'Benachrichtigungen konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as GuestNotificationResponse;
|
||||||
|
const rows = Array.isArray(body.data) ? body.data : [];
|
||||||
|
const notifications = rows.map(mapNotification);
|
||||||
|
const unreadCount = typeof body.meta?.unread_count === 'number'
|
||||||
|
? body.meta.unread_count
|
||||||
|
: notifications.filter((item) => item.status === 'new').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
etag: response.headers.get('ETag'),
|
||||||
|
notModified: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markGuestNotificationRead(eventToken: string, notificationId: number): Promise<void> {
|
||||||
|
await postNotificationAction(eventToken, notificationId, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissGuestNotification(eventToken: string, notificationId: number): Promise<void> {
|
||||||
|
await postNotificationAction(eventToken, notificationId, 'dismiss');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postNotificationAction(eventToken: string, notificationId: number, action: 'read' | 'dismiss'): Promise<void> {
|
||||||
|
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications/${notificationId}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const reason = await safeParseError(response);
|
||||||
|
throw new Error(reason ?? 'Aktion konnte nicht ausgeführt werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeParseError(response: Response): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const payload = await response.clone().json();
|
||||||
|
const message = payload?.error?.message ?? payload?.message;
|
||||||
|
if (typeof message === 'string' && message.trim() !== '') {
|
||||||
|
return message.trim();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse notification API error', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\PackageController;
|
|||||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventController;
|
use App\Http\Controllers\Api\Tenant\EventController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\EventGuestNotificationController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
||||||
@@ -67,6 +68,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
||||||
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||||
Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package');
|
Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package');
|
||||||
|
Route::get('/events/{token}/notifications', [EventPublicController::class, 'notifications'])->name('events.notifications');
|
||||||
|
Route::post('/events/{token}/notifications/{notification}/read', [EventPublicController::class, 'markNotificationRead'])
|
||||||
|
->whereNumber('notification')
|
||||||
|
->name('events.notifications.read');
|
||||||
|
Route::post('/events/{token}/notifications/{notification}/dismiss', [EventPublicController::class, 'dismissNotification'])
|
||||||
|
->whereNumber('notification')
|
||||||
|
->name('events.notifications.dismiss');
|
||||||
Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
|
Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
|
||||||
Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
||||||
Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
||||||
@@ -134,6 +142,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle');
|
||||||
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites');
|
||||||
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
||||||
|
Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index');
|
||||||
|
Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('join-tokens')->group(function () {
|
Route::prefix('join-tokens')->group(function () {
|
||||||
|
|||||||
92
tests/Feature/Api/Event/GuestNotificationCenterTest.php
Normal file
92
tests/Feature/Api/Event/GuestNotificationCenterTest.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use App\Enums\GuestNotificationAudience;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GuestNotificationCenterTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_guest_can_fetch_notifications(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
|
||||||
|
GuestNotification::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'title' => 'Broadcast',
|
||||||
|
]);
|
||||||
|
|
||||||
|
GuestNotification::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'title' => 'Target',
|
||||||
|
'audience_scope' => GuestNotificationAudience::GUEST,
|
||||||
|
'target_identifier' => 'device-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Device-Id' => 'device-123',
|
||||||
|
])->getJson("/api/v1/events/{$token}/notifications");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('meta.unread_count', 2);
|
||||||
|
$response->assertJsonCount(2, 'data');
|
||||||
|
$response->assertJsonFragment(['title' => 'Target']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_can_mark_notification_read(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
$notification = GuestNotification::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Device-Id' => 'device-abc',
|
||||||
|
])->postJson("/api/v1/events/{$token}/notifications/{$notification->id}/read");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('status', 'read');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('guest_notification_receipts', [
|
||||||
|
'guest_notification_id' => $notification->id,
|
||||||
|
'guest_identifier' => 'device-abc',
|
||||||
|
'status' => 'read',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guest_cannot_access_notification_from_other_event(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
$otherEvent = Event::factory()->for($tenant)->create(['status' => 'published']);
|
||||||
|
|
||||||
|
$notification = GuestNotification::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'event_id' => $otherEvent->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Device-Id' => 'device-999',
|
||||||
|
])->postJson("/api/v1/events/{$token}/notifications/{$notification->id}/read");
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
tests/Feature/Tenant/GuestNotificationBroadcastTest.php
Normal file
65
tests/Feature/Tenant/GuestNotificationBroadcastTest.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class GuestNotificationBroadcastTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_admin_can_list_guest_notifications(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->for($this->tenant)->create();
|
||||||
|
|
||||||
|
GuestNotification::factory()->count(2)->create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/guest-notifications");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonCount(2, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_broadcast_notification(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->for($this->tenant)->create();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'title' => 'Gruppenfoto gleich',
|
||||||
|
'message' => 'Kommt bitte zur Bühne, wir starten in 5 Minuten.',
|
||||||
|
'type' => 'broadcast',
|
||||||
|
'cta' => [
|
||||||
|
'label' => 'Route öffnen',
|
||||||
|
'url' => 'https://example.com/map',
|
||||||
|
],
|
||||||
|
'expires_in_minutes' => 120,
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/guest-notifications", $payload);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.title', 'Gruppenfoto gleich');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('guest_notifications', [
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'title' => 'Gruppenfoto gleich',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_access_foreign_event(): void
|
||||||
|
{
|
||||||
|
$otherTenantEvent = Event::factory()->create([
|
||||||
|
'slug' => 'foreign-'.Str::random(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$otherTenantEvent->slug}/guest-notifications", [
|
||||||
|
'title' => 'Hi',
|
||||||
|
'message' => 'Nope',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user